From f3c51e6c7b3fb2ecc59e7a2609f52188032c5e8f Mon Sep 17 00:00:00 2001 From: James Sadler Date: Tue, 6 Oct 2026 10:14:17 +1100 Subject: [PATCH 1/8] feat(stack-encrypt)!: keysets resolve through a KeysetRegistry (CIP-4140) Port of cipherstash/cipherstash-suite#2257 (16 commits, edee567c8 to 1912fee18), squashed into one commit. The suite history does not apply here: stack main has 82 commits on these crates since the import, so the patches were replayed on the import base (f1895d8bb, where they apply cleanly) and the result was merged onto this branch as one change. StackCipher replaces StackCipher. A keyset resolves to one vitaminc-kms key provider, so the cipher runs on ZeroKMS or on a vendor data key source without naming either. `impl KeysetRegistry for Arc` is behind a new `zerokms` feature (on by default). The sealed leaf carries the provider's opaque key id in place of ZeroKMS's IV and tag. stack-kms's DataKeySource, IndexKeySource and FakeDataKeySource are deleted. Conflicts resolved against stack main: * Main's source-mode parameter `M` on `Encryption`, `ciphertext` and the term constructors now sits beside the `K: KeysetRegistry` bound. * Main's `run_decryption` and `inspect` take the registry bound. * The derive's new plan-emitting `encryption` takes the registry bound; the code the suite PR edited there was deleted by main. * `stack_encrypt::kms` (main's re-export of stack-kms) is now gated on the `zerokms` feature, as stack-kms itself is. * `test-support` forwards to vitaminc-kms's fake, not stack-kms's. * Main's `loads_counting_cipher` fixture counts `resolve` calls on the fake registry, through a new `FakeKeysetRegistry::resolves` counter. * Descriptor pairs (main) and key ids (suite) meet in the pending tests. * The suite's ADR 0006 is renumbered 0007: main has its own 0006. This commit keeps the suite's vitaminc-kms pin (9cb1965) for stack-encrypt. The next commit repins it. BREAKING CHANGE: StackCipherBuilder::kms is now registry, and StackCipher::builder() is StackCipherBuilder::new(). SealedValue's parts are (KeysetId, key_id, ciphertext). KeysetRef replaces IdentifiedBy in this crate's API. stack_encrypt::kms exists only with the `zerokms` feature. stack-kms's DataKeySource, IndexKeySource and FakeDataKeySource are removed. --- .../golang/stackencrypt/guest/Cargo.toml | 10 +- .../golang/stackencrypt/guest/src/abi.rs | 25 +- .../golang/stackencrypt/guest/src/lib.rs | 2 +- .../golang/stackencrypt/guest/src/ops.rs | 21 +- .../golang/stackencrypt/guest/src/options.rs | 12 +- .../golang/stackencrypt/guest/src/status.rs | 50 +- .../stackencrypt/guest/tests/native_ops.rs | 144 ++-- languages/golang/stackencrypt/guest_test.go | 5 +- packages/stack-encrypt-derive/Cargo.toml | 6 +- packages/stack-encrypt-derive/src/decrypt.rs | 4 +- packages/stack-encrypt-derive/src/encrypt.rs | 4 +- packages/stack-encrypt-derive/src/lib.rs | 16 +- packages/stack-encrypt/Cargo.toml | 69 +- ...n-the-leaf-and-names-as-bounded-lookups.md | 34 + ...olve-through-a-registry-this-crate-owns.md | 128 ++++ .../examples/encrypted_record.rs | 2 +- .../stack-encrypt/examples/zerokms_auth.rs | 8 +- .../fuzz/fuzz_targets/check_record.rs | 4 +- packages/stack-encrypt/src/cipher.rs | 698 +++++++++++------- packages/stack-encrypt/src/descriptor.rs | 27 +- packages/stack-encrypt/src/dynamic/mod.rs | 5 +- packages/stack-encrypt/src/dynamic/record.rs | 106 +-- packages/stack-encrypt/src/dynamic/term.rs | 41 +- packages/stack-encrypt/src/keyset.rs | 184 +++-- packages/stack-encrypt/src/lib.rs | 64 +- packages/stack-encrypt/src/registry.rs | 192 +++++ packages/stack-encrypt/src/registry/fake.rs | 364 +++++++++ .../stack-encrypt/src/registry/zerokms.rs | 99 +++ packages/stack-encrypt/src/sem/mod.rs | 20 +- packages/stack-encrypt/src/target/core.rs | 20 +- packages/stack-encrypt/src/target/mod.rs | 4 +- .../stack-encrypt/src/target/operations.rs | 118 +-- packages/stack-encrypt/src/target/pending.rs | 498 +++++++------ packages/stack-encrypt/src/target/request.rs | 153 ++-- packages/stack-encrypt/tests/common/mod.rs | 251 ++----- packages/stack-encrypt/tests/derive.rs | 66 +- packages/stack-encrypt/tests/descriptor.rs | 20 +- packages/stack-encrypt/tests/frozen_bytes.rs | 206 +++--- packages/stack-encrypt/tests/keysets.rs | 405 +++++----- .../stack-encrypt/tests/registry_zerokms.rs | 130 ++++ packages/stack-encrypt/tests/roundtrip.rs | 99 ++- packages/stack-encrypt/tests/sem_terms.rs | 32 +- packages/stack-encrypt/tests/target.rs | 117 +-- packages/stack-encrypt/tests/term_bytes.rs | 27 +- packages/stack-encrypt/tests/transcode.rs | 38 +- .../stack-encrypt/tests/ui/bare_context.rs | 6 +- .../tests/ui/bare_context.stderr | 10 +- .../ui/divergent_context_in_target.stderr | 70 ++ .../tests/ui/execution_callback.stderr | 107 ++- .../tests/ui/foreign_cipher_scope.stderr | 90 ++- .../tests/ui/leaf_without_context.rs | 8 +- .../tests/ui/leaf_without_context.stderr | 26 +- .../tests/ui/override_encryption.stderr | 37 +- .../tests/ui/pass/aead_only_context.rs | 6 +- .../tests/ui/pass/query_only_and_borrowed.rs | 6 +- .../tests/ui/pass/which_form_compiles.rs | 4 +- .../tests/ui/plaintext_needs_encrypt.rs | 4 +- .../tests/ui/wrong_target_context.rs | 4 +- packages/stack-kms/Cargo.toml | 14 +- packages/stack-kms/src/client.rs | 4 +- .../stack-kms/src/client/test_connection.rs | 65 +- packages/stack-kms/src/key.rs | 14 - packages/stack-kms/src/key_source.rs | 531 ------------- packages/stack-kms/src/lib.rs | 12 +- packages/stack-kms/src/maybe_send.rs | 6 +- packages/stack-kms/src/provider.rs | 31 +- 66 files changed, 3314 insertions(+), 2269 deletions(-) create mode 100644 packages/stack-encrypt/docs/adr/0007-keysets-resolve-through-a-registry-this-crate-owns.md create mode 100644 packages/stack-encrypt/src/registry.rs create mode 100644 packages/stack-encrypt/src/registry/fake.rs create mode 100644 packages/stack-encrypt/src/registry/zerokms.rs create mode 100644 packages/stack-encrypt/tests/registry_zerokms.rs delete mode 100644 packages/stack-kms/src/key_source.rs diff --git a/languages/golang/stackencrypt/guest/Cargo.toml b/languages/golang/stackencrypt/guest/Cargo.toml index e676076a6..ff330e875 100644 --- a/languages/golang/stackencrypt/guest/Cargo.toml +++ b/languages/golang/stackencrypt/guest/Cargo.toml @@ -28,7 +28,10 @@ crate-type = ["cdylib", "rlib"] 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"] } +# `zerokms` is the `impl KeysetRegistry for Arc` this guest runs +# on. It carries no transport of its own — `http` is what would pull one in, +# and stays off. +stack-encrypt = { path = "../../../../packages/stack-encrypt", default-features = false, features = ["dynamic", "zerokms"] } # 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. @@ -52,7 +55,10 @@ uuid = "1" zeroize = "1" [dev-dependencies] -stack-kms = { path = "../../../../packages/stack-kms", default-features = false, features = ["test-support"] } +# The guest's own crate with the fake keyset registry on: the native tests +# drive a real `StackCipher` without a network. +stack-encrypt = { path = "../../../../packages/stack-encrypt", default-features = false, features = ["dynamic", "zerokms", "test-support"] } +stack-kms = { path = "../../../../packages/stack-kms", default-features = false } # 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 diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index 74ffeb297..22145dda2 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -17,7 +17,7 @@ //! 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>` (one +//! `StackCipher>>` (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 @@ -67,7 +67,7 @@ use std::cell::{Cell, RefCell}; use std::panic::{catch_unwind, AssertUnwindSafe}; use futures::executor::block_on; -use stack_encrypt::{KeysetCipher, StackCipher}; +use stack_encrypt::{KeysetCipher, StackCipher, StackCipherBuilder}; use stack_guest_abi::abi::{err_status, input, ok_buffer, take_plaintext, wipe_input}; use stack_guest_abi::buffers; use stack_kms::{ClientOpts, StackKms}; @@ -83,7 +83,7 @@ use stack_encrypt::dynamic::Scope; /// The instance's cipher: `stack-encrypt` over the host-transport ZeroKMS /// client with host-supplied tokens. -type GuestCipher = StackCipher>; +type GuestCipher = StackCipher>>; thread_local! { // Wasm is single-threaded, so a thread-local `RefCell` is a plain owner @@ -116,7 +116,9 @@ fn with_cipher(f: impl FnOnce(&GuestCipher) -> Result) -> Result( opts: &[u8], - f: impl FnOnce(&KeysetCipher<'_, StackKms>) -> Result, + f: impl FnOnce( + &KeysetCipher<'_, std::sync::Arc>>, + ) -> Result, ) -> Result { let options = parse_options(decode(opts)?, Side::Mint)?; with_cipher(|cipher| { @@ -129,7 +131,9 @@ fn with_keyset( /// client for `{"any"}`, one keyset's cipher otherwise. fn with_scope( opts: &[u8], - f: impl FnOnce(Scope<'_, StackKms>) -> Result, + f: impl FnOnce( + Scope<'_, std::sync::Arc>>, + ) -> Result, ) -> Result { let options = parse_options(decode(opts)?, Side::Open)?; with_cipher(|cipher| { @@ -203,12 +207,17 @@ fn cipher_init(decoded: FfiValue) -> Result, u32> { ) .map_err(|_| STATUS_KMS_TRANSPORT)?; - let mut builder = StackCipher::builder().kms(kms); + let mut builder = StackCipherBuilder::new().registry(std::sync::Arc::new(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(); + let default = cipher + .default_keyset() + .keyset_id() + .as_uuid() + .as_bytes() + .to_vec(); CIPHER.with(|c| *c.borrow_mut() = Some(cipher)); Ok(default) } @@ -259,7 +268,7 @@ pub unsafe extern "C" fn se_keyset(sel_ptr: *const u8, sel_len: u32) -> u64 { } with_cipher(|cipher| { let keyset = block_on(selector.resolve(cipher))?; - Ok(keyset.keyset_id().as_bytes().to_vec()) + Ok(keyset.keyset_id().as_uuid().as_bytes().to_vec()) }) })) .unwrap_or(Err(STATUS_INTERNAL)) diff --git a/languages/golang/stackencrypt/guest/src/lib.rs b/languages/golang/stackencrypt/guest/src/lib.rs index 93f6aa365..cab9ffa1d 100644 --- a/languages/golang/stackencrypt/guest/src/lib.rs +++ b/languages/golang/stackencrypt/guest/src/lib.rs @@ -57,7 +57,7 @@ //! [`status`] — everything that is pure logic over //! `StackCipher` / `KeysetCipher` / bytes. Compiles and unit-tests //! on the native host target (`cargo test` here, no wasm toolchain -//! needed) against `stack_kms::FakeDataKeySource`. +//! needed) against `stack_encrypt::registry::fake::FakeKeysetRegistry`. //! - [`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 diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index c294d796a..54cddaae9 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -1,6 +1,6 @@ //! The guest's operations, written against `StackCipher` / `KeysetCipher` for any -//! [`DataKeySource`] so they compile — and their tests run — on the native -//! host target with `FakeDataKeySource`. The wasm32-only [`crate::abi`] +//! [`KeysetRegistry`] so they compile — and their tests run — on the native +//! host target with `FakeKeysetRegistry`. The wasm32-only [`crate::abi`] //! module wires them to the session table and the packed ABI; nothing in //! here knows about linear memory. //! @@ -34,10 +34,10 @@ use stack_encrypt::dynamic::{self, Scalar, Scope}; use stack_encrypt::sem::MatchOptions; use stack_encrypt::target::IndexSpec; +use stack_encrypt::KeysetRegistry; 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; @@ -82,7 +82,8 @@ pub async fn encrypt_value( as_element: bool, ) -> Result, u32> where - K: DataKeySource + Sync, + K: KeysetRegistry + Sync, + K::Provider: Send + Sync, { let value = decode_value(value)?; let tree = if as_element { @@ -118,7 +119,8 @@ pub async fn decrypt_value( as_element: bool, ) -> Result, u32> where - K: DataKeySource + Sync, + K: KeysetRegistry + Sync, + K::Provider: Send + Sync, { let tree = decode_tree(ciphertext)?; // One `decrypt` per arm, not one `decipher` and two drives. The element @@ -169,7 +171,8 @@ pub async fn term( kind: u32, ) -> Result, u32> where - K: DataKeySource + Sync, + K: KeysetRegistry + Sync, + K::Provider: Send + Sync, { // The same proof every stack-encrypt leaf demands: an empty context is // `STATUS_ENCODING` here, before any derivation. @@ -230,7 +233,8 @@ pub async fn encrypt_record( plan: &[u8], ) -> Result, u32> where - K: DataKeySource + Sync, + K: KeysetRegistry + Sync, + K::Provider: Send + 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) @@ -252,7 +256,8 @@ pub async fn decrypt_record( plan: &[u8], ) -> Result, u32> where - K: DataKeySource + Sync + 'static, + K: KeysetRegistry + Sync + 'static, + K::Provider: Send + Sync, { 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) diff --git a/languages/golang/stackencrypt/guest/src/options.rs b/languages/golang/stackencrypt/guest/src/options.rs index 3fc9b6727..83c2ceb1d 100644 --- a/languages/golang/stackencrypt/guest/src/options.rs +++ b/languages/golang/stackencrypt/guest/src/options.rs @@ -29,7 +29,7 @@ use stack_encrypt::dynamic::Scope; use stack_encrypt::{KeysetCipher, StackCipher}; -use stack_kms::{IdentifiedBy, IndexKeySource}; +use stack_encrypt::{KeysetRef, KeysetRegistry}; use uuid::Uuid; use vitaminc_aead_value::FfiValue; use vitaminc_protected::Controlled; @@ -129,13 +129,13 @@ impl KeysetSelector { cipher: &'c StackCipher, ) -> Result, u32> where - K: IndexKeySource, + K: KeysetRegistry, { - let by: IdentifiedBy = match self { + let by: KeysetRef = 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), + KeysetSelector::Name(name) => KeysetRef::Name(name.to_string()), + KeysetSelector::Id(id) => KeysetRef::Id((*id).into()), }; cipher.keyset(by).await.map_err(|e| status_for_error(&e)) } @@ -150,7 +150,7 @@ pub async fn scope_for<'c, K>( selector: &KeysetSelector, ) -> Result, u32> where - K: IndexKeySource, + K: KeysetRegistry, { match selector { KeysetSelector::Any => Ok(Scope::Client(cipher)), diff --git a/languages/golang/stackencrypt/guest/src/status.rs b/languages/golang/stackencrypt/guest/src/status.rs index fcefd098f..a54e7305c 100644 --- a/languages/golang/stackencrypt/guest/src/status.rs +++ b/languages/golang/stackencrypt/guest/src/status.rs @@ -71,7 +71,31 @@ pub fn status_for_error(error: &stack_encrypt::Error) -> u32 { 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), + // The provider's error is boxed by `stack-encrypt`, which is + // backend-neutral and cannot name it. This guest is not: its + // provider is `ZeroKmsKeyset`, whose error is `stack_kms::Error`, so + // it downcasts and classifies exactly as it did when the type was + // concrete. Anything else — a different provider, a test double — is + // a backend failure this guest cannot read, and says so rather than + // guessing. + stack_encrypt::Error::Provider(error) => error + .downcast_ref::() + .map_or(STATUS_KMS_OTHER, status_for_kms), + // The registry could not answer at all, and the keyset it was asked + // for may or may not exist. Boxed for the same reason as `Provider`, + // and read the same way: this guest's registry is `Arc`, + // whose error is `stack_kms::Error`. It matters more here than there + // — resolving the default keyset is what `se_cipher_init` does, so + // this arm is how a host learns its token was refused or its + // endpoint was unreachable before it has a cipher at all. + stack_encrypt::Error::Registry(error) => error + .downcast_ref::() + .map_or(STATUS_KMS_OTHER, status_for_kms), + // The registry *answered*, and its answer was that no keyset goes by + // that name or id. That is a not-found, and it is the same verdict a + // host used to get when ZeroKMS's 404 surfaced as a raw request + // failure — the registry now reads the 404 and says what it means. + stack_encrypt::Error::UnknownKeyset { .. } => STATUS_KMS_NOT_FOUND, // 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, @@ -216,6 +240,15 @@ mod tests { ViturRequestError::new(kind, "stubbed", std::io::Error::other("boom")) } + /// A ZeroKMS failure as it reaches the guest: boxed by `stack-encrypt`, + /// which is backend-neutral and cannot name it. Going through the box + /// here is the point — it is the downcast that the classification + /// depends on, so a test that built the variant directly would not + /// exercise it. + fn provider_error(error: stack_kms::Error) -> stack_encrypt::Error { + stack_encrypt::Error::Provider(Box::new(error)) + } + #[test] fn aead_and_composition_errors_map_to_the_vitaminc_codes() { assert_eq!(status_for_error(&stack_encrypt::Error::Aead), STATUS_AUTH); @@ -240,7 +273,7 @@ mod tests { (ViturRequestErrorKind::ParseResponse, STATUS_KMS_OTHER), ]; for (i, (kind, expected)) in cases.into_iter().enumerate() { - let err = stack_encrypt::Error::Kms(stack_kms::Error::RetrieveKey( + let err = provider_error(stack_kms::Error::RetrieveKey( RetrieveKeyError::RequestFailed(vitur(kind)), )); assert_eq!(status_for_error(&err), expected, "case {i}"); @@ -249,7 +282,7 @@ mod tests { #[test] fn a_missing_data_key_is_not_found() { - let err = stack_encrypt::Error::Kms(stack_kms::Error::RetrieveKey( + let err = provider_error(stack_kms::Error::RetrieveKey( RetrieveKeyError::FailedRetrieval("no key".into()), )); assert_eq!(status_for_error(&err), STATUS_KMS_NOT_FOUND); @@ -276,7 +309,7 @@ mod tests { "expected InvalidToken, got: {err:?}" ); - let status = status_for_error(&stack_encrypt::Error::Kms(stack_kms::Error::Auth(err))); + let status = status_for_error(&provider_error(stack_kms::Error::Auth(err))); assert_eq!( status, STATUS_KMS_TRANSPORT, "a config fault must not tell the host to refresh and retry" @@ -290,7 +323,7 @@ mod tests { "host token_get failed with status 7".to_string(), )); assert_eq!( - status_for_error(&stack_encrypt::Error::Kms(stack_kms::Error::Auth(err))), + status_for_error(&provider_error(stack_kms::Error::Auth(err))), STATUS_KMS_TRANSPORT ); } @@ -299,7 +332,7 @@ mod tests { 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_for_error(&provider_error(stack_kms::Error::Auth(err))), STATUS_KMS_UNAUTHORIZED ); } @@ -324,7 +357,10 @@ mod tests { #[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)); + let (a, b) = ( + stack_encrypt::KeysetId::new(uuid::Uuid::from_u128(1)), + stack_encrypt::KeysetId::new(uuid::Uuid::from_u128(2)), + ); assert_eq!( status_for_error(&stack_encrypt::Error::ForeignKeyset { expected: a, diff --git a/languages/golang/stackencrypt/guest/tests/native_ops.rs b/languages/golang/stackencrypt/guest/tests/native_ops.rs index 541f548c6..1c8d74613 100644 --- a/languages/golang/stackencrypt/guest/tests/native_ops.rs +++ b/languages/golang/stackencrypt/guest/tests/native_ops.rs @@ -1,4 +1,4 @@ -//! Native tests of the guest's operations over `FakeDataKeySource` — the +//! Native tests of the guest's operations over `FakeKeysetRegistry` — the //! same functions the wasm ABI drives, minus linear memory. What they pin: //! //! * value trees round-trip through the FFI codec + the guest ops @@ -12,24 +12,18 @@ //! * 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::registry::fake::{FakeKeysetRegistry, FakeProvider}; use stack_encrypt::sem::DefaultMatch; -use stack_encrypt::{nonempty, CipherText, Encrypt, SealedValue, StackCipher}; +use stack_encrypt::{nonempty, CipherText, Encrypt, SealedValue, StackCipher, StackCipherBuilder}; 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 stack_encrypt_guest::status::{ + STATUS_AUTH, STATUS_ENCODING, STATUS_FOREIGN_KEYSET, STATUS_KMS_OTHER, }; -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`. @@ -41,53 +35,21 @@ fn block_on(f: F) -> F::Output { // 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>, - keyset_id: Option, - unverified_context: Option>, - ) -> Result, 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>, - keyset_id: Option, - unverified_context: Option<&UnverifiedContext>, - ) -> Result, 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, - ) -> Result<(Uuid, stack_kms::IndexKey), stack_kms::Error> { - self.inner.load_index_key(keyset_id).await - } +/// A cipher over the shared fake registry. Its provider counts the batched +/// calls it serves, so the tests can assert the batching contract ("one +/// `generate_keys` per invocation") instead of trusting it. +fn cipher() -> StackCipher { + block_on( + StackCipherBuilder::new() + .registry(FakeKeysetRegistry::new()) + .init(), + ) + .expect("build cipher") } -fn cipher() -> StackCipher { - block_on(StackCipher::builder().kms(Counting::default()).init()).expect("build cipher") +/// The provider serving a cipher's default keyset: the call counter. +fn provider(cipher: &StackCipher) -> FakeProvider { + cipher.registry().default_keyset().1 } fn encode(value: FfiValue) -> Vec { @@ -233,8 +195,8 @@ fn value_round_trips_through_the_guest_ops() { 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); + assert_eq!(provider(&cipher).call_counts().0, 1); + assert_eq!(provider(&cipher).call_counts().1, 1); } #[test] @@ -307,9 +269,10 @@ fn wrong_aad_and_malformed_inputs_map_to_statuses() { )) .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`.) + // Wrong AAD: the fake provider binds the descriptor, as ZeroKMS does, + // so the retrieve is refused before the AEAD runs. The guest downcasts + // a `stack_kms::Error` to classify it precisely; the fake's error is not + // one, so it reports the unclassified backend failure. assert_eq!( block_on(ops::decrypt_value( Scope::Client(&cipher), @@ -317,7 +280,7 @@ fn wrong_aad_and_malformed_inputs_map_to_statuses() { b"other", false )), - Err(STATUS_AUTH) + Err(STATUS_KMS_OTHER) ); // Garbage transport bytes on either path: encoding. assert_eq!( @@ -397,7 +360,7 @@ fn an_empty_aad_round_trips_on_the_value_paths() { b"ctx", as_element )), - Err(STATUS_AUTH), + Err(STATUS_KMS_OTHER), "element: {as_element}" ); } @@ -428,7 +391,7 @@ fn an_empty_aad_round_trips_on_the_value_paths() { b"ctx", false )), - Err(STATUS_AUTH) + Err(STATUS_KMS_OTHER) ); } @@ -544,8 +507,8 @@ fn guest_terms_match_the_native_sem_derivations() { 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); + assert_eq!(provider(&cipher).call_counts().0, 0); + assert_eq!(provider(&cipher).call_counts().1, 0); } #[test] @@ -627,7 +590,7 @@ fn a_record_batch_encrypts_in_one_call_and_round_trips() { )) .expect("encrypt records"); // Three rows, two ciphertext fields each: still exactly one call. - assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 1); + assert_eq!(provider(&cipher).call_counts().0, 1); let pt = block_on(ops::decrypt_record( Scope::Client(&cipher), @@ -635,7 +598,7 @@ fn a_record_batch_encrypts_in_one_call_and_round_trips() { &plan(), )) .expect("decrypt records"); - assert_eq!(cipher.kms().retrieve_calls.load(Ordering::SeqCst), 1); + assert_eq!(provider(&cipher).call_counts().1, 1); let FfiValue::Array(rows) = decode(&pt) else { panic!("expected an array of rows back"); @@ -923,7 +886,7 @@ fn a_natively_sealed_field_under_an_extended_context_opens_through_a_plan() { &record, &plan_with(s("users/age")) )), - Err(STATUS_AUTH), + Err(STATUS_KMS_OTHER), "the flat context is not the one it was sealed under" ); } @@ -958,7 +921,7 @@ fn a_structured_plan_context_is_validated_at_parse() { ); } assert_eq!( - cipher.kms().generate_calls.load(Ordering::SeqCst), + provider(&cipher).call_counts().0, 0, "nothing seals under a context that is not one" ); @@ -1058,7 +1021,7 @@ fn record_shape_violations_are_encoding_errors() { } // No data keys were minted for any rejected call. - assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 0); + assert_eq!(provider(&cipher).call_counts().0, 0); } /// An empty plan context is refused at plan-parse time, before anything is @@ -1089,7 +1052,7 @@ fn an_empty_plan_context_is_refused_before_anything_is_sealed() { Err(STATUS_ENCODING) ); assert_eq!( - cipher.kms().generate_calls.load(Ordering::SeqCst), + provider(&cipher).call_counts().0, 0, "a context that could never be decrypted under must not seal" ); @@ -1133,11 +1096,17 @@ fn an_empty_plan_context_is_refused_before_anything_is_sealed() { // Keysets: the scope a call selects // ============================================================================= +/// The provider serving a named keyset. Counters are per keyset now, so a +/// test that crosses keysets must ask each of them. +fn provider_named(cipher: &StackCipher, name: &str) -> FakeProvider { + cipher.registry().keyset(name).expect("registered keyset").1 +} + fn keyset_named<'c>( - cipher: &'c StackCipher, + cipher: &'c StackCipher, name: &str, -) -> stack_encrypt::KeysetCipher<'c, Counting> { - block_on(cipher.keyset(IdentifiedBy::Name(name.to_string().into()))).expect("select keyset") +) -> stack_encrypt::KeysetCipher<'c, FakeKeysetRegistry> { + block_on(cipher.keyset(name)).expect("select keyset") } /// A value sealed under a tenant's keyset opens through that keyset, through @@ -1166,7 +1135,7 @@ fn a_value_opens_under_its_own_keyset_or_any_but_not_another() { )) .expect("any opens"); assert_eq!(text(&decode(&pt)), "x"); - let retrieves = cipher.kms().retrieve_calls.load(Ordering::SeqCst); + let retrieves = provider(&cipher).call_counts().1; assert_eq!( block_on(ops::decrypt_value( @@ -1179,7 +1148,7 @@ fn a_value_opens_under_its_own_keyset_or_any_but_not_another() { "another tenant's keyset must refuse the leaf" ); assert_eq!( - cipher.kms().retrieve_calls.load(Ordering::SeqCst), + provider(&cipher).call_counts().1, retrieves, "the refusal happens before any key is retrieved" ); @@ -1225,12 +1194,19 @@ fn a_mixed_keyset_record_batch_opens_through_any_one_call_per_keyset() { out }; - let before = cipher.kms().retrieve_calls.load(Ordering::SeqCst); + let (acme_p, globex_p) = ( + provider_named(&cipher, "acme"), + provider_named(&cipher, "globex"), + ); + let before = (acme_p.call_counts().1, globex_p.call_counts().1); 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, + ( + acme_p.call_counts().1 - before.0, + globex_p.call_counts().1 - before.1 + ), + (1, 1), "one retrieve per keyset" ); let FfiValue::Array(rows) = decode(&pt) else { @@ -1238,12 +1214,12 @@ fn a_mixed_keyset_record_batch_opens_through_any_one_call_per_keyset() { }; assert_eq!(rows.len(), 2); - let before = cipher.kms().retrieve_calls.load(Ordering::SeqCst); + let before = provider(&cipher).call_counts().1; 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); + assert_eq!(provider(&cipher).call_counts().1, before); } /// Terms derive under the selected keyset's index key: the same probe @@ -1427,7 +1403,7 @@ fn record_validation_refuses_what_encrypt_record_refuses() { ), Ok(()) ); - assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 0); + assert_eq!(provider(&cipher).call_counts().0, 0); } #[test] @@ -1511,5 +1487,5 @@ fn record_tree_validation_refuses_what_decrypt_record_refuses() { "{label} must be refused by the op too" ); } - assert_eq!(cipher.kms().retrieve_calls.load(Ordering::SeqCst), 0); + assert_eq!(provider(&cipher).call_counts().1, 0); } diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index c71f59ffb..149b8df2b 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -599,8 +599,9 @@ func rawInstance(t *testing.T) *Client { } // 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") +// keyset id, key id length, key id, ciphertext) with no real key behind it. +// The same bytes the Rust side pins in `cipher.rs`, so the two cannot drift. +var fixtureLeaf = mustHex("016b65797365742d6669787475726531360300aabbccdeadbeef") func mustHex(s string) []byte { b, err := hex.DecodeString(s) diff --git a/packages/stack-encrypt-derive/Cargo.toml b/packages/stack-encrypt-derive/Cargo.toml index 223cd01bc..44a71aea0 100644 --- a/packages/stack-encrypt-derive/Cargo.toml +++ b/packages/stack-encrypt-derive/Cargo.toml @@ -30,9 +30,9 @@ development = ["stack-encrypt", "tokio"] # The plain-segment rule is copied from stack-encrypt (a proc-macro crate cannot # depend on the crate it serves); its test reads the fixture both suites share. serde_json = { workspace = true } -# 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). The fake key source comes +# The crate-level examples are real doctests, run against the fake keyset +# registry. A dev-dependency cycle back to `stack-encrypt` is the usual shape +# for a derive crate (cf. serde_derive -> serde). The fake registry comes # through stack-encrypt's `test-support` feature, as a user's tests take it, so # these doctests are what exercises that forwarding. stack-encrypt = { path = "../stack-encrypt", features = ["test-support"] } diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs index 1f9b12517..64c6f731f 100644 --- a/packages/stack-encrypt-derive/src/decrypt.rs +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -120,7 +120,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result { }); 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> { + fn decryption<__K: #krate::KeysetRegistry + 'static>(self, __context: Self::Context) -> #krate::target::Decryption<#plaintext, __K> { #body_check #stored #body } })); @@ -141,7 +141,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result { &generics, quote!(#krate::target::DecryptField<__P, __Ctx>), quote! { - fn decryption_field<__K: 'static>(self, context: __Ctx) -> Option<#krate::target::Decryption<__P, __K>> { + fn decryption_field<__K: #krate::KeysetRegistry + 'static>(self, context: __Ctx) -> Option<#krate::target::Decryption<__P, __K>> { Some(>::decryption(self, context.into())) } }, diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index f7e3ad459..d3c17fefe 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -132,7 +132,7 @@ fn struct_derive(input: &DeriveInput, record: &Record, fields: &[&Field]) -> Tok quote!(#krate::target::EncryptFrom<#source>), quote! { type Context = #krate::target::DeclaredContext; - fn encryption<#lifetime, __K: 'static>() -> #krate::target::Encryption<#lifetime, #source, Self, __K, Self::Context> where #source: #lifetime { + fn encryption<#lifetime, __K: #krate::KeysetRegistry + 'static>() -> #krate::target::Encryption<#lifetime, #source, Self, __K, Self::Context> where #source: #lifetime { match Self::plan::<__K>() { ::core::result::Result::Ok(__plan) => __plan .encryption(::core::option::Option::None) @@ -257,7 +257,7 @@ fn value_derive(input: &DeriveInput, record: &Record, fields: &[&Field]) -> Toke quote!(#krate::target::EncryptFrom<#source>), quote! { type Context = #context; - fn encryption<#lifetime, __K: 'static>() -> #krate::target::Encryption<#lifetime, #source, Self, __K, Self::Context> where #source: #lifetime { + fn encryption<#lifetime, __K: #krate::KeysetRegistry + 'static>() -> #krate::target::Encryption<#lifetime, #source, Self, __K, Self::Context> where #source: #lifetime { match Self::plan::<#source>() { ::core::result::Result::Ok(__plan) => #body, ::core::result::Result::Err(__error) => #krate::target::Encryption::failed(__error), diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index 99018bf47..3c33087eb 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -6,7 +6,7 @@ //! ``` //! use stack_encrypt::{EncryptFrom, DecryptInto, StackCipher, StackCipherText, nonempty}; //! use stack_encrypt::sem::EqualityTerm; -//! use stack_encrypt::kms::FakeDataKeySource; +//! use stack_encrypt::registry::fake::FakeKeysetRegistry; //! //! #[derive(EncryptFrom, DecryptInto)] //! #[stash(plaintext = String)] @@ -18,7 +18,10 @@ //! } //! //! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { -//! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! let cipher = stack_encrypt::StackCipherBuilder::new() +//! .registry(FakeKeysetRegistry::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`. @@ -111,7 +114,7 @@ //! CipherText, Decryptable, Encrypt, EncryptFrom, Encryption, Error, SealedValue, StackCipher, //! nonempty, //! }; -//! use stack_encrypt::kms::FakeDataKeySource; +//! use stack_encrypt::registry::fake::FakeKeysetRegistry; //! //! /// A column that stores one sealed leaf as bytes. //! struct LeafBytes(Vec); @@ -140,7 +143,7 @@ //! // It seals under the AEAD half of the context the record threads to it. //! impl EncryptFrom for LeafBytes { //! type Context = CallerContext; -//! fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> +//! fn encryption<'s, K: stack_encrypt::KeysetRegistry + 'static>() -> Encryption<'s, S, Self, K, Self::Context> //! where //! S: 's, //! { @@ -159,7 +162,10 @@ //! } //! //! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { -//! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! let cipher = stack_encrypt::StackCipherBuilder::new() +//! .registry(FakeKeysetRegistry::new()) +//! .init() +//! .await?; //! let keyset = cipher.default_keyset(); //! let value = "alice@example.com".to_owned(); //! let context = nonempty!("users/email"); diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index ea9c805d6..d6743dfae 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -17,7 +17,9 @@ license-file = "LICENSE" # `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", version = "0.1.0", default-features = false, features = ["profile"] } +stack-kms = { path = "../stack-kms", version = "0.1.0", default-features = false, features = [ + "profile", +], optional = true } # `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 } @@ -36,6 +38,13 @@ vitaminc-encrypt = { workspace = true } vitaminc-hmac = { workspace = true } vitaminc-prf = { workspace = true } vitaminc-protected = { workspace = true } +# The key-provider traits a `KeysetRegistry`'s provider implements. Pinned to +# the same git rev as `stack-kms`'s copy: a differently-sourced dependency is +# a different type at compile time, so the two must resolve to one package. +vitaminc-kms = { git = "https://github.com/cipherstash/vitaminc", rev = "9cb1965", default-features = false } +# The `Protected` those traits name. Same rev, same reason — see the comment +# on the identical pair in `stack-kms/Cargo.toml`. +vitaminc-protected-kms = { package = "vitaminc-protected", git = "https://github.com/cipherstash/vitaminc", rev = "9cb1965" } serde = { workspace = true } # `Descriptor`: the base64 rendering of a non-textual context for ZeroKMS. base64ct = { version = "1.7", features = ["alloc"] } @@ -46,34 +55,66 @@ uuid = { workspace = true } zeroize = { workspace = true } [features] -default = ["http"] +default = ["http", "zerokms"] +# `impl KeysetRegistry for Arc`: ZeroKMS as a source of keysets. +# The trait itself is backend-neutral and always available; this is the one +# backend whose implementation this crate carries, because the orphan rule +# puts `impl KeyProvider for ZeroKmsKeyset` in `stack-kms` and this impl — +# local trait, foreign type — here. +# +# Nothing outside this feature reaches for `stack-kms`, which is what the +# backend-neutral claim means in dependency terms: with it off, no ZeroKMS +# code is in the graph at all. +# +# It needs `stack-auth` for the bounds `StackKms` is generic over, but not +# `stack-auth/http` — that is the `http` feature's job, and pulling a +# transport in here would break the WASI gate. +zerokms = ["dep:stack-kms", "dep:stack-auth"] +# `stack_encrypt::registry::fake`: many keysets, no network. Downstream +# crates (the WASI guest's native tests) build a cipher on it; it pulls in +# vitaminc-kms's own `FakeKeyProvider`. A caller's dev-dependency on +# stack-encrypt with this feature is enough: no direct stack-kms or +# vitaminc-kms dependency has to agree with this crate's. +test-support = ["vitaminc-kms/test-support"] # `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"] +# in stack-auth. Off, a cipher is built over an explicit registry +# (`StackCipherBuilder::new().registry(..)`) and no HTTP client or TLS stack +# is in the dependency graph — the shape the WASI/wazero guest builds against. +# +# Implies `zerokms`: the environment it builds from is a ZeroKMS one. +http = ["zerokms", "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"] -# `stack_encrypt::kms::FakeDataKeySource`: the in-memory key source for tests -# with no ZeroKMS, so a caller's dev-dependency on stack-encrypt is enough and -# no direct stack-kms dependency has to agree with this crate's. -test-support = ["stack-kms/test-support"] [dev-dependencies] +# The crate under test, with its own `test-support` on: integration tests +# link the library as a dependency, and `cargo test` does not turn a crate's +# features on for its own test binaries. +stack-encrypt = { path = ".", features = ["test-support"] } serde_json = { workspace = true } -# `default-features = false` here too: dev-dependency features unify into the +# For `examples/zerokms_auth.rs`, which names the ZeroKMS client directly, and +# for `tests/registry_zerokms.rs`: `test-support` is the in-memory +# `ZeroKMSConnection` that lets `impl KeysetRegistry for Arc` — which +# the orphan rule puts in *this* crate — be tested without a network. +# `default-features = false`: 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"] } +# reqwest back into the "no HTTP" gate (`wasm:no-http-test`). +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 } +# `StaticTokenStrategy`, the auth double `tests/registry_zerokms.rs` builds its +# `StackKms` over. `default-features = false` for the no-HTTP gate, as above. +stack-auth = { workspace = true, default-features = false, features = [ + "test-utils", +] } tokio = { workspace = true, features = ["rt", "macros"] } # Compile-fail tests for the derive diagnostics (`tests/ui`). trybuild = "1" 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 index cda61fae9..6092dbd4a 100644 --- 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 @@ -39,6 +39,40 @@ mark. Here it is safe, and a bump would have bought nothing: 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. +### Amendment, 2026-09-24: the same reasoning, applied a second time + +CIP-4140 changed the layout again, and again left `FORMAT_VERSION` at `0x01`. +The ZeroKMS-shaped `iv` (16 bytes) and `tag` (`u16` length + bytes) became one +opaque `key_id` (`u16` length + bytes): + +```text +version(0x01) ‖ keyset_id:16 ‖ key_id_len:u16 ‖ key_id ‖ ciphertext +``` + +with the leaf AAD `PAE("stack-encrypt/leaf", version, keyset_id, derived_aad, +key_id)`. + +The reason for the field is ADR-0007: a `KeyProvider`'s `KeyId` is **opaque**. +A ZeroKMS key id happens to be an `(iv, tag)` pair, but an AWS or Vault one is +not, and the envelope must not know. Splitting the provider's own id into +ZeroKMS's two parts would have been generic-looking code that silently +corrupted leaves on every other backend. + +The three reasons above hold unchanged, and the first is still the load-bearing +one: the crate is unpublished and nothing it produces is stored anywhere. The +second is if anything stronger here — `key_id` is bound into the leaf AAD in +the position `tag` used to hold, so an old-layout leaf fails authentication +rather than parsing its `iv` as a length prefix. + +The keyset id stays in the envelope rather than moving inside `key_id`, for a +reason that only appears once the backend is a variable: `decrypt` must know +**which backend to ask** before it can parse a blob that only that backend +defines. Routing metadata cannot live inside the thing it routes. + +This is the second and last spend. The rule above is unchanged — the next +layout change, after the first release that stores leaves, must bump the +version byte. + ## 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 diff --git a/packages/stack-encrypt/docs/adr/0007-keysets-resolve-through-a-registry-this-crate-owns.md b/packages/stack-encrypt/docs/adr/0007-keysets-resolve-through-a-registry-this-crate-owns.md new file mode 100644 index 000000000..49942cc49 --- /dev/null +++ b/packages/stack-encrypt/docs/adr/0007-keysets-resolve-through-a-registry-this-crate-owns.md @@ -0,0 +1,128 @@ +--- +status: accepted +date: 2026-09-24 +--- + +# Keysets resolve through a registry this crate owns, and a key provider is bound to one backend key + +CIP-3965 moves `stack-encrypt` onto `vitaminc-kms`'s key-provider traits, so +that ZeroKMS becomes one backend among several. The question that decides the +shape of everything else is where the **keyset** lives. + +A keyset is a CipherStash concept. Vitamin C is a general-purpose cryptography +suite. An earlier proposal put a keyset-to-provider factory inside +`vitaminc-kms`, so that one provider could serve many keysets. This ADR records +why that was refused and what replaced it. + +## Decision + +**`vitaminc-kms` knows nothing about keysets.** A `KeyProvider` is bound at +construction to **one backend key** — a KMS key ARN, a Key Vault key name, a +Transit key name, a `CryptoKey`, or a ZeroKMS keyset. It has no per-call +selector. Selecting *which* backend key to use is the caller's business, and +the caller here is this crate. See cipherstash/vitaminc ADR-0003 and ADR-0004. + +**`stack-encrypt` owns the keyset boundary**, through `KeysetRegistry`: + +```rust +pub trait KeysetRegistry { + type Provider: KeyProvider<32> + IndexKeyProvider<32>; + type Error: std::error::Error + Send + Sync + 'static; + + fn resolve(&self, keyset: &KeysetRef) + -> impl Future>, Self::Error>> + MaybeSend; +} +``` + +`StackCipher` keeps the LRU and the name TTL that ADR-0002 +describes, so every backend inherits one tested implementation of them. A +registry does no caching of its own. + +**`impl KeysetRegistry for Arc` lives here**, behind a `zerokms` +feature, because the orphan rule leaves it nowhere else: `impl KeyProvider for +ZeroKmsKeyset` is a foreign trait on a local type and must be in `stack-kms`; +this one is a local trait on a foreign type and must be here. + +## Why the boundary is free + +The obvious objection is that a provider bound to one keyset must cost a round +trip per keyset, where a provider taking a keyset per call would not. + +It does not, because **ZeroKMS's own protocol carries `keyset_id` once per +request, not per payload.** `RetrieveKeyRequest` and `GenerateKeyRequest` each +hold one `keyset_id: Option`, which the server resolves to one +authority key for the whole batch through `get_authority_key`; `None` means the +client's default keyset. `RetrieveKeySpec` — the per-payload part — carries no +keyset id at all. + +So the per-call `keyset_id: Option` on the trait this replaced was never +a selector. It was a per-request constant the caller had already fixed before +it built the batch, and `main` already fanned out one `retrieve_keys` call per +distinct keyset. + +A provider bound at construction is therefore a **more faithful** model of the +wire protocol than the trait it replaces, and costs zero extra round trips. The +boundary this ADR draws is not a compromise paid for with latency; it is the +protocol's own shape. + +## What a keyset is when the backend is not ZeroKMS + +On ZeroKMS a keyset is a service-side object with an id, an optional name, and +a root from which the index key derives, and `load_keyset` returns all of it in +one round trip. + +Elsewhere there is no such object, so a keyset is a **deployment config entry**: +a backend key handle plus the persisted `KeyId` of that key's index key, +resolved from a static map. `vitaminc-kms`'s `FixedIndexKeySource` already +pairs exactly those two things, which is why this change needs no new vitaminc +code. A single-entry map is the common case — one backend key, one keyset, +`KeysetRef::Default`. + +**Every keyset must have an index key, loaded eagerly at resolution.** On +ZeroKMS it arrives in the same `load_keyset` round trip, so this is free; a +vendor registry must be configured with one. A keyset that resolved without an +index key would fail at the first *query* rather than at resolution, and a +freshly minted index key makes every term already written under the old one +unfindable. Neither failure is one a caller can act on where it appears. + +## Consequences + +- **`resolve` returns `Result, _>`, not `Result<_, _>`.** The three + outcomes are distinct because the name cache treats them differently. + `Ok(Some(_))` is an answer. `Ok(None)` is *also* an answer — a definite "no + such keyset" — and it unbinds a name the cache had bound, so a stale binding + cannot outlive the registry's own denial. `Err(_)` is **not** an answer: a + transport failure says nothing about the name and leaves the cache as it was. + Folding the negative into `Self::Error` would put that distinction out of + reach, since the cache cannot name a backend-specific error variant. + +- **`Provider` is an associated type, not a boxed one.** `KeyProvider` is + generic over `const N` and returns `impl Future`, so it is not + dyn-compatible and there is no `Box` to hand back. Every FFI + consumer must therefore name one concrete registry type. The WASI guest names + `Arc>`. + +- **`stack-kms` is an optional dependency of `stack-encrypt`.** With `zerokms` + off, `cargo tree` carries no ZeroKMS crate at all. That is what makes the + backend-neutral claim checkable rather than aspirational, and it is why + `Descriptor::MAX_LEN` is spelled here rather than imported from the ZeroKMS + protocol (with a `zerokms`-gated test pinning the two equal). + +- **No gating on `BINDING`, `ISOLATION` or `RECONSTRUCTION`.** A deployment + that requires a `Bound` backend asserts on the constant itself. Refusing to + build a cipher over an `Unbound` provider would make the traits' own + capability constants a policy this crate enforces on everyone. + +- **The keyset stays the whole merge rule.** Two `Pending`s merge when they + name the same keyset. Provider *instance* identity is deliberately not the + rule: two ciphers built separately over one keyset must merge, and a registry + is free to hand out a fresh provider value per resolution. + +- **A keyset the registry cannot resolve fails the whole batch** + (`Error::UnknownKeyset`), matching ZeroKMS's all-or-nothing batch semantics. + +## Not decided here + +Backend key rotation for vendor keysets. `CachingKeyProvider`, which would +rarely hit against `PerValue` isolation and would drop the per-retrieval audit +entry against a `Bound` backend. diff --git a/packages/stack-encrypt/examples/encrypted_record.rs b/packages/stack-encrypt/examples/encrypted_record.rs index d77e72940..17b7faf72 100644 --- a/packages/stack-encrypt/examples/encrypted_record.rs +++ b/packages/stack-encrypt/examples/encrypted_record.rs @@ -192,7 +192,7 @@ async fn main() -> Result<(), Box> { println!( "opening under another id: {}", match wrong_id { - Err(Error::Kms(e)) => format!("refused by ZeroKMS ({e})"), + Err(Error::Provider(e)) => format!("refused by ZeroKMS ({e})"), Err(Error::Aead) => "refused (AEAD)".to_owned(), _ => "unexpected".to_owned(), } diff --git a/packages/stack-encrypt/examples/zerokms_auth.rs b/packages/stack-encrypt/examples/zerokms_auth.rs index 9da02ab4c..3853f0c47 100644 --- a/packages/stack-encrypt/examples/zerokms_auth.rs +++ b/packages/stack-encrypt/examples/zerokms_auth.rs @@ -16,6 +16,7 @@ use stack_auth::{AuthError, AuthStrategyFn, SecretToken, ServiceToken}; use stack_encrypt::kms::{EnvKeyProvider, StackKmsBuilder}; use stack_encrypt::StackCipher; +use stack_encrypt::StackCipherBuilder; #[tokio::main(flavor = "current_thread")] async fn main() -> Result<(), Box> { @@ -46,7 +47,7 @@ async fn main() -> Result<(), Box> { // 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)) => { + Err(stack_encrypt::Error::Provider(why) | stack_encrypt::Error::Registry(why)) => { println!("could not reach or authenticate with ZeroKMS: {why}"); println!("check the credentials `auto()` detected (CS_* variables, ~/.cipherstash)."); return Ok(()); @@ -111,7 +112,10 @@ async fn main() -> Result<(), Box> { .build() .await?; - let _cipher = StackCipher::builder().kms(kms).init().await?; + let _cipher = StackCipherBuilder::new() + .registry(std::sync::Arc::new(kms)) + .init() + .await?; println!("built a second cipher over a custom auth strategy"); Ok(()) diff --git a/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs b/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs index 13c7d67ff..17e77bc8f 100644 --- a/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs +++ b/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs @@ -152,8 +152,8 @@ enum Tree { fn leaf() -> SealedValue { static LEAF: OnceLock = 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") + SealedValue::from_parts(Uuid::nil().into(), vec![0; 64], vec![1; 32]) + .expect("a fixed key id fits the length field") }) .clone() } diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 6a7024f6e..11f0b3d5e 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -67,7 +67,7 @@ //! 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)) +//! binding); a caller AAD (e.g. a [`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 @@ -89,21 +89,23 @@ use std::num::NonZeroUsize; use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; +use crate::target::ProviderKey; 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, + Cipher, CipherText, Context, ContextTag, Decipher, DecipherVisitor, Decrypt, Encrypt, IntoAad, + IntoContext, LocalCipherText, MapAccess, MapCipher, SeqAccess, SeqCipher, Unspecified, }; use vitaminc_encrypt::{Aes256Cipher, AesCipherText, Key as AesKey}; +use vitaminc_kms::GeneratedDataKey; use vitaminc_protected::{Controlled, Protected}; use crate::keyset::{KeysetCache, KeysetCipher, KeysetState, Lookup, DEFAULT_NAME_TTL}; +use crate::registry::{KeysetId, KeysetRef, KeysetRegistry, Resolved}; use crate::Descriptor; /// The passthrough payload type: type-erased, as for Rust-native vitaminc @@ -122,9 +124,28 @@ pub type StackCipherText = CipherText; #[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), + /// A key provider failed to mint or retrieve a key. Boxed because the + /// error type is the provider's, not this crate's — on ZeroKMS it is a + /// `stack_kms::Error`, which a caller that knows its own backend can + /// recover by downcasting. + #[error("the key provider failed: {0}")] + Provider(#[source] Box), + + /// The registry could not resolve a keyset — it gave no answer, as + /// opposed to answering that there is no such keyset. Boxed because the + /// error type is the registry's, not this crate's. + #[error("the keyset registry failed: {0}")] + Registry(#[source] Box), + + /// The registry answered that this deployment has no such keyset. + /// + /// Distinct from [`ForeignKeyset`](Self::ForeignKeyset), which is a + /// caller holding the wrong handle for a leaf. This one is a keyset the + /// deployment does not have at all: a ZeroKMS keyset that was deleted, + /// or vendor configuration that never named it. It fails the whole + /// batch, matching the all-or-nothing semantics of a key retrieval. + #[error("no such keyset: {keyset}")] + UnknownKeyset { keyset: String }, /// 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. @@ -195,14 +216,14 @@ pub enum Error { /// 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 }, + KeysetMismatch { left: KeysetId, right: KeysetId }, /// 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 }, + ForeignKeyset { expected: KeysetId, found: KeysetId }, /// 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 @@ -240,9 +261,9 @@ impl From for Error { } } -/// The CipherStash cipher, scoped to one client: a ZeroKMS client (a -/// [`DataKeySource`] — production: [`kms::StackKms`](crate::kms::StackKms); tests: -/// `stack_encrypt::kms::FakeDataKeySource`) and the keysets that client uses. +/// The CipherStash cipher, scoped to one client: a [`KeysetRegistry`] — +/// in production an `Arc`, in tests +/// `registry::fake::FakeKeysetRegistry` — and the keysets it resolves. /// /// Per-leaf keying is deliberate: every value access requires its own data-key /// retrieval, so individual value accesses are visible (and auditable) as @@ -281,28 +302,28 @@ environment, on that client's default keyset: ```no_run # async fn example() -> Result<(), stack_encrypt::Error> { -use stack_encrypt::StackCipher; +use stack_encrypt::{StackCipher, StackCipherBuilder}; let cipher = StackCipher::new().await?; # Ok(()) # } ``` -Override with [`builder`](Self::builder) — a different keyset, or a +Override with [`StackCipherBuilder`] — a different registry, or a different data-key source entirely:"# )] #[cfg_attr( not(feature = "http"), - doc = "Build with [`builder`](Self::builder), over an explicit data-key source:" + doc = "Build with [`StackCipherBuilder`], over an explicit keyset registry:" )] /// /// ``` /// # async fn example() -> Result<(), stack_encrypt::Error> { -/// use stack_encrypt::StackCipher; -/// use stack_encrypt::kms::FakeDataKeySource; +/// use stack_encrypt::{StackCipher, StackCipherBuilder}; +/// use stack_encrypt::registry::fake::FakeKeysetRegistry; /// -/// let cipher = StackCipher::builder() -/// .kms(FakeDataKeySource::new()) +/// let cipher = StackCipherBuilder::new() +/// .registry(FakeKeysetRegistry::new()) /// .init() /// .await?; /// # Ok(()) @@ -316,40 +337,26 @@ different data-key source entirely:"# /// 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 { - kms: K, +pub struct StackCipher { + registry: R, /// 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, + default: Arc>, /// Every other keyset this cipher has selected, least recently used /// first out. See [`keyset`](Self::keyset). - keysets: Mutex, + keysets: Mutex>, } #[cfg(feature = "http")] -impl StackCipher> { +impl StackCipher>> { /// 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). + /// Equivalent to `StackCipherBuilder::new().init()`. For a different + /// keyset registry, use [`StackCipherBuilder`]. pub async fn new() -> Result { - StackCipher::builder().init().await - } -} - -impl StackCipher { - /// 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` - /// purely so `StackCipher::builder()` names a single concrete `K` and - /// resolves without a type annotation; `StackCipher` is never - /// constructed. - pub fn builder() -> StackCipherBuilder { - StackCipherBuilder::new() + StackCipherBuilder::new().init().await } } @@ -358,17 +365,17 @@ impl StackCipher { /// 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 std::fmt::Debug for StackCipher { +impl std::fmt::Debug for StackCipher { 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::()) + .field("registry", &std::any::type_name::()) .finish_non_exhaustive() } } -impl StackCipher { +impl StackCipher { /// 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. @@ -377,16 +384,16 @@ impl StackCipher { /// 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> { + pub fn default_keyset(&self) -> KeysetCipher<'_, R> { KeysetCipher::new(self, Arc::clone(&self.default)) } - /// The underlying data-key source. - pub fn kms(&self) -> &K { - &self.kms + /// The registry this cipher resolves keysets through. + pub fn registry(&self) -> &R { + &self.registry } - fn keysets(&self) -> std::sync::MutexGuard<'_, KeysetCache> { + 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 @@ -395,7 +402,7 @@ impl StackCipher { } } -impl StackCipher { +impl StackCipher { /// The cipher bound to a keyset, by id or by name. /// /// The one async point of keyset selection: a keyset this cipher has @@ -407,35 +414,45 @@ impl StackCipher { /// /// ``` /// # async fn example() -> Result<(), stack_encrypt::Error> { - /// use stack_encrypt::{nonempty, StackCipher}; - /// use stack_encrypt::kms::{FakeDataKeySource, IdentifiedBy}; + /// use stack_encrypt::{nonempty, StackCipherBuilder}; + /// use stack_encrypt::registry::fake::FakeKeysetRegistry; /// - /// let cipher = StackCipher::builder() - /// .kms(FakeDataKeySource::new()) + /// let cipher = StackCipherBuilder::new() + /// .registry(FakeKeysetRegistry::new()) /// .init() /// .await?; - /// let tenant = cipher.keyset(IdentifiedBy::Name("acme".to_string().into())).await?; + /// let tenant = cipher.keyset("acme").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 + /// A keyset the registry does not know is [`Error::UnknownKeyset`]; a + /// registry that could not answer at all is [`Error::Registry`]. The + /// name-to-id resolution is the registry'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( + pub async fn keyset(&self, keyset: impl Into) -> Result, Error> { + let state = self.keyset_state(&keyset.into()).await?; + Ok(KeysetCipher::new(self, state)) + } + + /// The loaded state for one keyset: its provider, its PRF and its + /// identity. [`keyset`](Self::keyset) is this plus the handle wrapper; + /// the batching layer wants the state alone, for a keyset a stored leaf + /// named rather than one a caller selected. + pub(crate) async fn keyset_state( &self, - keyset: impl Into, - ) -> Result, Error> { - let keyset = keyset.into(); + keyset: &KeysetRef, + ) -> Result>, Error> { + let keyset = keyset.clone(); let resolution = match self.keysets().get(&keyset) { - Lookup::Hit(state) => return Ok(KeysetCipher::new(self, state)), + Lookup::Hit(state) => return Ok(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, @@ -445,12 +462,19 @@ impl StackCipher { // 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, + let asked_name = keyset.name().map(str::to_owned); + let state = match load_keyset(&self.registry, &keyset).await { + Ok(Some(state)) => state, + // The registry's own answer that no keyset has this name is an + // answer about the name, so the binding goes. + Ok(None) => { + if let Some(name) = &asked_name { + self.keysets().forget(name, resolution); + } + return Err(Error::UnknownKeyset { + keyset: format!("{keyset:?}"), + }); + } 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: @@ -458,14 +482,21 @@ impl StackCipher { // 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)) + Ok(self.keysets().insert(state, resolution)) + } + + /// The provider that serves `id`, loading the keyset if the cache has + /// evicted it. A stored leaf names its keyset by id, so this is the + /// path a decrypt takes; a caller's own selection goes through + /// [`keyset`](Self::keyset). + pub(crate) async fn provider_for( + &self, + id: KeysetId, + ) -> Result>, Error> { + self.keyset_state(&KeysetRef::Id(id)).await } } @@ -475,44 +506,58 @@ impl StackCipher { /// 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( + registry: &R, + keyset: &KeysetRef, +) -> Result>>, Error> { + let Some(resolved) = registry + .resolve(keyset) + .await + .map_err(|error| Error::Registry(Box::new(error)))? + else { + return Ok(None); + }; + Ok(Some(Arc::new(state_from(resolved).await?))) } -async fn load_keyset( - kms: &K, - keyset: IdentifiedBy, -) -> Result, 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), - })) +/// Build the cipher's per-keyset state from what the registry resolved: +/// its identity, the name it was asked for, the provider, and the PRF keyed +/// by the index key that provider serves. +/// +/// The index key is loaded here, eagerly, rather than on the first query. +/// On ZeroKMS it arrived in the same round trip that resolved the keyset, +/// so it is free; everywhere else a keyset that cannot serve one fails now, +/// loudly, instead of making every term written under it unfindable later. +async fn state_from

(resolved: Resolved

) -> Result, Error> +where + P: vitaminc_kms::provider::IndexKeyProvider<32>, +{ + let index_key = resolved + .provider + .load_index_key() + .await + .map_err(|error| Error::Registry(Box::new(error)))?; + + Ok(KeysetState { + id: resolved.id, + name: resolved.name, + prf: hmac_prf_from_index_key_material(index_key), + provider: resolved.provider, + }) } /// 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(kms: &K) -> Result, 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), - })) +async fn load_default_keyset( + registry: &R, +) -> Result>, Error> { + load_keyset(registry, &KeysetRef::Default) + .await? + .ok_or_else(|| Error::UnknownKeyset { + keyset: "the client's default keyset".to_owned(), + }) } /// The state of a [`StackCipherBuilder`] that has not been given a data-key @@ -559,9 +604,9 @@ impl KeyProvider for ProfileClientKey { } /// Builder for a [`StackCipher`]. Start with [`StackCipherBuilder::new`] (or -/// its alias [`StackCipher::builder`]). -pub struct StackCipherBuilder { - kms: K, +/// the canonical entry point). +pub struct StackCipherBuilder { + registry: R, cache_size: NonZeroUsize, name_ttl: Duration, } @@ -574,8 +619,8 @@ impl StackCipherBuilder { /// annotation whether or not the `http` feature is on. pub fn new() -> Self { Self { - kms: FromEnv, - cache_size: KeysetCache::DEFAULT_CAPACITY, + registry: FromEnv, + cache_size: KeysetCache::<()>::DEFAULT_CAPACITY, name_ttl: DEFAULT_NAME_TTL, } } @@ -587,7 +632,7 @@ impl Default for StackCipherBuilder { } } -impl StackCipherBuilder { +impl StackCipherBuilder { /// 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 @@ -614,16 +659,17 @@ impl StackCipherBuilder { } impl StackCipherBuilder { - /// Use an explicit data-key source rather than building a ZeroKMS client + /// Use an explicit keyset registry rather than building a ZeroKMS client /// from the environment. /// /// This is the seam for a custom authentication strategy: build a - /// [`kms::StackKms`](crate::kms::StackKms) — with `stack_encrypt::kms::StackKmsBuilder`, - /// or over the host's own transport — and hand it over. It is also how - /// tests inject `stack_encrypt::kms::FakeDataKeySource`. - pub fn kms(self, kms: K) -> StackCipherBuilder { + /// `stack_encrypt::kms::StackKms` — with `stack_encrypt::kms::StackKmsBuilder`, or over + /// the host's own transport — and hand it over. It is also the seam for + /// a non-ZeroKMS backend, and how tests inject + /// `stack_encrypt::registry::fake::FakeKeysetRegistry`. + pub fn registry(self, registry: R) -> StackCipherBuilder { StackCipherBuilder { - kms, + registry, cache_size: self.cache_size, name_ttl: self.name_ttl, } @@ -644,24 +690,24 @@ impl StackCipherBuilder { /// 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>, Error> { + pub async fn init(self) -> Result>>, Error> { let kms = StackKmsBuilder::auto()? .with_key_provider(client_key_provider()) .build() .await?; - self.kms(kms).init().await + self.registry(std::sync::Arc::new(kms)).init().await } } -impl StackCipherBuilder { +impl StackCipherBuilder { /// 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, Error> { - let default = load_default_keyset(&self.kms).await?; + pub async fn init(self) -> Result, Error> { + let default = load_default_keyset(&self.registry).await?; Ok(StackCipher { - kms: self.kms, + registry: self.registry, keysets: Mutex::new(KeysetCache::new( self.cache_size, self.name_ttl, @@ -673,20 +719,27 @@ impl StackCipherBuilder { } /// Build the local HMAC-SHA256 PRF from a per-keyset -/// [`IndexKey`](crate::kms::IndexKey), wiping the intermediate stack copy of the +/// index key, wiping the intermediate stack copy of the /// raw key bytes. -fn hmac_prf_from_index_key(index_key: &stack_kms::IndexKey) -> vitaminc_hmac::HmacSha256Prf { +/// +/// The unwrap is the `vitaminc-protected` boundary: the material arrives in +/// the git-sourced `Protected` that `vitaminc-kms` was built against, and is +/// re-wrapped in this crate's own before it goes anywhere. +fn hmac_prf_from_index_key_material( + index_key: vitaminc_kms::IndexKeyMaterial<32>, +) -> vitaminc_hmac::HmacSha256Prf { + use vitaminc_protected_kms::Controlled as _; 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 mut key = index_key.0.risky_unwrap(); let prf = ::new(Protected::new(key)); key.zeroize(); prf } -impl KeysetCipher<'_, K> { +impl KeysetCipher<'_, R> { /// 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`. @@ -710,6 +763,21 @@ impl KeysetCipher<'_, K> { { decrypt_through(self, ciphertext, aad).await } + + /// [`StackCipher::decrypt_tagged`], constrained to this keyset. + pub async fn decrypt_tagged<'a, T, Tag, A>( + &self, + ciphertext: StackCipherText, + context: ContextTag, + aad: A, + ) -> Result + where + T: Decrypt<'static> + 'static, + Tag: IntoAad<'static>, + A: IntoAad<'a>, + { + decrypt_tagged_through(self, ciphertext, context, aad).await + } } /// Retrieve every leaf's data key under `aad`'s descriptor and bind them @@ -726,7 +794,7 @@ impl KeysetCipher<'_, K> { /// 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>( +async fn decipher_through<'s, 'a, K: KeysetRegistry + 's>( scope: impl crate::target::CipherScope<'s, K>, ciphertext: StackCipherText, aad: impl IntoContext<'a>, @@ -738,7 +806,7 @@ async fn decipher_through<'s, 'a, K: DataKeySource + 's>( /// `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>( +async fn decrypt_through<'s, 'a, T, K: KeysetRegistry + 's>( scope: impl crate::target::CipherScope<'s, K>, ciphertext: StackCipherText, aad: impl IntoContext<'a>, @@ -751,7 +819,32 @@ where T::decrypt_with_aad(decipher, context.into_aad()).map_err(Error::from) } -impl StackCipher { +/// `decrypt_tagged`, for either scope: [`decipher_through`] under `aad` +/// alone, then the tag folded in at the [`Decipher`] — which is where the +/// [`Encrypt`] side folds it in too. +/// +/// The descriptor is rendered from `aad` and **not** from the tag, on both +/// sides. That is what makes the round trip work: a key minted under +/// `Descriptor::of(aad)` is retrieved under the same one, while the tag +/// binds the ciphertext at the AEAD, one layer above. See CIP-4144. +async fn decrypt_tagged_through<'s, 'a, T, Tag, K: KeysetRegistry + 's>( + scope: impl crate::target::CipherScope<'s, K>, + ciphertext: StackCipherText, + context: ContextTag, + aad: impl IntoAad<'a>, +) -> Result +where + T: Decrypt<'static> + 'static, + Tag: IntoAad<'static>, +{ + let aad = aad.into_context(); + let decipher = decipher_through(scope, ciphertext, aad.clone()).await?; + context + .decrypt_with_aad(decipher, aad.into_aad()) + .map_err(Error::from) +} + +impl StackCipher { /// Decrypt a [`StackCipherText`] into `T`, authenticating against `aad`. /// /// One batched `retrieve_keys` call per keyset the leaves were sealed @@ -785,6 +878,65 @@ impl StackCipher { { decrypt_through(self, ciphertext, aad).await } + + /// Decrypt a value sealed as a [`ContextTag`], authenticating against + /// the tag **and** `aad`. + /// + /// The decrypt mirror of sealing with + /// `encrypt(ContextTag::new(value, tag), aad)`: build `context` with + /// [`ContextTag::context`] (and [`refine`](ContextTag::refine)) exactly + /// as the tag was built at encrypt time, and pass the same `aad`. + /// + /// ```no_run + /// # async fn example( + /// # cipher: &stack_encrypt::StackCipher, + /// # ) -> Result<(), Box> { + /// use stack_encrypt::ContextTag; + /// + /// let keyset = cipher.default_keyset(); + /// let ct = keyset + /// .encrypt(ContextTag::new("token".to_string(), "user:42"), ()) + /// .await?; + /// + /// let token: String = cipher + /// .decrypt_tagged(ct, ContextTag::context("user:42"), ()) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// # Why this is not `decrypt` + /// + /// A `ContextTag` carries its tag inside the *value*, so at seal time + /// the tag is not part of the `aad` argument. The + /// [`Descriptor`] every data key is minted under is rendered from that + /// argument, and a key minted under one descriptor cannot be retrieved + /// under another. + /// + /// Passing [`ContextTag::aad`] to [`decrypt`](Self::decrypt) therefore + /// fails: it folds the tag into the descriptor on the open side only, + /// and the retrieve is refused before the AEAD is reached. That builder + /// is for driving a cipher's own decrypt entry point directly, not this + /// one. + /// + /// This method keeps the tag out of the descriptor and folds it in at + /// the [`Decipher`] instead — the same place the [`Encrypt`] impl folds + /// it in — so both sides render the descriptor from `aad` alone. The + /// tag still binds the ciphertext: it is a layer *above* the + /// descriptor, as the [module docs](self) describe. + pub async fn decrypt_tagged<'a, T, Tag, A>( + &self, + ciphertext: StackCipherText, + context: ContextTag, + aad: A, + ) -> Result + where + T: Decrypt<'static> + 'static, + Tag: IntoAad<'static>, + A: IntoAad<'a>, + { + decrypt_tagged_through(self, ciphertext, context, aad).await + } } /// A single sealed leaf: the ZeroKMS metadata needed to retrieve its data key @@ -804,14 +956,13 @@ impl StackCipher { /// 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` | +/// | offset | field | size | value | +/// |--------------------|------------------|-----------------|-------| +/// | 0 | envelope version | 1 | [`FORMAT_VERSION`](Self::FORMAT_VERSION) (`0x01`) | +/// | 1 | keyset id | 16 | the keyset the data key was minted under, raw UUID bytes | +/// | 17 | `key_id_len` | 2 | length of `key_id`, `u16` little-endian | +/// | 19 | `key_id` | `key_id_len` | the provider's own handle for the data key, opaque to this crate | +/// | 19 + `key_id_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, @@ -824,16 +975,16 @@ impl StackCipher { /// /// ```text /// ┌─ envelope (stack-encrypt, this table) ──────────────────────────────────────────────┐ -/// │ version ‖ keyset_id ‖ iv ‖ tag_len ‖ tag ‖ ┌─ local ciphertext (vitaminc) ───────────┐ │ -/// │ 0x01 │ version ‖ nonce ‖ ciphertext ‖ gcm_tag │ │ -/// │ └─────────────────────────────────────────┘ │ +/// │ version ‖ keyset_id ‖ key_id_len ‖ key_id ‖ ┌─ 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 +/// `PAE("stack-encrypt/leaf", version, keyset_id, derived_aad, key_id)`, 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 @@ -849,13 +1000,15 @@ impl StackCipher { #[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, + /// The keyset the data key was minted under. It routes the leaf to a + /// provider, so it is the envelope's own field rather than something + /// buried in `key_id`: decrypt must know which backend to ask *before* + /// it can parse a key id only that backend defines. + keyset_id: KeysetId, + /// The provider's opaque handle for this leaf's data key, bound whole + /// into the leaf's AAD. Its encoding is the backend's business — for + /// ZeroKMS it is the IV followed by the key tag. + key_id: Vec, /// The leaf sealed by [`vitaminc_encrypt::Aes256Cipher`] under the data /// key: `version ‖ nonce ‖ ciphertext ‖ gcm_tag`. ciphertext: LocalCipherText, @@ -874,18 +1027,17 @@ pub enum LeafBytesError { /// 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. + /// The buffer ends before the fixed-width fields, or before the key id + /// the `key_id_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), + /// The key id does not fit the format's `u16` length field. Every + /// construction site rejects an oversized id — [`SealedValue::from_parts`] + /// and `serde` deserialisation with this error, and the seal path (where + /// a provider could return one; a ZeroKMS key id is tens of bytes) by + /// failing the encrypt — so a live `SealedValue` always encodes. + #[error("key id of {0} bytes exceeds the format's u16 length field")] + KeyIdTooLong(usize), } impl SealedValue { @@ -900,23 +1052,21 @@ impl SealedValue { /// 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 + /// long for the `u16` length field ([`LeafBytesError::KeyIdTooLong`]), so a /// value that exists always encodes. pub fn to_bytes(&self) -> Vec { - // 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). + // Exact by the `key_id_fits_length_field` check every construction + // site applies — `from_parts`, serde deserialisation, and the seal + // path (which guards against a provider returning an oversized id). // 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 key_id_len = u16::try_from(self.key_id.len()).unwrap_or(u16::MAX); + debug_assert_eq!(usize::from(key_id_len), self.key_id.len()); let ciphertext = self.ciphertext.as_ref(); - let mut out = - Vec::with_capacity(1 + 16 + self.iv.len() + 2 + self.tag.len() + ciphertext.len()); + let mut out = Vec::with_capacity(1 + 16 + 2 + self.key_id.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(self.keyset_id.as_uuid().as_bytes()); + out.extend_from_slice(&key_id_len.to_le_bytes()); + out.extend_from_slice(&self.key_id); out.extend_from_slice(ciphertext); out } @@ -925,39 +1075,37 @@ impl SealedValue { /// [`to_bytes`](Self::to_bytes). Structural only: a decoded leaf is /// untrusted bytes until it decrypts. pub fn from_bytes(bytes: &[u8]) -> Result { - 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 { + if rest.len() < 16 + 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 { + let keyset_id = + KeysetId::new(Uuid::from_slice(keyset_bytes).map_err(|_| LeafBytesError::Truncated)?); + let (key_id_len_bytes, rest) = rest.split_at(2); + let key_id_len = usize::from(u16::from_le_bytes([ + key_id_len_bytes[0], + key_id_len_bytes[1], + ])); + if rest.len() < key_id_len { return Err(LeafBytesError::Truncated); } - let (tag, ciphertext) = rest.split_at(tag_len); + let (key_id, ciphertext) = rest.split_at(key_id_len); Ok(Self { keyset_id, - iv, - tag: tag.to_vec(), + key_id: key_id.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())); + /// the key id must fit the format's `u16` length field. + fn key_id_fits_length_field(key_id: &[u8]) -> Result<(), LeafBytesError> { + if key_id.len() > usize::from(u16::MAX) { + return Err(LeafBytesError::KeyIdTooLong(key_id.len())); } Ok(()) } @@ -965,30 +1113,27 @@ impl SealedValue { /// 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 + /// Fails with [`LeafBytesError::KeyIdTooLong`] if `key_id` 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, + keyset_id: KeysetId, + key_id: Vec, ciphertext: Vec, ) -> Result { - Self::tag_fits_length_field(&tag)?; + Self::key_id_fits_length_field(&key_id)?; Ok(Self { keyset_id, - iv, - tag, + key_id, ciphertext: LocalCipherText::from(ciphertext), }) } - /// Decompose into `(keyset_id, iv, tag, ciphertext)` for persistence. - pub fn into_parts(self) -> (Uuid, stack_kms::Iv, Vec, Vec) { + /// Decompose into `(keyset_id, key_id, ciphertext)` for persistence. + pub fn into_parts(self) -> (KeysetId, Vec, Vec) { ( self.keyset_id, - self.iv, - self.tag, + self.key_id, self.ciphertext.into_inner().to_vec(), ) } @@ -996,18 +1141,13 @@ impl SealedValue { /// 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 { + pub fn keyset_id(&self) -> KeysetId { 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 provider's opaque handle for this leaf's data key. + pub fn key_id(&self) -> &[u8] { + &self.key_id } /// The sealed bytes (`version ‖ nonce ‖ ciphertext ‖ gcm_tag`). @@ -1020,8 +1160,7 @@ impl Clone for SealedValue { fn clone(&self) -> Self { Self { keyset_id: self.keyset_id, - iv: self.iv, - tag: self.tag.clone(), + key_id: self.key_id.clone(), ciphertext: LocalCipherText::from(self.ciphertext.as_ref().to_vec()), } } @@ -1044,9 +1183,8 @@ impl TryFrom<&[u8]> for SealedValue { #[derive(Deserialize)] #[serde(rename = "SealedValue")] struct SealedValueRepr { - keyset_id: Uuid, - iv: stack_kms::Iv, - tag: Vec, + keyset_id: KeysetId, + key_id: Vec, ciphertext: LocalCipherText, } @@ -1056,15 +1194,13 @@ impl TryFrom for SealedValue { fn try_from(repr: SealedValueRepr) -> Result { let SealedValueRepr { keyset_id, - iv, - tag, + key_id, ciphertext, } = repr; - Self::tag_fits_length_field(&tag)?; + Self::key_id_fits_length_field(&key_id)?; Ok(Self { keyset_id, - iv, - tag, + key_id, ciphertext, }) } @@ -1076,10 +1212,10 @@ impl TryFrom for SealedValue { /// [`Decrypt`] impl supplies. pub(crate) struct KeyedLeaf { leaf: SealedValue, - key: DataKey, + key: ProviderKey, } -/// [`StackCipherText`] with a [`DataKey`] zipped onto every keyed leaf. +/// [`StackCipherText`] with one retrieved key zipped onto every keyed leaf. pub(crate) type KeyedCipherText = CipherText; /// Zip retrieved keys onto the tree in the same depth-first order the @@ -1088,11 +1224,11 @@ pub(crate) type KeyedCipherText = CipherText; /// assumptions. pub(crate) fn bind_keys( ciphertext: StackCipherText, - keys: &mut impl Iterator, + keys: &mut impl Iterator, ) -> Result { fn bind( leaf: SealedValue, - keys: &mut impl Iterator, + keys: &mut impl Iterator, ) -> Result { let key = keys.next().ok_or(Unspecified)?; Ok(KeyedLeaf { leaf, key }) @@ -1184,7 +1320,7 @@ impl PendingStackCipherText { /// ([`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>( + pub async fn seal<'a, K: KeysetRegistry>( self, cipher: &KeysetCipher<'_, K>, aad: impl IntoContext<'a>, @@ -1215,7 +1351,7 @@ impl PendingStackCipherText { /// under one here (a `NonEmpty` 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>( + pub fn into_pending<'c, 'a, K: KeysetRegistry>( self, cipher: &'a KeysetCipher<'_, K>, aad: impl IntoContext<'c>, @@ -1227,15 +1363,15 @@ impl PendingStackCipherText { /// `keys` in traversal order. pub(crate) fn seal_with( self, - keyset_id: Uuid, - keys: &mut impl Iterator, + keyset_id: KeysetId, + keys: &mut impl Iterator>, ) -> Result { // 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, + keyset_id: KeysetId, + keys: &mut impl Iterator>, ) -> Result { let key = keys.next().ok_or(Unspecified)?; seal_leaf(Protected::new(Vec::new()), &aad, keyset_id, key) @@ -1285,21 +1421,28 @@ impl PendingStackCipherText { /// 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 { - // `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())) +fn leaf_cipher(key: &ProviderKey) -> Result { + use vitaminc_protected_kms::Controlled as _; + use zeroize::Zeroize; + + // The `vitaminc-protected` boundary: the material arrives in the copy + // `vitaminc-kms` was built against and is re-wrapped in this crate's + // own. `[u8; 32]` is `Copy`, so wipe the stack copy the move leaves. + let mut material = key.clone().risky_unwrap(); + let cipher = Aes256Cipher::new(&AesKey::from(material)); + material.zeroize(); + cipher } /// Derives the effective AAD every leaf is sealed against — and opened -/// under — binding the caller's derived AAD, the ZeroKMS key `tag`, the +/// under — binding the caller's derived AAD, the provider's `key_id`, 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 +/// version and the keyset id under the key id 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`, @@ -1318,14 +1461,14 @@ fn leaf_cipher(key: &DataKey) -> Result { /// 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> { +fn leaf_aad(aad: &Context<'_>, keyset_id: KeysetId, key_id: &[u8]) -> Context<'static> { const LEAF_AAD_DOMAIN: &[u8] = b"stack-encrypt/leaf"; Context::pae(&[ LEAF_AAD_DOMAIN, &[SealedValue::FORMAT_VERSION], - keyset_id.as_bytes(), + keyset_id.as_uuid().as_bytes(), aad.as_bytes(), - tag, + key_id, ]) } @@ -1337,22 +1480,21 @@ fn leaf_aad(aad: &Context<'_>, keyset_id: Uuid, tag: &[u8]) -> Context<'static> fn seal_leaf( plaintext: Protected>, aad: &Context<'_>, - keyset_id: Uuid, - key: DataKeyWithTag, + keyset_id: KeysetId, + key: GeneratedDataKey<32>, ) -> Result { - // 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))? { + // The provider is caller-supplied, so its key id is not trusted to fit + // the byte format's `u16` length field: an oversized id must fail here + // — the last construction site — or `to_bytes` would emit a length + // field that no longer frames the id and `from_bytes` would stop + // inverting it. + let key_id = key.key_id.as_bytes().to_vec(); + SealedValue::key_id_fits_length_field(&key_id).map_err(|_| Unspecified)?; + let cipher = leaf_cipher(&key.plaintext)?; + match (&cipher).encrypt_bytes_vec(plaintext, leaf_aad(aad, keyset_id, &key_id))? { AesCipherText::Single(ciphertext) => Ok(SealedValue { keyset_id, - iv, - tag: key.tag, + key_id, ciphertext, }), _ => Err(Unspecified), @@ -1375,7 +1517,7 @@ fn open_leaf(keyed: KeyedLeaf, aad: &Context<'_>) -> Result>, let cipher = leaf_cipher(&key)?; cipher .decipher(AesCipherText::Single(leaf.ciphertext)) - .decrypt_bytes(ProtectedBytes, leaf_aad(aad, leaf.keyset_id, &leaf.tag)) + .decrypt_bytes(ProtectedBytes, leaf_aad(aad, leaf.keyset_id, &leaf.key_id)) } /// Open one marker leaf (absent / empty-sequence / empty-map) and require the @@ -1394,7 +1536,7 @@ fn verify_empty_marker(keyed: KeyedLeaf, aad: &Context<'_>) -> Result<(), Unspec // Encrypt side: `Cipher` impl over a `&KeysetCipher` (builds the pending tree) // ============================================================================= -impl<'c, 'k, K> Cipher for &'c KeysetCipher<'k, K> { +impl<'c, 'k, K: KeysetRegistry> Cipher for &'c KeysetCipher<'k, K> { type Ok = PendingStackCipherText; type Error = Unspecified; type Passthrough = BoxedPassthrough; @@ -1471,7 +1613,7 @@ impl<'c, 'k, K> Cipher for &'c KeysetCipher<'k, K> { /// [`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> { +pub struct PendingSeqCipher<'c, 'k, K: KeysetRegistry> { cipher: &'c KeysetCipher<'k, K>, items: Vec, /// The AAD fixed at [`Cipher::encrypt_seq`]; the empty marker is sealed @@ -1486,7 +1628,7 @@ pub struct PendingSeqCipher<'c, 'k, K> { encrypted: bool, } -impl SeqCipher for PendingSeqCipher<'_, '_, K> { +impl SeqCipher for PendingSeqCipher<'_, '_, K> { type Ok = PendingStackCipherText; type Error = Unspecified; type Passthrough = BoxedPassthrough; @@ -1529,7 +1671,7 @@ impl SeqCipher for PendingSeqCipher<'_, '_, K> { /// [`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> { +pub struct PendingMapCipher<'c, 'k, K: KeysetRegistry> { cipher: &'c KeysetCipher<'k, K>, entries: Vec<(String, PendingStackCipherText)>, /// Duplicate keys are rejected at encrypt time: `decrypt_tree` rejects @@ -1543,7 +1685,7 @@ pub struct PendingMapCipher<'c, 'k, K> { encrypted: bool, } -impl MapCipher for PendingMapCipher<'_, '_, K> { +impl MapCipher for PendingMapCipher<'_, '_, K> { type Ok = PendingStackCipherText; type Error = Unspecified; type Passthrough = BoxedPassthrough; @@ -1984,13 +2126,13 @@ mod tests { } /// 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 + /// registry'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()) + async fn debug_shows_the_keyset_and_the_registry_type_only() { + let cipher = crate::StackCipherBuilder::new() + .registry(crate::registry::fake::FakeKeysetRegistry::new()) .init() .await .unwrap(); @@ -2003,7 +2145,7 @@ mod tests { ] { assert!(debug.starts_with(type_name), "{debug}"); assert!(debug.contains(&keyset_id), "{debug}"); - assert!(debug.contains("FakeDataKeySource"), "{debug}"); + assert!(debug.contains("FakeKeysetRegistry"), "{debug}"); assert!(debug.ends_with(", .. }"), "non-exhaustive: {debug}"); } } @@ -2040,7 +2182,7 @@ mod tests { /// [`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 keyset = KeysetId::new(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 @@ -2051,4 +2193,40 @@ mod tests { "05000000000000001200000000000000737461636b2d656e63727970742f6c65616601000000000000000110000000000000006b65797365742d6669787475726531360a0000000000000063616c6c65722d61616407000000000000006b65792d746167" ); } + + /// Both length checks in the byte format are boundary conditions, and a + /// mutation of either comparison passes every test that only feeds them + /// obviously-wrong sizes. These feed them the exact edge. + #[test] + fn the_leaf_formats_length_checks_hold_at_their_edges() { + // 16 keyset bytes + the 2-byte length field is the smallest a leaf + // can be: an empty key id and an empty ciphertext are structurally + // valid, and nothing here is trusted until it decrypts. One byte + // fewer is truncated. + let mut smallest = vec![SealedValue::FORMAT_VERSION]; + smallest.extend_from_slice(&[0u8; 16]); + smallest.extend_from_slice(&0u16.to_le_bytes()); + assert!( + SealedValue::from_bytes(&smallest).is_ok(), + "a leaf with an empty key id and no ciphertext is structurally valid" + ); + assert!(matches!( + SealedValue::from_bytes(&smallest[..smallest.len() - 1]), + Err(LeafBytesError::Truncated) + )); + + // A key id of exactly `u16::MAX` fits the length field; one more + // does not, and `to_bytes` would otherwise have to truncate it. + let keyset = KeysetId::new(Uuid::nil()); + let at_limit = vec![0u8; usize::from(u16::MAX)]; + assert!( + SealedValue::from_parts(keyset, at_limit.clone(), vec![1; 8]).is_ok(), + "u16::MAX is the largest key id the field can describe, not one past it" + ); + let over = vec![0u8; usize::from(u16::MAX) + 1]; + assert!(matches!( + SealedValue::from_parts(keyset, over, vec![1; 8]), + Err(LeafBytesError::KeyIdTooLong(_)) + )); + } } diff --git a/packages/stack-encrypt/src/descriptor.rs b/packages/stack-encrypt/src/descriptor.rs index 64a398de5..14816b300 100644 --- a/packages/stack-encrypt/src/descriptor.rs +++ b/packages/stack-encrypt/src/descriptor.rs @@ -84,16 +84,21 @@ impl Descriptor { /// 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`](crate::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 + /// The longest descriptor a backend accepts, in bytes of the rendered + /// string. 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; + /// + /// The value is ZeroKMS's: it derives key material over a fixed block of + /// this size holding the descriptor, so a longer one cannot be bound. + /// It is spelled here rather than imported because `Descriptor` is + /// backend-neutral and must render the same on a deployment with no + /// ZeroKMS in its dependency graph. `zerokms_limit_is_the_rendered_one` + /// pins the two equal wherever both are present. + pub const MAX_LEN: usize = 512; /// Render `context` — any [`IntoContext`] type — from its parts. /// @@ -952,6 +957,16 @@ mod tests { } } } + + /// `MAX_LEN` is spelled here rather than imported, so that a + /// backend-neutral build carries no ZeroKMS crate. This pins it equal to + /// the protocol constant wherever ZeroKMS *is* in the graph — a protocol + /// bump that changed the limit would fail here rather than at a request. + #[cfg(feature = "zerokms")] + #[test] + fn zerokms_limit_is_the_rendered_one() { + assert_eq!(Descriptor::MAX_LEN, stack_kms::MAX_DESCRIPTOR_LEN); + } } #[cfg(test)] diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index e0891c3a9..eb08cfabf 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -61,6 +61,7 @@ mod kind; pub mod record; mod term; +use crate::registry::KeysetRegistry; use std::fmt; pub use context::{borrowed, context}; @@ -99,7 +100,7 @@ use crate::{KeysetCipher, StackCipher}; /// 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> { +pub enum Scope<'c, K: KeysetRegistry> { /// Leaves from any keyset the client holds: one batched retrieval per /// keyset the leaves were sealed under. Client(&'c StackCipher), @@ -109,7 +110,7 @@ pub enum Scope<'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 fmt::Debug for Scope<'_, K> { +impl 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(), diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 6040a57ae..af29f6af5 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -49,7 +49,7 @@ //! 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 crate::registry::KeysetRegistry; use vitaminc_aead_value::{FfiValue, ValueKind}; use vitaminc_protected::Protected; @@ -432,12 +432,12 @@ pub fn plan(value: FfiValue) -> Result { /// /// ``` /// use stack_encrypt::dynamic::{record, FfiValue, Scope}; -/// use stack_encrypt::StackCipher; -/// use stack_encrypt::kms::FakeDataKeySource; +/// use stack_encrypt::{StackCipher, StackCipherBuilder}; +/// use stack_encrypt::registry::fake::FakeKeysetRegistry; /// /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { -/// let cipher = StackCipher::builder() -/// .kms(FakeDataKeySource::new()) +/// let cipher = StackCipherBuilder::new() +/// .registry(FakeKeysetRegistry::new()) /// .init() /// .await?; /// let keyset = cipher.default_keyset(); @@ -497,7 +497,8 @@ pub async fn encrypt( plan: &Plan, ) -> Result where - K: DataKeySource + Sync, + K: KeysetRegistry + Sync, + K::Provider: Send + Sync, { let Rows { rows, batched } = source_rows(source, plan)?; @@ -569,7 +570,8 @@ pub async fn decrypt( plan: &Plan, ) -> Result where - K: DataKeySource + Sync + 'static, + K: KeysetRegistry + Sync + 'static, + K::Provider: Send + Sync, { let Rows { rows, batched } = record_leaves(record, plan)?; let opened = plan @@ -978,7 +980,8 @@ async fn build_row<'c, K>( pendings: &mut Vec>, ) -> Result, Error> where - K: DataKeySource + Sync, + K: KeysetRegistry + Sync, + K::Provider: Send + Sync, { // A row `source_rows` did not align is a bug here, not caller input. if row.len() != plan.fields.len() { @@ -1070,78 +1073,37 @@ fn record_leaves( #[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}; + use crate::registry::fake::{FakeKeysetRegistry, FakeProvider}; + use crate::{nonempty, StackCipher, StackCipherBuilder}; - /// `FakeDataKeySource` with call counters, so the batching contract — + /// `FakeKeysetRegistry` 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>, - keyset_id: Option, - unverified_context: Option>, - ) -> Result, 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>, - keyset_id: Option, - unverified_context: Option<&UnverifiedContext>, - ) -> Result, 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, - ) -> Result<(Uuid, IndexKey), stack_kms::Error> { - self.inner.load_index_key(keyset_id).await - } - } - - async fn cipher() -> StackCipher { - StackCipher::builder() - .kms(Counting::default()) + async fn cipher() -> StackCipher { + StackCipherBuilder::new() + .registry(FakeKeysetRegistry::new()) .init() .await .expect("build cipher") } - fn generates(cipher: &StackCipher) -> usize { - cipher.kms().generate_calls.load(Ordering::SeqCst) + /// The provider serving the cipher's default keyset: it counts the + /// batched calls it serves, which is what these tests assert rather than + /// trust. + fn provider(cipher: &StackCipher) -> FakeProvider { + cipher.registry().default_keyset().1 } - fn retrieves(cipher: &StackCipher) -> usize { - cipher.kms().retrieve_calls.load(Ordering::SeqCst) + fn generates(cipher: &StackCipher) -> usize { + provider(cipher).call_counts().0 + } + + fn retrieves(cipher: &StackCipher) -> usize { + provider(cipher).call_counts().1 } // ---- values, as a binding would decode them ---------------------------- @@ -2069,7 +2031,9 @@ mod tests { mod given_a_stored_record_that_does_not_fit_the_plan { use super::*; - async fn sealed(keyset: &KeysetCipher<'_, Counting>) -> Vec<(String, StackCipherText)> { + async fn sealed( + keyset: &KeysetCipher<'_, FakeKeysetRegistry>, + ) -> Vec<(String, StackCipherText)> { map(encrypt(keyset, row(34), &the_plan()) .await .expect("encrypt")) @@ -2240,15 +2204,11 @@ mod tests { 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 acme = cipher.keyset("acme").await.expect("acme"); + let globex = cipher.keyset("globex").await.expect("globex"); let plan = the_plan(); let sealed = encrypt(&acme, row(34), &plan).await.expect("encrypt"); diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs index 09af986ca..e85519a52 100644 --- a/packages/stack-encrypt/src/dynamic/term.rs +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -14,7 +14,7 @@ //! going to be **stored** should come from the record path, where it shares //! one context with the ciphertext beside it (ADR-0004). -use stack_kms::DataKeySource; +use crate::registry::KeysetRegistry; use vitaminc_aead_value::FfiValue; use vitaminc_protected::{Controlled, OpaqueDebug, Protected}; use zeroize::Zeroizing; @@ -250,12 +250,12 @@ impl Scalar { /// ``` /// use stack_encrypt::dynamic::{context, term, FfiValue, Scalar}; /// use stack_encrypt::target::IndexSpec; -/// use stack_encrypt::StackCipher; -/// use stack_encrypt::kms::FakeDataKeySource; +/// use stack_encrypt::{StackCipher, StackCipherBuilder}; +/// use stack_encrypt::registry::fake::FakeKeysetRegistry; /// /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { -/// let cipher = StackCipher::builder() -/// .kms(FakeDataKeySource::new()) +/// let cipher = StackCipherBuilder::new() +/// .registry(FakeKeysetRegistry::new()) /// .init() /// .await?; /// let keyset = cipher.default_keyset(); @@ -284,7 +284,8 @@ pub async fn term<'c, K, D>( context: NonEmpty, ) -> Result, Error> where - K: DataKeySource + Sync, + K: KeysetRegistry + Sync, + K::Provider: Send + Sync, D: IntoPrfContext<'c>, { match kind { @@ -303,7 +304,8 @@ async fn equality<'c, K, D>( context: NonEmpty, ) -> Result, Error> where - K: DataKeySource + Sync, + K: KeysetRegistry + Sync, + K::Provider: Send + Sync, D: IntoPrfContext<'c>, { let term = match scalar { @@ -339,7 +341,8 @@ async fn match_term<'c, K, D>( context: NonEmpty, ) -> Result, Error> where - K: DataKeySource + Sync, + K: KeysetRegistry + Sync, + K::Provider: Send + Sync, D: IntoPrfContext<'c>, { match scalar { @@ -367,7 +370,8 @@ async fn ore_of<'c, K, D>( context: NonEmpty, ) -> Result, Error> where - K: DataKeySource + Sync, + K: KeysetRegistry + Sync, + K::Provider: Send + Sync, D: IntoPrfContext<'c>, { match scalar { @@ -391,7 +395,8 @@ async fn ope_of<'c, K, D>( context: NonEmpty, ) -> Result, Error> where - K: DataKeySource + Sync, + K: KeysetRegistry + Sync, + K::Provider: Send + Sync, D: IntoPrfContext<'c>, { match scalar { @@ -415,7 +420,8 @@ async fn ore<'c, K, T, D>( context: NonEmpty, ) -> Result, Error> where - K: DataKeySource + Sync, + K: KeysetRegistry + Sync, + K::Provider: Send + Sync, T: CllwOreEncrypt + Send + 'static, T::Output: AsRef<[u8]> + Send + 'static, D: IntoPrfContext<'c>, @@ -433,7 +439,8 @@ async fn ope<'c, K, T, D>( context: NonEmpty, ) -> Result, Error> where - K: DataKeySource + Sync, + K: KeysetRegistry + Sync, + K::Provider: Send + Sync, T: CllwOpeEncrypt + Send + 'static, T::Output: AsRef<[u8]> + Send + 'static, D: IntoPrfContext<'c>, @@ -448,12 +455,12 @@ where mod tests { use super::*; use crate::dynamic::{context, Output}; - use crate::{nonempty, StackCipher}; - use stack_kms::FakeDataKeySource; + use crate::registry::fake::FakeKeysetRegistry; + use crate::{nonempty, StackCipher, StackCipherBuilder}; - async fn cipher() -> StackCipher { - StackCipher::builder() - .kms(FakeDataKeySource::new()) + async fn cipher() -> StackCipher { + StackCipherBuilder::new() + .registry(FakeKeysetRegistry::new()) .init() .await .expect("build cipher") diff --git a/packages/stack-encrypt/src/keyset.rs b/packages/stack-encrypt/src/keyset.rs index 78638ddc5..016260e0c 100644 --- a/packages/stack-encrypt/src/keyset.rs +++ b/packages/stack-encrypt/src/keyset.rs @@ -50,10 +50,9 @@ 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::registry::{KeysetId, KeysetRef, KeysetRegistry}; use crate::StackCipher; /// How long a name-to-id binding is trusted before a selection by that @@ -64,10 +63,20 @@ 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) struct KeysetState

{ + pub(crate) id: KeysetId, pub(crate) name: Option, pub(crate) prf: HmacSha256Prf, + /// The provider that mints and retrieves this keyset's data keys, as + /// the registry resolved it. Held here, not re-resolved per operation: + /// resolving costs a round trip. + /// + /// It holds the keyset's index key too — the material `prf` beside it + /// is keyed from — because `IndexKeyProvider::load_index_key` must be + /// able to answer again without going back to the backend. Two copies + /// of that material per cached keyset, both zeroizing, is the price of + /// keeping both reads cheap. + pub(crate) provider: P, } /// A loaded keyset in the cache, with the name it is currently bound under @@ -80,8 +89,8 @@ pub(crate) struct KeysetState { /// 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, +struct Entry

{ + state: Arc>, last_used: u64, name: Option, resolution: Resolution, @@ -90,7 +99,7 @@ struct Entry { /// A name-to-id binding: when ZeroKMS last confirmed it, and which lookup /// asked. struct Alias { - id: Uuid, + id: KeysetId, resolved_at: Instant, resolution: Resolution, } @@ -125,9 +134,9 @@ impl Resolution { } /// What a lookup found. -pub(crate) enum Lookup { +pub(crate) enum Lookup

{ /// A loaded keyset, and (for a name) a binding within its window. - Hit(Arc), + Hit(Arc>), /// 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 @@ -161,7 +170,7 @@ pub(crate) enum Lookup { /// 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 { +pub(crate) struct KeysetCache

{ capacity: NonZeroUsize, name_ttl: Duration, /// Monotonic use counter; an entry's tick is the last time it was hit. @@ -170,7 +179,7 @@ pub(crate) struct KeysetCache { 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, + 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". @@ -197,11 +206,11 @@ pub(crate) struct KeysetCache { /// trip on the next selection by such a name, in the eviction regime /// that is already paying them. watermark: Resolution, - by_id: HashMap, + by_id: HashMap>, by_name: HashMap, } -impl KeysetCache { +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. @@ -212,7 +221,7 @@ impl KeysetCache { pub(crate) fn new( capacity: NonZeroUsize, name_ttl: Duration, - default: Arc, + default: Arc>, ) -> Self { let mut by_name = HashMap::new(); if let Some(name) = &default.name { @@ -249,7 +258,7 @@ impl KeysetCache { } /// The keyset held apart from the bound. - fn default_id(&self) -> Uuid { + fn default_id(&self) -> KeysetId { self.default.state.id } @@ -257,7 +266,7 @@ impl KeysetCache { /// 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> { + fn entry(&self, id: KeysetId) -> Option<&Entry

> { if id == self.default_id() { Some(&self.default) } else { @@ -266,7 +275,7 @@ impl KeysetCache { } /// [`entry`](Self::entry), mutably. - fn entry_mut(&mut self, id: Uuid) -> Option<&mut Entry> { + fn entry_mut(&mut self, id: KeysetId) -> Option<&mut Entry

> { if id == self.default_id() { Some(&mut self.default) } else { @@ -276,7 +285,7 @@ impl KeysetCache { /// Mark `id` most recently used and hand back its state, if the cache /// holds it at all. - fn touch(&mut self, id: Uuid) -> Option> { + fn touch(&mut self, id: KeysetId) -> Option>> { let tick = self.tick + 1; let entry = self.entry_mut(id)?; entry.last_used = tick; @@ -286,10 +295,13 @@ impl KeysetCache { } /// Look a keyset up by id or name, marking it most recently used. - pub(crate) fn get(&mut self, by: &IdentifiedBy) -> Lookup { + pub(crate) fn get(&mut self, by: &KeysetRef) -> Lookup

{ let (id, fresh) = match by { - IdentifiedBy::Uuid(id) => (*id, true), - IdentifiedBy::Name(name) => match self.by_name.get::(name) { + // The default keyset is loaded once and never evicts, so it is + // always a hit and never a lookup. + KeysetRef::Default => (self.default_id(), true), + KeysetRef::Id(id) => (*id, true), + KeysetRef::Name(name) => match self.by_name.get::(name) { Some(alias) => (alias.id, alias.is_fresh_at(Instant::now(), self.name_ttl)), None => return Lookup::Miss(self.resolution()), }, @@ -337,9 +349,9 @@ impl KeysetCache { /// is still the latest thing the cache knows about its name). pub(crate) fn insert( &mut self, - state: Arc, + state: Arc>, resolution: Resolution, - ) -> Arc { + ) -> Arc> { // 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 @@ -412,7 +424,7 @@ impl KeysetCache { /// name from the id it named before. /// /// [`watermark`]: Self::watermark - fn bind(&mut self, name: &str, id: Uuid, resolution: Resolution) -> bool { + fn bind(&mut self, name: &str, id: KeysetId, resolution: Resolution) -> bool { if self.watermark.is_later_than(resolution) { return false; } @@ -447,7 +459,7 @@ impl KeysetCache { } /// The name `id` is currently bound under, if any. - fn current_name_of(&self, id: Uuid) -> Option { + fn current_name_of(&self, id: KeysetId) -> Option { self.entry(id).and_then(|entry| entry.name.clone()) } @@ -474,7 +486,7 @@ impl KeysetCache { } /// `name` moved away from `id`: the id no longer claims it. - fn forget_name_of(&mut self, id: Uuid, name: &str) { + fn forget_name_of(&mut self, id: KeysetId, name: &str) { if let Some(entry) = self.entry_mut(id) { if entry.name.as_deref() == Some(name) { entry.name = None; @@ -517,7 +529,7 @@ impl KeysetCache { /// 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) { + pub(crate) fn load(&mut self, state: Arc>) { let resolution = self.resolution(); let _ = self.insert(state, resolution); } @@ -549,12 +561,12 @@ impl KeysetCache { /// is authorised for. /// /// [`Error::ForeignKeyset`]: crate::Error::ForeignKeyset -pub struct KeysetCipher<'k, K> { - cipher: &'k StackCipher, - state: Arc, +pub struct KeysetCipher<'k, R: KeysetRegistry> { + cipher: &'k StackCipher, + state: Arc>, } -impl Clone for KeysetCipher<'_, K> { +impl Clone for KeysetCipher<'_, R> { fn clone(&self) -> Self { Self { cipher: self.cipher, @@ -567,30 +579,30 @@ impl Clone for KeysetCipher<'_, K> { /// 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 fmt::Debug for KeysetCipher<'_, K> { +impl fmt::Debug for KeysetCipher<'_, R> { 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::()) + .field("registry", &std::any::type_name::()) .finish_non_exhaustive() } } -impl<'k, K> KeysetCipher<'k, K> { - pub(crate) fn new(cipher: &'k StackCipher, state: Arc) -> Self { +impl<'k, R: KeysetRegistry> KeysetCipher<'k, R> { + pub(crate) fn new(cipher: &'k StackCipher, state: Arc>) -> Self { Self { cipher, state } } /// The client-scoped cipher this keyset belongs to. - pub fn cipher(&self) -> &'k StackCipher { + pub fn cipher(&self) -> &'k StackCipher { 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 { + pub fn keyset_id(&self) -> KeysetId { self.state.id } @@ -614,9 +626,10 @@ impl<'k, K> KeysetCipher<'k, K> { &self.state.prf } - /// The underlying data-key source. - pub fn kms(&self) -> &'k K { - self.cipher.kms() + /// The provider serving this keyset: the one backend key its data keys + /// and its index key come from. + pub fn provider(&self) -> &R::Provider { + &self.state.provider } } @@ -625,28 +638,37 @@ mod tests { #![allow(clippy::unwrap_used)] use super::*; + use uuid::Uuid; use vitaminc_prf::PrfKeyInit; use vitaminc_protected::Protected; - fn state(id: u128, name: Option<&str>) -> Arc { + /// These tests are about the cache's ordering, binding and eviction + /// rules, none of which touch the provider — so the provider type is + /// `()`, the smallest thing that stands in for one. + fn state(id: u128, name: Option<&str>) -> Arc> { Arc::new(KeysetState { - id: Uuid::from_u128(id), + id: ks(id), name: name.map(str::to_owned), prf: HmacSha256Prf::new(Protected::new([id as u8; 32])), + provider: (), }) } - fn name(name: &str) -> IdentifiedBy { - IdentifiedBy::Name(name.to_string().into()) + fn ks(id: u128) -> KeysetId { + KeysetId::new(Uuid::from_u128(id)) + } + + fn name(name: &str) -> KeysetRef { + KeysetRef::Name(name.to_string()) } - fn id(id: u128) -> IdentifiedBy { - IdentifiedBy::Uuid(Uuid::from_u128(id)) + fn id(id: u128) -> KeysetRef { + KeysetRef::Id(ks(id)) } /// A cache whose default is keyset 0 (unnamed) and whose name window /// never closes. - fn cache(capacity: usize) -> KeysetCache { + fn cache(capacity: usize) -> KeysetCache<()> { KeysetCache::new( NonZeroUsize::new(capacity).unwrap(), Duration::MAX, @@ -654,14 +676,14 @@ mod tests { ) } - fn hit(lookup: Lookup) -> Option { + fn hit(lookup: Lookup<()>) -> Option { match lookup { Lookup::Hit(state) => Some(state.id), Lookup::Stale(_) | Lookup::Miss(_) => None, } } - fn ticket(lookup: Lookup) -> Resolution { + fn ticket(lookup: Lookup<()>) -> Resolution { match lookup { Lookup::Stale(r) | Lookup::Miss(r) => r, Lookup::Hit(_) => panic!("expected a lookup that goes to ZeroKMS"), @@ -675,12 +697,12 @@ mod tests { assert_eq!( hit(cache.get(&id(1))), - Some(Uuid::from_u128(1)), + Some(ks(1)), "a loaded keyset is found by its id" ); assert_eq!( hit(cache.get(&name("customers"))), - Some(Uuid::from_u128(1)), + Some(ks(1)), "and by the name it loaded under" ); assert!( @@ -745,7 +767,7 @@ mod tests { fn a_binding_is_fresh_strictly_within_its_window() { let bound = Instant::now(); let alias = Alias { - id: Uuid::from_u128(1), + id: ks(1), resolved_at: bound, resolution: Resolution(1), }; @@ -820,12 +842,12 @@ mod tests { assert_eq!( hit(cache.get(&name("acme"))), - Some(Uuid::from_u128(2)), + Some(ks(2)), "the name stays with the keyset it moved to" ); assert_eq!( hit(cache.get(&name("legacy"))), - Some(Uuid::from_u128(1)), + Some(ks(1)), "and the old keyset answers to its new name" ); assert_eq!( @@ -847,12 +869,12 @@ mod tests { ); assert_eq!( hit(cache.get(&id(7))), - Some(Uuid::from_u128(7)), + Some(ks(7)), "the default is found by its id" ); assert_eq!( hit(cache.get(&name("primary"))), - Some(Uuid::from_u128(7)), + Some(ks(7)), "and by the name the builder gave it" ); @@ -862,12 +884,12 @@ mod tests { assert_eq!(cache.len(), 1, "the bounded part holds its one slot"); assert_eq!( hit(cache.get(&id(7))), - Some(Uuid::from_u128(7)), + Some(ks(7)), "the default survives an eviction that filled the bound" ); assert_eq!( hit(cache.get(&name("primary"))), - Some(Uuid::from_u128(7)), + Some(ks(7)), "and so does its name binding" ); @@ -884,7 +906,7 @@ mod tests { cache.load(state(7, Some("main"))); assert_eq!( hit(cache.get(&name("main"))), - Some(Uuid::from_u128(7)), + Some(ks(7)), "the default's new name selects it" ); assert!( @@ -935,7 +957,7 @@ mod tests { assert_eq!(cache.len(), 1, "the same id replaces, never adds"); assert_eq!( hit(cache.get(&name("one"))), - Some(Uuid::from_u128(1)), + Some(ks(1)), "a reload by id must not shed the binding the name load made" ); @@ -976,7 +998,7 @@ mod tests { ); assert_eq!( hit(cache.get(&name("tenant-1000"))), - Some(Uuid::from_u128(1000)), + Some(ks(1000)), "the surviving binding is the last one made" ); } @@ -991,7 +1013,7 @@ mod tests { cache.load(state(1, Some("uno"))); assert_eq!( hit(cache.get(&name("uno"))), - Some(Uuid::from_u128(1)), + Some(ks(1)), "the newer name selects the keyset" ); assert!( @@ -1032,7 +1054,7 @@ mod tests { assert_eq!( hit(cache.get(&name("acme"))), - Some(Uuid::from_u128(2)), + Some(ks(2)), "the binding follows the later lookup, not the later arrival" ); assert!( @@ -1046,7 +1068,7 @@ mod tests { cache.insert(state(1, Some("acme")), next); assert_eq!( hit(cache.get(&name("acme"))), - Some(Uuid::from_u128(1)), + Some(ks(1)), "a genuinely later answer moves the binding" ); assert_eq!(cache.names(), 1, "2 no longer claims the name"); @@ -1073,7 +1095,7 @@ mod tests { assert_eq!( hit(cache.get(&name("acme-corp"))), - Some(Uuid::from_u128(1)), + Some(ks(1)), "the later answer's name stands" ); assert!( @@ -1102,7 +1124,7 @@ mod tests { assert_eq!( hit(cache.get(&name("main"))), - Some(Uuid::from_u128(0)), + Some(ks(0)), "the default's binding follows the later lookup too" ); assert!( @@ -1121,7 +1143,7 @@ mod tests { cache.load(state(2, Some("acme"))); assert_eq!( hit(cache.get(&name("acme"))), - Some(Uuid::from_u128(2)), + Some(ks(2)), "the name moved to the keyset that now answers to it" ); @@ -1134,7 +1156,7 @@ mod tests { ); assert_eq!( hit(cache.get(&name("acme"))), - Some(Uuid::from_u128(2)), + Some(ks(2)), "evicting the keyset a name has left must not take the binding" ); } @@ -1168,7 +1190,7 @@ mod tests { cache.insert(state(1, Some("acme")), next); assert_eq!( hit(cache.get(&name("acme"))), - Some(Uuid::from_u128(1)), + Some(ks(1)), "a lookup later than the watermark binds as usual" ); } @@ -1193,7 +1215,7 @@ mod tests { cache.insert(state(2, Some("new")), newest); assert_eq!( hit(cache.get(&name("new"))), - Some(Uuid::from_u128(2)), + Some(ks(2)), "`new` means keyset 2 after the second rename" ); @@ -1228,12 +1250,12 @@ mod tests { let answer = cache.insert(state(1, Some("old")), oldest); assert_eq!( answer.id, - Uuid::from_u128(1), + ks(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)), + Some(ks(1)), "and keyset 1 is cached by id, evicting keyset 2" ); assert!( @@ -1259,12 +1281,12 @@ mod tests { let answer = cache.insert(state(1, Some("acme")), earlier); assert_eq!( answer.id, - Uuid::from_u128(2), + ks(2), "the earlier lookup is answered with what `acme` means now" ); assert_eq!( hit(cache.get(&id(1))), - Some(Uuid::from_u128(1)), + Some(ks(1)), "keyset 1 is still cached by id: its key material is right whichever lookup asked" ); @@ -1295,12 +1317,12 @@ mod tests { let answer = cache.insert(state(1, Some("acme")), earlier); assert_eq!( answer.id, - Uuid::from_u128(2), + ks(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)), + Some(ks(1)), "keyset 1 is cached by id all the same" ); assert!( @@ -1326,17 +1348,17 @@ mod tests { let answer = cache.insert(state(1, Some("old")), oldest); assert_eq!( answer.id, - Uuid::from_u128(2), + ks(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)), + Some(ks(2)), "`old` still means keyset 2" ); assert_eq!( hit(cache.get(&name("new"))), - Some(Uuid::from_u128(1)), + Some(ks(1)), "and `new` still means keyset 1" ); assert_eq!(cache.names(), 2, "no other binding was made"); @@ -1366,14 +1388,14 @@ mod tests { ); assert_eq!( hit(cache.get(&id(1))), - Some(Uuid::from_u128(1)), + Some(ks(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), + ks(1), "nothing later positive is known about `acme`, so the answer stands for its own caller" ); assert!( diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index df300fe4b..67814e422 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -37,14 +37,14 @@ //! // `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 +// `StackCipherBuilder::new().registry(..)` over an explicit keyset registry — 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> { -use stack_encrypt::StackCipher; +use stack_encrypt::{StackCipher, StackCipherBuilder}; // 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. @@ -63,7 +63,7 @@ assert_eq!(plaintext, "secret message"); #![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\ + keyset registry — `StackCipherBuilder::new().registry(..).init()` — rather than from\ the environment. Enable `http` for `StackCipher::new()`, which discovers\ ZeroKMS credentials itself." )] @@ -85,7 +85,7 @@ assert_eq!(plaintext, "secret message"); //! id) so it cannot be replayed elsewhere: //! //! ```no_run -//! # async fn example(cipher: stack_encrypt::StackCipher) -> Result<(), stack_encrypt::Error> { +//! # async fn example(cipher: stack_encrypt::StackCipher) -> 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 @@ -94,7 +94,7 @@ assert_eq!(plaintext, "secret message"); //! ``` //! // Credentials only exist on the `http` path: without it there is no client -// to authenticate, only the `DataKeySource` the caller supplies. +// to authenticate, only the registry the caller supplies. #![cfg_attr( feature = "http", doc = r#"# Credentials @@ -123,14 +123,14 @@ 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 +[`registry`](StackCipherBuilder::registry) is for: build the [`StackKms`](crate::kms::StackKms) over the strategy you want and hand it to -the builder. +the builder as the keyset registry. ```no_run # async fn example() -> Result<(), Box> { use stack_auth::{AuthError, AuthStrategyFn, SecretToken, ServiceToken}; -use stack_encrypt::StackCipher; +use stack_encrypt::{StackCipher, StackCipherBuilder}; use stack_encrypt::kms::{EnvKeyProvider, StackKmsBuilder}; // Any `AuthStrategy` goes in this slot — `AccessKeyStrategy`, @@ -153,7 +153,10 @@ let kms = StackKmsBuilder::new(strategy) .build() .await?; -let cipher = StackCipher::builder().kms(kms).init().await?; +let cipher = StackCipherBuilder::new() + .registry(std::sync::Arc::new(kms)) + .init() + .await?; # Ok(()) # } ``` @@ -161,7 +164,7 @@ let cipher = StackCipher::builder().kms(kms).init().await?; 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. +builder through the same `registry` 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 @@ -174,7 +177,7 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! //! # Testing without ZeroKMS //! -//! `stack_encrypt::kms::FakeDataKeySource` is an in-memory stub that needs no +//! `stack_encrypt::registry::fake::FakeKeysetRegistry` 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, @@ -183,16 +186,15 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! those are the service's, and tests of them belong against a real ZeroKMS. //! It lives behind this crate's `test-support` feature, so add //! `stack-encrypt = { version = "..", features = ["test-support"] }` to your -//! `[dev-dependencies]`. stack-kms is re-exported as [`kms`], so -//! there is no separate stack-kms dependency to keep in step: +//! `[dev-dependencies]`: //! //! ``` -//! use stack_encrypt::StackCipher; -//! use stack_encrypt::kms::FakeDataKeySource; +//! use stack_encrypt::{StackCipher, StackCipherBuilder}; +//! use stack_encrypt::registry::fake::FakeKeysetRegistry; //! //! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { -//! let cipher = StackCipher::builder() -//! .kms(FakeDataKeySource::new()) +//! let cipher = StackCipherBuilder::new() +//! .registry(FakeKeysetRegistry::new()) //! .init() //! .await?; //! let keyset = cipher.default_keyset(); @@ -228,13 +230,15 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! 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 +//! retrieval surfaces as [`Error::Provider`], carrying the backend's own +//! error. A ciphertext opened under the *wrong context* is refused by the +//! backend first, wherever the backend is +//! [`Bound`](registry::BindingSupport::Bound): 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 +//! ([`Error::Provider`]) 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`]. +//! provider that ignores descriptors lets a wrong context reach the AEAD, +//! where it is [`Error::Aead`]. //! //! For one-row reads of a batch-encrypted collection, decrypt as //! [`Element`](Element) under the same AAD used for the whole collection: @@ -276,9 +280,12 @@ pub mod descriptor; pub mod dynamic; pub mod keyset; pub mod plan; +pub mod registry; pub mod sem; pub mod target; +pub use registry::{KeysetId, KeysetRef, KeysetRegistry, Resolved}; + pub use cipher::{ BoxedPassthrough, Error, FromEnv, LeafBytesError, PendingStackCipherText, SealedValue, StackCipher, StackCipherBuilder, StackCipherText, StackDecipher, @@ -286,11 +293,14 @@ pub use cipher::{ pub use descriptor::{Describe, Description, Descriptor, Label, LabelError}; pub use keyset::KeysetCipher; pub use plan::{all, Plan, PlanError}; -// stack-kms is a public dependency: `StackCipher` is generic over its -// `DataKeySource`, and `StackCipherBuilder::kms` takes its `StackKms`. It is -// versioned on its own (release-plz.toml), so a caller reaches it through -// here and always gets the version this crate was built against, never a -// second copy whose types do not fit `StackCipher`'s bounds. +// With the `zerokms` feature, stack-kms is a public dependency: +// `Arc` is a `KeysetRegistry`, and `StackCipher::new()` builds a +// cipher over one. It is versioned on its own (release-plz.toml), so a +// caller reaches it through here and always gets the version this crate was +// built against, never a second copy whose `StackKms` is not the registry. +// Without the feature no ZeroKMS crate is in the graph, so there is nothing +// to re-export. +#[cfg(feature = "zerokms")] pub use stack_kms as kms; pub use target::{ CallerContext, CipherScope, DecryptField, DecryptFrom, DecryptInto, Decryptable, Decryption, diff --git a/packages/stack-encrypt/src/registry.rs b/packages/stack-encrypt/src/registry.rs new file mode 100644 index 000000000..dbfd51513 --- /dev/null +++ b/packages/stack-encrypt/src/registry.rs @@ -0,0 +1,192 @@ +//! Keyset selection: turning the keyset a caller names into the key +//! provider that serves it. +//! +//! A [`KeysetRegistry`] is the deployment's map. ZeroKMS's implementation +//! asks the service and gets a keyset back; a vendor deployment's reads a +//! static table of backend keys it was configured with. Either way the +//! answer is one [`vitaminc_kms::provider::KeyProvider`] bound to one +//! backend key, which is the only shape `vitaminc-kms` offers — selection +//! is this crate's business, not that crate's. See cipherstash/vitaminc +//! ADR 0004 for why the boundary sits here. +//! +//! Registries do no caching. [`StackCipher`](crate::StackCipher) holds the +//! LRU and the name TTL, so every backend inherits one tested +//! implementation of them rather than writing its own. + +use std::fmt; +use std::future::Future; + +use uuid::Uuid; +use vitaminc_kms::provider::MaybeSend; + +/// The vitaminc types a [`KeysetRegistry`] implementor has to name. +/// Re-exported so that implementing one needs no direct dependency on +/// `vitaminc-kms` — and, more to the point, no second copy of it: a +/// differently-sourced `vitaminc-kms` is a different `KeyProvider` trait at +/// compile time, and an impl written against it would not satisfy this one. +pub use vitaminc_kms::provider::{ + Binding, BindingSupport, IndexKeyProvider, KeyProvider, MaybeSend as ProviderMaybeSend, +}; +pub use vitaminc_kms::{ + GeneratedDataKey, IndexKeyMaterial, KeyId, KeyIsolation, KeyReconstruction, +}; +/// The `Protected` those traits carry key material in. +pub use vitaminc_protected_kms::Protected as ProviderProtected; + +#[cfg(any(test, feature = "test-support"))] +pub mod fake; +#[cfg(feature = "zerokms")] +mod zerokms; + +/// A keyset's identity: globally unique, carried in every sealed leaf, and +/// never re-checked once resolved. +/// +/// A newtype over `Uuid` rather than a bare one, so a client id or a +/// workspace id cannot be passed where a keyset id belongs. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] +#[serde(transparent)] +pub struct KeysetId(Uuid); + +impl KeysetId { + pub const fn new(id: Uuid) -> Self { + Self(id) + } + + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } + + pub const fn into_uuid(self) -> Uuid { + self.0 + } +} + +impl From for KeysetId { + fn from(id: Uuid) -> Self { + Self(id) + } +} + +impl fmt::Display for KeysetId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +/// How a caller names the keyset it wants. +/// +/// [`Default`](Self::Default) is a case rather than a separate entry point: +/// ZeroKMS's wire protocol already spells "the client's default keyset" as +/// an absent id, which its server resolves for the request, and a vendor +/// registry answers it from configuration. One selector covers all three. +/// +/// An id is an identity and is never re-checked. A name is a *lookup the +/// registry answers*, and answers can change — ZeroKMS allows renames — so +/// a resolved name is trusted only for a bounded window. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeysetRef { + Default, + Id(KeysetId), + Name(String), +} + +impl KeysetRef { + /// The name this reference selects by, if it selects by name. + pub fn name(&self) -> Option<&str> { + match self { + Self::Name(name) => Some(name), + Self::Default | Self::Id(_) => None, + } + } +} + +impl From for KeysetRef { + fn from(id: KeysetId) -> Self { + Self::Id(id) + } +} + +impl From for KeysetRef { + fn from(id: Uuid) -> Self { + Self::Id(KeysetId::new(id)) + } +} + +impl From for KeysetRef { + fn from(name: String) -> Self { + Self::Name(name) + } +} + +impl From<&str> for KeysetRef { + fn from(name: &str) -> Self { + Self::Name(name.to_owned()) + } +} + +/// What a registry hands back: the keyset's resolved identity, the name it +/// was selected by if it was selected by one, and the provider that serves +/// it. +/// +/// The name is the *asked* name, not a property of the keyset. A selection +/// by id or by default carries none, because nothing was looked up. +pub struct Resolved

{ + pub id: KeysetId, + pub name: Option, + pub provider: P, +} + +/// The deployment's map from a keyset reference to the provider that serves +/// it. +/// +/// One associated `Provider` type, not a boxed one: `KeyProvider` is generic +/// over `const N` and returns `impl Future`, so it is not dyn-compatible and +/// there is no `Box` to hand back. +/// +/// Resolving may cost a round trip, so it is async, and +/// [`StackCipher`](crate::StackCipher) does it outside its cache lock. +pub trait KeysetRegistry { + /// The provider this registry produces. It must serve both data keys and + /// the keyset's one index key: a keyset with no index key would fail at + /// the first query rather than at resolution, and a freshly minted index + /// key makes every term written under the old one unfindable. + type Provider: KeyProvider<32> + IndexKeyProvider<32>; + + type Error: std::error::Error + Send + Sync + 'static; + + /// Resolve `keyset`, or report that this deployment has no such keyset. + /// + /// The three outcomes are deliberately distinct, because the name cache + /// treats them differently. `Ok(Some(_))` is an answer. `Ok(None)` is + /// *also* an answer — a definite "no keyset by that name or id" — and it + /// unbinds a name the cache had bound, so a stale binding cannot + /// outlive the registry's own denial. `Err(_)` is **not** an answer: a + /// transport failure or an expired credential says nothing about the + /// name, and leaves the cache exactly as it was. + /// + /// Folding the negative into `Self::Error` would make that distinction + /// unreachable — the cache would have to match on a backend-specific + /// error variant it cannot name. + fn resolve( + &self, + keyset: &KeysetRef, + ) -> impl Future>, Self::Error>> + MaybeSend; +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A keyset id reaches a caller through error messages and logs, so its + /// rendering is the uuid and nothing else — not a newtype wrapper, and + /// not empty. + #[test] + fn a_keyset_id_renders_as_its_uuid() { + let uuid = Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef); + let id = KeysetId::new(uuid); + assert_eq!(id.to_string(), uuid.to_string()); + assert_eq!(id.to_string(), "01234567-89ab-cdef-0123-456789abcdef"); + } +} diff --git a/packages/stack-encrypt/src/registry/fake.rs b/packages/stack-encrypt/src/registry/fake.rs new file mode 100644 index 000000000..21d479ad2 --- /dev/null +++ b/packages/stack-encrypt/src/registry/fake.rs @@ -0,0 +1,364 @@ +//! A [`KeysetRegistry`] for tests: many keysets, no network, and a record +//! of every binding sent to each. +//! +//! Each keyset gets its **own** [`FakeKeyProvider`], so two keysets have +//! distinct index keys and distinct key material. That is what makes the +//! foreign-keyset refusal, the keyset merge rule and the per-keyset +//! grouping in `dispatch` testable at all — a registry that handed the same +//! provider to every keyset would pass those tests without them meaning +//! anything. +//! +//! `FakeKeyProvider` declares `BINDING = Bound`, so retrieving a key under +//! a descriptor it was not minted with fails here exactly as it would at +//! ZeroKMS. It models no authorization, auditing or reconstruction +//! semantics beyond that. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use uuid::Uuid; +use vitaminc_kms::provider::{ + Binding, BindingSupport, FakeKeyProvider, FakeKeyProviderError, IndexKeyProvider, KeyProvider, +}; +use vitaminc_kms::{GeneratedDataKey, IndexKeyMaterial, KeyId, KeyIsolation, KeyReconstruction}; +use vitaminc_protected_kms::Protected; + +use super::{KeysetId, KeysetRef, KeysetRegistry, Resolved}; + +/// Every binding a fake keyset was sent, grouped by the call it arrived in. +/// +/// Grouped by call, not flattened, because the claim these tests exist to +/// hold is that an assembly of any size settles in **one** batched call per +/// request kind. A flat list of bindings cannot tell one call of ten from +/// ten calls of one. +#[derive(Debug, Clone, Default)] +pub struct Calls { + pub generate: Vec>, + pub retrieve: Vec>, +} + +impl Calls { + /// Every generate-side binding, all calls flattened. + pub fn generated(&self) -> Vec { + self.generate.iter().flatten().cloned().collect() + } + + /// Every retrieve-side binding, all calls flattened. + pub fn retrieved(&self) -> Vec { + self.retrieve.iter().flatten().cloned().collect() + } +} + +fn record(bindings: impl Iterator>) -> Vec { + bindings + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) + .collect() +} + +/// One fake keyset's provider. Cloning shares the keys and the call record, +/// so the copy the cipher caches and the handle a test holds are the same +/// keyset. +pub struct FakeProvider { + keys: Arc>, + calls: Arc>, +} + +impl Clone for FakeProvider { + fn clone(&self) -> Self { + Self { + keys: Arc::clone(&self.keys), + calls: Arc::clone(&self.calls), + } + } +} + +impl FakeProvider { + fn new(index_key: Protected<[u8; 32]>) -> Self { + Self { + keys: Arc::new(FakeKeyProvider::with_index_key(index_key)), + calls: Arc::new(Mutex::new(Calls::default())), + } + } + + /// What this keyset has been sent. + pub fn calls(&self) -> Calls { + self.lock().clone() + } + + /// How many batched calls of each kind this keyset has served, as + /// `(generate, retrieve)`. + pub fn call_counts(&self) -> (usize, usize) { + let calls = self.lock(); + (calls.generate.len(), calls.retrieve.len()) + } + + /// A poisoned lock only means another test thread panicked mid-record; + /// the record is still a valid record. + fn lock(&self) -> MutexGuard<'_, Calls> { + self.calls.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl KeyProvider<32> for FakeProvider { + type Error = FakeKeyProviderError; + + const RECONSTRUCTION: KeyReconstruction = + as KeyProvider<32>>::RECONSTRUCTION; + const ISOLATION: KeyIsolation = as KeyProvider<32>>::ISOLATION; + const BINDING: BindingSupport = as KeyProvider<32>>::BINDING; + + async fn generate_keys( + &self, + bindings: &[Binding<'_>], + ) -> Result>, Self::Error> { + self.lock() + .generate + .push(record(bindings.iter().map(|b| b.as_bytes().to_vec()))); + self.keys.generate_keys(bindings).await + } + + async fn retrieve_keys( + &self, + keys: &[(KeyId, Binding<'_>)], + ) -> Result>, Self::Error> { + self.lock() + .retrieve + .push(record(keys.iter().map(|(_, b)| b.as_bytes().to_vec()))); + self.keys.retrieve_keys(keys).await + } +} + +impl IndexKeyProvider<32> for FakeProvider { + type Error = FakeKeyProviderError; + + async fn load_index_key(&self) -> Result, Self::Error> { + self.keys.load_index_key().await + } +} + +/// The error a fake registry reports. It never fails to *answer* — an +/// unknown keyset is `Ok(None)`, the registry's denial — so this exists +/// only to satisfy the trait. +#[derive(Debug, thiserror::Error)] +#[error("the fake keyset registry does not fail")] +pub struct FakeRegistryError; + +/// Many keysets, none of them real. +/// +/// A reference this registry has not seen mints a keyset for it, seeded +/// deterministically by what was asked for — so two ciphers built +/// separately agree on what "acme" means, which is what lets one test stand +/// in for a write path and a query path in another process. That mirrors +/// the source it replaced. +/// +/// [`deny`](Self::deny) makes a name resolve to `Ok(None)` instead: the +/// registry's own denial, which is what `Error::UnknownKeyset` is made +/// from. Minting on demand and denying on request are the two halves — a +/// registry that could only deny would make every test register first, and +/// one that could only mint would put the denial path out of reach. +/// +/// There is deliberately no "register this name first" builder. A name's id +/// is a function of the name, so registering one could only record what +/// resolution already computes — state that cannot differ from its own +/// fallback. Reach for [`keyset`](Self::keyset) to get a handle on a +/// provider before the cipher touches it. +#[derive(Default)] +pub struct FakeKeysetRegistry { + seeds: Mutex>, + denied: Mutex>, + resolves: Arc, +} + +/// A keyset id derived from what was asked for, so the same name always +/// means the same keyset. +fn derived_id(seed: &str) -> KeysetId { + let mut bytes = [0u8; 16]; + // FNV-1a over the name, spread across the 16 bytes. Not a hash anyone + // depends on — only that it is a function of the name. + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in seed.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + bytes[..8].copy_from_slice(&hash.to_le_bytes()); + bytes[8..].copy_from_slice(&hash.rotate_left(17).to_le_bytes()); + KeysetId::new(Uuid::from_bytes(bytes)) +} + +impl FakeKeysetRegistry { + pub fn new() -> Self { + Self::default() + } + + /// The id the default keyset always has. + pub const DEFAULT_ID: Uuid = Uuid::from_u128(1); + + fn lock_seeds(&self) -> MutexGuard<'_, HashMap> { + self.seeds.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// The provider for `id`, minting one seeded by `seed` if this is the + /// first time it has been asked for. + fn provider_for(&self, id: KeysetId, seed: &str) -> FakeProvider { + self.lock_seeds() + .entry(id) + .or_insert_with(|| { + let mut material = [0u8; 32]; + for (slot, byte) in material.iter_mut().zip(seed.as_bytes().iter().cycle()) { + *slot = *byte; + } + // A seed of "" would key every keyset alike; the id is + // always in the mix so it cannot. + for (slot, byte) in material.iter_mut().zip(id.as_uuid().as_bytes()) { + *slot ^= byte; + } + FakeProvider::new(Protected::new(material)) + }) + .clone() + } + + /// Every lookup of `name` from now on answers "no such keyset". + pub fn deny(&self, name: &str) { + let _ = self + .denied + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(name.to_owned(), ()); + } + + /// Undo [`deny`](Self::deny). + pub fn allow(&self, name: &str) { + let _ = self + .denied + .lock() + .unwrap_or_else(PoisonError::into_inner) + .remove(name); + } + + fn is_denied(&self, name: &str) -> bool { + self.denied + .lock() + .unwrap_or_else(PoisonError::into_inner) + .contains_key(name) + } + + /// The default keyset's id, and the provider serving it. + pub fn default_keyset(&self) -> (KeysetId, FakeProvider) { + let id = KeysetId::new(Self::DEFAULT_ID); + (id, self.provider_for(id, "default")) + } + + /// The counter of [`resolve`](KeysetRegistry::resolve) calls: what + /// loading a keyset by id or name asks the backend. A test holds the + /// handle and reads it after the cipher has run. + pub fn resolves(&self) -> Arc { + Arc::clone(&self.resolves) + } + + /// The id and provider a name resolves to. + pub fn keyset(&self, name: &str) -> Option<(KeysetId, FakeProvider)> { + let id = derived_id(name); + Some((id, self.provider_for(id, name))) + } +} + +impl KeysetRegistry for FakeKeysetRegistry { + type Provider = FakeProvider; + type Error = FakeRegistryError; + + async fn resolve( + &self, + keyset: &KeysetRef, + ) -> Result>, Self::Error> { + let _ = self.resolves.fetch_add(1, Ordering::SeqCst); + let (id, name, seed) = match keyset { + KeysetRef::Default => { + let (id, _) = self.default_keyset(); + (id, None, "default".to_owned()) + } + KeysetRef::Id(id) => (*id, None, id.to_string()), + KeysetRef::Name(name) => { + if self.is_denied(name) { + return Ok(None); + } + (derived_id(name), Some(name.clone()), name.clone()) + } + }; + + let provider = self.provider_for(id, &seed); + Ok(Some(Resolved { id, name, provider })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn resolve(registry: &FakeKeysetRegistry, name: &str) -> Option { + registry + .resolve(&KeysetRef::Name(name.to_owned())) + .await + .expect("the fake never fails to answer") + .map(|resolved| resolved.id) + } + + /// Minting on demand and denying on request are the two halves the fake + /// exists for: without the first every test must register, and without + /// the second `Error::UnknownKeyset` is unreachable. + #[tokio::test] + async fn a_denied_name_has_no_keyset_until_it_is_allowed_again() { + let registry = FakeKeysetRegistry::new(); + + let before = resolve(®istry, "acme") + .await + .expect("an unknown name mints a keyset"); + + registry.deny("acme"); + assert!( + registry.is_denied("acme"), + "deny must record the name it was given" + ); + assert_eq!( + resolve(®istry, "acme").await, + None, + "a denied name is the registry's own `Ok(None)`, not an error" + ); + assert!( + resolve(®istry, "other").await.is_some(), + "denying one name must not deny every name" + ); + + registry.allow("acme"); + assert!(!registry.is_denied("acme")); + assert_eq!( + resolve(®istry, "acme").await, + Some(before), + "allowing a name restores the same keyset, not a fresh one" + ); + } + + /// The handle a test holds and the keyset the cipher resolves must be the + /// same keyset, or a test that asserts on what a provider was sent is + /// asserting about a provider nothing used. + #[tokio::test] + async fn the_handle_a_test_holds_is_the_keyset_the_cipher_resolves() { + let registry = FakeKeysetRegistry::new(); + let (id, _) = registry.keyset("acme").expect("minted on demand"); + assert_eq!(resolve(®istry, "acme").await, Some(id)); + } + + /// Two ciphers built in separate processes have to agree on what a name + /// means, which is the whole reason the id is derived rather than + /// counted. Pinned, because a hash that is merely *a* function of the + /// name is not enough — it must be the *same* function every run. + #[test] + fn a_name_always_derives_the_same_id() { + assert_eq!( + derived_id("acme").to_string(), + "0fdef6f4-83d3-2407-490e-1ebcede907a7" + ); + assert_ne!(derived_id("acme"), derived_id("acme2")); + assert_ne!(derived_id(""), derived_id("a")); + } +} diff --git a/packages/stack-encrypt/src/registry/zerokms.rs b/packages/stack-encrypt/src/registry/zerokms.rs new file mode 100644 index 000000000..5df0be07f --- /dev/null +++ b/packages/stack-encrypt/src/registry/zerokms.rs @@ -0,0 +1,99 @@ +//! ZeroKMS as a source of keysets. +//! +//! An `Arc` is the registry; a [`ZeroKmsKeyset`] is what it +//! resolves to. The `Arc` is the point: every keyset this cipher selects +//! shares one client, one set of credentials and one connection pool. +//! +//! This impl lives here rather than in `stack-kms` because the trait is +//! local and the type is foreign. Its mirror image — +//! `impl KeyProvider for ZeroKmsKeyset` — has to live in `stack-kms` for the +//! same rule read the other way. + +use std::sync::Arc; + +use stack_kms::{IdentifiedBy, StackKms, ZeroKmsKeyset}; + +use super::{KeysetId, KeysetRef, KeysetRegistry, Resolved}; + +/// How a [`KeysetRef`] reaches ZeroKMS. +/// +/// `Default` becomes an absent id, which is what the protocol already means +/// by it: the server resolves the client's default keyset for the request. +fn identified_by(keyset: &KeysetRef) -> Option { + match keyset { + KeysetRef::Default => None, + KeysetRef::Id(id) => Some(IdentifiedBy::Uuid(id.into_uuid())), + KeysetRef::Name(name) => Some(IdentifiedBy::Name(name.clone().into())), + } +} + +impl KeysetRegistry for Arc> +where + C: stack_auth::AuthStrategyBounds, + for<'a> &'a C: stack_auth::AuthStrategy, + Conn: stack_kms::ZeroKMSConnection + Send + Sync, +{ + type Provider = ZeroKmsKeyset; + type Error = stack_kms::Error; + + /// One `load_keyset` round trip, which resolves the keyset *and* returns + /// its index key — so loading the index key eagerly costs nothing extra + /// here, and a keyset that cannot serve one fails now rather than at the + /// first query. + async fn resolve( + &self, + keyset: &KeysetRef, + ) -> Result>, Self::Error> { + let provider = match ZeroKmsKeyset::new(Arc::clone(self), identified_by(keyset)).await { + Ok(provider) => provider, + // ZeroKMS's own answer that it holds no such keyset, as opposed + // to not answering at all. + Err(stack_kms::Error::LoadKeyset(stack_kms::LoadKeysetError::KeysetNotFound(_))) => { + return Ok(None) + } + Err(error) => return Err(error), + }; + + Ok(Some(Resolved { + id: KeysetId::new(provider.keyset_id()), + // The name asked for, not a property of the keyset: a selection + // by id or by default looked nothing up and carries none. + name: keyset.name().map(str::to_owned), + provider, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + /// `None` means "the client's default keyset" on the wire, so every + /// selector must map to its *own* `IdentifiedBy` or a caller that asked + /// for one tenant silently gets another's. Only `Default` may be absent. + #[test] + fn every_selector_reaches_zerokms_as_itself() { + assert!( + identified_by(&KeysetRef::Default).is_none(), + "the protocol already spells the default keyset as an absent id" + ); + + let id = Uuid::from_u128(7); + assert!( + matches!( + identified_by(&KeysetRef::Id(KeysetId::new(id))), + Some(IdentifiedBy::Uuid(sent)) if sent == id + ), + "an id selection must carry that id, not fall back to the default" + ); + + assert!( + matches!( + identified_by(&KeysetRef::Name("acme".to_owned())), + Some(IdentifiedBy::Name(sent)) if &*sent == "acme" + ), + "a name selection must carry that name, not fall back to the default" + ); + } +} diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index bde08aae7..6c4771b7f 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -50,8 +50,9 @@ //! //! The backend today is the local //! [`HmacSha256Prf`] — keyed by the -//! deterministic per-keyset [`IndexKey`](crate::kms::IndexKey) from -//! [`kms::IndexKeySource`](crate::kms::IndexKeySource), loaded when the keyset is selected — so +//! deterministic per-keyset index key from the keyset's +//! [`IndexKeyProvider`](crate::registry::IndexKeyProvider), 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 @@ -128,6 +129,7 @@ mod tokenize; pub use tokenize::Tokenizer; +use crate::registry::KeysetRegistry; use std::fmt; use std::marker::PhantomData; @@ -144,7 +146,7 @@ use vitaminc_prf::{ use vitaminc_protected::NonEmpty; use zeroize::Zeroize; -use stack_kms::MaybeSend; +use vitaminc_kms::provider::MaybeSend; use crate::target::core::Term; use crate::target::{DecryptField, Decryptable, Decryption}; @@ -339,7 +341,7 @@ where /// 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> for EqualityTerm +impl<'c, S, K: KeysetRegistry, T> Term> for EqualityTerm where S: PrfValue, T: IntoPrfContext<'c>, @@ -660,7 +662,7 @@ fn match_term( /// 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> for MatchTerms +impl<'c, S, K: KeysetRegistry, O, T> Term> for MatchTerms where S: AsRef, O: MatchConfig, @@ -709,7 +711,7 @@ macro_rules! index_term { const DECRYPTABLE: bool = false; } impl DecryptField for $ty { - fn decryption_field(self, _: Ctx) -> Option> { + fn decryption_field(self, _: Ctx) -> Option> { None } } @@ -949,7 +951,7 @@ where /// 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> for OreTerm +impl<'c, S, K: KeysetRegistry, T> Term> for OreTerm where S: CllwOreEncrypt + Send + 'static, S::Output: Send + 'static, @@ -974,7 +976,7 @@ where /// 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> for OpeTerm +impl<'c, S, K: KeysetRegistry, T> Term> for OpeTerm where S: CllwOpeEncrypt + Send + 'static, S::Output: Send + 'static, @@ -1039,7 +1041,7 @@ where /// `nonempty!("users").with("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 KeysetCipher<'_, K> { +impl 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 diff --git a/packages/stack-encrypt/src/target/core.rs b/packages/stack-encrypt/src/target/core.rs index 7b76831bd..0ae25dd83 100644 --- a/packages/stack-encrypt/src/target/core.rs +++ b/packages/stack-encrypt/src/target/core.rs @@ -1,6 +1,7 @@ //! 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::registry::KeysetRegistry; use crate::{Descriptor, Error, KeysetCipher, StackCipher, StackCipherText}; use vitaminc_aead::{CipherText, Decrypt, Encrypt, IntoAad, IntoContext}; use vitaminc_protected::NonEmpty; @@ -10,7 +11,7 @@ use vitaminc_protected::NonEmpty; /// `S` is what the operation is handed, by value: the PRF and ORE schemes /// consume their input, so a term that needs the plaintext takes it, and one /// that only reads it (a match term) is handed a reference as its `S`. -pub(crate) trait Term: Sized { +pub(crate) trait Term: Sized { fn encrypt_from<'a>( source: S, cipher: &'a KeysetCipher<'_, K>, @@ -24,7 +25,7 @@ pub(crate) trait Term: Sized { /// `Encrypt` consumes it: a caller holding only a borrow clones before it /// gets here (see [`ConsumeSource`](super::ConsumeSource)), and one holding /// the value hands it over without a copy. -pub(crate) fn encrypt_native<'a, 'c, S: Encrypt, K, T: IntoContext<'c>>( +pub(crate) fn encrypt_native<'a, 'c, S: Encrypt, K: KeysetRegistry, T: IntoContext<'c>>( source: S, cipher: &'a KeysetCipher<'_, K>, context: NonEmpty, @@ -40,7 +41,13 @@ pub(crate) fn encrypt_native<'a, 'c, S: Encrypt, K, T: IntoContext<'c>>( Err(_) => Pending::ready(cipher, Err(Error::Aead)), } } -pub(crate) fn open_native<'a, 'c, P: Decrypt<'static> + 'static, K, T: IntoContext<'c>>( +pub(crate) fn open_native< + 'a, + 'c, + P: Decrypt<'static> + 'static, + K: KeysetRegistry, + T: IntoContext<'c>, +>( tree: StackCipherText, cipher: &'a StackCipher, context: NonEmpty, @@ -58,7 +65,7 @@ pub(crate) fn open_native<'a, 'c, P: Decrypt<'static> + 'static, K, T: IntoConte P::decrypt_with_aad(decipher, aad).map_err(Error::from) }) } -pub(crate) fn seal_pending<'a, K>( +pub(crate) fn seal_pending<'a, K: KeysetRegistry>( cipher: &'a KeysetCipher<'_, K>, tree: PendingStackCipherText, descriptor: Descriptor, @@ -79,7 +86,7 @@ pub(crate) fn seal_pending<'a, K>( }) } -pub(crate) fn decipher_pending<'a, K>( +pub(crate) fn decipher_pending<'a, K: KeysetRegistry>( scope: impl CipherScope<'a, K>, ciphertext: StackCipherText, descriptor: Descriptor, @@ -127,8 +134,7 @@ fn collect_retrieve_requests( | CipherText::EmptySequence(leaf) | CipherText::EmptyMap(leaf) => { out.push(Request::retrieve_under( - *leaf.iv(), - leaf.tag().to_vec(), + leaf.key_id().to_vec(), descriptor.clone(), leaf.keyset_id(), )); diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index b65cb29cf..264d2b007 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -48,7 +48,7 @@ //! impl EncryptFrom for StoredEquality //! where EqualityTerm: EncryptFrom { //! type Context = CallerContext; -//! fn encryption<'s,K:'static>()->Encryption<'s,S,Self,K,Self::Context> +//! fn encryption<'s,K:stack_encrypt::KeysetRegistry+'static>()->Encryption<'s,S,Self,K,Self::Context> //! where S:'s { //! >::encryption().map(|term| Self(term.into_bytes())) //! } @@ -107,7 +107,7 @@ pub use operations::{ DecryptInto, Decryptable, Decryption, EncryptFrom, EncryptInto, Encryption, }; pub use pending::{CipherScope, Pending, PendingFuture}; -pub use request::{Request, Responses}; +pub use request::{ProviderKey, Request, Responses}; pub use source::{Borrowed, ConsumeSource, Owned, ShareSource, SourceMode}; pub use stack_encrypt_derive::{DecryptInto, EncryptFrom}; pub use tuples::JoinContext; diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 604d3d46f..a19709a4f 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -13,9 +13,10 @@ use super::context::{AeadContext, CallerContext, DeclaredContext, Extends}; use super::core::{encrypt_native, open_native, Term}; use super::source::{Borrowed, ConsumeSource, ShareSource, SourceMode}; use super::{CipherScope, Pending}; +use crate::registry::KeysetRegistry; use crate::{Error, IntoContext, KeysetCipher, NonEmpty, StackCipher, StackCipherText}; -use stack_kms::MaybeSend; use std::fmt; +use vitaminc_kms::provider::MaybeSend; /// Declaration that an encrypted target is produced from `S`. /// @@ -36,7 +37,7 @@ pub trait EncryptFrom: Sized + 'static { /// 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> + fn encryption<'s, K: KeysetRegistry + 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's; /// The indexes this target's terms answer queries by, as data, under @@ -64,7 +65,7 @@ pub trait DecryptInto

: Sized { /// which may name the destination the caller believes it is opening. type Context; /// The description the cipher executes to recover `P`. - fn decryption(self, context: Self::Context) -> Decryption; + fn decryption(self, context: Self::Context) -> Decryption; } // What the description is handed is `M::Source`: `&'s S` in the default @@ -126,7 +127,7 @@ type Open = Box FnOnce(&'a StackCipher) -> Pending<'a, T, K /// a single operation consumes the plaintext without copying it, so `S` need /// not be `Clone`; [`zip`](Self::zip) is the one place that asks for it. #[must_use = "an encryption description does nothing until a keyset cipher executes it"] -pub struct Encryption<'s, S: 's, T, K, Ctx, M: SourceMode<'s, S> = Borrowed> { +pub struct Encryption<'s, S: 's, T, K: KeysetRegistry, Ctx, M: SourceMode<'s, S> = Borrowed> { build: Build<'s, S, T, K, Ctx, M>, } /// A composable description of how `T` is recovered from a stored target. @@ -134,23 +135,23 @@ pub struct Encryption<'s, S: 's, T, K, Ctx, M: SourceMode<'s, S> = Borrowed> { /// 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 { +pub struct Decryption { inner: Opening, } /// 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 { +enum Opening { Failed(Error), Open(Open), } -impl<'s, S: 's, T, K, Ctx, M: SourceMode<'s, S>> fmt::Debug for Encryption<'s, S, T, K, Ctx, M> { +impl<'s, S: 's, T, K: KeysetRegistry, Ctx, M: SourceMode<'s, S>> fmt::Debug for Encryption<'s, S, T, K, Ctx, M> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Encryption").finish_non_exhaustive() } } -impl fmt::Debug for Decryption { +impl fmt::Debug for Decryption { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let mut debug = f.debug_struct("Decryption"); if let Opening::Failed(error) = &self.inner { @@ -160,7 +161,7 @@ impl fmt::Debug for Decryption { } } -impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's, M: SourceMode<'s, S>> +impl<'s, S: 's, T: 'static, K: KeysetRegistry + 'static, Ctx: 's, M: SourceMode<'s, S>> Encryption<'s, S, T, K, Ctx, M> { /// A description whose output is already known — metadata a record @@ -377,7 +378,9 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { /// error without I/O. Zipped beside a record's fields, a failed check fails /// the whole record before any key is requested. Crate-internal: the plan /// builder uses it to refuse a value whose fields the plan does not match. -pub(crate) fn inspect<'s, S: 's, K: 'static, Ctx: 's, C>(check: C) -> Encryption<'s, S, (), K, Ctx> +pub(crate) fn inspect<'s, S: 's, K: KeysetRegistry + 'static, Ctx: 's, C>( + check: C, +) -> Encryption<'s, S, (), K, Ctx> where C: FnOnce(&S) -> Result<(), Error> + MaybeSend + 's, { @@ -386,7 +389,7 @@ where } } -impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's + Clone + MaybeSend + 'static, M> +impl<'s, S: 's, T: 'static, K: KeysetRegistry + 'static, Ctx: 's + Clone + MaybeSend + 'static, M> Encryption<'s, S, T, K, Ctx, M> where M: SourceMode<'s, S>, @@ -423,7 +426,7 @@ where /// `Encrypt` consumes the plaintext. In [`Owned`](super::Owned) mode it is /// handed over, so `S` need not be `Clone`; in the default [`Borrowed`] mode /// it is cloned once, which is what `M: ConsumeSource<'s, S>` asks. -pub fn ciphertext<'s, S: crate::Encrypt + 's, K: 'static, M: ConsumeSource<'s, S>>( +pub fn ciphertext<'s, S: crate::Encrypt + 's, K: KeysetRegistry + 'static, M: ConsumeSource<'s, S>>( ) -> Encryption<'s, S, StackCipherText, K, AeadContext, M> { Encryption { build: Box::new( @@ -492,7 +495,7 @@ macro_rules! term_operation { $function:ident, $output:ty, view, [$($generics:tt)*], [$($bounds:tt)*] ) => { $(#[$doc])* - pub fn $function<'s, S, K: 'static, M: SourceMode<'s, S>, $($generics)*>( + pub fn $function<'s, S, K: KeysetRegistry + 'static, M: SourceMode<'s, S>, $($generics)*>( ) -> Encryption<'s, S, $output, K, CallerContext, M> where S: 's, @@ -531,7 +534,7 @@ term_operation!( [S: cllw_ore::CllwOpeEncrypt + Send + 'static, S::Output: Send + 'static] ); -impl Decryption { +impl Decryption { /// 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 @@ -621,7 +624,7 @@ impl Decryption { } /// The canonical opening operation: retrieve the tree's keys and decode `P` /// through its own Vitamin C `Decrypt` implementation, under `context`. -pub fn open + 'static, K: 'static>( +pub fn open + 'static, K: KeysetRegistry + 'static>( tree: StackCipherText, context: impl Into, ) -> Decryption { @@ -632,7 +635,7 @@ pub fn open + 'static, K: 'static>( })) } -impl KeysetCipher<'_, K> { +impl 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. @@ -740,7 +743,7 @@ impl KeysetCipher<'_, K> { source.decryption(context).open_in(self) } } -impl StackCipher { +impl StackCipher { /// Run a [`Decryption`] held in a variable through the client: leaves /// from any of its keysets open here. See /// [`KeysetCipher::run_decryption`], which refuses a foreign one. @@ -770,7 +773,10 @@ impl StackCipher { 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> + fn encrypt_into<'a, T, K: KeysetRegistry + 'static>( + &self, + cipher: &'a KeysetCipher<'_, K>, + ) -> Pending<'a, T, K> where T: EncryptFrom, T::Context: Default, @@ -779,7 +785,7 @@ pub trait EncryptInto: Sized { } /// 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>( + fn encrypt_into_with_context<'a, T, K: KeysetRegistry + 'static>( &self, cipher: &'a KeysetCipher<'_, K>, context: impl Into, @@ -791,7 +797,7 @@ pub trait EncryptInto: Sized { } /// [`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>( + fn encrypt_from<'a, S, K: KeysetRegistry + 'static>( source: &S, cipher: &'a KeysetCipher<'_, K>, context: impl Into<>::Context>, @@ -809,7 +815,7 @@ impl EncryptInto for T {} 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>( + fn decrypt_into<'a, P: 'static, K: KeysetRegistry + 'static>( self, cipher: impl CipherScope<'a, K>, context: impl Into<>::Context>, @@ -822,7 +828,7 @@ pub trait DecryptFrom: Sized + 'static { /// 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>( + fn decrypt_from<'a, S, K: KeysetRegistry + 'static>( source: S, cipher: impl CipherScope<'a, K>, ) -> Pending<'a, Self, K> @@ -833,7 +839,7 @@ pub trait DecryptFrom: Sized + 'static { source.decrypt_into(cipher, S::Context::default()) } /// Recover `Self` from `source` under `context`. - fn decrypt_from_with_context<'a, S, K: 'static>( + fn decrypt_from_with_context<'a, S, K: KeysetRegistry + 'static>( source: S, cipher: impl CipherScope<'a, K>, context: impl Into, @@ -854,7 +860,7 @@ impl DecryptFrom for T {} // capability, and run a non-`Clone` plaintext in `Owned` mode. impl EncryptFrom for StackCipherText { type Context = AeadContext; - fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + fn encryption<'s, K: KeysetRegistry + 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { @@ -863,13 +869,13 @@ impl EncryptFrom for StackCipherText { } impl + 'static> DecryptInto

for StackCipherText { type Context = AeadContext; - fn decryption(self, context: Self::Context) -> Decryption { + fn decryption(self, context: Self::Context) -> Decryption { open(self, context) } } impl EncryptFrom for crate::sem::EqualityTerm { type Context = CallerContext; - fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + fn encryption<'s, K: KeysetRegistry + 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { @@ -883,7 +889,7 @@ impl, O: crate::sem::MatchConfig + 'static> EncryptFrom for crate::sem::MatchTerms { type Context = CallerContext; - fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + fn encryption<'s, K: KeysetRegistry + 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { @@ -899,7 +905,7 @@ where S::Output: Send + 'static, { type Context = CallerContext; - fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + fn encryption<'s, K: KeysetRegistry + 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { @@ -915,7 +921,7 @@ where S::Output: Send + 'static, { type Context = CallerContext; - fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + fn encryption<'s, K: KeysetRegistry + 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { @@ -936,7 +942,8 @@ where T::Context: Clone + 'static + MaybeSend, { type Context = T::Context; - fn encryption<'s, K: 'static>() -> Encryption<'s, Vec, Self, K, Self::Context> + fn encryption<'s, K: KeysetRegistry + 'static>( + ) -> Encryption<'s, Vec, Self, K, Self::Context> where S: 's, { @@ -957,7 +964,8 @@ where T::Context: 'static + MaybeSend, { type Context = T::Context; - fn encryption<'s, K: 'static>() -> Encryption<'s, Option, Self, K, Self::Context> + fn encryption<'s, K: KeysetRegistry + 'static>( + ) -> Encryption<'s, Option, Self, K, Self::Context> where S: 's, { @@ -974,7 +982,10 @@ where T::Context: Clone + 'static, { type Context = T::Context; - fn decryption(self, context: Self::Context) -> Decryption, K> { + fn decryption( + self, + context: Self::Context, + ) -> Decryption, K> { Decryption::all( self.into_iter() .map(|item| item.decryption(context.clone())), @@ -983,7 +994,10 @@ where } impl + 'static> DecryptInto> for Option { type Context = T::Context; - fn decryption(self, context: Self::Context) -> Decryption, K> { + fn decryption( + self, + context: Self::Context, + ) -> Decryption, K> { Decryption::optional(self.map(|item| item.decryption(context))) } } @@ -996,21 +1010,30 @@ impl + 'static> DecryptInto> /// [`StackCipherText`] implements it alongside [`Decryptable`]. pub trait DecryptField: Sized { /// The opening description, if this field holds recoverable ciphertext. - fn decryption_field(self, context: Ctx) -> Option>; + fn decryption_field( + self, + context: Ctx, + ) -> Option>; } impl DecryptField for StackCipherText where Self: DecryptInto

, Ctx: Into<>::Context>, { - fn decryption_field(self, context: Ctx) -> Option> { + fn decryption_field( + self, + context: Ctx, + ) -> Option> { Some(self.decryption(context.into())) } } impl, Ctx: Clone + 'static> DecryptField, Ctx> for Vec { - fn decryption_field(self, context: Ctx) -> Option, K>> { + fn decryption_field( + self, + context: Ctx, + ) -> Option, K>> { if !T::DECRYPTABLE { return None; } @@ -1023,7 +1046,10 @@ impl, Ctx: Clone + ' impl, Ctx: 'static> DecryptField, Ctx> for Option { - fn decryption_field(self, context: Ctx) -> Option, K>> { + fn decryption_field( + self, + context: Ctx, + ) -> Option, K>> { if !T::DECRYPTABLE { return None; } @@ -1053,13 +1079,14 @@ impl Decryptable for Option { #[cfg(test)] mod tests { use super::*; + use crate::registry::fake::FakeKeysetRegistry; + use crate::StackCipherBuilder; 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()) + let cipher = StackCipherBuilder::new() + .registry(FakeKeysetRegistry::new()) .init() .await .unwrap(); @@ -1071,7 +1098,7 @@ mod tests { Some(value) => Some(keyset.encrypt_as(value, context()).await.unwrap()), None => None, }; - let opening: Decryption, FakeDataKeySource> = sealed + let opening: Decryption, FakeKeysetRegistry> = sealed .decryption_field(context()) .expect("an optional ciphertext is a recoverable field even when absent"); assert_eq!( @@ -1085,7 +1112,7 @@ mod tests { #[test] fn an_optional_term_is_never_a_recoverable_field() { for term in [Some(EqualityTerm::from_bytes([7; 32])), None] { - let opening: Option, FakeDataKeySource>> = + let opening: Option, FakeKeysetRegistry>> = term.decryption_field(CallerContext::from(nonempty!("users/nickname"))); assert!(opening.is_none(), "a term cannot recover plaintext"); } @@ -1093,20 +1120,21 @@ mod tests { #[test] fn operation_debug_describes_the_operation_without_its_captured_value() { - let encryption: Encryption<'_, (), _, (), ()> = Encryption::ready(Ok("secret metadata")); + let encryption: Encryption<'_, (), _, FakeKeysetRegistry, ()> = + 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"); + let decryption = Decryption::<_, FakeKeysetRegistry>::ready("secret plaintext"); assert_eq!( format!("{decryption:?}"), "Decryption { .. }", "a ready decryption should not print its plaintext" ); - let failure = Decryption::<(), ()>::failed(Error::NotOpened); + let failure = Decryption::<(), FakeKeysetRegistry>::failed(Error::NotOpened); assert_eq!( format!("{failure:?}"), "Decryption { failed: NotOpened, .. }", diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index ca7f2af5b..d3f15aead 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -10,19 +10,20 @@ //! 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 vitaminc_kms::provider::MaybeSend; +use vitaminc_kms::provider::{Binding, KeyProvider as _}; +use vitaminc_kms::KeyId; -use super::request::{tally, Request, RequestKind, Responses}; +use super::request::{tally, ProviderKey, Request, RequestKind, Responses}; +use crate::registry::{KeysetId, KeysetRegistry}; 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 [`kms::MaybeSend`](crate::kms::MaybeSend) — +/// produces the output. The `Send` split mirrors [`vitaminc_kms::provider::MaybeSend`] — /// the underlying ZeroKMS futures are not `Send` on wasm32. #[cfg(not(target_arch = "wasm32"))] type FulfilBox<'a, T> = Box Result + Send + 'a>; @@ -53,7 +54,7 @@ pub type PendingFuture<'a, T> = Pin> + /// 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> { +pub struct Pending<'a, T, K: KeysetRegistry> { /// 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. @@ -64,7 +65,7 @@ pub struct Pending<'a, T, K> { /// 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, + keyset: Option, requests: Vec, /// Set when the value already failed during the synchronous build /// (`ready(Err(..))`, a keyset mismatch, a failed sibling). A failed @@ -88,40 +89,41 @@ pub struct Pending<'a, T, K> { /// 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 { +pub trait CipherScope<'a, K: KeysetRegistry>: sealed::Sealed { /// The client-scoped cipher the pending settles through. fn cipher(&self) -> &'a StackCipher; /// The keyset the pending is scoped to, if any. - fn keyset(&self) -> Option; + fn keyset(&self) -> Option; } mod sealed { pub trait Sealed {} - impl Sealed for &crate::StackCipher {} - impl Sealed for &crate::KeysetCipher<'_, K> {} + use crate::registry::KeysetRegistry; + impl Sealed for &crate::StackCipher {} + impl Sealed for &crate::KeysetCipher<'_, K> {} } -impl<'a, K> CipherScope<'a, K> for &'a StackCipher { +impl<'a, K: KeysetRegistry> CipherScope<'a, K> for &'a StackCipher { fn cipher(&self) -> &'a StackCipher { self } - fn keyset(&self) -> Option { + fn keyset(&self) -> Option { None } } -impl<'a, K> CipherScope<'a, K> for &'a KeysetCipher<'_, K> { +impl<'a, K: KeysetRegistry> CipherScope<'a, K> for &'a KeysetCipher<'_, K> { fn cipher(&self) -> &'a StackCipher { KeysetCipher::cipher(self) } - fn keyset(&self) -> Option { + fn keyset(&self) -> Option { Some(self.keyset_id()) } } -impl<'a, T: 'a, K> Pending<'a, T, K> { +impl<'a, T: 'a, K: KeysetRegistry> 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) -> Self @@ -244,7 +246,7 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { /// 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 { + pub(crate) fn scoped_to(self, keyset: KeysetId) -> Self { if let Some(existing) = self.keyset { if existing != keyset { return Pending::failed( @@ -292,7 +294,7 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { /// 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. + /// ([`Error::Provider`]), exactly as it would be on its own. pub fn zip(self, other: Pending<'a, U, K>) -> Pending<'a, (T, U), K> { let keyset = match merge_scopes(self.keyset, other.keyset) { Ok(keyset) => keyset, @@ -370,11 +372,8 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { } } -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 +impl<'a, T: 'a, K: KeysetRegistry> Pending<'a, T, K> { + /// Settle: one batched provider 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 @@ -393,7 +392,10 @@ where /// 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, right: Option) -> Result, Error> { +fn merge_scopes( + left: Option, + right: Option, +) -> Result, Error> { match (left, right) { (Some(left), Some(right)) if left != right => Err(Error::KeysetMismatch { left, right }), (Some(keyset), _) | (_, Some(keyset)) => Ok(Some(keyset)), @@ -406,7 +408,7 @@ fn merge_scopes(left: Option, right: Option) -> Result, /// 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, requests: &[Request]) -> Result<(), Error> { +fn check_scope(keyset: Option, requests: &[Request]) -> Result<(), Error> { for request in requests { match (keyset, request.retrieve_keyset()) { (None, None) => return Err(Error::NoKeyset), @@ -426,9 +428,9 @@ fn check_scope(keyset: Option, requests: &[Request]) -> Result<(), Error> /// 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> +impl<'a, T: 'a, K: KeysetRegistry + Sync> IntoFuture for Pending<'a, T, K> where - K: DataKeySource + Sync, + K::Provider: Send + Sync, { type Output = Result; type IntoFuture = PendingFuture<'a, T>; @@ -438,12 +440,11 @@ where } } -/// See the native impl above; identical minus the `Sync` bound. +/// See the native impl above; identical minus the `Send`/`Sync` bounds, +/// which a single-threaded wasm target does not need and cannot always +/// satisfy. #[cfg(target_arch = "wasm32")] -impl<'a, T: 'a, K> IntoFuture for Pending<'a, T, K> -where - K: DataKeySource, -{ +impl<'a, T: 'a, K: KeysetRegistry> IntoFuture for Pending<'a, T, K> { type Output = Result; type IntoFuture = PendingFuture<'a, T>; @@ -456,10 +457,9 @@ where /// [`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, + key_id: Vec, descriptor: Descriptor, - keyset_id: Uuid, + keyset_id: KeysetId, } /// Issue the batched ZeroKMS calls for `requests`: at most one @@ -467,9 +467,9 @@ struct Retrieve { /// 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( +async fn dispatch( cipher: &StackCipher, - keyset: Option, + keyset: Option, requests: Vec, ) -> Result { let mut generates: Vec = Vec::new(); @@ -478,25 +478,23 @@ async fn dispatch( match request.into_kind() { RequestKind::GenerateDataKey { descriptor } => generates.push(descriptor), RequestKind::RetrieveDataKey { - iv, - tag, + key_id, descriptor, keyset_id, } => retrieves.push(Retrieve { - iv, - tag, + key_id, 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. + // A bound backend binds the 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)) @@ -506,20 +504,21 @@ async fn dispatch( 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. + // 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> = generates + let state = cipher.provider_for(keyset).await?; + let provider = &state.provider; + // Each leaf's descriptor is its binding, rendered. + let bindings: Vec> = generates .iter() - .map(|descriptor| GenerateKeyPayload::new(descriptor.as_str(), Cow::Owned(Vec::new()))) + .map(|descriptor| Binding::new(descriptor.as_str().as_bytes())) .collect(); - let expected = payloads.len(); - let keys = cipher - .kms() - .generate_keys(payloads, Some(keyset), None) - .await?; + let expected = bindings.len(); + let keys = provider + .generate_keys(&bindings) + .await + .map_err(|error| Error::Provider(Box::new(error)))?; if keys.len() != expected { return Err(Error::KeyCountMismatch { expected, @@ -532,8 +531,12 @@ async fn dispatch( // 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)> = Vec::new(); - let mut group_of: HashMap = HashMap::new(); + // + // One call per keyset is not an optimisation this layer chose: a + // provider is bound to one backend key, and ZeroKMS's own protocol + // carries one keyset per request too. + let mut groups: Vec<(KeysetId, Vec)> = Vec::new(); + let mut group_of: HashMap = 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())); @@ -541,22 +544,27 @@ async fn dispatch( }); groups[group].1.push(index); } - let mut retrieved: Vec> = std::iter::repeat_with(|| None) + let mut retrieved: Vec> = std::iter::repeat_with(|| None) .take(retrieves.len()) .collect(); for (keyset_id, indices) in groups { - let payloads: Vec> = indices + let state = cipher.provider_for(keyset_id).await?; + let provider = &state.provider; + let pairs: Vec<(KeyId, Binding<'_>)> = indices .iter() .map(|&index| { let retrieve = &retrieves[index]; - RetrieveKeyPayload::new(retrieve.iv, retrieve.descriptor.as_str(), &retrieve.tag) + ( + KeyId::new(retrieve.key_id.clone()), + Binding::new(retrieve.descriptor.as_str().as_bytes()), + ) }) .collect(); - let expected = payloads.len(); - let keys = cipher - .kms() - .retrieve_keys(payloads, Some(keyset_id), None) - .await?; + let expected = pairs.len(); + let keys = provider + .retrieve_keys(&pairs) + .await + .map_err(|error| Error::Provider(Box::new(error)))?; if keys.len() != expected { return Err(Error::KeyCountMismatch { expected, @@ -569,7 +577,7 @@ async fn dispatch( } // 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 = retrieved + let retrieved: Vec = retrieved .into_iter() .map(|key| key.ok_or(Error::ResponseShape)) .collect::>()?; @@ -582,123 +590,65 @@ 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 crate::registry::fake::{FakeKeysetRegistry, FakeProvider}; + use crate::StackCipherBuilder; 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>>, - retrieve_descriptors: Mutex>>, - /// The keyset each call named, in call order. - generate_keysets: Mutex>>, - retrieve_keysets: Mutex>>, - } - - 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> { - self.generate_descriptors.lock().unwrap().clone() - } - - fn retrieve_descriptors(&self) -> Vec> { - self.retrieve_descriptors.lock().unwrap().clone() - } - - fn generate_keysets(&self) -> Vec> { - self.generate_keysets.lock().unwrap().clone() - } + /// The second keyset, for the tests that cross keysets. + const OTHER: &str = "other"; - fn retrieve_keysets(&self) -> Vec> { - self.retrieve_keysets.lock().unwrap().clone() - } - } - - impl DataKeySource for CountingSource { - async fn generate_keys( - &self, - payloads: Vec>, - keyset_id: Option, - unverified_context: Option>, - ) -> Result, 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>, - keyset_id: Option, - unverified_context: Option<&UnverifiedContext>, - ) -> Result, 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, - ) -> Result<(Uuid, IndexKey), stack_kms::Error> { - self.inner.load_index_key(keyset_id).await - } + /// A registry with two keysets, each with its own provider — so a test + /// that groups retrieves by keyset is grouping something real. + fn registry() -> FakeKeysetRegistry { + FakeKeysetRegistry::new() } fn d() -> Descriptor { Descriptor::of("test/field") } - async fn cipher() -> StackCipher { - StackCipher::builder() - .kms(CountingSource::default()) + async fn cipher() -> StackCipher { + StackCipherBuilder::new() + .registry(registry()) .init() .await .unwrap() } + /// The provider serving a cipher's default keyset: the counter and the + /// binding record in one. + fn default_provider(cipher: &StackCipher) -> FakeProvider { + cipher.registry().default_keyset().1 + } + + /// The provider serving the second keyset. Which provider served a call + /// is how the routing is observed now: a keyset *is* a provider, so + /// there is no keyset argument on a call to assert against. + fn other_provider(cipher: &StackCipher) -> FakeProvider { + cipher + .registry() + .keyset(OTHER) + .expect("registered keyset") + .1 + } + /// A pending that asks for `n` data keys and resolves to their tags. fn generating<'a>( - keyset: &'a KeysetCipher<'_, CountingSource>, + keyset: &'a KeysetCipher<'_, FakeKeysetRegistry>, n: usize, - ) -> Pending<'a, Vec>, CountingSource> { + ) -> Pending<'a, Vec>, FakeKeysetRegistry> { 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)) + .map(|_| { + responses + .next_generated_key() + .map(|key| key.key_id.as_bytes().to_vec()) + }) .collect() }) } @@ -710,11 +660,15 @@ mod tests { assert_eq!(value, 7, "a ready pending resolves to the value it holds"); assert_eq!( - cipher.kms().generate_calls(), + default_provider(&cipher).call_counts().0, 0, "a ready pending must not generate any key" ); - assert_eq!(cipher.kms().retrieve_calls(), 0, "nor retrieve one"); + assert_eq!( + default_provider(&cipher).call_counts().1, + 0, + "nor retrieve one" + ); } #[tokio::test] @@ -727,7 +681,7 @@ mod tests { "the error a ready pending was given comes back: {result:?}" ); assert_eq!( - cipher.kms().generate_calls(), + default_provider(&cipher).call_counts().0, 0, "a failed pending does no I/O" ); @@ -765,7 +719,7 @@ mod tests { assert_eq!(tags, 3, "map sees all three keys the pending asked for"); assert_eq!( - cipher.kms().generate_calls(), + default_provider(&cipher).call_counts().0, 1, "mapping does not split the batch" ); @@ -779,7 +733,7 @@ mod tests { assert_eq!(tags.len(), 5, "every requested key comes back"); assert_eq!( - cipher.kms().generate_calls(), + default_provider(&cipher).call_counts().0, 1, "five keys, one generate_keys call" ); @@ -800,7 +754,7 @@ mod tests { "each side draws exactly its own keys" ); assert_eq!( - cipher.kms().generate_calls(), + default_provider(&cipher).call_counts().0, 1, "zipping merges the two request lists into one call" ); @@ -832,7 +786,7 @@ mod tests { assert_eq!(pair, (1, "two"), "both ready values resolve, in order"); assert_eq!( - cipher.kms().generate_calls(), + default_provider(&cipher).call_counts().0, 0, "nothing was requested, so nothing is dispatched" ); @@ -867,7 +821,7 @@ mod tests { assert_eq!(column.len(), 5, "every item resolves, in build order"); assert_eq!( - cipher.kms().generate_calls(), + default_provider(&cipher).call_counts().0, 1, "a whole column is one generate_keys call" ); @@ -885,7 +839,11 @@ mod tests { let column: Vec = 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"); + assert_eq!( + default_provider(&cipher).call_counts().0, + 0, + "and dispatches nothing" + ); } #[tokio::test] @@ -919,7 +877,7 @@ mod tests { 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); + assert_eq!(default_provider(&cipher).call_counts().0, 0); } /// Over-drawing is the fulfilment's own error, not a stolen sibling key: @@ -932,7 +890,9 @@ mod tests { 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) + responses + .next_generated_key() + .map(|key| key.key_id.as_bytes().to_vec()) }); let result = greedy.zip(generating(&keyset, 1)).await; @@ -973,7 +933,9 @@ mod tests { let keyset = cipher.default_keyset(); let requests = vec![Request::generate_under(d()), Request::generate_under(d())]; let lazy: Pending<'_, Vec, _> = Pending::request(&keyset, requests, |responses| { - responses.next_generated_key().map(|key| key.tag) + responses + .next_generated_key() + .map(|key| key.key_id.as_bytes().to_vec()) }); let result = lazy.await; @@ -990,13 +952,15 @@ mod tests { 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 (key_id, _) = pairs.remove(0); let requests = vec![ Request::generate_under(d()), - Request::retrieve_under(iv, tag, d(), keyset.keyset_id()), + Request::retrieve_under(key_id, d(), keyset.keyset_id()), ]; let lazy: Pending<'_, Vec, _> = Pending::request(&keyset, requests, |responses| { - responses.next_generated_key().map(|key| key.tag) + responses + .next_generated_key() + .map(|key| key.key_id.as_bytes().to_vec()) }); let result = lazy.await; @@ -1016,23 +980,30 @@ mod tests { assert_eq!(value, 9, "a request-free pending still resolves"); assert_eq!( - cipher.kms().generate_calls(), + default_provider(&cipher).call_counts().0, 0, "no requests, no generate_keys call" ); assert_eq!( - cipher.kms().retrieve_calls(), + default_provider(&cipher).call_counts().1, 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. + /// One minted key, as the test needs it back: the opaque id that + /// retrieves it, and the material it was minted as, so a retrieval can + /// be checked against what was generated. + type Minted = (Vec, [u8; 32]); + + /// A pending that asks for `n` data keys and resolves to what each was + /// minted as. fn generating_pairs<'a>( - keyset: &'a KeysetCipher<'_, CountingSource>, + keyset: &'a KeysetCipher<'_, FakeKeysetRegistry>, n: usize, - ) -> Pending<'a, Vec<(Iv, Vec)>, CountingSource> { + ) -> Pending<'a, Vec, FakeKeysetRegistry> { + use vitaminc_protected_kms::Controlled as _; + let requests = std::iter::repeat_with(|| Request::generate_under(d())) .take(n) .collect(); @@ -1041,7 +1012,7 @@ mod tests { .map(|_| { responses .next_generated_key() - .map(|key| (key.key.iv, key.tag)) + .map(|key| (key.key_id.as_bytes().to_vec(), key.plaintext.risky_unwrap())) }) .collect() }) @@ -1055,14 +1026,14 @@ mod tests { let keyset = cipher.default_keyset(); let pairs = generating_pairs(&keyset, 2).await.unwrap(); assert_eq!( - cipher.kms().generate_calls(), + default_provider(&cipher).call_counts().0, 1, "the setup seal is one call" ); let requests: Vec = pairs .iter() - .map(|(iv, tag)| Request::retrieve_under(*iv, tag.clone(), d(), keyset.keyset_id())) + .map(|(key_id, _)| Request::retrieve_under(key_id.clone(), d(), keyset.keyset_id())) .collect(); let retrieve: Pending<'_, usize, _> = Pending::request(&keyset, requests, |responses| { Ok(responses.drain_retrieved().count()) @@ -1072,12 +1043,12 @@ mod tests { 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(), + default_provider(&cipher).call_counts().0, 2, "one generate for the setup, one for the mixed assembly" ); assert_eq!( - cipher.kms().retrieve_calls(), + default_provider(&cipher).call_counts().1, 1, "the mixed assembly retrieves in one call" ); @@ -1094,29 +1065,33 @@ mod tests { Request::generate_under(Descriptor::of(("users", "email"))), Request::generate_under(Descriptor::of(("users", "name"))), ]; - let pairs: Vec<(Iv, Vec)> = Pending::request(&keyset, requests, |responses| { + let pairs: Vec> = Pending::request(&keyset, requests, |responses| { (0..2) .map(|_| { responses .next_generated_key() - .map(|key| (key.key.iv, key.tag)) + .map(|key| key.key_id.as_bytes().to_vec()) }) .collect() }) .await .unwrap(); assert_eq!( - cipher.kms().generate_descriptors(), + default_provider(&cipher).calls().generate, vec![vec!["users/email".to_owned(), "users/name".to_owned()]] ); + // Retrieved in the reverse order they were minted, each key still + // under its *own* descriptor — the provider is `Bound`, so pairing a + // key with another's descriptor is refused rather than ignored, and + // what is being proven here is the order, not a mismatch. let requests: Vec = pairs .iter() + .rev() .zip([("users", "name"), ("users", "email")]) - .map(|((iv, tag), descriptor)| { + .map(|(key_id, descriptor)| { Request::retrieve_under( - *iv, - tag.clone(), + key_id.clone(), Descriptor::of(descriptor), keyset.keyset_id(), ) @@ -1130,7 +1105,7 @@ mod tests { assert_eq!(count, 2, "both keys were retrieved"); assert_eq!( - cipher.kms().retrieve_descriptors(), + default_provider(&cipher).calls().retrieve, vec![vec!["users/name".to_owned(), "users/email".to_owned()]] ); } @@ -1155,26 +1130,26 @@ mod tests { "{err}" ); assert_eq!( - cipher.kms().generate_calls(), + default_provider(&cipher).call_counts().0, 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 (key_id, _) = pairs.remove(0); + let requests = vec![Request::retrieve_under(key_id, 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(), + default_provider(&cipher).call_counts().1, 0, "and none is retrieved either" ); // At the limit is fine. - let before = cipher.kms().generate_calls(); + let before = default_provider(&cipher).call_counts().0; let requests = vec![Request::generate_under(Descriptor::of( "a".repeat(Descriptor::MAX_LEN), @@ -1186,7 +1161,7 @@ mod tests { "at the limit" ); assert_eq!( - cipher.kms().generate_calls(), + default_provider(&cipher).call_counts().0, before + 1, "a descriptor exactly at the limit is dispatched" ); @@ -1204,13 +1179,15 @@ mod tests { let cipher = cipher().await; let pending: Pending<'_, Vec, _> = Pending::request(&cipher, vec![Request::generate_under(d())], |responses| { - responses.next_generated_key().map(|key| key.tag) + responses + .next_generated_key() + .map(|key| key.key_id.as_bytes().to_vec()) }); let result = pending.await; assert!(matches!(result, Err(Error::NoKeyset)), "{result:?}"); assert_eq!( - cipher.kms().generate_calls(), + default_provider(&cipher).call_counts().0, 0, "refused at construction, before any call" ); @@ -1221,13 +1198,18 @@ mod tests { #[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(); + let tenant = cipher.keyset(OTHER).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" + other_provider(&cipher).call_counts().0, + 1, + "the scope's own keyset minted the keys" + ); + assert_eq!( + default_provider(&cipher).call_counts().0, + 0, + "and no other keyset was asked" ); } @@ -1237,10 +1219,10 @@ mod tests { 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 other = KeysetId::new(uuid::Uuid::from_u128(404)); let pending: Pending<'_, usize, _> = Pending::request( &keyset, - vec![Request::retrieve_under(Iv::default(), vec![1], d(), other)], + vec![Request::retrieve_under(vec![1], d(), other)], |responses| Ok(responses.drain_retrieved().count()), ); let result = pending.await; @@ -1254,7 +1236,7 @@ mod tests { "{result:?}" ); assert_eq!( - cipher.kms().retrieve_calls(), + default_provider(&cipher).call_counts().1, 0, "refused at construction, before any key is retrieved" ); @@ -1266,10 +1248,10 @@ mod tests { 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 other = KeysetId::new(uuid::Uuid::from_u128(404)); let pending: Pending<'_, usize, _> = Pending::request( &cipher, - vec![Request::retrieve_under(Iv::default(), vec![1], d(), other)], + vec![Request::retrieve_under(vec![1], d(), other)], |responses| Ok(responses.drain_retrieved().count()), ); let result = pending.scoped_to(keyset.keyset_id()).await; @@ -1279,7 +1261,7 @@ mod tests { "{result:?}" ); assert_eq!( - cipher.kms().retrieve_calls(), + default_provider(&cipher).call_counts().1, 0, "scoping applies the rule before any key is retrieved" ); @@ -1290,13 +1272,13 @@ mod tests { #[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 a = cipher.default_keyset(); + let b = cipher.keyset(OTHER).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)), + if left == a.keyset_id() && right == b.keyset_id()), "{result:?}" ); @@ -1306,7 +1288,7 @@ mod tests { "{result:?}" ); assert_eq!( - cipher.kms().generate_calls(), + default_provider(&cipher).call_counts().0, 0, "a mismatched merge mints nothing" ); @@ -1318,16 +1300,17 @@ mod tests { #[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 a = cipher.default_keyset(); + let other = cipher.keyset(OTHER).await.unwrap().keyset_id(); - let result = generating(&a, 1).scoped_to(Uuid::from_u128(2)).await; + let result = generating(&a, 1).scoped_to(other).await; assert!( matches!(result, Err(Error::KeysetMismatch { left, right }) - if left == Uuid::from_u128(1) && right == Uuid::from_u128(2)), + if left == a.keyset_id() && right == other), "{result:?}" ); assert_eq!( - cipher.kms().generate_calls(), + default_provider(&cipher).call_counts().0, 0, "a mismatched scope mints nothing" ); @@ -1342,8 +1325,11 @@ mod tests { "rescoping to the same keyset should produce one tag" ); assert_eq!( - cipher.kms().generate_keysets(), - vec![Some(a.keyset_id())], + ( + default_provider(&cipher).call_counts().0, + other_provider(&cipher).call_counts().0 + ), + (1, 0), "minted under the one keyset it was scoped to" ); } @@ -1354,8 +1340,8 @@ mod tests { #[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 a = cipher.default_keyset(); + let b = cipher.keyset(OTHER).await.unwrap(); let scoped = Pending::ready(&cipher, Ok(())).scoped_to(a.keyset_id()); let result = scoped.zip(generating(&b, 1)).await; @@ -1365,8 +1351,11 @@ mod tests { "{result:?}" ); assert_eq!( - cipher.kms().generate_calls(), - 0, + ( + default_provider(&cipher).call_counts().0, + other_provider(&cipher).call_counts().0 + ), + (0, 0), "a mismatched merge should not mint a key" ); } @@ -1377,17 +1366,22 @@ mod tests { #[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 tenant = cipher.keyset(OTHER).await.unwrap(); let unscoped: Pending<'_, Vec, _> = Pending::request(&cipher, vec![Request::generate_under(d())], |responses| { - responses.next_generated_key().map(|key| key.tag) + responses + .next_generated_key() + .map(|key| key.key_id.as_bytes().to_vec()) }); let result = unscoped.zip(generating(&tenant, 1)).await; assert!(matches!(result, Err(Error::NoKeyset)), "{result:?}"); assert_eq!( - cipher.kms().generate_calls(), - 0, + ( + default_provider(&cipher).call_counts().0, + other_provider(&cipher).call_counts().0 + ), + (0, 0), "the tenant's keyset mints nothing for it" ); } @@ -1409,9 +1403,9 @@ mod tests { "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" + default_provider(&cipher).call_counts().0, + 1, + "the merged assembly minted through the scoped side's keyset" ); } @@ -1421,8 +1415,8 @@ mod tests { #[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 a = cipher.default_keyset(); + let b = cipher.keyset(OTHER).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)); @@ -1430,30 +1424,32 @@ mod tests { // 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()), + Request::retrieve_under(a1.0.clone(), d(), a.keyset_id()), + Request::retrieve_under(b1.0.clone(), d(), b.keyset_id()), + Request::retrieve_under(a2.0.clone(), d(), a.keyset_id()), ]; - let ivs: Vec = Pending::request(&cipher, requests, |responses| { - Ok(responses.drain_retrieved().map(|key| key.iv).collect()) + let material: Vec<[u8; 32]> = Pending::request(&cipher, requests, |responses| { + use vitaminc_protected_kms::Controlled as _; + Ok(responses + .drain_retrieved() + .map(|key| key.risky_unwrap()) + .collect()) }) .await .unwrap(); assert_eq!( - ivs, - vec![a1.0, b1.0, a2.0], + material, + vec![a1.1, b1.1, a2.1], "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" + ( + default_provider(&cipher).call_counts().1, + other_provider(&cipher).call_counts().1 + ), + (1, 1), + "two keysets, one batched retrieve each — not one call per key" ); } } diff --git a/packages/stack-encrypt/src/target/request.rs b/packages/stack-encrypt/src/target/request.rs index 10257b69d..e924d2540 100644 --- a/packages/stack-encrypt/src/target/request.rs +++ b/packages/stack-encrypt/src/target/request.rs @@ -14,11 +14,15 @@ use super::context::AeadContext; use std::collections::VecDeque; -use stack_kms::{DataKey, DataKeyWithTag, Iv}; -use uuid::Uuid; +use vitaminc_kms::GeneratedDataKey; +use crate::registry::KeysetId; use crate::{Descriptor, Error}; +/// One retrieved data key's material, in the `Protected` that +/// `vitaminc-kms` was built against. +pub type ProviderKey = vitaminc_protected_kms::Protected<[u8; 32]>; + /// 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. @@ -30,14 +34,12 @@ 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. + /// Re-derive the data key `key_id` names, under the `descriptor` it was + /// generated with, from the keyset it was minted under. RetrieveDataKey { - iv: Iv, - tag: Vec, + key_id: Vec, descriptor: Descriptor, - keyset_id: Uuid, + keyset_id: KeysetId, }, } @@ -62,19 +64,18 @@ impl Request { Self::generate_under(Descriptor::of(context.into())) } - /// Request re-derivation of the data key identified by `iv` + `tag` + /// Request re-derivation of the data key `key_id` names /// (decrypt side), under `context` — which must be the one the key was - /// generated under, or ZeroKMS refuses — from `keyset_id`, the keyset it + /// generated under, or a bound backend 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, + key_id: Vec, context: impl Into, - keyset_id: Uuid, + keyset_id: KeysetId, ) -> Self { - Self::retrieve_under(iv, tag, Descriptor::of(context.into()), keyset_id) + Self::retrieve_under(key_id, Descriptor::of(context.into()), keyset_id) } /// [`generate_data_key`](Self::generate_data_key) over a descriptor that @@ -93,14 +94,12 @@ impl Request { /// rendered descriptor. Crate-internal, as /// [`generate_under`](Self::generate_under). pub(crate) fn retrieve_under( - iv: Iv, - tag: Vec, + key_id: Vec, descriptor: Descriptor, - keyset_id: Uuid, + keyset_id: KeysetId, ) -> Self { Self(RequestKind::RetrieveDataKey { - iv, - tag, + key_id, descriptor, keyset_id, }) @@ -113,7 +112,7 @@ impl Request { /// 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 { + pub(super) fn retrieve_keyset(&self) -> Option { match &self.0 { RequestKind::GenerateDataKey { .. } => None, RequestKind::RetrieveDataKey { keyset_id, .. } => Some(*keyset_id), @@ -139,13 +138,13 @@ pub(super) fn tally(requests: &[Request]) -> (usize, usize) { /// asked for (never a neighbour's), and drawing past that is /// [`Error::ResponseShape`]. pub struct Responses { - generated: VecDeque, - retrieved: VecDeque, + generated: VecDeque>, + retrieved: VecDeque, } impl Responses { /// The full response set of one batched dispatch, in request order. - pub(super) fn new(generated: Vec, retrieved: Vec) -> Self { + pub(super) fn new(generated: Vec>, retrieved: Vec) -> Self { Self { generated: generated.into(), retrieved: retrieved.into(), @@ -153,12 +152,12 @@ impl Responses { } /// The next generated data key, in [`Request::generate_data_key`] order. - pub fn next_generated_key(&mut self) -> Result { + pub fn next_generated_key(&mut self) -> Result, 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 { + pub fn next_retrieved_key(&mut self) -> Result { self.retrieved.pop_front().ok_or(Error::ResponseShape) } @@ -187,11 +186,11 @@ impl Responses { self.generated.is_empty() && self.retrieved.is_empty() } - pub(crate) fn drain_generated(&mut self) -> impl Iterator + '_ { + pub(crate) fn drain_generated(&mut self) -> impl Iterator> + '_ { self.generated.drain(..) } - pub(crate) fn drain_retrieved(&mut self) -> impl Iterator + '_ { + pub(crate) fn drain_retrieved(&mut self) -> impl Iterator + '_ { self.retrieved.drain(..) } } @@ -200,9 +199,7 @@ impl Responses { mod tests { #![allow(clippy::unwrap_used, clippy::panic)] - use std::borrow::Cow; - - use stack_kms::{DataKeySource, FakeDataKeySource, GenerateKeyPayload, RetrieveKeyPayload}; + use vitaminc_protected_kms::Controlled as _; use super::*; use crate::{ContextPiece, IntoContext, MaybeEmpty, NonEmpty}; @@ -214,34 +211,22 @@ mod tests { /// `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, Vec) { - 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(); + async fn key_pairs(n: usize) -> (Vec>, Vec) { + use vitaminc_kms::provider::{Binding, FakeKeyProvider, KeyProvider as _}; + + let provider = FakeKeyProvider::<32>::new(); + let bindings: Vec> = (0..n).map(|_| Binding::EMPTY).collect(); + let generated = provider.generate_keys(&bindings).await.unwrap(); + let pairs: Vec<_> = generated + .iter() + .map(|key| (key.key_id.clone(), Binding::EMPTY)) + .collect(); + let retrieved = provider.retrieve_keys(&pairs).await.unwrap(); (generated, retrieved) } - fn ks() -> Uuid { - Uuid::from_u128(7) + fn ks() -> KeysetId { + KeysetId::new(uuid::Uuid::from_u128(7)) } async fn responses(generated: usize, retrieved: usize) -> Responses { @@ -259,9 +244,9 @@ mod tests { fn tally_separates_the_two_kinds() { let requests = vec![ Request::generate_under(d()), - Request::retrieve_under(Iv::default(), vec![1], d(), ks()), + Request::retrieve_under(vec![1], d(), ks()), Request::generate_under(d()), - Request::retrieve_under(Iv::default(), vec![2], d(), ks()), + Request::retrieve_under(vec![2], d(), ks()), Request::generate_under(d()), ]; assert_eq!(tally(&requests), (3, 2)); @@ -279,7 +264,7 @@ mod tests { 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() { + match Request::retrieve_data_key(vec![1], context, ks()).into_kind() { RequestKind::RetrieveDataKey { descriptor, .. } => assert_eq!(descriptor, expected), RequestKind::GenerateDataKey { .. } => panic!("expected a retrieve request"), } @@ -311,7 +296,7 @@ mod tests { 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() { + match Request::retrieve_data_key(vec![1], context, ks()).into_kind() { RequestKind::RetrieveDataKey { descriptor, .. } => { assert_eq!(descriptor.as_str(), "acme") } @@ -328,18 +313,16 @@ mod tests { } #[test] - fn a_retrieve_request_carries_its_iv_tag_and_descriptor() { - let request = Request::retrieve_under(Iv::default(), vec![7, 8, 9], d(), ks()); + fn a_retrieve_request_carries_its_key_id_keyset_and_descriptor() { + let request = Request::retrieve_under(vec![7, 8, 9], d(), ks()); match request.into_kind() { RequestKind::RetrieveDataKey { - iv, - tag, + key_id, descriptor, keyset_id, } => { assert_eq!(keyset_id, ks()); - assert_eq!(iv, Iv::default()); - assert_eq!(tag, vec![7, 8, 9]); + assert_eq!(key_id, vec![7, 8, 9]); assert_eq!(descriptor, d()); } RequestKind::GenerateDataKey { .. } => panic!("expected a retrieve request"), @@ -349,22 +332,36 @@ mod tests { #[tokio::test] async fn generated_keys_come_back_in_request_order() { let (generated, _) = key_pairs(3).await; - let tags: Vec> = generated.iter().map(|key| key.tag.clone()).collect(); + let ids: Vec> = generated + .iter() + .map(|key| key.key_id.as_bytes().to_vec()) + .collect(); let mut responses = Responses::new(generated, Vec::new()); - for tag in tags { - assert_eq!(responses.next_generated_key().unwrap().tag, tag); + for id in ids { + assert_eq!( + responses.next_generated_key().unwrap().key_id.as_bytes(), + id + ); } } #[tokio::test] async fn retrieved_keys_come_back_in_request_order() { let (_, retrieved) = key_pairs(3).await; - let ivs: Vec = retrieved.iter().map(|key| key.iv).collect(); + // A retrieved key is material, not a handle — its own bytes are the + // only thing that identifies it in order. + let material: Vec<[u8; 32]> = retrieved + .iter() + .map(|key| key.clone().risky_unwrap()) + .collect(); let mut responses = Responses::new(Vec::new(), retrieved); - for iv in ivs { - assert_eq!(responses.next_retrieved_key().unwrap().iv, iv); + for bytes in material { + assert_eq!( + responses.next_retrieved_key().unwrap().risky_unwrap(), + bytes + ); } } @@ -422,15 +419,21 @@ mod tests { #[tokio::test] async fn split_front_takes_from_the_front_and_leaves_the_rest() { let (generated, _) = key_pairs(3).await; - let tags: Vec> = generated.iter().map(|key| key.tag.clone()).collect(); + let ids: Vec> = generated + .iter() + .map(|key| key.key_id.as_bytes().to_vec()) + .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]); + assert_eq!( + first.next_generated_key().unwrap().key_id.as_bytes(), + ids[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]); + assert_eq!(rest.next_generated_key().unwrap().key_id.as_bytes(), ids[1]); + assert_eq!(rest.next_generated_key().unwrap().key_id.as_bytes(), ids[2]); } #[tokio::test] diff --git a/packages/stack-encrypt/tests/common/mod.rs b/packages/stack-encrypt/tests/common/mod.rs index 20fdd0fff..97d612ef6 100644 --- a/packages/stack-encrypt/tests/common/mod.rs +++ b/packages/stack-encrypt/tests/common/mod.rs @@ -1,221 +1,88 @@ //! 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 +//! Everything runs on [`FakeKeysetRegistry`], which gives each keyset its +//! own provider — its own index key and its own key material — so a test +//! that crosses keysets is testing something. The provider counts *calls*, +//! not keys: 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. +//! +//! The fake provider is `Bound`, so retrieving a key under a descriptor it +//! was not minted with fails here exactly as it would at ZeroKMS. Tests +//! assert against the bindings it records, never against a stub's +//! (non-)enforcement. // 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 std::sync::Arc; -use stack_encrypt::StackCipher; -use stack_kms::{ - DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IdentifiedBy, - IndexKey, IndexKeySource, RetrieveKeyPayload, UnverifiedContext, -}; -use uuid::Uuid; +use stack_encrypt::registry::fake::{Calls, FakeKeysetRegistry, FakeProvider}; +use stack_encrypt::{StackCipher, StackCipherBuilder}; -/// 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 { - StackCipher::builder() - .kms(FakeDataKeySource::new()) - .init() - .await - .expect("build cipher") -} +/// The name the secondary keyset is registered under, for tests that cross +/// keysets. +pub const OTHER_KEYSET: &str = "other"; -pub struct CountingSource { - inner: FakeDataKeySource, - generate_calls: Arc, - retrieve_calls: Arc, - load_calls: Arc, +/// A cipher over the deterministic fake registry. 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 { + cipher_over(FakeKeysetRegistry::new()).await } -impl CountingSource { - pub fn new() -> Self { - Self { - inner: FakeDataKeySource::new(), - generate_calls: Arc::new(AtomicUsize::new(0)), - retrieve_calls: Arc::new(AtomicUsize::new(0)), - load_calls: Arc::new(AtomicUsize::new(0)), - } - } - - /// The counter of keyset lookups (`load_index_key` calls): what loading - /// a keyset by id or name asks ZeroKMS. - pub fn loads(&self) -> Arc { - self.load_calls.clone() - } - - pub fn counters(&self) -> (Arc, Arc) { - (self.generate_calls.clone(), self.retrieve_calls.clone()) - } -} - -impl DataKeySource for CountingSource { - async fn generate_keys( - &self, - payloads: Vec>, - keyset_id: Option, - unverified_context: Option>, - ) -> Result, 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>, - keyset_id: Option, - unverified_context: Option<&UnverifiedContext>, - ) -> Result, 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, - ) -> Result<(Uuid, IndexKey), stack_kms::Error> { - self.load_calls.fetch_add(1, AtomicOrdering::SeqCst); - self.inner.load_index_key(keyset_id).await - } -} - -/// A cipher over [`CountingSource`], with its counter of keyset lookups, -/// zeroed once the cipher has loaded its default keyset. -pub async fn loads_counting_cipher() -> (StackCipher, Arc) { - let source = CountingSource::new(); - let loads = source.loads(); - let cipher = StackCipher::builder() - .kms(source) - .init() - .await - .expect("build cipher"); +/// A cipher over the fake registry, with its counter of keyset lookups +/// (`resolve` calls), zeroed once the cipher has loaded its default keyset. +pub async fn loads_counting_cipher() -> (StackCipher, Arc) { + let registry = FakeKeysetRegistry::new(); + let loads = registry.resolves(); + let cipher = cipher_over(registry).await; loads.store(0, AtomicOrdering::SeqCst); (cipher, loads) } -/// A cipher over [`CountingSource`], with its `(generate, retrieve)` call -/// counters. -pub async fn counting_cipher() -> ( - StackCipher, - Arc, - Arc, -) { - let source = CountingSource::new(); - let (generates, retrieves) = source.counters(); - let cipher = StackCipher::builder() - .kms(source) +/// A cipher over `registry`. +pub async fn cipher_over(registry: FakeKeysetRegistry) -> StackCipher { + StackCipherBuilder::new() + .registry(registry) .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>, - pub retrieve: Vec>, -} - -impl SentDescriptors { - /// Every generate-side descriptor, all calls flattened. - pub fn generated(&self) -> Vec { - self.generate.iter().flatten().cloned().collect() - } - - /// Every retrieve-side descriptor, all calls flattened. - pub fn retrieved(&self) -> Vec { - 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>, + .expect("build cipher") } -impl RecordingSource { - pub fn new() -> Self { - Self { - inner: FakeDataKeySource::new(), - sent: Arc::new(Mutex::new(SentDescriptors::default())), - } - } - - pub fn sent(&self) -> Arc> { - self.sent.clone() - } +/// A cipher and the provider serving its default keyset, which counts the +/// batched calls it serves and records every binding it is sent. +/// +/// One handle for both: the provider *is* the counter and the recorder, so +/// there is nothing to keep in step. +pub async fn counting_cipher() -> (StackCipher, FakeProvider) { + let registry = FakeKeysetRegistry::new(); + let (_, provider) = registry.default_keyset(); + (cipher_over(registry).await, provider) } -impl DataKeySource for RecordingSource { - async fn generate_keys( - &self, - payloads: Vec>, - keyset_id: Option, - unverified_context: Option>, - ) -> Result, 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>, - keyset_id: Option, - unverified_context: Option<&UnverifiedContext>, - ) -> Result, 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 - } +/// Alias for [`counting_cipher`]: the same fixture, named for the tests +/// that care about descriptors rather than call counts. +pub async fn recording_cipher() -> (StackCipher, FakeProvider) { + counting_cipher().await } -impl IndexKeySource for RecordingSource { - async fn load_index_key( - &self, - keyset_id: Option, - ) -> Result<(Uuid, IndexKey), stack_kms::Error> { - self.inner.load_index_key(keyset_id).await - } +/// A cipher, its default keyset's provider, and the second keyset's id and +/// provider — for tests that cross keysets. +pub async fn two_keyset_cipher() -> ( + StackCipher, + FakeProvider, + stack_encrypt::KeysetId, + FakeProvider, +) { + let registry = FakeKeysetRegistry::new(); + let (_, default) = registry.default_keyset(); + let (other_id, other) = registry.keyset(OTHER_KEYSET).expect("registered keyset"); + (cipher_over(registry).await, default, other_id, other) } -/// A cipher over [`RecordingSource`], with the descriptors it sends. -pub async fn recording_cipher() -> (StackCipher, Arc>) { - let source = RecordingSource::new(); - let sent = source.sent(); - let cipher = StackCipher::builder() - .kms(source) - .init() - .await - .expect("build cipher"); - (cipher, sent) +/// Every binding sent to `provider`, grouped by call. +pub fn sent(provider: &FakeProvider) -> Calls { + provider.calls() } diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index d5b87a823..8380da12b 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -6,7 +6,7 @@ mod common; -use std::sync::atomic::Ordering as AtomicOrdering; +use stack_encrypt::KeysetRegistry; use cllw_ore::CllwOreEncrypt; use common::{counting_cipher, recording_cipher, stack_cipher}; @@ -242,7 +242,8 @@ where EqualityTerm: EncryptFrom, { type Context = >::Context; - fn encryption<'s, K: 'static>() -> stack_encrypt::Encryption<'s, S, Self, K, Self::Context> + fn encryption<'s, K: KeysetRegistry + 'static>( + ) -> stack_encrypt::Encryption<'s, S, Self, K, Self::Context> where S: 's, { @@ -299,7 +300,7 @@ impl Decryptable for Lying { } impl DecryptField for Lying { - fn decryption_field( + fn decryption_field( self, _context: Ctx, ) -> Option> { @@ -426,19 +427,21 @@ async fn a_ciphertext_only_record_accepts_an_aead_only_context_like_the_leaf_doe .unwrap(); assert_eq!(opened, name); - // Bound to the context like any other leaf. + // Bound to the context like any other leaf. The fake provider is + // `Bound`, as ZeroKMS is, so the wrong context is refused at the + // backend — before the AEAD ever runs. let record: SealedName = name .encrypt_into_with_context(&keyset, tenant()) .await .unwrap(); let other = NonEmpty::new(Tenant("other".into())).unwrap(); let result: Result = record.decrypt_into(&cipher, other).await; - assert!(matches!(result, Err(Error::Aead)), "{result:?}"); + assert!(matches!(result, Err(Error::Provider(_))), "{result:?}"); } #[tokio::test] async fn a_failed_field_fails_the_derived_record_before_any_io() { - let (cipher, generates, _) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let keyset = cipher.default_keyset(); // Text that yields no match tokens fails that leaf during the @@ -449,7 +452,7 @@ async fn a_failed_field_fails_the_derived_record_before_any_io() { .encrypt_into_with_context(&keyset, nonempty!("users/name")) .await; assert!(matches!(result, Err(Error::Term(_)))); - assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); + assert_eq!(provider.call_counts().0, 0); } // --- Structs: each field from one field of the plaintext, under its own context @@ -488,7 +491,7 @@ fn user() -> User { #[tokio::test] async fn a_struct_is_one_batched_call_and_rebuilds_its_plaintext() { - let (cipher, generates, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let keyset = cipher.default_keyset(); let generator = stack_cipher().await; let generator = generator.default_keyset(); @@ -497,7 +500,7 @@ async fn a_struct_is_one_batched_call_and_rebuilds_its_plaintext() { // 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), + provider.call_counts().0, 1, "a two-ciphertext struct must be ONE generate_keys call" ); @@ -526,7 +529,7 @@ async fn a_struct_is_one_batched_call_and_rebuilds_its_plaintext() { let recovered = User::decrypt_from(row, &cipher).await.unwrap(); assert_eq!(recovered, user()); assert_eq!( - retrieves.load(AtomicOrdering::SeqCst), + provider.call_counts().1, 1, "opening a two-ciphertext struct must be ONE retrieve_keys call" ); @@ -534,7 +537,7 @@ async fn a_struct_is_one_batched_call_and_rebuilds_its_plaintext() { #[tokio::test] async fn a_column_of_structs_is_still_one_call_each_way() { - let (cipher, generates, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let keyset = cipher.default_keyset(); let users: Vec = (0..4) @@ -546,16 +549,16 @@ async fn a_column_of_structs_is_still_one_call_each_way() { let rows: Vec = users.encrypt_into(&keyset).await.unwrap(); assert_eq!(rows.len(), 4); - assert_eq!(generates.load(AtomicOrdering::SeqCst), 1); + assert_eq!(provider.call_counts().0, 1); let recovered = Vec::::decrypt_from(rows, &cipher).await.unwrap(); assert_eq!(recovered, users); - assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 1); + assert_eq!(provider.call_counts().1, 1); } #[tokio::test] async fn a_struct_extends_its_contexts_with_the_callers() { - let (cipher, generates, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let keyset = cipher.default_keyset(); let generator = stack_cipher().await; let generator = generator.default_keyset(); @@ -567,7 +570,7 @@ async fn a_struct_extends_its_contexts_with_the_callers() { .encrypt_into_with_context(&keyset, 7u64) .await .unwrap(); - assert_eq!(generates.load(AtomicOrdering::SeqCst), 1); + assert_eq!(provider.call_counts().0, 1); let age_hm: EqualityTerm = 42u32 .encrypt_into_with_context(&generator, nonempty!("user").with("age").with(7u64)) .await @@ -584,23 +587,28 @@ async fn a_struct_extends_its_contexts_with_the_callers() { .await .unwrap(); assert_eq!(recovered, user()); - assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 1); + assert_eq!(provider.call_counts().1, 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. + // The fake provider binds the descriptor, as ZeroKMS does, so a wrong + // context is refused at the key retrieval rather than by the AEAD + // afterwards — `examples/encrypted_record.rs` shows the same live. let other_row = User::decrypt_from_with_context(row, &cipher, 8u64).await; - assert!(matches!(other_row, Err(Error::Aead))); + assert!( + matches!(other_row, Err(Error::Provider(_))), + "{other_row:?}" + ); let row: EncryptedUser = user() .encrypt_into_with_context(&keyset, 7u64) .await .unwrap(); + // Opening with no context at all is a different descriptor again, so + // the backend refuses it for the same reason. let no_row = User::decrypt_from(row, &cipher).await; - assert!(matches!(no_row, Err(Error::Aead))); + assert!(matches!(no_row, Err(Error::Provider(_))), "{no_row:?}"); // Any context does: a string, a pair, an `Option`. let row: EncryptedUser = user() @@ -620,15 +628,17 @@ async fn a_struct_field_opened_under_the_wrong_context_fails() { 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. + // is caught exactly as for a leaf: by the backend's descriptor check, + // the fake binding descriptors as ZeroKMS does. let transplanted: Result = row .age .c .decrypt_into(&cipher, nonempty!("user").with("height")) .await; - assert!(matches!(transplanted, Err(Error::Aead))); + assert!( + matches!(transplanted, Err(Error::Provider(_))), + "{transplanted:?}" + ); } #[derive(Debug, Clone, PartialEq, Eq)] @@ -650,7 +660,7 @@ struct EncryptedAccount { #[tokio::test] async fn a_struct_nests_in_a_struct_as_an_ordinary_field() { - let (cipher, generates, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let keyset = cipher.default_keyset(); let generator = stack_cipher().await; let generator = generator.default_keyset(); @@ -660,7 +670,7 @@ async fn a_struct_nests_in_a_struct_as_an_ordinary_field() { plan: "pro".to_string(), }; let row: EncryptedAccount = account.encrypt_into(&keyset).await.unwrap(); - assert_eq!(generates.load(AtomicOrdering::SeqCst), 1); + assert_eq!(provider.call_counts().0, 1); // The inner struct's fields are under their own contexts, extended by // the outer field's pair. @@ -677,7 +687,7 @@ async fn a_struct_nests_in_a_struct_as_an_ordinary_field() { let recovered = Account::decrypt_from(row, &cipher).await.unwrap(); assert_eq!(recovered, account); - assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 1); + assert_eq!(provider.call_counts().1, 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. diff --git a/packages/stack-encrypt/tests/descriptor.rs b/packages/stack-encrypt/tests/descriptor.rs index 89dc05671..1f849a420 100644 --- a/packages/stack-encrypt/tests/descriptor.rs +++ b/packages/stack-encrypt/tests/descriptor.rs @@ -1,7 +1,7 @@ //! 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 +//! The fake source ignores descriptors (see `FakeKeysetRegistry`'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. @@ -15,6 +15,7 @@ use stack_encrypt::{ nonempty, DecryptInto, Describe, Descriptor, EncryptFrom, Error, Label, NonEmpty, StackCipherText, }; +use std::sync::atomic::{AtomicUsize, Ordering}; #[derive(EncryptFrom, DecryptInto)] #[stash(plaintext = u32)] @@ -58,7 +59,7 @@ async fn a_leaf_sends_its_context_as_the_descriptor_both_ways() -> Result<(), Er .decrypt_into(&cipher, nonempty!("users").with("email")) .await?; - let sent = sent.lock().expect("lock").clone(); + let sent = sent.calls(); assert_eq!(sent.generated(), ["users/email"]); assert_eq!(sent.retrieved(), ["users/email"]); Ok(()) @@ -105,7 +106,7 @@ async fn a_struct_sends_one_descriptor_per_field_context() -> Result<(), Error> // The inferred pair `users/email`, the pair `users/nickname` the // field's `identity` keys it under, and the inner record under the pair // `users/age`; the term derives no key. One call each way. - let sent = sent.lock().expect("lock").clone(); + let sent = sent.calls(); assert_eq!(sent.generate.len(), 1, "one generate_keys call"); assert_eq!(sent.retrieve.len(), 1, "one retrieve_keys call"); assert_eq!( @@ -147,7 +148,7 @@ async fn a_callers_context_extends_every_fields_descriptor() -> Result<(), Error ], "a composite context renders readably" ); - let sent = sent.lock().expect("lock").clone(); + let sent = sent.calls(); assert_eq!(sent.generated(), expected); assert_eq!(sent.retrieved(), expected); Ok(()) @@ -167,7 +168,7 @@ async fn every_leaf_of_a_tree_shares_the_root_descriptor() -> Result<(), Error> // 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(); + let sent = sent.calls(); assert_eq!(sent.generated(), ["users/age"; 3]); assert_eq!(sent.retrieved(), ["users/age"; 3]); Ok(()) @@ -184,7 +185,7 @@ async fn the_cipher_directed_path_renders_its_aad_the_same_way() -> Result<(), E let ct = keyset.encrypt(42u32, ()).await?; let _: u32 = cipher.decrypt(ct, ()).await?; - let sent = sent.lock().expect("lock").clone(); + let sent = sent.calls(); assert_eq!(sent.generated(), ["users/age", ""]); assert_eq!(sent.retrieved(), ["users/age", ""]); Ok(()) @@ -195,7 +196,6 @@ async fn the_cipher_directed_path_renders_its_aad_the_same_way() -> Result<(), E /// 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 @@ -248,7 +248,7 @@ async fn a_column_renders_an_over_long_context_once() -> Result<(), Error> { "one rendering on decrypt" ); - let sent = sent.lock().expect("lock").clone(); + let sent = sent.calls(); assert_eq!( sent.generated(), ["users/age"; 3], @@ -281,7 +281,7 @@ async fn a_column_with_nothing_to_bind_takes_any_context() -> Result<(), Error> let opened: Vec = sealed.decrypt_into(&cipher, long).await?; assert!(opened.is_empty()); - let sent = sent.lock().expect("lock").clone(); + let sent = sent.calls(); assert!(sent.generated().is_empty() && sent.retrieved().is_empty()); Ok(()) } @@ -313,7 +313,7 @@ async fn an_over_long_context_is_refused_before_any_request_on_either_path() -> "{opened:?}" ); - let sent = sent.lock().expect("lock").clone(); + let sent = sent.calls(); assert_eq!( sent.generated(), ["users/age"], diff --git a/packages/stack-encrypt/tests/frozen_bytes.rs b/packages/stack-encrypt/tests/frozen_bytes.rs index f930043a0..eb34edf2c 100644 --- a/packages/stack-encrypt/tests/frozen_bytes.rs +++ b/packages/stack-encrypt/tests/frozen_bytes.rs @@ -19,23 +19,25 @@ //! 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::registry::fake::{FakeKeysetRegistry, FakeProvider}; +use stack_encrypt::registry::{ + Binding, BindingSupport, GeneratedDataKey, IndexKeyMaterial, IndexKeyProvider, KeyId, + KeyIsolation, KeyProvider, KeyReconstruction, ProviderProtected, Resolved, +}; use stack_encrypt::sem::{ DefaultMatch, EqualityTerm, MatchTerms, 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 stack_encrypt::{ + CipherText, Error, LeafBytesError, SealedValue, StackCipher, StackCipherBuilder, }; +use stack_encrypt::{KeysetId, KeysetRef, KeysetRegistry}; use uuid::Uuid; -async fn cipher() -> StackCipher { - StackCipher::builder() - .kms(FakeDataKeySource::new()) +async fn cipher() -> StackCipher { + StackCipherBuilder::new() + .registry(FakeKeysetRegistry::new()) .init() .await .expect("build cipher") @@ -53,15 +55,13 @@ fn hex(bytes: &[u8]) -> String { /// "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"; + let keyset_id = KeysetId::new(Uuid::from_bytes(*b"keyset-fixture16")); SealedValue::from_parts( keyset_id, - iv, vec![0xAA, 0xBB, 0xCC], vec![0xDE, 0xAD, 0xBE, 0xEF], ) - .expect("fixture tag fits the length field") + .expect("fixture key id fits the length field") } #[test] @@ -69,11 +69,11 @@ 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) + // key_id_len(0300 — 3, u16 LE) ‖ key_id(aabbcc) ‖ + // local_ciphertext(deadbeef) assert_eq!( hex(&bytes), - "016b65797365742d666978747572653136303132333435363738396162636465660300aabbccdeadbeef" + "016b65797365742d6669787475726531360300aabbccdeadbeef" ); } @@ -84,8 +84,7 @@ fn sealed_value_from_bytes_inverts_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.key_id(), original.key_id()); assert_eq!(decoded.ciphertext(), original.ciphertext()); // The std conversion is the same decoder. @@ -108,12 +107,12 @@ fn sealed_value_rejects_unknown_version() { 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, + // Every prefix shorter than the key id's end is truncated: empty, + // mid-keyset-id, mid-length-field, and mid-key-id. (Anything at or past + // the key id'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 { + let key_id_end = 1 + 16 + 2 + 3; + for len in 0..key_id_end { assert!( matches!( SealedValue::from_bytes(&bytes[..len]), @@ -122,37 +121,36 @@ fn sealed_value_rejects_truncation() { "prefix of {len} bytes must be rejected" ); } - assert!(SealedValue::from_bytes(&bytes[..tag_end]).is_ok()); + assert!(SealedValue::from_bytes(&bytes[..key_id_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 — + // An empty key id and an empty ciphertext leave exactly the fixed-width + // fields: version ‖ keyset_id ‖ key_id_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"); + // truncated. With a non-empty key id the key-id check would reject a + // short buffer anyway, so only this shape pins the fixed-width check + // itself. + let leaf = SealedValue::from_parts(KeysetId::new(Uuid::nil()), Vec::new(), Vec::new()) + .expect("an empty key id 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" + 1 + 16 + 2, + "an empty key id 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(), + KeysetId::new(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.key_id().is_empty(), + "the key id should decode as empty" ); - assert!(decoded.tag().is_empty(), "the tag should decode as empty"); assert!( decoded.ciphertext().is_empty(), "the ciphertext should decode as empty" @@ -168,39 +166,40 @@ fn sealed_value_decodes_the_shortest_leaf_the_layout_allows() { } #[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"); +fn sealed_value_accepts_the_longest_key_id_the_length_field_frames() { + // `u16::MAX` bytes is the last key id the length field can state, so it + // is a valid leaf — and it must survive the byte format intact. + let key_id = vec![0x5A; usize::from(u16::MAX)]; + let leaf = + SealedValue::from_parts(KeysetId::new(Uuid::nil()), key_id.clone(), vec![0xDE, 0xAD]) + .expect("a u16::MAX-byte key id 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" + decoded.key_id(), + key_id.as_slice(), + "a u16::MAX-byte key id 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" + "the ciphertext after the longest key id 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. +fn sealed_value_rejects_oversized_key_id_on_construction() { + // `to_bytes` is infallible because the key id 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], + KeysetId::new(Uuid::nil()), vec![0; usize::from(u16::MAX) + 1], vec![0xDE, 0xAD], ); assert!(matches!( result, - Err(LeafBytesError::TagTooLong(len)) if len == usize::from(u16::MAX) + 1 + Err(LeafBytesError::KeyIdTooLong(len)) if len == usize::from(u16::MAX) + 1 )); } @@ -243,68 +242,89 @@ async fn sealed_value_keyset_id_is_authenticated() { 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"); + let (keyset_id, key_id, bytes) = leaf.into_parts(); + let other_keyset = KeysetId::new(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 tampered = SealedValue::from_parts(other_keyset, key_id, bytes).expect("rebuild leaf"); + + // Two layers refuse this, and the outer one speaks first: a keyset *is* + // a provider now, so a re-pointed leaf is sent to a provider that never + // minted its key and is refused before any crypto runs. The keyset id + // stays bound into the leaf AAD underneath that — see + // `leaf_aad_bytes_are_pinned` — so a deployment that mapped two keysets + // onto one backend key would still catch it. let result = cipher .decrypt::(CipherText::Single(tampered), aad) .await; assert!( - matches!(result, Err(Error::Aead)), + result.is_err(), "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); +/// Delegates to the fake registry but inflates every generated key id past +/// the `u16` length field — the misbehaving provider 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 OversizedKeyIdRegistry(FakeKeysetRegistry); + +#[derive(Clone)] +struct OversizedKeyIds(FakeProvider); + +impl KeyProvider<32> for OversizedKeyIds { + type Error = >::Error; + + const RECONSTRUCTION: KeyReconstruction = >::RECONSTRUCTION; + const ISOLATION: KeyIsolation = >::ISOLATION; + const BINDING: BindingSupport = >::BINDING; -impl DataKeySource for OversizedTagSource { async fn generate_keys( &self, - payloads: Vec>, - keyset_id: Option, - unverified_context: Option>, - ) -> Result, stack_kms::Error> { - let mut keys = self - .0 - .generate_keys(payloads, keyset_id, unverified_context) - .await?; + bindings: &[Binding<'_>], + ) -> Result>, Self::Error> { + let mut keys = self.0.generate_keys(bindings).await?; for key in &mut keys { - key.tag = vec![0; usize::from(u16::MAX) + 1]; + key.key_id = KeyId::new(vec![0; usize::from(u16::MAX) + 1]); } Ok(keys) } async fn retrieve_keys( &self, - payloads: Vec>, - keyset_id: Option, - unverified_context: Option<&UnverifiedContext>, - ) -> Result, stack_kms::Error> { - self.0 - .retrieve_keys(payloads, keyset_id, unverified_context) - .await + keys: &[(KeyId, Binding<'_>)], + ) -> Result>, Self::Error> { + self.0.retrieve_keys(keys).await } } -impl IndexKeySource for OversizedTagSource { - async fn load_index_key( +impl IndexKeyProvider<32> for OversizedKeyIds { + type Error = >::Error; + + async fn load_index_key(&self) -> Result, Self::Error> { + self.0.load_index_key().await + } +} + +impl KeysetRegistry for OversizedKeyIdRegistry { + type Provider = OversizedKeyIds; + type Error = ::Error; + + async fn resolve( &self, - keyset_id: Option, - ) -> Result<(Uuid, IndexKey), stack_kms::Error> { - self.0.load_index_key(keyset_id).await + keyset: &KeysetRef, + ) -> Result>, Self::Error> { + Ok(self.0.resolve(keyset).await?.map(|resolved| Resolved { + id: resolved.id, + name: resolved.name, + provider: OversizedKeyIds(resolved.provider), + })) } } #[tokio::test] -async fn seal_rejects_a_key_tag_the_length_field_cannot_frame() { - let cipher = StackCipher::builder() - .kms(OversizedTagSource(FakeDataKeySource::new())) +async fn seal_rejects_a_key_id_the_length_field_cannot_frame() { + let cipher = StackCipherBuilder::new() + .registry(OversizedKeyIdRegistry(FakeKeysetRegistry::new())) .init() .await .expect("build cipher"); @@ -315,7 +335,7 @@ async fn seal_rejects_a_key_tag_the_length_field_cannot_frame() { .await; assert!( matches!(result, Err(Error::Aead)), - "an oversized key tag must fail the seal, not mis-encode: {result:?}" + "an oversized key id must fail the seal, not mis-encode: {result:?}" ); } @@ -378,7 +398,7 @@ async fn match_term_bytes_are_pinned() { let bytes = term.to_bytes(); assert_eq!( hex(&bytes), - "040005000a000d000e001e002700350038003d0055005e005f0064006f007d007f009c00ad00bc00bd00ca00d000e000e500ef00" + "010024002e0037003a0042004d0066006f008a008f009000930099009e009f00a800a900b100b700bd00c600c900d100db00e000f900" ); assert_eq!( MatchTerms::::from_bytes(&bytes).expect("decode match term"), @@ -470,7 +490,7 @@ async fn ore_term_encoding_is_the_raw_cllw_bytes() { // differ (see the `sem` module docs). assert_eq!( hex(term.as_bytes()), - "1ae5f8558dc2d7dddd6c5b714e9d285586a1b8390d9140421e78906cba1bd651" + "4670ea1803ebb80320366cd5cc006e9eb235e85450c68096ec98874d407c8b5d" ); assert_eq!(term.to_bytes(), term.as_bytes()); assert_eq!(term.as_ref(), term.as_bytes()); @@ -496,7 +516,7 @@ async fn ope_term_encoding_is_the_raw_cllw_bytes() { assert_eq!( hex(term.as_bytes()), - "00837615a1ea2fdcbebf7efe34cf4d2ee432c7eeff84fbd72e1bf05efa2338033c" + "00f6bb865fbb63936656ce27932e3a2f061e6b21ba10a6fb12b91dcf74c2e290ba" ); assert_eq!( OpeTerm::::from_bytes(term.as_bytes()).expect("decode ope term"), diff --git a/packages/stack-encrypt/tests/keysets.rs b/packages/stack-encrypt/tests/keysets.rs index f36238188..2eef16177 100644 --- a/packages/stack-encrypt/tests/keysets.rs +++ b/packages/stack-encrypt/tests/keysets.rs @@ -2,53 +2,65 @@ //! 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::sync::{Arc, Mutex}; use std::time::Duration; +use stack_encrypt::registry::fake::{FakeKeysetRegistry, FakeProvider}; +use stack_encrypt::registry::{ + Binding, BindingSupport, GeneratedDataKey, IndexKeyMaterial, IndexKeyProvider, KeyId, + KeyIsolation, KeyProvider, KeyReconstruction, ProviderProtected, Resolved, +}; 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 stack_encrypt::{ + nonempty, CipherText, Error, SealedValue, StackCipher, StackCipherBuilder, StackCipherText, }; +use stack_encrypt::{KeysetId, KeysetRef, KeysetRegistry}; 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. + +/// The fake registry, plus a count of keyset resolutions and a log of the +/// keyset each retrieve call was served by — the two facts the cache and the +/// grouped dispatch are about — and two knobs for the name-lookup races: a +/// name can be *refused*, 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, + inner: FakeKeysetRegistry, loads: AtomicUsize, - retrieve_keysets: Mutex>>, + retrieve_keysets: Arc>>, refused: Mutex>, /// The name whose *next* lookup waits for [`release`](Self::release). held: Mutex>, 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. +/// How a refused name's lookup fails. +/// +/// The two are a different *kind* of outcome, not a different error: a +/// registry that knows there is no such keyset has **answered** +/// (`Ok(None)`), and the cache unbinds the name on the strength of it. One +/// that could not reach its backend has not answered at all (`Err`), and the +/// cache must be left exactly as it was. #[derive(Clone, Copy)] enum Refusal { Unknown, Unreachable, } +/// The registry could not answer — as opposed to answering that there is no +/// such keyset. +#[derive(Debug, thiserror::Error)] +#[error("the keyset registry could not be reached")] +struct Unreachable; + impl Observed { fn loads(&self) -> usize { self.loads.load(Ordering::Relaxed) } - fn retrieve_keysets(&self) -> Vec> { + fn retrieve_keysets(&self) -> Vec { self.retrieve_keysets.lock().expect("lock").clone() } @@ -77,43 +89,60 @@ impl Observed { } } -impl DataKeySource for Observed { +/// A [`FakeProvider`] that notes which keyset served each retrieve, so the +/// grouped dispatch is observable now that the keyset is the provider rather +/// than an argument beside it. +#[derive(Clone)] +struct ObservedProvider { + inner: FakeProvider, + id: KeysetId, + log: Arc>>, +} + +impl KeyProvider<32> for ObservedProvider { + type Error = >::Error; + + const RECONSTRUCTION: KeyReconstruction = >::RECONSTRUCTION; + const ISOLATION: KeyIsolation = >::ISOLATION; + const BINDING: BindingSupport = >::BINDING; + async fn generate_keys( &self, - payloads: Vec>, - keyset_id: Option, - unverified_context: Option>, - ) -> Result, stack_kms::Error> { - self.inner - .generate_keys(payloads, keyset_id, unverified_context) - .await + bindings: &[Binding<'_>], + ) -> Result>, Self::Error> { + self.inner.generate_keys(bindings).await } async fn retrieve_keys( &self, - payloads: Vec>, - keyset_id: Option, - unverified_context: Option<&UnverifiedContext>, - ) -> Result, stack_kms::Error> { - self.retrieve_keysets.lock().expect("lock").push(keyset_id); - self.inner - .retrieve_keys(payloads, keyset_id, unverified_context) - .await + keys: &[(KeyId, Binding<'_>)], + ) -> Result>, Self::Error> { + self.log.lock().expect("lock").push(self.id); + self.inner.retrieve_keys(keys).await } } -impl IndexKeySource for Observed { - async fn load_index_key( +impl IndexKeyProvider<32> for ObservedProvider { + type Error = >::Error; + + async fn load_index_key(&self) -> Result, Self::Error> { + self.inner.load_index_key().await + } +} + +impl KeysetRegistry for Observed { + type Provider = ObservedProvider; + type Error = Unreachable; + + async fn resolve( &self, - keyset_id: Option, - ) -> Result<(Uuid, IndexKey), stack_kms::Error> { + keyset: &KeysetRef, + ) -> Result>, Self::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 asked = keyset.name().map(str::to_owned); + + // The answer is decided when the lookup arrives, as the backend + // would decide it; holding only delays its return. let refusal = asked .as_deref() .and_then(|name| self.refused.lock().expect("lock").get(name).copied()); @@ -131,30 +160,37 @@ impl IndexKeySource for Observed { 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, + Some(Refusal::Unknown) => return Ok(None), + Some(Refusal::Unreachable) => return Err(Unreachable), + None => {} } - } -} -fn name(name: &str) -> IdentifiedBy { - IdentifiedBy::Name(name.to_string().into()) + let Some(resolved) = self + .inner + .resolve(keyset) + .await + .expect("the fake registry does not fail") + else { + return Ok(None); + }; + + Ok(Some(Resolved { + id: resolved.id, + name: resolved.name, + provider: ObservedProvider { + inner: resolved.provider, + id: resolved.id, + log: Arc::clone(&self.retrieve_keysets), + }, + })) + } } async fn cipher() -> StackCipher { - StackCipher::builder() - .kms(Observed::default()) + StackCipherBuilder::new() + .registry(Observed::default()) .init() .await .expect("build cipher") @@ -167,13 +203,13 @@ async fn cipher() -> StackCipher { #[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"); + assert_eq!( + cipher.registry().loads(), + 1, + "the default keyset loads at init" + ); - let expected = FakeDataKeySource::new() - .load_index_key(None) - .await - .expect("resolve") - .0; + let (expected, _) = FakeKeysetRegistry::new().default_keyset(); assert_eq!( cipher.default_keyset().keyset_id(), expected, @@ -184,7 +220,7 @@ async fn init_loads_the_default_keyset_once() { 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"); + assert_eq!(cipher.registry().loads(), 1, "default_keyset() never loads"); } /// The default is the client's, and stays the client's. A ZeroKMS @@ -195,7 +231,7 @@ 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"); + let customers = cipher.keyset("customers").await.expect("select"); assert_ne!(customers.keyset_id(), default, "a distinct keyset"); assert_eq!( cipher.default_keyset().keyset_id(), @@ -203,7 +239,7 @@ async fn selecting_a_keyset_never_moves_the_default() { "the default is unchanged by a selection" ); - let _ = cipher.keyset(name("acme")).await.expect("select another"); + let _ = cipher.keyset("acme").await.expect("select another"); assert_eq!( cipher.default_keyset().keyset_id(), default, @@ -219,20 +255,20 @@ async fn selecting_a_keyset_never_moves_the_default() { #[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"); + let first = cipher.keyset("acme").await.expect("select"); + assert_eq!(cipher.registry().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 again = cipher.keyset("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!(cipher.registry().loads(), 2, "later selections are lookups"); assert_eq!( again.keyset_id(), first.keyset_id(), @@ -259,27 +295,31 @@ async fn a_keyset_selected_by_id_is_not_known_by_name() { None, "a selection by id knows no name to report" ); - assert_eq!(cipher.kms().loads(), 2, "default + the selected keyset"); + assert_eq!( + cipher.registry().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()) + let cipher = StackCipherBuilder::new() + .registry(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"); + let a = cipher.keyset(Uuid::from_u128(101)).await.expect("a"); + let _b = cipher.keyset(Uuid::from_u128(102)).await.expect("b"); + assert_eq!(cipher.registry().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"); + let a_again = cipher.keyset(Uuid::from_u128(101)).await.expect("a again"); assert_eq!( - cipher.kms().loads(), + cipher.registry().loads(), 4, "an evicted keyset is loaded again on its next selection" ); @@ -296,7 +336,7 @@ async fn an_evicted_keyset_reloads_on_its_next_selection() { .await .expect("default by id"); assert_eq!( - cipher.kms().loads(), + cipher.registry().loads(), 4, "the default never evicts, however small the cache" ); @@ -306,18 +346,22 @@ async fn an_evicted_keyset_reloads_on_its_next_selection() { /// 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()) + let cipher = StackCipherBuilder::new() + .registry(Observed::default()) .keyset_name_ttl(Duration::ZERO) .init() .await .expect("build cipher"); - assert_eq!(cipher.kms().loads(), 1, "init loads the default keyset"); + assert_eq!( + cipher.registry().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"); + let acme = cipher.keyset("acme").await.expect("acme"); + let _ = cipher.keyset("acme").await.expect("acme again"); assert_eq!( - cipher.kms().loads(), + cipher.registry().loads(), 3, "every selection by name asks again" ); @@ -328,19 +372,19 @@ async fn a_name_selection_is_re_resolved_after_its_window() { .await .expect("acme by id again"); assert_eq!( - cipher.kms().loads(), + cipher.registry().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("primary").await.expect("primary"); + assert_eq!(cipher.registry().loads(), 4, "another name, another ask"); let _ = cipher .keyset(cipher.default_keyset().keyset_id()) .await .expect("default by id"); assert_eq!( - cipher.kms().loads(), + cipher.registry().loads(), 4, "the client's default is seeded by id, and an id is never re-asked" ); @@ -352,48 +396,38 @@ async fn a_name_selection_is_re_resolved_after_its_window() { /// 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()) + let cipher = StackCipherBuilder::new() + .registry(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"); + let acme = cipher.keyset("acme").await.expect("acme"); + assert_eq!(cipher.registry().loads(), 2, "init and acme"); - cipher.kms().refuse("acme", Refusal::Unknown); - let error = cipher.keyset(name("acme")).await.expect_err("refused"); + cipher.registry().refuse("acme", Refusal::Unknown); + let error = cipher.keyset("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:?}" + matches!(error, Error::UnknownKeyset { .. }), + "the registry's own denial reaches the caller as UnknownKeyset, got {error:?}" ); let by_id = cipher.keyset(acme.keyset_id()).await.expect("acme by id"); assert_eq!( - cipher.kms().loads(), + cipher.registry().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"); + cipher.registry().refuse("acme", Refusal::Unreachable); + let error = cipher.keyset("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:?}" + matches!(error, Error::Registry(_)), + "a lookup that got no answer surfaces as a registry failure, got {error:?}" ); - cipher.kms().allow("acme"); - let again = cipher.keyset(name("acme")).await.expect("acme once more"); + cipher.registry().allow("acme"); + let again = cipher.keyset("acme").await.expect("acme once more"); assert_eq!( again.keyset_id(), acme.keyset_id(), @@ -409,12 +443,12 @@ async fn a_refused_name_is_zerokms_answer_and_leaves_the_keyset_cached_by_id() { #[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")); + cipher.registry().hold("acme"); + let earlier = cipher.keyset("acme"); let meanwhile = async { - cipher.kms().refuse("acme", Refusal::Unknown); - let refused = cipher.keyset(name("acme")).await; - cipher.kms().release(); + cipher.registry().refuse("acme", Refusal::Unknown); + let refused = cipher.keyset("acme").await; + cipher.registry().release(); refused }; // `join!` polls in order: the earlier lookup takes its ticket and parks @@ -423,24 +457,19 @@ async fn an_answer_in_flight_does_not_rebind_a_name_zerokms_has_since_refused() 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(_) - ))) - ), + matches!(refused, Err(Error::UnknownKeyset { .. })), "the later lookup was refused, got {refused:?}" ); assert_eq!( - cipher.kms().loads(), + cipher.registry().loads(), 3, "init, the held lookup, the refused one" ); - cipher.kms().allow("acme"); - let later = cipher.keyset(name("acme")).await.expect("acme afterwards"); + cipher.registry().allow("acme"); + let later = cipher.keyset("acme").await.expect("acme afterwards"); assert_eq!( - cipher.kms().loads(), + cipher.registry().loads(), 4, "the earlier answer bound nothing: a selection by the name asks ZeroKMS" ); @@ -459,35 +488,30 @@ async fn an_answer_in_flight_does_not_rebind_a_name_zerokms_has_since_refused() #[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")); + cipher.registry().hold("acme"); + let earlier = cipher.keyset("acme"); let meanwhile = async { - cipher.kms().refuse("acme", Refusal::Unreachable); - let failed = cipher.keyset(name("acme")).await; - cipher.kms().release(); + cipher.registry().refuse("acme", Refusal::Unreachable); + let failed = cipher.keyset("acme").await; + cipher.registry().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(_) - ))) - ), + matches!(failed, Err(Error::Registry(_))), "the later lookup got no answer, got {failed:?}" ); assert_eq!( - cipher.kms().loads(), + cipher.registry().loads(), 3, "init, the held lookup, the failed one" ); - cipher.kms().allow("acme"); - let later = cipher.keyset(name("acme")).await.expect("acme afterwards"); + cipher.registry().allow("acme"); + let later = cipher.keyset("acme").await.expect("acme afterwards"); assert_eq!( - cipher.kms().loads(), + cipher.registry().loads(), 3, "a failure to get an answer forgot nothing: the earlier answer bound the name and serves" ); @@ -497,8 +521,8 @@ async fn a_lookup_that_got_no_answer_forgets_nothing() { #[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 a = cipher.keyset(Uuid::from_u128(101)).await.expect("a"); + let b = cipher.keyset(Uuid::from_u128(102)).await.expect("b"); let term_a = a .equality_term(7u32, nonempty!("users/age")) @@ -534,7 +558,7 @@ fn leaf_of(tree: StackCipherText) -> SealedValue { #[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 tenant = cipher.keyset("acme").await.expect("select"); let sealed = tenant .encrypt("hello".to_string(), "greeting") @@ -565,7 +589,7 @@ async fn a_sealed_leaf_names_the_keyset_it_was_sealed_under() { #[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 tenant = cipher.keyset("acme").await.expect("select"); let sealed = tenant .encrypt("hello".to_string(), "greeting") .await @@ -574,8 +598,8 @@ async fn the_client_opens_a_leaf_from_any_keyset() { 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())], + cipher.registry().retrieve_keysets(), + vec![tenant.keyset_id()], "the retrieve names the leaf's keyset, not the default" ); } @@ -583,7 +607,7 @@ async fn the_client_opens_a_leaf_from_any_keyset() { #[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 tenant = cipher.keyset("acme").await.expect("select"); let sealed = tenant .encrypt("hello".to_string(), "greeting") .await @@ -596,8 +620,8 @@ async fn a_keyset_handle_opens_its_own_leaves() { #[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 acme = cipher.keyset("acme").await.expect("acme"); + let globex = cipher.keyset("globex").await.expect("globex"); let sealed = acme .encrypt("hello".to_string(), "greeting") .await @@ -613,7 +637,7 @@ async fn a_keyset_handle_refuses_another_keysets_leaf_before_any_retrieve() { "{result:?}" ); assert!( - cipher.kms().retrieve_keysets().is_empty(), + cipher.registry().retrieve_keysets().is_empty(), "refused before any key was retrieved" ); } @@ -621,8 +645,8 @@ async fn a_keyset_handle_refuses_another_keysets_leaf_before_any_retrieve() { #[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"); + let acme = cipher.keyset("acme").await.expect("acme"); + let globex = cipher.keyset("globex").await.expect("globex"); // A ciphertext tree is not `Clone`; seal three, one per path. let seal = || async { let sealed: StackCipherText = 34u32 @@ -659,8 +683,8 @@ async fn the_target_path_through_a_keyset_handle_is_constrained_too() { #[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"); + let acme = cipher.keyset("acme").await.expect("acme"); + let globex = cipher.keyset("globex").await.expect("globex"); // A column whose rows belong to two tenants, interleaved. let mut column: Vec = Vec::new(); @@ -682,8 +706,8 @@ async fn a_mixed_keyset_column_opens_through_the_client_in_one_call_per_keyset() "a two-tenant column opens in row order" ); assert_eq!( - cipher.kms().retrieve_keysets(), - vec![Some(acme.keyset_id()), Some(globex.keyset_id())], + cipher.registry().retrieve_keysets(), + vec![acme.keyset_id(), globex.keyset_id()], "one retrieve per keyset, first seen first" ); } @@ -691,8 +715,8 @@ async fn a_mixed_keyset_column_opens_through_the_client_in_one_call_per_keyset() #[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 acme = cipher.keyset("acme").await.expect("acme"); + let globex = cipher.keyset("globex").await.expect("globex"); let mut column: Vec = Vec::new(); for tenant in [&acme, &globex] { let sealed: StackCipherText = 1u32 @@ -708,7 +732,50 @@ async fn a_mixed_keyset_column_does_not_open_through_a_keyset_handle() { "{result:?}" ); assert!( - cipher.kms().retrieve_keysets().is_empty(), + cipher.registry().retrieve_keysets().is_empty(), "refused before any key was retrieved" ); } + +/// Both cipher handles carry key material: `StackCipher` its keyset cache +/// and, through the registry, the client key; `KeysetCipher` the keyset's +/// index-key PRF. Their `Debug` shows the keyset's *identity* — which every +/// sealed leaf carries in the clear already, and which ZeroKMS logs — and +/// nothing else. Pinned, because a `Debug` that grew a field is the kind of +/// change that looks harmless in review and puts a key in a log line. +#[tokio::test] +async fn the_cipher_handles_print_their_keyset_and_no_key_material() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let default_id = keyset.keyset_id().to_string(); + + for (what, printed) in [ + ("StackCipher", format!("{cipher:?}")), + ("KeysetCipher", format!("{keyset:?}")), + ] { + assert!( + printed.starts_with(what), + "{what} must name itself: {printed}" + ); + assert!( + printed.contains(&default_id), + "{what} must name the keyset it is bound to: {printed}" + ); + assert!( + printed.contains(".."), + "{what} must stay non-exhaustive, so a new field is opt-in: {printed}" + ); + // The registry appears as a *type name*, never a value — a value + // would drag the client key and the access token into the output. + assert!( + printed.contains("Observed"), + "{what} must name the registry type: {printed}" + ); + for secret in ["Protected", "Prf", "index_key", "prf"] { + assert!( + !printed.contains(secret), + "{what} printed {secret:?}: {printed}" + ); + } + } +} diff --git a/packages/stack-encrypt/tests/registry_zerokms.rs b/packages/stack-encrypt/tests/registry_zerokms.rs new file mode 100644 index 000000000..c597ea5eb --- /dev/null +++ b/packages/stack-encrypt/tests/registry_zerokms.rs @@ -0,0 +1,130 @@ +//! `impl KeysetRegistry for Arc`: the one backend implementation +//! this crate carries. +//! +//! It lives here rather than in `stack-kms` because the orphan rule puts it +//! here — local trait, foreign type — and so it can only be tested here. +//! `stack-kms`'s `test-support` feature exports the in-memory +//! `ZeroKMSConnection` that makes that possible without a network. +//! +//! What is worth pinning is the *shape of the answer*, because the cache +//! above treats the three outcomes differently: `Ok(Some(_))` is an answer, +//! `Ok(None)` is also an answer (a definite "no such keyset", which unbinds +//! a cached name), and `Err(_)` is not an answer at all and must leave the +//! cache alone. Collapsing any pair of those is invisible to a type checker. + +#![cfg(all(feature = "zerokms", not(target_arch = "wasm32")))] + +use std::sync::Arc; + +use stack_auth::StaticTokenStrategy; +use stack_encrypt::registry::{KeysetRef, KeysetRegistry}; +use stack_encrypt::KeysetId; +use stack_kms::test_connection::{ + load_keyset_response, random_client_key, TestConnection, TestConnectionBuilder, TEST_KEYSET_ID, +}; +use stack_kms::{ClientOpts, StackKms}; +use zerokms_protocol::{LoadKeysetRequest, ViturRequestError, ViturRequestErrorKind}; + +type Registry = Arc>; + +/// A `StackKms` over a stub connection the callback configures. The stub +/// panics on an unstubbed endpoint, so a test that expects exactly one +/// `load_keyset` gets that checked for free. +fn build_kms(callback: impl FnOnce(TestConnectionBuilder) -> TestConnectionBuilder) -> Registry { + 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"), + ) +} + +fn vitur(kind: ViturRequestErrorKind) -> ViturRequestError { + ViturRequestError::new(kind, "stubbed", std::io::Error::other("boom")) +} + +/// The positive answer, and the two things it must carry: the id ZeroKMS +/// resolved, and a provider that already holds the keyset's index key — +/// loading it eagerly is the point of doing this in one round trip. +#[tokio::test] +async fn a_resolved_keyset_carries_its_id_and_its_index_key() { + let kms = build_kms(|b| b.add_success_response::(load_keyset_response())); + + let resolved = kms + .resolve(&KeysetRef::Default) + .await + .expect("the stub answers") + .expect("the default keyset exists"); + + assert_eq!( + resolved.id, + KeysetId::new(TEST_KEYSET_ID), + "the id must be the one ZeroKMS resolved, not one this crate invented" + ); + assert_eq!( + resolved.name, None, + "nothing was looked up by name, so the answer carries none" + ); + // The provider is only useful if it can serve the keyset's index key + // without another round trip; the stub would panic on a second request. + let _ = vitaminc_kms::provider::IndexKeyProvider::load_index_key(&resolved.provider) + .await + .expect("the index key came back in the same load_keyset call"); +} + +/// A name selection carries the name it asked under — the cache binds names, +/// and a binding it cannot attribute is a binding it cannot expire. +#[tokio::test] +async fn a_name_selection_carries_the_name_it_asked_under() { + let kms = build_kms(|b| b.add_success_response::(load_keyset_response())); + + let resolved = kms + .resolve(&KeysetRef::Name("acme".to_owned())) + .await + .expect("the stub answers") + .expect("the keyset exists"); + + assert_eq!(resolved.name.as_deref(), Some("acme")); +} + +/// ZeroKMS's 404 is an *answer*: it holds no such keyset. That has to arrive +/// as `Ok(None)` and not as an error, because the cache unbinds a name on the +/// former and leaves it alone on the latter. +#[tokio::test] +async fn a_keyset_zerokms_does_not_hold_is_an_answer_not_a_failure() { + let kms = build_kms(|b| { + b.add_failed_response::(vitur(ViturRequestErrorKind::NotFound)) + }); + + let answer = kms + .resolve(&KeysetRef::Name("nope".to_owned())) + .await + .expect("a 404 is an answer, so `resolve` must not fail"); + + assert!(answer.is_none(), "and the answer is that there is none"); +} + +/// Everything else is *not* an answer. A refused credential says nothing +/// about whether the keyset exists, so it must not be flattened into +/// `Ok(None)` — that would let a cached name be unbound by a transport +/// hiccup. +#[tokio::test] +async fn a_request_that_got_no_verdict_is_an_error() { + for kind in [ + ViturRequestErrorKind::Unauthorized, + ViturRequestErrorKind::Forbidden, + ViturRequestErrorKind::Other, + ] { + let named = format!("{kind:?}"); + let kms = build_kms(|b| b.add_failed_response::(vitur(kind))); + let result = kms.resolve(&KeysetRef::Name("acme".to_owned())).await; + assert!( + result.is_err(), + "{named} is not an answer about the keyset, got {:?}", + result.map(|r| r.map(|r| r.id)) + ); + } +} diff --git a/packages/stack-encrypt/tests/roundtrip.rs b/packages/stack-encrypt/tests/roundtrip.rs index 985f65792..29eef4f3a 100644 --- a/packages/stack-encrypt/tests/roundtrip.rs +++ b/packages/stack-encrypt/tests/roundtrip.rs @@ -1,22 +1,22 @@ //! End-to-end encrypt/decrypt tests for `StackCipher` against the in-memory -//! `FakeDataKeySource` — no ZeroKMS credentials or network required. The fake +//! `FakeKeysetRegistry` — 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::registry::fake::FakeKeysetRegistry; use stack_encrypt::{ BoxedPassthrough, Cipher, CipherText, ContextTag, Element, Encrypt, IntoAad, SealedValue, - StackCipher, + StackCipher, StackCipherBuilder, }; -use stack_kms::FakeDataKeySource; use vitaminc_aead::{MapCipher, Passthrough}; use vitaminc_protected::{Controlled, Protected}; -async fn cipher() -> StackCipher { - StackCipher::builder() - .kms(FakeDataKeySource::new()) +async fn cipher() -> StackCipher { + StackCipherBuilder::new() + .registry(FakeKeysetRegistry::new()) .init() .await .expect("build cipher") @@ -150,6 +150,9 @@ async fn nested_vec_roundtrips() { assert_eq!(pt, nested); } +/// `ContextTag` carries its tag inside the *value*, so `decrypt_tagged` is +/// the mirror of sealing one — it keeps the tag out of the descriptor and +/// folds it in at the decipher, where `Encrypt` folds it in too. CIP-4144. #[tokio::test] async fn context_tag_binds_and_roundtrips() { let cipher = cipher().await; @@ -161,14 +164,19 @@ async fn context_tag_binds_and_roundtrips() { // Matching context recovers the value. let pt: String = cipher - .decrypt(ct, ContextTag::aad("user:42")) + .decrypt_tagged(ct, ContextTag::context("user:42"), ()) .await .expect("decrypt"); assert_eq!(pt, "token"); } +/// A wrong tag must not decrypt — and must fail at the AEAD, *after* the +/// key is retrieved, rather than at the provider. That is the property +/// which makes `decrypt_tagged` work at all: both sides render the +/// descriptor from `aad` alone, so the retrieve succeeds and the tag does +/// its binding one layer above. #[tokio::test] -async fn context_tag_wrong_context_fails() { +async fn context_tag_binds_above_the_descriptor() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset @@ -176,8 +184,68 @@ async fn context_tag_wrong_context_fails() { .await .expect("encrypt"); - let result: Result = cipher.decrypt(ct, ContextTag::aad("user:99")).await; - assert!(result.is_err(), "wrong context tag must not decrypt"); + let result: Result = cipher + .decrypt_tagged(ct, ContextTag::context("user:99"), ()) + .await; + assert!( + matches!(result, Err(stack_encrypt::Error::Aead)), + "a wrong tag must fail at the AEAD, not at the provider: {result:?}" + ); +} + +/// The same tag with different extra AAD is a different binding, and the +/// extra AAD *is* in the descriptor — so this one is refused by the +/// provider, before the AEAD. +#[tokio::test] +async fn context_tag_extra_aad_is_in_the_descriptor() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let seal = || keyset.encrypt(ContextTag::new("token".to_string(), "user:42"), "row:1"); + + // Same tag, same extra AAD: recovers the value. + let pt: String = cipher + .decrypt_tagged( + seal().await.expect("encrypt"), + ContextTag::context("user:42"), + "row:1", + ) + .await + .expect("decrypt"); + assert_eq!(pt, "token"); + + // Same tag, different extra AAD: a different descriptor, so the + // provider refuses the retrieve. + let result: Result = cipher + .decrypt_tagged( + seal().await.expect("encrypt"), + ContextTag::context("user:42"), + "row:2", + ) + .await; + assert!( + matches!(result, Err(stack_encrypt::Error::Provider(_))), + "extra AAD belongs to the descriptor, so a mismatch is the provider's to refuse: {result:?}" + ); +} + +/// `ContextTag::aad` is vitaminc's low-level builder for driving a cipher's +/// own decrypt entry point. Handed to `decrypt`, it folds the tag into the +/// descriptor on the open side only, so the retrieve is refused. Pinned so +/// the trap stays a documented refusal rather than a silent one. +#[tokio::test] +async fn context_tag_aad_is_refused_by_plain_decrypt() { + 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 = cipher.decrypt(ct, ContextTag::aad("user:42")).await; + assert!( + matches!(result, Err(stack_encrypt::Error::Provider(_))), + "ContextTag::aad puts the tag in the descriptor, which the seal side never did: {result:?}" + ); } #[tokio::test] @@ -476,8 +544,8 @@ async fn leaf_survives_persistence_via_parts() { 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 (keyset_id, key_id, bytes) = leaf.into_parts(); + let rebuilt = SealedValue::from_parts(keyset_id, key_id, bytes).expect("rebuild leaf"); let pt: String = cipher .decrypt(CipherText::Single(rebuilt), b"ctx".as_slice()) @@ -501,8 +569,7 @@ async fn leaf_survives_persistence_via_serde() { 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.key_id(), leaf.key_id()); assert_eq!(restored.ciphertext(), leaf.ciphertext()); let pt: String = cipher @@ -524,10 +591,10 @@ async fn tampered_leaf_bytes_fail() { CipherText::Single(leaf) => leaf, other => panic!("expected a Single leaf, got {other:?}"), }; - let (keyset_id, iv, tag, mut bytes) = leaf.into_parts(); + let (keyset_id, key_id, 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 tampered = SealedValue::from_parts(keyset_id, key_id, bytes).expect("rebuild leaf"); let result: Result = 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 index 1ce6b799f..ffd833d24 100644 --- a/packages/stack-encrypt/tests/sem_terms.rs +++ b/packages/stack-encrypt/tests/sem_terms.rs @@ -4,10 +4,9 @@ use std::cmp::Ordering; use stack_encrypt::nonempty; +use stack_encrypt::registry::fake::FakeKeysetRegistry; use stack_encrypt::sem::{DefaultMatch, MatchConfig, MatchOptions, MatchTerms, Tokenizer}; -use stack_encrypt::{Error, StackCipher}; -use stack_kms::{FakeDataKeySource, IdentifiedBy}; -use uuid::Uuid; +use stack_encrypt::{Error, StackCipher, StackCipherBuilder}; /// Type-level config with the v1 `Standard` (word) tokenizer. struct WordMatch; @@ -39,22 +38,19 @@ bad_config!(NonPowerOfTwoM, m: 100); bad_config!(TooSmallM, m: 16); bad_config!(ZeroNgram, tokenizer: Tokenizer::Ngram { length: 0 }); -async fn generator() -> StackCipher { - StackCipher::builder() - .kms(FakeDataKeySource::new()) +async fn generator() -> StackCipher { + StackCipherBuilder::new() + .registry(FakeKeysetRegistry::new()) .init() .await .expect("build cipher") } -async fn generator_for(keyset: Uuid) -> StackCipher { +async fn generator_for(keyset: &str) -> StackCipher { 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"); + let _ = cipher.keyset(keyset).await.expect("select keyset"); cipher } @@ -105,16 +101,10 @@ async fn equality_terms_differ_by_value() { #[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 cipher_a = generator_for("tenant-a").await; + let cipher_b = generator_for("tenant-b").await; + let gen_a = cipher_a.keyset("tenant-a").await.expect("tenant-a"); + let gen_b = cipher_b.keyset("tenant-b").await.expect("tenant-b"); let a = gen_a .equality_term("alice", nonempty!("users/email")) .await diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index dba4be098..7275f7f69 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -4,18 +4,20 @@ //! 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 stack_encrypt::KeysetRegistry; use std::cmp::Ordering; use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; use std::sync::Arc; +use stack_encrypt::registry::fake::FakeKeysetRegistry; use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerms, 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; +use stack_encrypt::{ + nonempty, EmptyError, Error, NonEmpty, StackCipher, StackCipherBuilder, StackCipherText, +}; mod common; use common::{counting_cipher, stack_cipher}; @@ -24,7 +26,7 @@ use common::{counting_cipher, stack_cipher}; /// 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 { +async fn generator() -> StackCipher { stack_cipher().await } @@ -105,7 +107,7 @@ async fn term_derivation_makes_no_kms_calls() { // 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, provider) = counting_cipher().await; let cipher = cipher.default_keyset(); let _term: EqualityTerm = "alice" @@ -117,8 +119,8 @@ async fn term_derivation_makes_no_kms_calls() { .await .unwrap(); - assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); - assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 0); + assert_eq!(provider.call_counts().0, 0); + assert_eq!(provider.call_counts().1, 0); } #[tokio::test] @@ -263,7 +265,7 @@ async fn ope_leaf_compares_with_plain_byte_order() { #[tokio::test] async fn a_column_encrypts_in_one_batched_call() { - let (cipher, generates, _) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let cipher = cipher.default_keyset(); let ages: Vec = vec![29, 34, 41, 34, 57]; @@ -274,7 +276,7 @@ async fn a_column_encrypts_in_one_batched_call() { assert_eq!(sealed.len(), 5); assert_eq!( - generates.load(AtomicOrdering::SeqCst), + provider.call_counts().0, 1, "five records must share ONE generate_keys call" ); @@ -282,7 +284,7 @@ async fn a_column_encrypts_in_one_batched_call() { #[tokio::test] async fn a_column_decrypts_in_one_batched_call() { - let (cipher, _, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let keyset = cipher.default_keyset(); let ages: Vec = vec![29, 34, 41]; @@ -298,7 +300,7 @@ async fn a_column_decrypts_in_one_batched_call() { assert_eq!(roundtrip, ages); assert_eq!( - retrieves.load(AtomicOrdering::SeqCst), + provider.call_counts().1, 1, "three rows must share ONE retrieve_keys call" ); @@ -344,7 +346,7 @@ struct EncryptedAge { impl EncryptFrom for EncryptedAge { type Context = CallerContext; - fn encryption<'s, K: 'static>() -> Encryption<'s, u32, Self, K, Self::Context> + fn encryption<'s, K: KeysetRegistry + 'static>() -> Encryption<'s, u32, Self, K, Self::Context> where u32: 's, { @@ -360,7 +362,7 @@ impl EncryptFrom for EncryptedAge { } impl DecryptInto for EncryptedAge { type Context = CallerContext; - fn decryption(self, context: Self::Context) -> Decryption { + fn decryption(self, context: Self::Context) -> Decryption { stack_encrypt::target::open(self.c, context) } } @@ -405,7 +407,7 @@ async fn composite_record_encrypts_every_field_from_one_source() { #[tokio::test] async fn a_composite_record_is_one_batched_call() { - let (cipher, generates, _) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let cipher = cipher.default_keyset(); let _record: EncryptedAge = 42u32 @@ -413,7 +415,7 @@ async fn a_composite_record_is_one_batched_call() { .await .unwrap(); assert_eq!( - generates.load(AtomicOrdering::SeqCst), + provider.call_counts().0, 1, "ciphertext + two terms must settle in ONE generate_keys call" ); @@ -425,7 +427,7 @@ async fn a_composite_record_is_one_batched_call() { .await .unwrap(); assert_eq!( - generates.load(AtomicOrdering::SeqCst), + provider.call_counts().0, 2, "a column of composite records must add ONE more call, not one per row" ); @@ -457,7 +459,7 @@ where EqualityTerm: EncryptFrom, { type Context = >::Context; - fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + fn encryption<'s, K: KeysetRegistry + 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { @@ -491,31 +493,26 @@ async fn init_pins_the_cipher_to_the_keyset_it_resolved() { // 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()) + let cipher = StackCipherBuilder::new() + .registry(FakeKeysetRegistry::new()) .init() .await .expect("build cipher"); - let (expected, _) = FakeDataKeySource::new() - .load_index_key(None) - .await - .expect("load index key"); + let (expected, _) = FakeKeysetRegistry::new().default_keyset(); 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()) + let registry = FakeKeysetRegistry::new(); + let (keyset, _) = registry.keyset("tenant").expect("registered keyset"); + let cipher = StackCipherBuilder::new() + .registry(registry) .init() .await .expect("build cipher"); - let explicit = cipher - .keyset(IdentifiedBy::Uuid(keyset)) - .await - .expect("select keyset"); + let explicit = cipher.keyset("tenant").await.expect("select keyset"); assert_eq!(explicit.keyset_id(), keyset); // Selecting one does not move the cipher's default: that is the client's, @@ -582,7 +579,7 @@ async fn wrapped_and_extended_contexts_bind_like_plain_ones() { // 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`.) + // key retrieval under the other descriptor first, as `Error::Provider`.) let sealed: StackCipherText = "secret" .to_string() .encrypt_into_with_context(&keyset, nonempty!("users/email").with(42u64)) @@ -591,14 +588,21 @@ async fn wrapped_and_extended_contexts_bind_like_plain_ones() { let wrong_row: Result = sealed .decrypt_into(&cipher, nonempty!("users/email").with(43u64)) .await; - assert!(matches!(wrong_row, Err(Error::Aead))); + // The provider binds the descriptor, as ZeroKMS does, so the wrong + // context is refused at the key retrieval rather than by the AEAD. + assert!( + matches!(wrong_row, Err(Error::Provider(_))), + "{wrong_row:?}" + ); let sealed: StackCipherText = "secret" .to_string() .encrypt_into_with_context(&keyset, nonempty!("users/email").with(42u64)) .await .unwrap(); + // Dropping the extension is a different descriptor again, so the + // provider refuses the retrieve before the AEAD is reached. let no_row: Result = sealed.decrypt_into(&cipher, nonempty!("users/email")).await; - assert!(matches!(no_row, Err(Error::Aead))); + assert!(matches!(no_row, Err(Error::Provider(_))), "{no_row:?}"); // 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 @@ -680,7 +684,7 @@ async fn containers_pass_the_context_through_to_their_leaves() { struct Refused(Arc); impl DecryptInto for Refused { type Context = CallerContext; - fn decryption(self, _: Self::Context) -> Decryption { + fn decryption(self, _: Self::Context) -> Decryption { self.0.fetch_add(1, AtomicOrdering::SeqCst); Decryption::failed(Error::NotOpened) } @@ -688,7 +692,7 @@ impl DecryptInto for Refused { #[tokio::test] async fn a_column_stops_declaring_at_the_first_refused_row() { - let (cipher, _, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let declared = Arc::new(AtomicUsize::new(0)); let column: Vec = (0..1_000).map(|_| Refused(Arc::clone(&declared))).collect(); @@ -700,7 +704,7 @@ async fn a_column_stops_declaring_at_the_first_refused_row() { .await; assert!(matches!(result, Err(Error::NotOpened)), "{result:?}"); assert_eq!(declared.load(AtomicOrdering::SeqCst), 1); - assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 0); + assert_eq!(provider.call_counts().1, 0); } #[tokio::test] @@ -708,7 +712,7 @@ 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, provider) = counting_cipher().await; let cipher = cipher.default_keyset(); let b = "b".to_string(); @@ -732,7 +736,7 @@ async fn a_failed_field_fails_the_record_before_any_kms_call() { assert!(matches!(column, Err(Error::NotOpened))); assert_eq!( - generates.load(AtomicOrdering::SeqCst), + provider.call_counts().0, 0, "a failed sibling must drop the batch, not dispatch it" ); @@ -746,7 +750,7 @@ async fn a_failed_field_fails_the_record_before_any_kms_call() { /// 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, provider) = counting_cipher().await; let cipher_a = cipher_a.default_keyset(); let cipher_b = counting_cipher().await.0; let cipher_b = cipher_b.default_keyset(); @@ -786,7 +790,7 @@ async fn pendings_from_two_ciphers_over_the_same_keyset_merge() { ); assert_eq!( - generates.load(AtomicOrdering::SeqCst), + provider.call_counts().0, 2, "each merged assembly settles as exactly one generate_keys call" ); @@ -796,16 +800,10 @@ async fn pendings_from_two_ciphers_over_the_same_keyset_merge() { /// 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_a, provider) = counting_cipher().await; + let acme = cipher_a.keyset("acme").await.unwrap(); let cipher_b = counting_cipher().await.0; - let globex = cipher_b - .keyset(IdentifiedBy::Name("globex".to_string().into())) - .await - .unwrap(); + let globex = cipher_b.keyset("globex").await.unwrap(); let v = "v".to_string(); let w = "w".to_string(); @@ -823,7 +821,7 @@ async fn pendings_from_two_ciphers_over_different_keysets_refuse_to_merge() { ); assert_eq!( - generates.load(AtomicOrdering::SeqCst), + provider.call_counts().0, 0, "a mismatched merge must be refused before any key is minted" ); @@ -903,7 +901,7 @@ async fn pending_futures_are_send() { #[tokio::test] async fn cipher_directed_encrypt_and_decrypt_are_one_batched_call_each() { - let (cipher, generates, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let keyset = cipher.default_keyset(); let names: Vec = ["ada", "grace", "edsger", "barbara"] @@ -912,7 +910,7 @@ async fn cipher_directed_encrypt_and_decrypt_are_one_batched_call_each() { .collect(); let ct = keyset.encrypt(names.clone(), "users/name").await.unwrap(); assert_eq!( - generates.load(AtomicOrdering::SeqCst), + provider.call_counts().0, 1, "cipher.encrypt of a four-leaf value must be ONE generate_keys call" ); @@ -920,7 +918,7 @@ async fn cipher_directed_encrypt_and_decrypt_are_one_batched_call_each() { let roundtrip: Vec = cipher.decrypt(ct, "users/name").await.unwrap(); assert_eq!(roundtrip, names); assert_eq!( - retrieves.load(AtomicOrdering::SeqCst), + provider.call_counts().1, 1, "cipher.decrypt of a four-leaf value must be ONE retrieve_keys call" ); @@ -960,9 +958,9 @@ async fn cipher_directed_decrypt_rejects_a_transplanted_ciphertext() { .unwrap(); let result: Result = cipher.decrypt(ct, "users/name").await; assert!( - matches!(result, Err(Error::Aead)), + matches!(result, Err(Error::Provider(_))), "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)" + (the provider binds the descriptor, as ZeroKMS does, and refuses the retrieve)" ); } @@ -979,7 +977,8 @@ struct OwnedEmail { impl EncryptFrom for OwnedEmail { type Context = DeclaredContext; - fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> + fn encryption<'s, K: KeysetRegistry + 'static>( + ) -> Encryption<'s, String, Self, K, Self::Context> where String: 's, { @@ -1042,7 +1041,8 @@ struct ShadowedEmail { impl EncryptFrom for ShadowedEmail { type Context = CallerContext; - fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> + fn encryption<'s, K: KeysetRegistry + 'static>( + ) -> Encryption<'s, String, Self, K, Self::Context> where String: 's, { @@ -1091,7 +1091,8 @@ struct TenantEmail { impl EncryptFrom for TenantEmail { type Context = NonEmpty; - fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> + fn encryption<'s, K: KeysetRegistry + 'static>( + ) -> Encryption<'s, String, Self, K, Self::Context> where String: 's, { diff --git a/packages/stack-encrypt/tests/term_bytes.rs b/packages/stack-encrypt/tests/term_bytes.rs index ebe5f04cb..6cdcfdc96 100644 --- a/packages/stack-encrypt/tests/term_bytes.rs +++ b/packages/stack-encrypt/tests/term_bytes.rs @@ -7,7 +7,7 @@ //! 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 +//! Keyed by `FakeKeysetRegistry`'s deterministic index key, so the expected //! bytes are stable without ZeroKMS. //! //! The pins moved once without the derivation moving: vitaminc 0.5 changed @@ -18,13 +18,14 @@ //! this crate's own moved. use stack_encrypt::nonempty; +use stack_encrypt::registry::fake::FakeKeysetRegistry; use stack_encrypt::sem::{DefaultMatch, MatchConfig, MatchOptions}; use stack_encrypt::StackCipher; -use stack_kms::FakeDataKeySource; +use stack_encrypt::StackCipherBuilder; -async fn cipher() -> StackCipher { - StackCipher::builder() - .kms(FakeDataKeySource::new()) +async fn cipher() -> StackCipher { + StackCipherBuilder::new() + .registry(FakeKeysetRegistry::new()) .init() .await .expect("build cipher") @@ -45,7 +46,7 @@ async fn equality_term_bytes_are_pinned() { assert_eq!( hex(term.as_bytes()), - "c101ef066547cb33003c352dcd02a42bfe4a24e2d0609caca0189d9f1af8262a" + "4dc2ba6bbf77704d99bc7b762430cb801f1649f87cd39b95f08e208a3952d001" ); } @@ -63,8 +64,8 @@ async fn match_term_positions_are_pinned() { 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 + 1, 36, 46, 55, 58, 66, 77, 102, 111, 138, 143, 144, 147, 153, 158, 159, 168, 169, 177, + 183, 189, 198, 201, 209, 219, 224, 249 ] ); } @@ -82,7 +83,7 @@ async fn ore_term_bytes_are_pinned() { assert_eq!( hex(term.as_ref()), - "1ae5f8558dc2d7dddd6c5b714e9d285586a1b8390d9140421e78906cba1bd651" + "4670ea1803ebb80320366cd5cc006e9eb235e85450c68096ec98874d407c8b5d" ); } @@ -100,7 +101,7 @@ async fn ope_term_bytes_are_pinned() { assert_eq!( hex(term.as_ref()), - "00837615a1ea2fdcbebf7efe34cf4d2ee432c7eeff84fbd72e1bf05efa2338033c" + "00f6bb865fbb63936656ce27932e3a2f061e6b21ba10a6fb12b91dcf74c2e290ba" ); } @@ -130,9 +131,9 @@ async fn match_term_positions_are_pinned_for_a_wide_filter() { 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 + 480, 1847, 6056, 7056, 8125, 9417, 23729, 23967, 26521, 28563, 32721, 40550, 40740, + 41217, 42095, 43422, 46249, 46894, 47814, 48015, 50234, 51021, 52443, 53497, 53570, + 54199, 61322 ], "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 index 23c011cef..ba55fc641 100644 --- a/packages/stack-encrypt/tests/transcode.rs +++ b/packages/stack-encrypt/tests/transcode.rs @@ -4,12 +4,12 @@ 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::KeysetRegistry; 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 { @@ -52,7 +52,7 @@ impl Transcode for StoredLeaf { } impl EncryptFrom for StoredLeaf { type Context = CallerContext; - fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + fn encryption<'s, K: KeysetRegistry + 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { @@ -61,7 +61,7 @@ impl EncryptFrom for StoredLeaf { } impl + 'static> DecryptInto

for StoredLeaf { type Context = CallerContext; - fn decryption(self, context: Self::Context) -> Decryption { + fn decryption(self, context: Self::Context) -> Decryption { match SealedValue::from_bytes(&self.0) { Ok(leaf) => target::open(CipherText::Single(leaf), context), Err(error) => Decryption::failed(Error::Other(Box::new(error))), @@ -76,7 +76,7 @@ where Self: DecryptInto

, Ctx: Into<>::Context>, { - fn decryption_field(self, ctx: Ctx) -> Option> { + fn decryption_field(self, ctx: Ctx) -> Option> { Some(self.decryption(ctx.into())) } } @@ -102,7 +102,7 @@ fn other_column() -> NonEmpty { #[tokio::test] async fn stored_identifier_supplies_every_operations_context() { - let (cipher, generates, _) = common::counting_cipher().await; + let (cipher, provider) = common::counting_cipher().await; let keyset = cipher.default_keyset(); let email = "alice@example.com".to_owned(); let record: TextEq = keyset @@ -116,7 +116,7 @@ async fn stored_identifier_supplies_every_operations_context() { ); assert_eq!(record.v, 3, "a defaulted field is filled, not derived"); assert_eq!( - generates.load(Ordering::SeqCst), + provider.call_counts().0, 1, "one ciphertext leaf means one data key, in one batch" ); @@ -173,14 +173,14 @@ async fn canonical_ciphertext_opens_through_the_record() { #[tokio::test] async fn expected_identifier_is_checked_before_any_key_is_retrieved() { - let (cipher, _, retrieves) = common::counting_cipher().await; + let (cipher, provider) = 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 before = provider.call_counts().1; let result = cipher .decrypt_as::(record, other_column().into()) .await; @@ -189,7 +189,7 @@ async fn expected_identifier_is_checked_before_any_key_is_retrieved() { "a stored identifier that differs from the expected one is a mismatch, got {result:?}" ); assert_eq!( - retrieves.load(Ordering::SeqCst), + provider.call_counts().1, before, "the mismatch is refused before ZeroKMS is asked for a key" ); @@ -197,7 +197,7 @@ async fn expected_identifier_is_checked_before_any_key_is_retrieved() { #[tokio::test] async fn empty_stored_identifier_is_refused_before_any_key_is_retrieved() { - let (cipher, _, retrieves) = common::counting_cipher().await; + let (cipher, provider) = common::counting_cipher().await; let keyset = cipher.default_keyset(); let email = "alice@example.com".to_owned(); let mut record: TextEq = keyset @@ -205,7 +205,7 @@ async fn empty_stored_identifier_is_refused_before_any_key_is_retrieved() { .await .unwrap(); record.i.column.clear(); - let before = retrieves.load(Ordering::SeqCst); + let before = provider.call_counts().1; let result = cipher .decrypt_as::(record, Default::default()) .await; @@ -214,7 +214,7 @@ async fn empty_stored_identifier_is_refused_before_any_key_is_retrieved() { "a stored identifier is data, not a proof: an empty one fails validation" ); assert_eq!( - retrieves.load(Ordering::SeqCst), + provider.call_counts().1, before, "validation runs before ZeroKMS is asked for a key" ); @@ -285,7 +285,7 @@ async fn scalar_destination_refuses_a_sequence() { struct FixedLeaf(StoredLeaf); impl EncryptFrom for FixedLeaf { type Context = (); - fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + fn encryption<'s, K: KeysetRegistry + 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { @@ -300,7 +300,7 @@ impl EncryptFrom for FixedLeaf { } impl + 'static> DecryptInto

for FixedLeaf { type Context = (); - fn decryption(self, (): ()) -> Decryption { + fn decryption(self, (): ()) -> Decryption { self.0.decryption(nonempty!("fixed/leaf").into()) } } @@ -464,7 +464,7 @@ fn native_readers_report_the_number_of_remaining_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 (cipher, provider) = common::counting_cipher().await; let keyset = cipher.default_keyset(); let value: HashMap>> = HashMap::from([ ("entries".into(), vec![Some("secret".into()), None]), @@ -484,15 +484,11 @@ async fn native_readers_preserve_map_keys_and_authenticated_markers() { "a tree read into a destination and back opens as the original value" ); assert_eq!( - generates.load(Ordering::SeqCst), + provider.call_counts().0, 1, "the whole document sealed under one data key" ); - assert_eq!( - retrieves.load(Ordering::SeqCst), - 1, - "and opened with one retrieval" - ); + assert_eq!(provider.call_counts().1, 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 { diff --git a/packages/stack-encrypt/tests/ui/bare_context.rs b/packages/stack-encrypt/tests/ui/bare_context.rs index e93bdd9b2..188d2bb47 100644 --- a/packages/stack-encrypt/tests/ui/bare_context.rs +++ b/packages/stack-encrypt/tests/ui/bare_context.rs @@ -8,7 +8,7 @@ use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::{DecryptFrom, EncryptInto}; use stack_encrypt::{DecryptInto, EncryptFrom, KeysetCipher, StackCipher, StackCipherText}; -use stack_kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; struct User { email: String, @@ -27,7 +27,7 @@ struct EncryptedAge { hm: EqualityTerm, } -async fn encrypt(cipher: &KeysetCipher<'_, FakeDataKeySource>, user: User) { +async fn encrypt(cipher: &KeysetCipher<'_, FakeKeysetRegistry>, user: User) { let _term: EqualityTerm = "alice" .encrypt_into_with_context(cipher, "users/email") .await @@ -43,7 +43,7 @@ async fn encrypt(cipher: &KeysetCipher<'_, FakeDataKeySource>, user: User) { } async fn decrypt( - cipher: &StackCipher, + cipher: &StackCipher, row: EncryptedUser, sealed: StackCipherText, ) { diff --git a/packages/stack-encrypt/tests/ui/bare_context.stderr b/packages/stack-encrypt/tests/ui/bare_context.stderr index ad2d31cb0..6b2944da6 100644 --- a/packages/stack-encrypt/tests/ui/bare_context.stderr +++ b/packages/stack-encrypt/tests/ui/bare_context.stderr @@ -20,7 +20,7 @@ error[E0277]: the trait bound `CallerContext: From<&str>` is not satisfied note: required by a bound in `encrypt_into_with_context` --> src/target/operations.rs | - | fn encrypt_into_with_context<'a, T, K: 'static>( + | fn encrypt_into_with_context<'a, T, K: KeysetRegistry + 'static>( | ------------------------- required by a bound in this associated function ... | context: impl Into, @@ -48,7 +48,7 @@ error[E0277]: the trait bound `DeclaredContext: From<&str>` is not satisfied note: required by a bound in `encrypt_into_with_context` --> src/target/operations.rs | - | fn encrypt_into_with_context<'a, T, K: 'static>( + | fn encrypt_into_with_context<'a, T, K: KeysetRegistry + 'static>( | ------------------------- required by a bound in this associated function ... | context: impl Into, @@ -76,7 +76,7 @@ error[E0277]: the trait bound `CallerContext: From<&str>` is not satisfied note: required by a bound in `encrypt_into_with_context` --> src/target/operations.rs | - | fn encrypt_into_with_context<'a, T, K: 'static>( + | fn encrypt_into_with_context<'a, T, K: KeysetRegistry + 'static>( | ------------------------- required by a bound in this associated function ... | context: impl Into, @@ -104,7 +104,7 @@ error[E0277]: the trait bound `DeclaredContext: From<&str>` is not satisfied note: required by a bound in `decrypt_from_with_context` --> src/target/operations.rs | - | fn decrypt_from_with_context<'a, S, K: 'static>( + | fn decrypt_from_with_context<'a, S, K: KeysetRegistry + 'static>( | ------------------------- required by a bound in this associated function ... | context: impl Into, @@ -132,7 +132,7 @@ error[E0277]: the trait bound `AeadContext: From<&str>` is not satisfied note: required by a bound in `decrypt_into` --> src/target/operations.rs | - | fn decrypt_into<'a, P: 'static, K: 'static>( + | fn decrypt_into<'a, P: 'static, K: KeysetRegistry + 'static>( | ------------ required by a bound in this associated function ... | context: impl Into<>::Context>, diff --git a/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr b/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr index b532d4ea4..a29b6acd0 100644 --- a/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr +++ b/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr @@ -1,3 +1,19 @@ +error[E0277]: the trait bound `K: KeysetRegistry` is not satisfied + --> tests/ui/divergent_context_in_target.rs:24:40 + | +24 | fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `KeysetRegistry` is not implemented for `K` + | +note: required by a bound in `Encryption` + --> src/target/operations.rs + | + | pub struct Encryption<'s, S, T, K: KeysetRegistry, Ctx> { + | ^^^^^^^^^^^^^^ required by this bound in `Encryption` +help: consider further restricting type parameter `K` with trait `KeysetRegistry` + | +24 | fn encryption<'s, K: 'static + stack_encrypt::KeysetRegistry>() -> Encryption<'s, String, Self, K, Self::Context> + | +++++++++++++++++++++++++++++++ + error[E0308]: mismatched types --> tests/ui/divergent_context_in_target.rs:30:18 | @@ -13,3 +29,57 @@ note: method defined here | | pub fn zip( | ^^^ + +error[E0277]: the trait bound `K: KeysetRegistry` is not satisfied + --> tests/ui/divergent_context_in_target.rs:28:9 + | +28 | ciphertext() + | ^^^^^^^^^^^^ the trait `KeysetRegistry` is not implemented for `K` + | +note: required by a bound in `ciphertext` + --> src/target/operations.rs + | + | pub fn ciphertext<'s, S: crate::Encrypt + Clone + 's, K: KeysetRegistry + 'static>( + | ^^^^^^^^^^^^^^ required by this bound in `ciphertext` +help: consider further restricting type parameter `K` with trait `KeysetRegistry` + | +24 | fn encryption<'s, K: 'static + stack_encrypt::KeysetRegistry>() -> Encryption<'s, String, Self, K, Self::Context> + | +++++++++++++++++++++++++++++++ + +error[E0277]: the trait bound `K: KeysetRegistry` is not satisfied + --> tests/ui/divergent_context_in_target.rs:30:14 + | +30 | .zip(equality()) + | ^^^ the trait `KeysetRegistry` is not implemented for `K` + | +note: required by a bound in `Encryption::<'s, S, T, K, Ctx>::zip` + --> src/target/operations.rs + | + | impl<'s, S: 's, T: 'static, K: KeysetRegistry + 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { + | ^^^^^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx>::zip` +... + | pub fn zip( + | --- required by a bound in this associated function +help: consider further restricting type parameter `K` with trait `KeysetRegistry` + | +24 | fn encryption<'s, K: 'static + stack_encrypt::KeysetRegistry>() -> Encryption<'s, String, Self, K, Self::Context> + | +++++++++++++++++++++++++++++++ + +error[E0277]: the trait bound `K: KeysetRegistry` is not satisfied + --> tests/ui/divergent_context_in_target.rs:31:14 + | +31 | .map(|(c, hm)| Self { c, hm }) + | ^^^ the trait `KeysetRegistry` is not implemented for `K` + | +note: required by a bound in `Encryption::<'s, S, T, K, Ctx>::map` + --> src/target/operations.rs + | + | impl<'s, S: 's, T: 'static, K: KeysetRegistry + 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { + | ^^^^^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx>::map` +... + | pub fn map(self, f: F) -> Encryption<'s, S, U, K, Ctx> + | --- required by a bound in this associated function +help: consider further restricting type parameter `K` with trait `KeysetRegistry` + | +24 | fn encryption<'s, K: 'static + stack_encrypt::KeysetRegistry>() -> Encryption<'s, String, Self, K, Self::Context> + | +++++++++++++++++++++++++++++++ diff --git a/packages/stack-encrypt/tests/ui/execution_callback.stderr b/packages/stack-encrypt/tests/ui/execution_callback.stderr index 2369603a3..5ede3f782 100644 --- a/packages/stack-encrypt/tests/ui/execution_callback.stderr +++ b/packages/stack-encrypt/tests/ui/execution_callback.stderr @@ -1,7 +1,106 @@ -error[E0451]: field `build` of struct `Encryption` is private - --> tests/ui/execution_callback.rs:6:9 +error[E0277]: the trait bound `(): KeysetRegistry` is not satisfied + --> tests/ui/execution_callback.rs:5:12 | 5 | let _: Encryption<'_, u32, StackCipherText, (), CallerContext> = Encryption { - | ---------- in this type + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `KeysetRegistry` is not implemented for `()` + | +help: the following other types implement trait `KeysetRegistry` + --> src/registry/fake.rs + | + | impl KeysetRegistry for FakeKeysetRegistry { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `FakeKeysetRegistry` + | + ::: src/registry/zerokms.rs + | + | / impl KeysetRegistry for Arc> + | | where + | | C: stack_auth::AuthStrategyBounds, + | | for<'a> &'a C: stack_auth::AuthStrategy, + | | Conn: stack_kms::ZeroKMSConnection + Send + Sync, + | |_____________________________________________________^ `Arc>` +note: required by a bound in `Encryption` + --> src/target/operations.rs + | + | pub struct Encryption<'s, S, T, K: KeysetRegistry, Ctx> { + | ^^^^^^^^^^^^^^ required by this bound in `Encryption` + +error[E0277]: the trait bound `(): KeysetRegistry` is not satisfied + --> tests/ui/execution_callback.rs:6:16 + | +6 | build: Box::new(|_, cipher, _| { + | ________________^ +7 | | stack_encrypt::Pending::failed(cipher, stack_encrypt::Error::Aead) +8 | | }), + | |__________^ the trait `KeysetRegistry` is not implemented for `()` + | +help: the following other types implement trait `KeysetRegistry` + --> src/registry/fake.rs + | + | impl KeysetRegistry for FakeKeysetRegistry { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `FakeKeysetRegistry` + | + ::: src/registry/zerokms.rs + | + | / impl KeysetRegistry for Arc> + | | where + | | C: stack_auth::AuthStrategyBounds, + | | for<'a> &'a C: stack_auth::AuthStrategy, + | | Conn: stack_kms::ZeroKMSConnection + Send + Sync, + | |_____________________________________________________^ `Arc>` +note: required by a bound in `Encryption` + --> src/target/operations.rs + | + | pub struct Encryption<'s, S, T, K: KeysetRegistry, Ctx> { + | ^^^^^^^^^^^^^^ required by this bound in `Encryption` + +error[E0277]: the trait bound `(): KeysetRegistry` is not satisfied + --> tests/ui/execution_callback.rs:6:29 + | 6 | build: Box::new(|_, cipher, _| { - | ^^^^^ private field + | ^^^^^^ the trait `KeysetRegistry` is not implemented for `()` + | +help: the following other types implement trait `KeysetRegistry` + --> src/registry/fake.rs + | + | impl KeysetRegistry for FakeKeysetRegistry { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `FakeKeysetRegistry` + | + ::: src/registry/zerokms.rs + | + | / impl KeysetRegistry for Arc> + | | where + | | C: stack_auth::AuthStrategyBounds, + | | for<'a> &'a C: stack_auth::AuthStrategy, + | | Conn: stack_kms::ZeroKMSConnection + Send + Sync, + | |_____________________________________________________^ `Arc>` +note: required by a bound in `KeysetCipher` + --> src/keyset.rs + | + | pub struct KeysetCipher<'k, R: KeysetRegistry> { + | ^^^^^^^^^^^^^^ required by this bound in `KeysetCipher` + +error[E0277]: the trait bound `(): KeysetRegistry` is not satisfied + --> tests/ui/execution_callback.rs:7:13 + | +7 | stack_encrypt::Pending::failed(cipher, stack_encrypt::Error::Aead) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `KeysetRegistry` is not implemented for `()` + | +help: the following other types implement trait `KeysetRegistry` + --> src/registry/fake.rs + | + | impl KeysetRegistry for FakeKeysetRegistry { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `FakeKeysetRegistry` + | + ::: src/registry/zerokms.rs + | + | / impl KeysetRegistry for Arc> + | | where + | | C: stack_auth::AuthStrategyBounds, + | | for<'a> &'a C: stack_auth::AuthStrategy, + | | Conn: stack_kms::ZeroKMSConnection + Send + Sync, + | |_____________________________________________________^ `Arc>` +note: required by a bound in `stack_encrypt::Pending` + --> src/target/pending.rs + | + | pub struct Pending<'a, T, K: KeysetRegistry> { + | ^^^^^^^^^^^^^^ required by this bound in `Pending` diff --git a/packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr b/packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr index f0646d8f2..385db4572 100644 --- a/packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr +++ b/packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr @@ -1,3 +1,65 @@ +error[E0277]: the trait bound `K: KeysetRegistry` is not satisfied + --> tests/ui/foreign_cipher_scope.rs:9:29 + | +9 | struct AnyKeyset<'a, K>(&'a StackCipher, Uuid); + | ^^^^^^^^^^^^^^ the trait `KeysetRegistry` is not implemented for `K` + | +note: required by a bound in `StackCipher` + --> src/cipher.rs + | + | pub struct StackCipher { + | ^^^^^^^^^^^^^^ required by this bound in `StackCipher` +help: consider restricting type parameter `K` with trait `KeysetRegistry` + | +9 | struct AnyKeyset<'a, K: stack_encrypt::KeysetRegistry>(&'a StackCipher, Uuid); + | +++++++++++++++++++++++++++++++ + +error[E0277]: the trait bound `K: KeysetRegistry` is not satisfied + --> tests/ui/foreign_cipher_scope.rs:12:5 + | +12 | fn cipher(&self) -> &'a StackCipher { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `KeysetRegistry` is not implemented for `K` + | +note: required by a bound in `StackCipher` + --> src/cipher.rs + | + | pub struct StackCipher { + | ^^^^^^^^^^^^^^ required by this bound in `StackCipher` +help: consider restricting type parameter `K` with trait `KeysetRegistry` + | +11 | impl<'a, K: stack_encrypt::KeysetRegistry> CipherScope<'a, K> for AnyKeyset<'a, K> { + | +++++++++++++++++++++++++++++++ + +error[E0053]: method `keyset` has an incompatible type for trait + --> tests/ui/foreign_cipher_scope.rs:16:25 + | +16 | fn keyset(&self) -> Option { + | ^^^^^^^^^^^^ expected `KeysetId`, found `Uuid` + | + = note: expected signature `fn(&AnyKeyset<'a, K>) -> Option` + found signature `fn(&AnyKeyset<'a, K>) -> Option` +help: change the output type to match the trait + | +16 - fn keyset(&self) -> Option { +16 + fn keyset(&self) -> Option { + | + +error[E0277]: the trait bound `K: KeysetRegistry` is not satisfied + --> tests/ui/foreign_cipher_scope.rs:11:13 + | +11 | impl<'a, K> CipherScope<'a, K> for AnyKeyset<'a, K> { + | ^^^^^^^^^^^^^^^^^^ the trait `KeysetRegistry` is not implemented for `K` + | +note: required by a bound in `CipherScope` + --> src/target/pending.rs + | + | pub trait CipherScope<'a, K: KeysetRegistry>: sealed::Sealed { + | ^^^^^^^^^^^^^^ required by this bound in `CipherScope` +help: consider restricting type parameter `K` with trait `KeysetRegistry` + | +11 | impl<'a, K: stack_encrypt::KeysetRegistry> CipherScope<'a, K> for AnyKeyset<'a, K> { + | +++++++++++++++++++++++++++++++ + error[E0277]: the trait bound `AnyKeyset<'a, K>: target::pending::sealed::Sealed` is not satisfied --> tests/ui/foreign_cipher_scope.rs:11:36 | @@ -13,16 +75,32 @@ help: the trait `target::pending::sealed::Sealed` is not implemented for `AnyKey help: the following other types implement trait `target::pending::sealed::Sealed` --> src/target/pending.rs | - | impl Sealed for &crate::StackCipher {} - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `&StackCipher` - | impl Sealed for &crate::KeysetCipher<'_, K> {} - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `&KeysetCipher<'_, K>` + | impl Sealed for &crate::StackCipher {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `&StackCipher` + | impl 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` + | pub trait CipherScope<'a, K: KeysetRegistry>: 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 &stack_encrypt::KeysetCipher<'_, K> + +error[E0277]: the trait bound `K: KeysetRegistry` is not satisfied + --> tests/ui/foreign_cipher_scope.rs:12:29 + | +12 | fn cipher(&self) -> &'a StackCipher { + | ^^^^^^^^^^^^^^ the trait `KeysetRegistry` is not implemented for `K` + | +note: required by a bound in `StackCipher` + --> src/cipher.rs + | + | pub struct StackCipher { + | ^^^^^^^^^^^^^^ required by this bound in `StackCipher` +help: consider restricting type parameter `K` with trait `KeysetRegistry` + | +11 | impl<'a, K: stack_encrypt::KeysetRegistry> CipherScope<'a, K> for AnyKeyset<'a, K> { + | +++++++++++++++++++++++++++++++ diff --git a/packages/stack-encrypt/tests/ui/leaf_without_context.rs b/packages/stack-encrypt/tests/ui/leaf_without_context.rs index 4e867f61d..08f2e775a 100644 --- a/packages/stack-encrypt/tests/ui/leaf_without_context.rs +++ b/packages/stack-encrypt/tests/ui/leaf_without_context.rs @@ -4,7 +4,7 @@ use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::{DecryptFrom, EncryptInto}; use stack_encrypt::{DecryptInto, EncryptFrom, KeysetCipher, StackCipher, StackCipherText}; -use stack_kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; #[derive(EncryptFrom, DecryptInto)] #[stash(plaintext = u32)] @@ -13,17 +13,17 @@ struct EncryptedAge { hm: EqualityTerm, } -async fn encrypt(cipher: &KeysetCipher<'_, FakeDataKeySource>) { +async fn encrypt(cipher: &KeysetCipher<'_, FakeKeysetRegistry>) { let _term: EqualityTerm = "alice".encrypt_into(cipher).await.unwrap(); let _record: EncryptedAge = 42u32.encrypt_into(cipher).await.unwrap(); let _column: Vec = vec![1u32].encrypt_into(cipher).await.unwrap(); } -async fn decrypt(cipher: &StackCipher, record: EncryptedAge) { +async fn decrypt(cipher: &StackCipher, record: EncryptedAge) { let _age = u32::decrypt_from(record, cipher).await.unwrap(); } -async fn decrypt_leaf(cipher: &StackCipher, record: EncryptedAge) { +async fn decrypt_leaf(cipher: &StackCipher, record: EncryptedAge) { let _age: u32 = record.decrypt_into(cipher, ()).await.unwrap(); } diff --git a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr index 7e167bc2a..ba037e7da 100644 --- a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr +++ b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr @@ -1,62 +1,62 @@ -error[E0277]: the trait bound `CallerContext: Default` is not satisfied +error[E0277]: the trait bound `CallerContext: std::default::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` + | ^^^^^^^^^^^^ the trait `std::default::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> + | fn encrypt_into<'a, T, K: KeysetRegistry + 'static>( | ------------ 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 +error[E0277]: the trait bound `CallerContext: std::default::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` + | ^^^^^^^^^^^^ the trait `std::default::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> + | fn encrypt_into<'a, T, K: KeysetRegistry + 'static>( | ------------ 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 +error[E0277]: the trait bound `AeadContext: std::default::Default` is not satisfied --> tests/ui/leaf_without_context.rs:19:41 | 19 | let _column: Vec = vec![1u32].encrypt_into(cipher).await.unwrap(); | ^^^^^^^^^^ ------------ required by a bound introduced by this call | | - | the trait `Default` is not implemented for `AeadContext` + | the trait `std::default::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> + | fn encrypt_into<'a, T, K: KeysetRegistry + 'static>( | ------------ 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 +error[E0277]: the trait bound `CallerContext: std::default::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` + | ----------------- ^^^^^^ the trait `std::default::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>( + | fn decrypt_from<'a, S, K: KeysetRegistry + 'static>( | ------------ required by a bound in this associated function ... | S::Context: Default, @@ -84,7 +84,7 @@ error[E0277]: the trait bound `CallerContext: From<()>` is not satisfied note: required by a bound in `decrypt_into` --> src/target/operations.rs | - | fn decrypt_into<'a, P: 'static, K: 'static>( + | fn decrypt_into<'a, P: 'static, K: KeysetRegistry + 'static>( | ------------ required by a bound in this associated function ... | context: impl Into<>::Context>, diff --git a/packages/stack-encrypt/tests/ui/override_encryption.stderr b/packages/stack-encrypt/tests/ui/override_encryption.stderr index e6e84667a..0e383925f 100644 --- a/packages/stack-encrypt/tests/ui/override_encryption.stderr +++ b/packages/stack-encrypt/tests/ui/override_encryption.stderr @@ -15,4 +15,39 @@ error[E0046]: not all trait items implemented, missing: `encryption` 3 | impl EncryptFrom for Target { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `encryption` in implementation | - = help: implement the missing item: `fn encryption<'s, K>() -> Encryption<'s, u32, Self, K, >::Context> { todo!() }` + = help: implement the missing item: `fn encryption<'s, K>() -> Encryption<'s, u32, Self, K, >::Context> where K: KeysetRegistry { todo!() }` + +error[E0277]: the trait bound `K: KeysetRegistry` is not satisfied + --> tests/ui/override_encryption.rs:7:25 + | +7 | Pending::failed(cipher, stack_encrypt::Error::Aead) + | --------------- ^^^^^^ the trait `KeysetRegistry` is not implemented for `K` + | | + | required by a bound introduced by this call + | + = note: required for `&KeysetCipher<'_, K>` to implement `CipherScope<'_, K>` +note: required by a bound in `stack_encrypt::Pending::<'a, T, K>::failed` + --> src/target/pending.rs + | + | pub fn failed(scope: impl CipherScope<'a, K>, error: Error) -> Self { + | ^^^^^^^^^^^^^^^^^^ required by this bound in `Pending::<'a, T, K>::failed` +help: consider restricting type parameter `K` with trait `KeysetRegistry` + | +6 | fn encrypt_from<'a,K: stack_encrypt::KeysetRegistry>(_: &u32, cipher: &'a KeysetCipher<'_,K>, _:())->Pending<'a,Self,K> { + | +++++++++++++++++++++++++++++++ + +error[E0277]: the trait bound `K: KeysetRegistry` is not satisfied + --> tests/ui/override_encryption.rs:7:9 + | +7 | Pending::failed(cipher, stack_encrypt::Error::Aead) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `KeysetRegistry` is not implemented for `K` + | +note: required by a bound in `stack_encrypt::Pending` + --> src/target/pending.rs + | + | pub struct Pending<'a, T, K: KeysetRegistry> { + | ^^^^^^^^^^^^^^ required by this bound in `Pending` +help: consider restricting type parameter `K` with trait `KeysetRegistry` + | +6 | fn encrypt_from<'a,K: stack_encrypt::KeysetRegistry>(_: &u32, cipher: &'a KeysetCipher<'_,K>, _:())->Pending<'a,Self,K> { + | +++++++++++++++++++++++++++++++ diff --git a/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs b/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs index 0401ec89f..ad63307ab 100644 --- a/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs +++ b/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs @@ -6,7 +6,7 @@ //! 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; +use stack_encrypt::registry::fake::FakeKeysetRegistry; /// Declared through `AeadContext` below: it can seal, but the record /// derives no term under it. @@ -43,12 +43,12 @@ fn tenant() -> NonEmpty { NonEmpty::new(Tenant("acme".into())).unwrap() } -async fn canonical(cipher: &KeysetCipher<'_, FakeDataKeySource>, value: &String) { +async fn canonical(cipher: &KeysetCipher<'_, FakeKeysetRegistry>, 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) { +async fn derived(cipher: &KeysetCipher<'_, FakeKeysetRegistry>, 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(); 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 index 3ff7a83e1..567c41614 100644 --- a/packages/stack-encrypt/tests/ui/pass/query_only_and_borrowed.rs +++ b/packages/stack-encrypt/tests/ui/pass/query_only_and_borrowed.rs @@ -1,18 +1,18 @@ use stack_encrypt::{EncryptFrom, StackCipher, StackCipherText, nonempty}; use stack_encrypt::sem::EqualityTerm; -use stack_kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; #[derive(EncryptFrom)] struct Probe { hm: EqualityTerm } // No Encrypt bound: producing a term requires only the PRF capability. -fn query(cipher:&StackCipher, value:&S) { +fn query(cipher:&StackCipher, 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) { +async fn borrowed(cipher:&StackCipher) { let keyset=cipher.default_keyset(); let pending = { let text=String::from("borrowed"); diff --git a/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs b/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs index adf60790b..edb6b9603 100644 --- a/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs +++ b/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs @@ -4,11 +4,11 @@ use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::{DecryptFrom, EncryptInto}; use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, KeysetCipher, NonEmpty, StackCipherText}; -use stack_kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; /// 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>; +type Cipher<'k> = KeysetCipher<'k, FakeKeysetRegistry>; /// A record whose fields have no context of their own: the caller's is /// the only one there is, so it must be given. diff --git a/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.rs b/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.rs index 89c5d97ff..05eb15eae 100644 --- a/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.rs +++ b/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.rs @@ -1,10 +1,10 @@ use stack_encrypt::{EncryptFrom, StackCipherText, StackCipher, nonempty}; -use stack_kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; #[derive(Clone, serde::Serialize)] struct SerdeOnly { value: String } #[derive(EncryptFrom)] struct Target { c: StackCipherText } -fn wrong(cipher:&StackCipher) { +fn wrong(cipher:&StackCipher) { let keyset=cipher.default_keyset(); let _=keyset.encrypt_as::<_,Target>(&SerdeOnly {value:"x".into()},nonempty!("column").into()); } diff --git a/packages/stack-encrypt/tests/ui/wrong_target_context.rs b/packages/stack-encrypt/tests/ui/wrong_target_context.rs index bf92d4763..20ee7a0b4 100644 --- a/packages/stack-encrypt/tests/ui/wrong_target_context.rs +++ b/packages/stack-encrypt/tests/ui/wrong_target_context.rs @@ -1,5 +1,5 @@ use stack_encrypt::{EncryptFrom, StackCipherText, StackCipher, nonempty}; -use stack_kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; #[derive(EncryptFrom)] #[stash(plaintext = String)] struct Target { @@ -7,7 +7,7 @@ struct Target { identifier: u32, c: StackCipherText, } -fn wrong(cipher: &StackCipher) { +fn wrong(cipher: &StackCipher) { let keyset = cipher.default_keyset(); let _ = keyset.encrypt_as::<_,Target>(&"value".to_owned(), nonempty!("wrong type")); } diff --git a/packages/stack-kms/Cargo.toml b/packages/stack-kms/Cargo.toml index 484a046ab..d4a4a5bff 100644 --- a/packages/stack-kms/Cargo.toml +++ b/packages/stack-kms/Cargo.toml @@ -25,10 +25,12 @@ http = ["dep:reqwest", "dep:lazy_static", "stack-auth/http"] # 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 = [] +# Exposes `test_connection`: the in-memory `ZeroKMSConnection` this crate's own +# tests stub responses on, plus the client key and load-keyset fixtures they +# need. For downstream crates that must drive a `StackKms` without a network — +# `stack-encrypt`, which owns `impl KeysetRegistry for Arc` and so +# cannot test it from here. +test-support = ["dep:async-mutex"] # 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 @@ -43,6 +45,10 @@ recipher = { workspace = true } # default. stack-auth = { workspace = true } zerokms-protocol = { workspace = true } +# `test_connection`'s stub only, behind `test-support`. Also a dev-dependency, +# non-optional, so the crate's own `cfg(test)` build has it whatever the +# feature set — `wasm:no-http-test` runs the tests with every feature off. +async-mutex = { version = "1.4.0", optional = true } vitaminc = { workspace = true, features = ["protected", "random"] } # The key-provider traits `ZeroKmsKeyset` implements (`KeyProvider`, diff --git a/packages/stack-kms/src/client.rs b/packages/stack-kms/src/client.rs index 30529207a..40cce84c9 100644 --- a/packages/stack-kms/src/client.rs +++ b/packages/stack-kms/src/client.rs @@ -561,8 +561,8 @@ where } } -#[cfg(test)] -pub(crate) mod test_connection; +#[cfg(any(test, feature = "test-support"))] +pub mod test_connection; #[cfg(test)] mod tests { diff --git a/packages/stack-kms/src/client/test_connection.rs b/packages/stack-kms/src/client/test_connection.rs index 84d634409..a910c80a7 100644 --- a/packages/stack-kms/src/client/test_connection.rs +++ b/packages/stack-kms/src/client/test_connection.rs @@ -4,11 +4,30 @@ //! 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. +//! key-provider implementation over the same stub — and, behind the +//! `test-support` feature, by `stack-encrypt`, whose +//! `impl KeysetRegistry for Arc` cannot be tested from this crate +//! (the trait is not ours) and cannot be tested without a connection. + +// Test scaffolding, compiled into the library only behind `test-support`. The +// crate's production lints are relaxed here for the same reason they are +// relaxed under `cfg(test)`: a fixture that cannot build its own stub has +// nothing useful to return, and a panic is the right answer to a test that +// asked for an endpoint it never stubbed. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +// `client` is a private module, so these `pub` items are only reachable +// through the `test-support` re-export in `lib.rs`. With the feature off and +// `cfg(test)` on — `wasm:no-http-test` runs the tests that way — nothing +// re-exports them and `unreachable_pub` is right to say so; they are +// crate-internal in that configuration, and `pub` is harmless. +#![cfg_attr(not(feature = "test-support"), allow(unreachable_pub))] use async_mutex::Mutex; use uuid::uuid; -use zerokms_protocol::{GeneratedKey, ViturKeyMaterial, ViturRequest, ViturRequestError}; +use zerokms_protocol::{ + GeneratedKey, Keyset, LoadKeysetResponse, RetrievedKey, ViturKeyMaterial, ViturRequest, + ViturRequestError, +}; use recipher::keyset::{EncryptionKeySet, ProxyKeySet}; @@ -19,7 +38,7 @@ 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 { +pub 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); @@ -31,7 +50,7 @@ pub(crate) fn random_client_key() -> ClientKey { } /// A low-level [`Client`] over a stub connection the callback configures. -pub(crate) fn build_client( +pub fn build_client( callback: impl FnOnce(TestConnectionBuilder) -> TestConnectionBuilder, ) -> Client { let builder = callback(TestConnectionBuilder::new()); @@ -45,11 +64,31 @@ pub(crate) fn build_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 { +pub fn key_material() -> ViturKeyMaterial { ViturKeyMaterial::from(vec![7u8; 528]) } -pub(crate) fn generated_key(tag: Vec) -> GeneratedKey { +/// The keyset id [`load_keyset_response`] resolves to. +pub const TEST_KEYSET_ID: uuid::Uuid = uuid!("11111111-1111-1111-1111-111111111111"); + +/// The `load_keyset` response `ZeroKmsKeyset::new` consumes: the keyset it +/// resolves to, plus the partial key material its index key derives from. +pub fn load_keyset_response() -> LoadKeysetResponse { + LoadKeysetResponse { + partial_index_key: RetrievedKey { + key_material: key_material(), + }, + keyset: Keyset { + id: TEST_KEYSET_ID, + name: "default".to_string(), + description: String::new(), + is_disabled: false, + is_default: true, + }, + } +} + +pub fn generated_key(tag: Vec) -> GeneratedKey { GeneratedKey { key_material: key_material(), tag, @@ -60,13 +99,13 @@ pub(crate) fn generated_key(tag: Vec) -> GeneratedKey { type EffectHandlers = Vec<(String, Box)>; type RequestHandlers = Vec<(String, Result)>; -pub(crate) struct TestConnectionBuilder { +pub struct TestConnectionBuilder { handlers: RequestHandlers, effects: EffectHandlers, } impl TestConnectionBuilder { - pub(crate) fn new() -> Self { + pub fn new() -> Self { Self { handlers: vec![], effects: vec![], @@ -76,7 +115,7 @@ impl TestConnectionBuilder { /// Add a matcher for a particular request, returning a success message. /// /// The matcher is only run once. - pub(crate) fn add_success_response(mut self, response: R::Response) -> Self { + pub fn add_success_response(mut self, response: R::Response) -> Self { self.handlers.push(( R::ENDPOINT.to_string(), Ok(serde_json::to_string(&response) @@ -88,7 +127,7 @@ impl TestConnectionBuilder { /// Add a matcher for a particular request, returning a [`ViturRequestError`]. /// /// The matcher is only run once. - pub(crate) fn add_failed_response(mut self, error: ViturRequestError) -> Self { + pub fn add_failed_response(mut self, error: ViturRequestError) -> Self { self.handlers.push((R::ENDPOINT.to_string(), Err(error))); self } @@ -96,7 +135,7 @@ impl TestConnectionBuilder { /// 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( + pub fn add_effect( mut self, handler: H, ) -> Self { @@ -114,7 +153,7 @@ impl TestConnectionBuilder { self } - pub(crate) fn build(self) -> TestConnection { + pub fn build(self) -> TestConnection { TestConnection { handlers: Mutex::new(self.handlers), effects: Mutex::new(self.effects), @@ -128,7 +167,7 @@ impl Default for TestConnectionBuilder { } } -pub(crate) struct TestConnection { +pub struct TestConnection { handlers: Mutex, effects: Mutex, } diff --git a/packages/stack-kms/src/key.rs b/packages/stack-kms/src/key.rs index c919ba9a1..1dd44a6af 100644 --- a/packages/stack-kms/src/key.rs +++ b/packages/stack-kms/src/key.rs @@ -231,20 +231,6 @@ impl IndexKey { } } -/// 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 for IndexKey { - fn from(key: Key) -> Self { - Self(key) - } -} - #[derive(Debug, Clone, Zeroize, ZeroizeOnDrop)] pub struct V1KeySet(pub(crate) KeySet); diff --git a/packages/stack-kms/src/key_source.rs b/packages/stack-kms/src/key_source.rs deleted file mode 100644 index 70e7f00ed..000000000 --- a/packages/stack-kms/src/key_source.rs +++ /dev/null @@ -1,531 +0,0 @@ -//! 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>, - keyset_id: Option, - unverified_context: Option>, - ) -> impl Future, 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>, - keyset_id: Option, - unverified_context: Option<&UnverifiedContext>, - ) -> impl Future, 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, - ) -> impl Future> + MaybeSend; -} - -impl DataKeySource for crate::StackKms -where - C: stack_auth::AuthStrategyBounds, - for<'a> &'a C: stack_auth::AuthStrategy, - Conn: crate::ZeroKMSConnection + Send + Sync, -{ - async fn generate_keys( - &self, - payloads: Vec>, - keyset_id: Option, - unverified_context: Option>, - ) -> Result, 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>, - keyset_id: Option, - unverified_context: Option<&UnverifiedContext>, - ) -> Result, Error> { - crate::StackKms::retrieve_keys(self, payloads, keyset_id, unverified_context).await - } -} - -impl IndexKeySource for crate::StackKms -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, - ) -> 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), 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), Key>> { - lock(&self.keys) - } - } - - /// A poisoned lock only means another test thread panicked mid-insert; - /// the map is still a valid map. - fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { - m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) - } - - fn random(rng: &mut SafeRand) -> Result { - Ok(Generatable::random(rng).map_err(GenerateKeyError::GenerateIv)?) - } - - impl IndexKeySource for FakeDataKeySource { - async fn load_index_key( - &self, - keyset_id: Option, - ) -> 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>, - _keyset_id: Option, - _unverified_context: Option>, - ) -> Result, 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>, - _keyset_id: Option, - _unverified_context: Option<&UnverifiedContext>, - ) -> Result, 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 { - src.retrieve_keys(vec![payload], None, None) - .await - .map(|mut keys| keys.remove(0)) - } - - fn assert_not_found(result: Result, 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() {} - assert_send_sync::(); - } -} diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs index 24adc8cef..d2f2e4ba8 100644 --- a/packages/stack-kms/src/lib.rs +++ b/packages/stack-kms/src/lib.rs @@ -97,7 +97,6 @@ mod errors; mod futures; mod key; mod key_provider; -mod key_source; mod maybe_send; mod payload; pub mod provider; @@ -139,15 +138,16 @@ pub use errors::{ // 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}; - // 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; +// The in-memory connection every test of the above is built on. Exported +// because `stack-encrypt` owns `impl KeysetRegistry for Arc` — the +// orphan rule puts it there — and cannot test it without a ZeroKMS to stub. +#[cfg(feature = "test-support")] +pub use client::test_connection; + // 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 diff --git a/packages/stack-kms/src/maybe_send.rs b/packages/stack-kms/src/maybe_send.rs index f07ca75e1..8d0b64018 100644 --- a/packages/stack-kms/src/maybe_send.rs +++ b/packages/stack-kms/src/maybe_send.rs @@ -1,8 +1,8 @@ //! 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 +//! Async traits here ([`ZeroKMSConnection`](crate::ZeroKMSConnection), and +//! the `vitaminc-kms` provider traits [`ZeroKmsKeyset`](crate::ZeroKmsKeyset) +//! implements) 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 diff --git a/packages/stack-kms/src/provider.rs b/packages/stack-kms/src/provider.rs index 66e33cb24..52d4a4ca5 100644 --- a/packages/stack-kms/src/provider.rs +++ b/packages/stack-kms/src/provider.rs @@ -192,41 +192,26 @@ where mod tests { use super::*; use crate::client::test_connection::{ - generated_key, key_material, random_client_key, TestConnection, TestConnectionBuilder, + generated_key, key_material, load_keyset_response, random_client_key, TestConnection, + TestConnectionBuilder, TEST_KEYSET_ID, }; 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, + GenerateKeyRequest, GenerateKeyResponse, LoadKeysetRequest, RetrieveKeyRequest, + RetrieveKeyResponse, RetrievedKey, }; - const KEYSET_ID: Uuid = uuid!("11111111-1111-1111-1111-111111111111"); + /// From the shared test-support module, so the id this crate's tests + /// assert on and the one `stack-encrypt`'s registry tests assert on + /// cannot drift apart. `load_keyset_response` comes from there too. + const KEYSET_ID: Uuid = TEST_KEYSET_ID; 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" From 3958324ec1d6e27ef0ffdf93521805e6b42d625a Mon Sep 17 00:00:00 2001 From: James Sadler Date: Tue, 6 Oct 2026 10:14:30 +1100 Subject: [PATCH 2/8] chore(stack-encrypt): repin vitaminc-kms to the key-provider branch rebased onto vitaminc 0.5.1 stack-encrypt pins vitaminc-kms and its vitaminc-protected itself, apart from stack-kms. Move both to b3bc6eb, the rev stack-kms pins, so the workspace builds one vitaminc-kms. The lockfiles that build stack-encrypt by path follow. The stack-encrypt fuzz crate builds stack-encrypt without default features, so with the registry it no longer has stack-kms, stack-auth or their dependencies in its graph. --- Cargo.lock | 4 + .../golang/stackencrypt/guest/Cargo.lock | 4 + packages/eql/Cargo.lock | 5 +- packages/stack-encrypt/Cargo.toml | 4 +- packages/stack-encrypt/fuzz/Cargo.lock | 1934 +---------------- 5 files changed, 65 insertions(+), 1886 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e40a6e72..1f273815c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3281,6 +3281,7 @@ dependencies = [ "serde", "serde_json", "stack-auth", + "stack-encrypt", "stack-encrypt-derive", "stack-kms", "thiserror 1.0.69", @@ -3291,8 +3292,10 @@ dependencies = [ "vitaminc-aead-value", "vitaminc-encrypt", "vitaminc-hmac", + "vitaminc-kms", "vitaminc-prf", "vitaminc-protected 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-protected 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", "zeroize", "zerokms-protocol", ] @@ -4177,6 +4180,7 @@ dependencies = [ "thiserror 2.0.18", "vitaminc-async-traits", "vitaminc-protected 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", + "vitaminc-random 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", "vitaminc-traits 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", "zeroize", ] diff --git a/languages/golang/stackencrypt/guest/Cargo.lock b/languages/golang/stackencrypt/guest/Cargo.lock index fb65934f8..9444134fd 100644 --- a/languages/golang/stackencrypt/guest/Cargo.lock +++ b/languages/golang/stackencrypt/guest/Cargo.lock @@ -1983,6 +1983,7 @@ dependencies = [ "base64ct", "cllw-ore", "serde", + "stack-auth", "stack-encrypt-derive", "stack-kms", "thiserror 1.0.69", @@ -1991,8 +1992,10 @@ dependencies = [ "vitaminc-aead-value", "vitaminc-encrypt", "vitaminc-hmac", + "vitaminc-kms", "vitaminc-prf", "vitaminc-protected 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-protected 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", "zeroize", ] @@ -2550,6 +2553,7 @@ dependencies = [ "thiserror 2.0.20", "vitaminc-async-traits", "vitaminc-protected 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", + "vitaminc-random 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", "vitaminc-traits 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", "zeroize", ] diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index c73e49d20..f0812b2f9 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -4227,14 +4227,15 @@ dependencies = [ "cllw-ore 0.5.0", "serde", "stack-encrypt-derive", - "stack-kms", "thiserror 1.0.69", "uuid", "vitaminc-aead 0.5.1", "vitaminc-encrypt 0.5.1", "vitaminc-hmac", + "vitaminc-kms", "vitaminc-prf", "vitaminc-protected 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-protected 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", "zeroize", ] @@ -4251,6 +4252,7 @@ dependencies = [ name = "stack-kms" version = "0.1.0" dependencies = [ + "async-mutex", "base16ct", "base64ct", "blake3", @@ -4264,7 +4266,6 @@ dependencies = [ "serdect", "sha2 0.10.9", "stack-auth 0.43.0", - "stack-profile 0.43.0", "thiserror 1.0.69", "tracing", "url", diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index d6743dfae..53b783f3c 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -41,10 +41,10 @@ vitaminc-protected = { workspace = true } # The key-provider traits a `KeysetRegistry`'s provider implements. Pinned to # the same git rev as `stack-kms`'s copy: a differently-sourced dependency is # a different type at compile time, so the two must resolve to one package. -vitaminc-kms = { git = "https://github.com/cipherstash/vitaminc", rev = "9cb1965", default-features = false } +vitaminc-kms = { git = "https://github.com/cipherstash/vitaminc", rev = "b3bc6eb", default-features = false } # The `Protected` those traits name. Same rev, same reason — see the comment # on the identical pair in `stack-kms/Cargo.toml`. -vitaminc-protected-kms = { package = "vitaminc-protected", git = "https://github.com/cipherstash/vitaminc", rev = "9cb1965" } +vitaminc-protected-kms = { package = "vitaminc-protected", git = "https://github.com/cipherstash/vitaminc", rev = "b3bc6eb" } serde = { workspace = true } # `Descriptor`: the base64 rendering of a non-textual context for ZeroKMS. base64ct = { version = "1.7", features = ["alloc"] } diff --git a/packages/stack-encrypt/fuzz/Cargo.lock b/packages/stack-encrypt/fuzz/Cargo.lock index eb3ba9c5e..f156fd826 100644 --- a/packages/stack-encrypt/fuzz/Cargo.lock +++ b/packages/stack-encrypt/fuzz/Cargo.lock @@ -38,62 +38,12 @@ dependencies = [ "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" @@ -114,19 +64,6 @@ 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" @@ -158,24 +95,6 @@ dependencies = [ "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" @@ -212,16 +131,6 @@ dependencies = [ "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]] @@ -240,12 +149,6 @@ 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" @@ -255,39 +158,6 @@ 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" @@ -314,22 +184,10 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core 0.10.0", + "rand_core", "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" @@ -340,18 +198,6 @@ dependencies = [ "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" @@ -366,17 +212,6 @@ dependencies = [ "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" @@ -392,18 +227,6 @@ 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" @@ -416,21 +239,6 @@ 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" @@ -477,32 +285,6 @@ 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" @@ -512,59 +294,6 @@ 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" @@ -576,172 +305,43 @@ dependencies = [ "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", + "block-buffer", "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" @@ -854,53 +454,30 @@ dependencies = [ ] [[package]] -name = "gethostname" -version = "0.5.0" +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "rustix", - "windows-targets 0.52.6", + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", ] [[package]] name = "getrandom" -version = "0.2.17" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" 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", + "r-efi 6.0.0", + "rand_core", + "wasip2", + "wasip3", "wasm-bindgen", ] @@ -914,22 +491,6 @@ dependencies = [ "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" @@ -957,19 +518,13 @@ 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", + "digest", ] [[package]] @@ -981,163 +536,12 @@ 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" @@ -1159,34 +563,6 @@ 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" @@ -1213,24 +589,6 @@ dependencies = [ "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" @@ -1253,132 +611,24 @@ dependencies = [ "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" @@ -1400,62 +650,6 @@ 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" @@ -1486,30 +680,6 @@ dependencies = [ "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" @@ -1520,28 +690,6 @@ dependencies = [ "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" @@ -1551,202 +699,49 @@ 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", +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "r-efi" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "redox_users" -version = "0.4.6" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "regex" -version = "1.12.3" +name = "radium" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] -name = "regex-automata" -version = "0.4.14" +name = "rand" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "chacha20", + "getrandom 0.4.2", + "rand_core", ] [[package]] -name = "regex-syntax" -version = "0.8.9" +name = "rand_core" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" [[package]] name = "rmp" @@ -1767,46 +762,12 @@ dependencies = [ "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" @@ -1833,16 +794,6 @@ dependencies = [ "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" @@ -1876,46 +827,12 @@ dependencies = [ "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" @@ -1924,7 +841,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.3", + "digest", ] [[package]] @@ -1933,91 +850,12 @@ 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.43.0" -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 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", - "web-time", - "zeroize", - "zerokms-protocol", -] - [[package]] name = "stack-encrypt" version = "0.2.0" @@ -2026,15 +864,16 @@ dependencies = [ "cllw-ore", "serde", "stack-encrypt-derive", - "stack-kms", "thiserror 1.0.69", "uuid", "vitaminc-aead", "vitaminc-aead-value", "vitaminc-encrypt", "vitaminc-hmac", + "vitaminc-kms", "vitaminc-prf", "vitaminc-protected 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-protected 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", "zeroize", ] @@ -2057,60 +896,6 @@ dependencies = [ "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-kms", - "vitaminc-protected 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", - "vitaminc-protected 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", - "zeroize", - "zerokms-protocol", -] - -[[package]] -name = "stack-profile" -version = "0.43.0" -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" @@ -2139,17 +924,6 @@ dependencies = [ "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" @@ -2196,47 +970,6 @@ dependencies = [ "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" @@ -2252,78 +985,12 @@ 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" @@ -2339,18 +1006,6 @@ 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" @@ -2364,58 +1019,14 @@ 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", + "subtle", ] [[package]] -name = "utoipa-gen" -version = "5.4.0" +name = "untrusted" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", - "url", - "uuid", -] +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" [[package]] name = "uuid" @@ -2423,65 +1034,19 @@ 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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" -dependencies = [ - "vitaminc-aead", - "vitaminc-context", - "vitaminc-encrypt", - "vitaminc-protected 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", - "vitaminc-random 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", - "vitaminc-traits 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", -] - [[package]] name = "vitaminc-aead" version = "0.5.1" @@ -2525,7 +1090,7 @@ version = "0.5.1" source = "git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb#b3bc6eb484f3ed1b933b5706f18b5e2fedc0d823" dependencies = [ "vitaminc-protected 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", - "vitaminc-traits 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", + "vitaminc-traits", ] [[package]] @@ -2559,7 +1124,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15fe558a5e92d4f2b3f811c5b67d863d9255da5f66d08a71d4f0dd3d0011c563" dependencies = [ "hmac", - "sha2 0.11.0", + "sha2", "vitaminc-prf", "vitaminc-protected 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", "zeroize", @@ -2574,7 +1139,7 @@ dependencies = [ "thiserror 2.0.18", "vitaminc-async-traits", "vitaminc-protected 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", - "vitaminc-traits 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", + "vitaminc-traits", "zeroize", ] @@ -2597,7 +1162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7b514f605a63f88874ba46ffea81775fad64994475ab239cb19da159e884b7b" dependencies = [ "bitvec", - "digest 0.11.3", + "digest", "libc", "serde", "serde_bytes", @@ -2613,7 +1178,7 @@ version = "0.5.1" source = "git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb#b3bc6eb484f3ed1b933b5706f18b5e2fedc0d823" dependencies = [ "bitvec", - "digest 0.11.3", + "digest", "libc", "serde", "serde_bytes", @@ -2652,7 +1217,7 @@ checksum = "fa7b1bfab35ee322de37269c6fd94d8cd94047a66f97cf6722e53e3321482167" dependencies = [ "chacha20", "getrandom 0.4.2", - "rand 0.10.1", + "rand", "thiserror 2.0.18", "vitaminc-protected 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", "vitaminc-random-derives 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", @@ -2666,7 +1231,7 @@ source = "git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb#b3bc6eb484f3ed dependencies = [ "chacha20", "getrandom 0.4.2", - "rand 0.10.1", + "rand", "thiserror 2.0.18", "vitaminc-protected 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", "vitaminc-random-derives 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", @@ -2694,22 +1259,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "vitaminc-traits" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b31ec300e351a7a59e64899d0a2d7e7227cfa74ac0514f4e745acb2cc9335cee" -dependencies = [ - "anyhow", - "bytes", - "rmp-serde", - "serde", - "thiserror 2.0.18", - "vitaminc-protected 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", - "vitaminc-random 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", - "zeroize", -] - [[package]] name = "vitaminc-traits" version = "0.5.1" @@ -2725,12 +1274,6 @@ dependencies = [ "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" @@ -2828,253 +1371,6 @@ dependencies = [ "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" @@ -3163,12 +1459,6 @@ dependencies = [ "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" @@ -3178,70 +1468,6 @@ 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" @@ -3262,62 +1488,6 @@ dependencies = [ "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" From 4dec0e1758cc078c904c2ad29168143792c12579 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Tue, 6 Oct 2026 10:30:44 +1100 Subject: [PATCH 3/8] fix(stack-encrypt): carry main's plan builder and newer tests onto the keyset registry stack main added code after the suite branch was cut that has no textual conflict with it but still names the old shape: the plan module (build, chain, value), target::index, target::tuples, the derive's emitted `plan()`, and their tests, doctests and UI fixtures. This commit moves all of it onto `K: KeysetRegistry`. * Every generic `K` that reaches `Encryption`, `Decryption`, `Pending`, `KeysetCipher` or `StackCipher` takes the registry bound, as the suite PR did for the code it touched. * `PlanKms` is now a `KeysetRegistry` whose provider is `Send + Sync` (on native targets), in place of `DataKeySource + IndexKeySource`. * `KeysetChoice::Named` holds a `KeysetRef`, and is built from a `KeysetRef`, `KeysetId`, `Uuid` or name. * `Plan::context`, `Plan::fields` and `Plan::value` were associated functions of `Plan<(), ()>`. `()` cannot be a registry, so the anchor is now `Plan<(), NoRegistry>`: a new uninhabited registry that no cipher can be built over. The call sites do not change. * The fake registry gains `resolves()`, a counter of keyset lookups, and `FakeProvider::clear()`. * Main's tests that expected `Error::Aead` from a wrong context now expect `Error::Provider`: the fake provider is `Bound`, so the key retrieval is refused first, as ZeroKMS refuses it. Tests that cross keysets count each keyset's own provider. * The UI snapshots are re-blessed with the rustc wrapper off, on the pinned Rust 1.94.1. --- packages/stack-encrypt-derive/src/encrypt.rs | 2 +- .../fuzz/fuzz_targets/plan_build.rs | 10 +- packages/stack-encrypt/src/lib.rs | 2 +- packages/stack-encrypt/src/plan/build.rs | 71 ++++--- packages/stack-encrypt/src/plan/chain.rs | 187 +++++++++-------- packages/stack-encrypt/src/plan/mod.rs | 24 ++- packages/stack-encrypt/src/plan/value.rs | 56 ++--- packages/stack-encrypt/src/registry.rs | 54 +++++ packages/stack-encrypt/src/registry/fake.rs | 6 + packages/stack-encrypt/src/target/index.rs | 35 ++-- .../stack-encrypt/src/target/operations.rs | 38 ++-- packages/stack-encrypt/src/target/tuples.rs | 9 +- packages/stack-encrypt/tests/derive.rs | 35 ++-- packages/stack-encrypt/tests/derive_plan.rs | 65 +++--- packages/stack-encrypt/tests/descriptor.rs | 2 +- packages/stack-encrypt/tests/index.rs | 36 ++-- packages/stack-encrypt/tests/plan.rs | 197 +++++++++--------- packages/stack-encrypt/tests/plan_grammar.rs | 123 +++++------ packages/stack-encrypt/tests/source_mode.rs | 4 +- .../tests/ui/divergent_context_in_target.rs | 2 +- .../ui/divergent_context_in_target.stderr | 70 ------- .../tests/ui/execution_callback.stderr | 44 ++-- .../tests/ui/foreign_cipher_scope.stderr | 4 +- .../tests/ui/indexes_empty_set.rs | 4 +- .../tests/ui/indexes_empty_set.stderr | 10 +- .../tests/ui/match_in_a_tuple_on_integer.rs | 4 +- .../ui/match_in_a_tuple_on_integer.stderr | 4 +- .../tests/ui/match_on_integer.rs | 4 +- .../tests/ui/match_on_integer.stderr | 4 +- .../tests/ui/owned_fan_out_needs_clone.rs | 4 +- .../tests/ui/pass/context_type_records.rs | 8 +- .../stack-encrypt/tests/ui/pass/indexes.rs | 14 +- .../tests/ui/pass/owned_without_clone.rs | 22 +- .../stack-encrypt/tests/ui/pass/plan_chain.rs | 4 +- .../tests/ui/pass/plan_grammar.rs | 4 +- .../ui/pass/struct_over_borrowed_source.rs | 6 +- .../tests/ui/plan_empty_index_set.rs | 4 +- .../tests/ui/plan_match_on_integer.rs | 4 +- .../tests/ui/plan_picker_wrong_type.rs | 4 +- .../tests/ui/plan_query_undeclared_index.rs | 4 +- .../tests/ui/struct_field_must_open.rs | 2 +- 41 files changed, 613 insertions(+), 573 deletions(-) diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index d3c17fefe..9789c3c80 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -103,7 +103,7 @@ fn struct_derive(input: &DeriveInput, record: &Record, fields: &[&Field]) -> Tok /// field's type, not the indexes it declares, so this fails /// when the plan is built, not at compile time; every other /// input whose plan would not build does not compile. - pub fn plan<__K: 'static>() -> ::core::result::Result<#krate::Plan<#source, __K>, #krate::Error> { + pub fn plan<__K: #krate::KeysetRegistry + 'static>() -> ::core::result::Result<#krate::Plan<#source, __K>, #krate::Error> { #krate::Plan::context(#prefix) .fields::<#source, __K>() #(#verbs)* diff --git a/packages/stack-encrypt/fuzz/fuzz_targets/plan_build.rs b/packages/stack-encrypt/fuzz/fuzz_targets/plan_build.rs index 3d039ea67..b9fc5f171 100644 --- a/packages/stack-encrypt/fuzz/fuzz_targets/plan_build.rs +++ b/packages/stack-encrypt/fuzz/fuzz_targets/plan_build.rs @@ -31,7 +31,7 @@ use std::collections::HashSet; use arbitrary::Arbitrary; use libfuzzer_sys::fuzz_target; use stack_encrypt::plan::{FieldKind, FieldValues, Opens, PlanError, Runs}; -use stack_encrypt::{Equality, Error, Label, Plan}; +use stack_encrypt::{Equality, Error, Label, NoRegistry, Plan}; /// A name: one of a few that collide or sit on a rule's edge, or free text. #[derive(Arbitrary, Debug)] @@ -87,8 +87,8 @@ struct Input { fuzz_target!(|input: Input| { let mut builder = match &input.context { - Some(context) => Plan::context(context.as_str()).fields::(), - None => Plan::fields::(), + Some(context) => Plan::context(context.as_str()).fields::(), + None => Plan::fields::(), }; for field in &input.fields { let name = field.name.as_str(); @@ -182,13 +182,13 @@ fuzz_target!(|input: Input| { }), _ => None, }; - let run = Runs::, ()>::check(&plan, &Vec::new(), call); + let run = Runs::, NoRegistry>::check(&plan, &Vec::new(), call); match (&expected, run) { (None, Ok(())) => {} (Some(expected), Err(Error::Plan(error))) => assert_eq!(&error, expected), (expected, run) => panic!("an empty run checked {run:?}, not {expected:?}"), } - let opened = Opens::, ()>::check(&plan, &Vec::new(), call); + let opened = Opens::, NoRegistry>::check(&plan, &Vec::new(), call); match (&expected, opened) { (_, Ok(())) if context_fields == 1 => {} (None, Ok(())) => {} diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 67814e422..9f7d9bd55 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -284,7 +284,7 @@ pub mod registry; pub mod sem; pub mod target; -pub use registry::{KeysetId, KeysetRef, KeysetRegistry, Resolved}; +pub use registry::{KeysetId, KeysetRef, KeysetRegistry, NoRegistry, Resolved}; pub use cipher::{ BoxedPassthrough, Error, FromEnv, LeafBytesError, PendingStackCipherText, SealedValue, diff --git a/packages/stack-encrypt/src/plan/build.rs b/packages/stack-encrypt/src/plan/build.rs index d8537f64a..4a5aae913 100644 --- a/packages/stack-encrypt/src/plan/build.rs +++ b/packages/stack-encrypt/src/plan/build.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use super::field_ref::{resolve, Access, FieldRef, FieldsOf, Reader}; use super::values::Slot; use super::{FieldValues, PlanError}; +use crate::registry::{KeysetRegistry, NoRegistry}; use crate::target::{ ciphertext, indexed, inspect, open, passthrough, AeadContext, Borrowed, CallerContext, DeclaredContext, DecryptField, Decryptable, Decryption, EncryptFrom, Encryption, @@ -243,10 +244,11 @@ pub(crate) fn check_declared( /// # use stack_encrypt::plan::pick; /// # struct User { email: String, id: u64 } /// # async fn example() -> Result<(), stack_encrypt::Error> { -/// use stack_encrypt::kms::FakeDataKeySource; +/// use stack_encrypt::registry::fake::FakeKeysetRegistry; +/// use stack_encrypt::StackCipherBuilder; /// use stack_encrypt::{Equality, Plan, StackCipher}; /// -/// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +/// let cipher = StackCipherBuilder::new().registry(FakeKeysetRegistry::new()).init().await?; /// let user = User { email: "bob@example.com".into(), id: 42 }; /// /// // Built without a context: each call names its own. @@ -278,11 +280,11 @@ pub(crate) fn check_declared( /// # } /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); /// ``` -pub struct Plan { +pub struct Plan { inner: Arc>, } -struct PlanInner { +struct PlanInner { source: Source, names: Arc<[Arc]>, fields: Vec>, @@ -348,7 +350,7 @@ impl ContextField { } } -struct Built { +struct Built { plan: FieldPlan, lower: Lower, probe: Probe, @@ -376,12 +378,12 @@ type Holds = fn(&Slot) -> Result<(), &'static str>; /// How a field comes back from a stored record. A field that does not come /// back (terms alone, which are one-way) has none. -struct FieldOpener { +struct FieldOpener { holds: Holds, open: Opener, } -impl Clone for FieldOpener { +impl Clone for FieldOpener { fn clone(&self) -> Self { Self { holds: self.holds, @@ -399,7 +401,7 @@ fn holds(slot: &Slot) -> Result<(), &'static str> { } } -impl Clone for Plan { +impl Clone for Plan { fn clone(&self) -> Self { Self { inner: Arc::clone(&self.inner), @@ -407,7 +409,7 @@ impl Clone for Plan { } } -impl fmt::Debug for Plan { +impl fmt::Debug for Plan { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let context = match &self.inner.source { Source::Plan(label) => label.to_string(), @@ -429,13 +431,13 @@ impl fmt::Debug for Plan { } } -impl Plan<(), ()> { +impl Plan<(), NoRegistry> { /// Start a plan under `context`, with no value: the saved form of /// `cipher.encrypt(&value).context(..)`. Follow it with /// [`fields`](PlanContext::fields) or [`with`](PlanContext::with), then /// `build()`. The common spelling of `Plan::fields().context(c)`. /// - /// Anchored on `Plan<(), ()>` only so `Plan::context` resolves without + /// Anchored on `Plan<(), NoRegistry>` only so `Plan::context` resolves without /// naming a type; that plan is never built. pub fn context(context: impl IntoLabel) -> PlanContext { PlanContext { @@ -447,7 +449,7 @@ impl Plan<(), ()> { /// [`context`](FieldsBuilder::context) or /// [`context_field`](FieldsBuilder::context_field), or leave it for the /// call that runs it. - pub fn fields() -> FieldsBuilder { + pub fn fields() -> FieldsBuilder { FieldsBuilder { sources: Vec::new(), fields: Vec::new(), @@ -476,7 +478,7 @@ impl PlanContext { /// Seal each top-level field of the value on its own, under /// `/`, as the field verbs that follow declare. - pub fn fields(self) -> FieldsBuilder { + pub fn fields(self) -> FieldsBuilder { let mut fields = Plan::fields(); fields.sources.push(Declare::Plan(self.context)); fields @@ -520,14 +522,14 @@ impl Declare { /// /// A field is either the data verbs (`encrypt`, `encrypt_index`, `index`, /// `passthrough`) or one typed target (`encrypt_into`), never both. -pub struct FieldsBuilder { +pub struct FieldsBuilder { sources: Vec>, fields: Vec>, identity_without_field: bool, fields_of: Option>, } -struct Declared { +struct Declared { name: Arc, identity: Option>, kind: FieldKind, @@ -539,7 +541,7 @@ struct Declared { open: Option>, } -impl fmt::Debug for FieldsBuilder { +impl fmt::Debug for FieldsBuilder { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("FieldsBuilder") .field("context", &self.sources) @@ -566,7 +568,7 @@ where S: 's, F: 'static, T: std::any::Any + Send + 'static, - K: 'static, + K: KeysetRegistry + 'static, { let name = Arc::clone(name); let read = Arc::clone(read); @@ -605,12 +607,12 @@ fn stored(field: &str, slot: Slot) -> Result { fn open_sealed(ciphertext: StackCipherText, context: CallerContext) -> Decryption where F: crate::Decrypt<'static> + Send + 'static, - K: 'static, + K: KeysetRegistry + 'static, { open::(ciphertext, AeadContext::from(context)).map(Slot::new) } -impl FieldsBuilder { +impl FieldsBuilder { fn declare( mut self, name: Arc, @@ -672,10 +674,11 @@ impl FieldsBuilder { /// # use stack_encrypt::plan::pick; /// # struct Note { tenant: String, text: String } /// # async fn example() -> Result<(), stack_encrypt::Error> { - /// use stack_encrypt::kms::FakeDataKeySource; + /// use stack_encrypt::registry::fake::FakeKeysetRegistry; + /// use stack_encrypt::StackCipherBuilder; /// use stack_encrypt::{Error, Plan, StackCipher}; /// - /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; + /// let cipher = StackCipherBuilder::new().registry(FakeKeysetRegistry::new()).init().await?; /// let notes_plan = Plan::fields() /// .context_field(pick("tenant", |n: &Note| &n.tenant)) /// .encrypt(pick("text", |n: &Note| &n.text)) @@ -913,11 +916,12 @@ impl FieldsBuilder { /// # use stack_encrypt::plan::pick; /// # struct User { email: String } /// # async fn example() -> Result<(), stack_encrypt::Error> { - /// use stack_encrypt::kms::FakeDataKeySource; + /// use stack_encrypt::registry::fake::FakeKeysetRegistry; + /// use stack_encrypt::StackCipherBuilder; /// use stack_encrypt::sem::{EqualityTerm, MatchTerms}; /// use stack_encrypt::{Encrypted, Plan, StackCipher}; /// - /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; + /// let cipher = StackCipherBuilder::new().registry(FakeKeysetRegistry::new()).init().await?; /// let users_plan = Plan::context("users") /// .fields() /// .encrypt_into::, _>(pick("email", |u: &User| &u.email)) @@ -1119,7 +1123,10 @@ pub(crate) fn check_indexes(at: &str, indexes: &[IndexSpec]) -> Result<(), PlanE /// name declared once as a target and once with data verbs is reported as /// that, and one declared once passthrough and once indexed likewise, since /// those are the likelier mistakes. -fn check_pair(earlier: &Declared, field: &Declared) -> Result<(), PlanError> { +fn check_pair( + earlier: &Declared, + field: &Declared, +) -> Result<(), PlanError> { if earlier.name == field.name { let target = |f: &Declared| matches!(f.kind, FieldKind::EncryptInto); if target(earlier) != target(field) { @@ -1158,7 +1165,7 @@ fn check_pair(earlier: &Declared, field: &Declared) -> Result< /// A plan against a type that fixes its fields: every plan field is one of /// the type's, at the type the plan declares, and every field of the type /// is named. -fn check_schema( +fn check_schema( schema: &[super::FieldSchema], fields: &[Built], ) -> Result<(), PlanError> { @@ -1210,7 +1217,7 @@ enum Base { Field(OfValue), } -impl Plan { +impl Plan { /// The plan's context, when it was built with one. pub fn label(&self) -> Option<&Label> { match &self.inner.source { @@ -1492,7 +1499,7 @@ fn field_label(field: &str, source: LabelError) -> Error { /// them, and for a one-value plan likewise. A slice or a `Vec` runs one /// description per item and settles all of them in one batch: one key /// request for the whole collection. -pub trait Runs { +pub trait Runs { /// What one run produces. type Output: 'static; /// Lower the plan and run it over `source`, under the context the call @@ -1521,7 +1528,7 @@ pub trait Runs { /// A plan that can open a stored `R`, producing `Output`: what /// [`using`](crate::plan::OpenBuilder::using) asks of its plan. -pub trait Opens { +pub trait Opens { /// What opening produces. type Output: 'static; /// The opening of `record`, under the context the call names (if any; @@ -1548,7 +1555,7 @@ pub trait Opens { } } -impl Runs for Plan { +impl Runs for Plan { type Output = FieldValues; fn pending<'p>( &self, @@ -1625,10 +1632,10 @@ macro_rules! runs_over_collections { } pub(crate) use runs_over_collections; runs_over_collections! { - [S, K] Plan => S where [S: 'static, K: 'static] check_call; + [S, K: KeysetRegistry] Plan => S where [S: 'static, K: KeysetRegistry + 'static] check_call; } -impl Opens for Plan { +impl Opens for Plan { type Output = FieldValues; fn decryption( &self, @@ -1643,7 +1650,7 @@ impl Opens for Plan { } } -impl Opens, K> for Plan { +impl Opens, K> for Plan { type Output = Vec; fn decryption( &self, diff --git a/packages/stack-encrypt/src/plan/chain.rs b/packages/stack-encrypt/src/plan/chain.rs index abe9b4622..65b86b951 100644 --- a/packages/stack-encrypt/src/plan/chain.rs +++ b/packages/stack-encrypt/src/plan/chain.rs @@ -4,32 +4,39 @@ use std::future::IntoFuture; use std::marker::PhantomData; -use stack_kms::{DataKeySource, IdentifiedBy, IndexKeySource, MaybeSend}; use uuid::Uuid; +use vitaminc_kms::provider::MaybeSend; use super::build::{FieldPlan, FieldsBuilder, IntoLabel, Opens, PlanContext, Runs}; use super::field_ref::FieldRef; use super::value::{Indexed, Typed, ValuePlan, ValueShape}; use super::PlanError; +use crate::registry::{KeysetId, KeysetRef, KeysetRegistry}; use crate::target::{ ciphertext, Borrowed, CallerContext, DeclaredContext, DecryptField, Decryptable, EncryptFrom, Equality, Index, Indexes, Owned, Pending, PendingFuture, Select, }; use crate::{Error, KeysetCipher, Label, LabelError, NonEmpty, StackCipher, StackCipherText}; -/// What a cipher's data-key source must offer for a chain to run: data keys -/// to seal and open, index keys to load a keyset named in the chain, and, -/// on native targets, `Sync`, because the future holds the cipher across an +/// What a cipher's keyset registry must offer for a chain to run: a +/// [`KeysetRegistry`], to load a keyset named in the chain and the provider +/// that seals and opens under it, and, on native targets, `Sync` on both, +/// because the future holds the cipher and the loaded keyset across an /// await (as awaiting a [`Pending`] does). #[cfg(not(target_arch = "wasm32"))] -pub trait PlanKms: DataKeySource + IndexKeySource + Sync + 'static {} +pub trait PlanKms: KeysetRegistry + Sync + 'static {} #[cfg(not(target_arch = "wasm32"))] -impl PlanKms for K {} -/// See the native definition; the same minus `Sync`. +impl PlanKms for K +where + K: KeysetRegistry + Sync + 'static, + K::Provider: Send + Sync, +{ +} +/// See the native definition; the same minus `Send` and `Sync`. #[cfg(target_arch = "wasm32")] -pub trait PlanKms: DataKeySource + IndexKeySource + 'static {} +pub trait PlanKms: KeysetRegistry + 'static {} #[cfg(target_arch = "wasm32")] -impl PlanKms for K {} +impl PlanKms for K {} /// The keyset a chain names with `.keyset(..)`: a [`KeysetCipher`] already /// in hand, or a keyset by id or name, loaded when the chain is awaited (as @@ -39,14 +46,14 @@ impl PlanKms for K {} /// and opens leaves from any keyset the client may use. Inside [`all`], a /// chain that names none encrypts under the keyset the batch's other chains /// name (or the default), and still opens leaves from any keyset. -pub enum KeysetChoice<'a, K> { +pub enum KeysetChoice<'a, K: KeysetRegistry> { /// A keyset already selected. Handle(KeysetCipher<'a, K>), /// A keyset to select when the chain runs. - Named(IdentifiedBy), + Named(KeysetRef), } -impl std::fmt::Debug for KeysetChoice<'_, K> { +impl std::fmt::Debug for KeysetChoice<'_, K> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { KeysetChoice::Handle(keyset) => f.debug_tuple("Handle").field(keyset).finish(), @@ -55,37 +62,42 @@ impl std::fmt::Debug for KeysetChoice<'_, K> { } } -impl<'a, K> From> for KeysetChoice<'a, K> { +impl<'a, K: KeysetRegistry> From> for KeysetChoice<'a, K> { fn from(keyset: KeysetCipher<'a, K>) -> Self { KeysetChoice::Handle(keyset) } } -impl<'a, K> From<&KeysetCipher<'a, K>> for KeysetChoice<'a, K> { +impl<'a, K: KeysetRegistry> From<&KeysetCipher<'a, K>> for KeysetChoice<'a, K> { fn from(keyset: &KeysetCipher<'a, K>) -> Self { KeysetChoice::Handle(keyset.clone()) } } -impl From for KeysetChoice<'_, K> { - fn from(keyset: IdentifiedBy) -> Self { +impl From for KeysetChoice<'_, K> { + fn from(keyset: KeysetRef) -> Self { KeysetChoice::Named(keyset) } } -impl From for KeysetChoice<'_, K> { +impl From for KeysetChoice<'_, K> { + fn from(keyset: KeysetId) -> Self { + KeysetChoice::Named(KeysetRef::Id(keyset)) + } +} +impl From for KeysetChoice<'_, K> { fn from(keyset: Uuid) -> Self { - KeysetChoice::Named(IdentifiedBy::Uuid(keyset)) + KeysetChoice::Named(KeysetRef::from(keyset)) } } -impl From<&str> for KeysetChoice<'_, K> { +impl From<&str> for KeysetChoice<'_, K> { fn from(name: &str) -> Self { - KeysetChoice::Named(IdentifiedBy::Name(name.to_owned().into())) + KeysetChoice::Named(KeysetRef::from(name)) } } -impl From for KeysetChoice<'_, K> { +impl From for KeysetChoice<'_, K> { fn from(name: String) -> Self { - KeysetChoice::Named(IdentifiedBy::Name(name.into())) + KeysetChoice::Named(KeysetRef::from(name)) } } -impl From<&String> for KeysetChoice<'_, K> { +impl From<&String> for KeysetChoice<'_, K> { fn from(name: &String) -> Self { Self::from(name.as_str()) } @@ -94,14 +106,14 @@ impl From<&String> for KeysetChoice<'_, K> { /// What every chain carries besides its value: the cipher, the keyset it /// names, the context it names for a plan built without one, and the /// caller's extension of the plan's contexts. -struct Common<'a, K> { +struct Common<'a, K: KeysetRegistry> { cipher: &'a StackCipher, keyset: Option>, context: Option>, extend: DeclaredContext, } -impl<'a, K> Common<'a, K> { +impl<'a, K: KeysetRegistry> Common<'a, K> { fn new(cipher: &'a StackCipher) -> Self { Self { cipher, @@ -145,7 +157,7 @@ mod sealed { /// Awaiting a chain checks it before it loads a keyset it names, so a plan /// that does not hold up is refused before any request to ZeroKMS, the /// keyset lookup included. -pub trait Operation<'a, K: 'static>: sealed::Sealed + Sized { +pub trait Operation<'a, K: KeysetRegistry + 'static>: sealed::Sealed + Sized { /// What the chain produces. type Output: MaybeSend + 'static; /// The cipher the chain was started on. @@ -258,7 +270,7 @@ macro_rules! settles { )+}; } -impl StackCipher { +impl StackCipher { /// Start a chain that encrypts `value`. /// /// Finish it with a context ([`context`](EncryptBuilder::context)) or a @@ -308,12 +320,12 @@ impl StackCipher { } /// A chain encrypting a value, before its context or plan is chosen. -pub struct EncryptBuilder<'a, S: ?Sized, K> { +pub struct EncryptBuilder<'a, S: ?Sized, K: KeysetRegistry> { common: Common<'a, K>, source: &'a S, } -impl<'a, S: ?Sized, K: 'static> EncryptBuilder<'a, S, K> { +impl<'a, S: ?Sized, K: KeysetRegistry + 'static> EncryptBuilder<'a, S, K> { /// Encrypt under `context`: one tree, sealed under that label, as /// `keyset.encrypt(&value, label)` does. Text is parsed as a /// [`Label`] (`"documents/v2/body"` is three segments), and text that @@ -344,13 +356,13 @@ impl<'a, S: ?Sized, K: 'static> EncryptBuilder<'a, S, K> { /// A chain encrypting a value under a context: awaited as it is, one tree; /// or indexed ([`with`](Self::with)), or field by field /// ([`fields`](Self::fields)). -pub struct EncryptWithContext<'a, S: ?Sized, K> { +pub struct EncryptWithContext<'a, S: ?Sized, K: KeysetRegistry> { common: Common<'a, K>, source: &'a S, context: Result, } -impl<'a, S: ?Sized, K: 'static> EncryptWithContext<'a, S, K> { +impl<'a, S: ?Sized, K: KeysetRegistry + 'static> EncryptWithContext<'a, S, K> { /// Encrypt as the saved `plan` declares, under this context: for a plan /// built without a context of its own, this call names it. A plan that /// has one (built with it, or from a context field) is refused with @@ -394,7 +406,7 @@ impl<'a, S: ?Sized, K: 'static> EncryptWithContext<'a, S, K> { } } -impl<'a, S, K: 'static> Operation<'a, K> for EncryptWithContext<'a, S, K> +impl<'a, S, K: KeysetRegistry + 'static> Operation<'a, K> for EncryptWithContext<'a, S, K> where S: crate::Encrypt + Clone, { @@ -427,13 +439,13 @@ where } /// A chain encrypting one value with indexes beside it. -pub struct EncryptIndexed<'a, S, X, K> { +pub struct EncryptIndexed<'a, S, X, K: KeysetRegistry> { common: Common<'a, K>, source: &'a S, plan: super::value::ValuePlanBuilder>, } -impl<'a, S, X, K: 'static> Operation<'a, K> for EncryptIndexed<'a, S, X, K> +impl<'a, S, X, K: KeysetRegistry + 'static> Operation<'a, K> for EncryptIndexed<'a, S, X, K> where S: crate::Encrypt + Clone, X: Indexes + Clone, @@ -463,13 +475,13 @@ where /// A chain encrypting a value field by field: the field verbs of /// [`FieldsBuilder`], then `.await`. The plan is validated when it is /// awaited, with the same errors as [`FieldsBuilder::build`]. -pub struct EncryptFields<'a, S: 'static, K: 'static> { +pub struct EncryptFields<'a, S: 'static, K: KeysetRegistry + 'static> { common: Common<'a, K>, source: &'a S, plan: FieldsBuilder, } -impl<'a, S: 'static, K: 'static> EncryptFields<'a, S, K> { +impl<'a, S: 'static, K: KeysetRegistry + 'static> EncryptFields<'a, S, K> { /// [`FieldsBuilder::encrypt`]. pub fn encrypt(mut self, field: impl FieldRef) -> Self where @@ -528,7 +540,7 @@ impl<'a, S: 'static, K: 'static> EncryptFields<'a, S, K> { } } -impl<'a, S: 'static, K: 'static> Operation<'a, K> for EncryptFields<'a, S, K> { +impl<'a, S: 'static, K: KeysetRegistry + 'static> Operation<'a, K> for EncryptFields<'a, S, K> { type Output = super::FieldValues; fn cipher(&self) -> &'a StackCipher { self.common.cipher @@ -551,13 +563,14 @@ impl<'a, S: 'static, K: 'static> Operation<'a, K> for EncryptFields<'a, S, K> { } /// A chain encrypting a value, a slice or a `Vec` as a saved plan declares. -pub struct EncryptUsing<'a, S: ?Sized, P, K> { +pub struct EncryptUsing<'a, S: ?Sized, P, K: KeysetRegistry> { common: Common<'a, K>, source: &'a S, plan: &'a P, } -impl<'a, S: ?Sized, P: Runs, K: 'static> Operation<'a, K> for EncryptUsing<'a, S, P, K> +impl<'a, S: ?Sized, P: Runs, K: KeysetRegistry + 'static> Operation<'a, K> + for EncryptUsing<'a, S, P, K> where P::Output: MaybeSend, { @@ -589,12 +602,12 @@ where } /// A chain deriving a query term, before its plan is named. -pub struct QueryBuilder<'a, F, K> { +pub struct QueryBuilder<'a, F, K: KeysetRegistry> { common: Common<'a, K>, value: F, } -impl<'a, F, K: 'static> QueryBuilder<'a, F, K> { +impl<'a, F, K: KeysetRegistry + 'static> QueryBuilder<'a, F, K> { /// Name the context the term is derived under, for a plan built /// without one: the context the write's call named, or for a plan with /// a context field, the value that field held. A plan built with its @@ -618,13 +631,13 @@ impl<'a, F, K: 'static> QueryBuilder<'a, F, K> { /// A chain deriving a query term through a plan, before its index is /// chosen. -pub struct QueryUsing<'a, F, P, K> { +pub struct QueryUsing<'a, F, P, K: KeysetRegistry> { common: Common<'a, K>, value: F, plan: &'a P, } -impl<'a, F: 'static, K: 'static> QueryUsing<'a, F, FieldPlan, K> { +impl<'a, F: 'static, K: KeysetRegistry + 'static> QueryUsing<'a, F, FieldPlan, K> { /// The equality term: sugar for `index(Equality)`. pub fn equality(self) -> QueryIndex<'a, F, Equality, K> where @@ -651,7 +664,9 @@ impl<'a, F: 'static, K: 'static> QueryUsing<'a, F, FieldPlan, K> { } } -impl<'a, F: 'static, X: Indexes, K: 'static> QueryUsing<'a, F, ValuePlan>, K> { +impl<'a, F: 'static, X: Indexes, K: KeysetRegistry + 'static> + QueryUsing<'a, F, ValuePlan>, K> +{ /// The equality term, selected from the plan's indexes by type: a plan /// with no `Equality` index does not compile here. pub fn equality(self) -> QueryIndex<'a, F, Equality, K> @@ -683,7 +698,7 @@ impl<'a, F: 'static, X: Indexes, K: 'static> QueryUsing<'a, F, ValuePlan QueryUsing<'a, F, ValuePlan>, K> +impl<'a, F: 'static, T, K: KeysetRegistry + 'static> QueryUsing<'a, F, ValuePlan>, K> where Typed: ValueShape, { @@ -713,14 +728,15 @@ where } /// A chain deriving one query term, ready to await. -pub struct QueryIndex<'a, F, I, K> { +pub struct QueryIndex<'a, F, I, K: KeysetRegistry> { common: Common<'a, K>, value: F, index: I, label: Result, } -impl<'a, F: 'static, I: Index, K: 'static> Operation<'a, K> for QueryIndex<'a, F, I, K> +impl<'a, F: 'static, I: Index, K: KeysetRegistry + 'static> Operation<'a, K> + for QueryIndex<'a, F, I, K> where I::Term: MaybeSend, { @@ -755,12 +771,12 @@ where } /// A chain decrypting a stored record, before its plan is named. -pub struct OpenBuilder<'a, R, K> { +pub struct OpenBuilder<'a, R, K: KeysetRegistry> { common: Common<'a, K>, record: R, } -impl<'a, R, K: 'static> OpenBuilder<'a, R, K> { +impl<'a, R, K: KeysetRegistry + 'static> OpenBuilder<'a, R, K> { /// Name the context: for a plan built without one, the context the /// write's call named; for a plan with a context field, the context the /// caller expects the record to hold, so a record whose field says @@ -785,13 +801,13 @@ impl<'a, R, K: 'static> OpenBuilder<'a, R, K> { } /// A chain decrypting a stored record through a plan, ready to await. -pub struct OpenUsing<'a, R, P, K> { +pub struct OpenUsing<'a, R, P, K: KeysetRegistry> { common: Common<'a, K>, record: R, plan: &'a P, } -impl<'a, R, P: Opens, K: 'static> Operation<'a, K> for OpenUsing<'a, R, P, K> +impl<'a, R, P: Opens, K: KeysetRegistry + 'static> Operation<'a, K> for OpenUsing<'a, R, P, K> where P::Output: MaybeSend, { @@ -828,25 +844,25 @@ where } chain_options! { - ['a, S: ?Sized, K: 'static] EncryptBuilder<'a, S, K>; - ['a, S: ?Sized, K: 'static] EncryptWithContext<'a, S, K>; - ['a, S, X, K: 'static] EncryptIndexed<'a, S, X, K>; - ['a, S: 'static, K: 'static] EncryptFields<'a, S, K>; - ['a, S: ?Sized, P, K: 'static] EncryptUsing<'a, S, P, K>; - ['a, F, K: 'static] QueryBuilder<'a, F, K>; - ['a, F, P, K: 'static] QueryUsing<'a, F, P, K>; - ['a, F, I, K: 'static] QueryIndex<'a, F, I, K>; - ['a, R, K: 'static] OpenBuilder<'a, R, K>; - ['a, R, P, K: 'static] OpenUsing<'a, R, P, K>; + ['a, S: ?Sized, K: KeysetRegistry + 'static] EncryptBuilder<'a, S, K>; + ['a, S: ?Sized, K: KeysetRegistry + 'static] EncryptWithContext<'a, S, K>; + ['a, S, X, K: KeysetRegistry + 'static] EncryptIndexed<'a, S, X, K>; + ['a, S: 'static, K: KeysetRegistry + 'static] EncryptFields<'a, S, K>; + ['a, S: ?Sized, P, K: KeysetRegistry + 'static] EncryptUsing<'a, S, P, K>; + ['a, F, K: KeysetRegistry + 'static] QueryBuilder<'a, F, K>; + ['a, F, P, K: KeysetRegistry + 'static] QueryUsing<'a, F, P, K>; + ['a, F, I, K: KeysetRegistry + 'static] QueryIndex<'a, F, I, K>; + ['a, R, K: KeysetRegistry + 'static] OpenBuilder<'a, R, K>; + ['a, R, P, K: KeysetRegistry + 'static] OpenUsing<'a, R, P, K>; } settles! { - ['a, S: ?Sized, K] EncryptWithContext<'a, S, K>; - ['a, S, X, K] EncryptIndexed<'a, S, X, K>; - ['a, S: 'static, K: 'static] EncryptFields<'a, S, K>; - ['a, S: ?Sized, P, K] EncryptUsing<'a, S, P, K>; - ['a, F, I, K] QueryIndex<'a, F, I, K>; - ['a, R, P, K] OpenUsing<'a, R, P, K>; + ['a, S: ?Sized, K: KeysetRegistry] EncryptWithContext<'a, S, K>; + ['a, S, X, K: KeysetRegistry] EncryptIndexed<'a, S, X, K>; + ['a, S: 'static, K: KeysetRegistry + 'static] EncryptFields<'a, S, K>; + ['a, S: ?Sized, P, K: KeysetRegistry] EncryptUsing<'a, S, P, K>; + ['a, F, I, K: KeysetRegistry] QueryIndex<'a, F, I, K>; + ['a, R, P, K: KeysetRegistry] OpenUsing<'a, R, P, K>; } /// Each chain's `Debug` names the chain and nothing it holds: the value @@ -861,21 +877,21 @@ macro_rules! opaque_debug { )+}; } opaque_debug! { - ['a, S: ?Sized, K] EncryptBuilder<'a, S, K>, "EncryptBuilder"; - ['a, S: ?Sized, K] EncryptWithContext<'a, S, K>, "EncryptWithContext"; - ['a, S, X, K] EncryptIndexed<'a, S, X, K>, "EncryptIndexed"; - ['a, S: 'static, K: 'static] EncryptFields<'a, S, K>, "EncryptFields"; - ['a, S: ?Sized, P, K] EncryptUsing<'a, S, P, K>, "EncryptUsing"; - ['a, F, K] QueryBuilder<'a, F, K>, "QueryBuilder"; - ['a, F, P, K] QueryUsing<'a, F, P, K>, "QueryUsing"; - ['a, F, I, K] QueryIndex<'a, F, I, K>, "QueryIndex"; - ['a, R, K] OpenBuilder<'a, R, K>, "OpenBuilder"; - ['a, R, P, K] OpenUsing<'a, R, P, K>, "OpenUsing"; + ['a, S: ?Sized, K: KeysetRegistry] EncryptBuilder<'a, S, K>, "EncryptBuilder"; + ['a, S: ?Sized, K: KeysetRegistry] EncryptWithContext<'a, S, K>, "EncryptWithContext"; + ['a, S, X, K: KeysetRegistry] EncryptIndexed<'a, S, X, K>, "EncryptIndexed"; + ['a, S: 'static, K: KeysetRegistry + 'static] EncryptFields<'a, S, K>, "EncryptFields"; + ['a, S: ?Sized, P, K: KeysetRegistry] EncryptUsing<'a, S, P, K>, "EncryptUsing"; + ['a, F, K: KeysetRegistry] QueryBuilder<'a, F, K>, "QueryBuilder"; + ['a, F, P, K: KeysetRegistry] QueryUsing<'a, F, P, K>, "QueryUsing"; + ['a, F, I, K: KeysetRegistry] QueryIndex<'a, F, I, K>, "QueryIndex"; + ['a, R, K: KeysetRegistry] OpenBuilder<'a, R, K>, "OpenBuilder"; + ['a, R, P, K: KeysetRegistry] OpenUsing<'a, R, P, K>, "OpenUsing"; } /// Several chains settled together: one ZeroKMS request per request kind /// for all of them, under one keyset. Built by [`all`]. -pub struct All<'a, K, T> { +pub struct All<'a, K: KeysetRegistry, T> { operations: T, scope: PhantomData>, } @@ -884,7 +900,7 @@ pub struct All<'a, K, T> { /// the type. type Scope<'a, K> = (&'a (), fn() -> K); -impl std::fmt::Debug for All<'_, K, T> { +impl std::fmt::Debug for All<'_, K, T> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("All").finish_non_exhaustive() } @@ -910,10 +926,11 @@ impl std::fmt::Debug for All<'_, K, T> { /// /// ``` /// # async fn example() -> Result<(), stack_encrypt::Error> { -/// use stack_encrypt::kms::FakeDataKeySource; +/// use stack_encrypt::registry::fake::FakeKeysetRegistry; +/// use stack_encrypt::StackCipherBuilder; /// use stack_encrypt::{Equality, Plan, StackCipher}; /// -/// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +/// let cipher = StackCipherBuilder::new().registry(FakeKeysetRegistry::new()).init().await?; /// let email_plan = Plan::context("users/email").with(Equality).build()?; /// /// let email = String::from("bob@example.com"); @@ -927,7 +944,7 @@ impl std::fmt::Debug for All<'_, K, T> { /// # } /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); /// ``` -pub fn all<'a, K: 'static, T: Batch<'a, K>>(operations: T) -> All<'a, K, T> { +pub fn all<'a, K: KeysetRegistry + 'static, T: Batch<'a, K>>(operations: T) -> All<'a, K, T> { All { operations, scope: PhantomData, @@ -935,14 +952,14 @@ pub fn all<'a, K: 'static, T: Batch<'a, K>>(operations: T) -> All<'a, K, T> { } /// A tuple of two to four chains [`all`] can settle together. Sealed. -pub trait Batch<'a, K: 'static>: sealed::Sealed {} +pub trait Batch<'a, K: KeysetRegistry + 'static>: sealed::Sealed {} /// One chain's pending, routed to the group it settles in: the batch, /// under the batch keyset, or, for an opening that named no keyset and has /// not failed, the openings that read from any keyset. The other group /// holds an empty place for it, so a failure always lands in the batch and /// stops it before any I/O. -fn route<'p, T, K>( +fn route<'p, T, K: KeysetRegistry>( cipher: &'p StackCipher, pending: Pending<'p, T, K>, ) -> (Pending<'p, Option, K>, Pending<'p, Option, K>) @@ -970,7 +987,7 @@ macro_rules! all_of { $(, $rest:ident $rop:ident $rnamed:ident $rbatch:ident $rany:ident)+ ) => ($batch:pat, $any:pat),)+) => {$( impl<$first, $($rest),+> sealed::Sealed for ($first, $($rest),+) {} - impl<'a, K: 'static, $first: Operation<'a, K>, $($rest: Operation<'a, K>),+> Batch<'a, K> + impl<'a, K: KeysetRegistry + 'static, $first: Operation<'a, K>, $($rest: Operation<'a, K>),+> Batch<'a, K> for ($first, $($rest),+) { } diff --git a/packages/stack-encrypt/src/plan/mod.rs b/packages/stack-encrypt/src/plan/mod.rs index f09b76bfe..2ee515bc6 100644 --- a/packages/stack-encrypt/src/plan/mod.rs +++ b/packages/stack-encrypt/src/plan/mod.rs @@ -15,11 +15,12 @@ //! //! ``` //! # async fn example() -> Result<(), stack_encrypt::Error> { -//! use stack_encrypt::kms::FakeDataKeySource; +//! use stack_encrypt::registry::fake::FakeKeysetRegistry; +//! use stack_encrypt::StackCipherBuilder; //! use stack_encrypt::sem::{EqualityTerm, OreTerm}; //! use stack_encrypt::{Equality, Ore, StackCipher}; //! -//! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! let cipher = StackCipherBuilder::new().registry(FakeKeysetRegistry::new()).init().await?; //! //! // One tree, one context: the cipher-directed call, by name. //! let doc = String::from("the body"); @@ -76,11 +77,12 @@ //! # fn field(&self, name: &str) -> Option<&u64> { (name == "id").then_some(&self.id) } //! # } //! # async fn example() -> Result<(), stack_encrypt::Error> { -//! use stack_encrypt::kms::FakeDataKeySource; +//! use stack_encrypt::registry::fake::FakeKeysetRegistry; +//! use stack_encrypt::StackCipherBuilder; //! use stack_encrypt::sem::{EqualityTerm, MatchTerms}; //! use stack_encrypt::{Encrypted, Equality, Match, Ore, StackCipher}; //! -//! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! let cipher = StackCipherBuilder::new().registry(FakeKeysetRegistry::new()).init().await?; //! let user = User { email: "bob@example.com".into(), age: 34, notes: "hi".into(), id: 42 }; //! //! let mut record = cipher @@ -149,11 +151,12 @@ //! # fn field(&self, name: &str) -> Option<&u64> { (name == "id").then_some(&self.id) } //! # } //! # async fn example() -> Result<(), stack_encrypt::Error> { -//! use stack_encrypt::kms::FakeDataKeySource; +//! use stack_encrypt::registry::fake::FakeKeysetRegistry; +//! use stack_encrypt::StackCipherBuilder; //! use stack_encrypt::sem::{EqualityTerm, MatchTerms}; //! use stack_encrypt::{Encrypted, Equality, Match, Ore, Plan, StackCipher}; //! -//! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! let cipher = StackCipherBuilder::new().registry(FakeKeysetRegistry::new()).init().await?; //! let user = User { email: "bob@example.com".into(), age: 34, notes: "hi".into(), id: 42 }; //! //! let users_plan: Plan = Plan::context("users") @@ -219,7 +222,8 @@ //! //! ``` //! # async fn example() -> Result<(), stack_encrypt::Error> { -//! use stack_encrypt::kms::FakeDataKeySource; +//! use stack_encrypt::registry::fake::FakeKeysetRegistry; +//! use stack_encrypt::StackCipherBuilder; //! use stack_encrypt::plan::pick; //! use stack_encrypt::sem::EqualityTerm; //! use stack_encrypt::{Encrypted, EncryptFrom, Plan, StackCipher, StackCipherText}; @@ -237,8 +241,8 @@ //! nickname: StackCipherText, //! } //! -//! let derived = EncryptedUser::plan::()?; -//! let by_hand: Plan = Plan::context("users") +//! let derived = EncryptedUser::plan::()?; +//! let by_hand: Plan = Plan::context("users") //! .fields() //! .encrypt_into::, _>(pick("email", |u: &User| &u.email)) //! .encrypt_into::(pick("nickname", |u: &User| &u.nickname)) @@ -249,7 +253,7 @@ //! assert_eq!(derived.field("nickname")?.label().map(ToString::to_string).as_deref(), Some("users/handle")); //! //! // And the record's equality term is the one a query through the plan derives. -//! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! let cipher = StackCipherBuilder::new().registry(FakeKeysetRegistry::new()).init().await?; //! let user = User { email: "bob@example.com".into(), nickname: "bob".into() }; //! let record: EncryptedUser = cipher.default_keyset().encrypt_as(&user, ().into()).await?; //! let query_value = cipher.query("bob@example.com").using(&by_hand.field("email")?).equality().await?; diff --git a/packages/stack-encrypt/src/plan/value.rs b/packages/stack-encrypt/src/plan/value.rs index 836786a99..1c95117c1 100644 --- a/packages/stack-encrypt/src/plan/value.rs +++ b/packages/stack-encrypt/src/plan/value.rs @@ -9,6 +9,7 @@ use super::build::{ PlanContext, Runs, FROM_CALL, FROM_FIELD, FROM_PLAN, }; use super::PlanError; +use crate::registry::{KeysetRegistry, NoRegistry}; use crate::target::{ indexed, Borrowed, CallerContext, DeclaredContext, DecryptInto, Decryption, EncryptFrom, Encrypted, Encryption, ExpectedContext, IndexSpec, Indexes, Pending, @@ -17,9 +18,9 @@ use crate::{ Error, IntoContext, KeysetCipher, Label, LabelError, MaybeEmpty, NonEmpty, Plan, StackCipherText, }; -use stack_kms::MaybeSend; +use vitaminc_kms::provider::MaybeSend; -impl Plan<(), ()> { +impl Plan<(), NoRegistry> { /// Start a one-value plan over plaintext `S`, with no context yet: give /// it one with [`context`](ValueStart::context), or leave it for the /// call that runs it. Then [`with`](ValueStart::with) its indexes or @@ -27,11 +28,12 @@ impl Plan<(), ()> { /// /// ``` /// # async fn example() -> Result<(), stack_encrypt::Error> { - /// use stack_encrypt::kms::FakeDataKeySource; + /// use stack_encrypt::registry::fake::FakeKeysetRegistry; + /// use stack_encrypt::StackCipherBuilder; /// use stack_encrypt::sem::EqualityTerm; /// use stack_encrypt::{Equality, Ore, Plan, StackCipher, StackCipherText}; /// - /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; + /// let cipher = StackCipherBuilder::new().registry(FakeKeysetRegistry::new()).init().await?; /// /// // With its context and indexes. /// let age_plan = Plan::value::().context("users/age").with((Equality, Ore)).build()?; @@ -244,7 +246,7 @@ impl ValuePlan> { /// The description of one run: `T`'s layout under the context the call /// hands over, which is carried out beside it as the record's `C`. /// Run it with [`KeysetCipher::run`] and a `NonEmpty`. - pub fn encryption_with_context<'s, K: 'static>( + pub fn encryption_with_context<'s, K: KeysetRegistry + 'static>( &self, ) -> Encryption<'s, S, (C, T), K, NonEmpty> where @@ -264,7 +266,7 @@ impl ValuePlan> { /// `expected` (which [`ExpectedContext::default`] leaves unchecked beyond /// being nonempty), before any key is requested, then `T` opened under /// it through its own [`DecryptInto`]. - pub fn decryption_with_context( + pub fn decryption_with_context( &self, record: (C, T), expected: ExpectedContext, @@ -345,7 +347,9 @@ pub trait ValueLayout: sealed::Sealed + Clone { /// and a query through the plan ask. Sealed. pub trait ValueShape: ValueLayout { /// The description of one value, run under its whole context. - fn lower<'s, K: 'static>(&self) -> Encryption<'s, S, Self::Output, K, CallerContext> + fn lower<'s, K: KeysetRegistry + 'static>( + &self, + ) -> Encryption<'s, S, Self::Output, K, CallerContext> where S: 's; } @@ -366,7 +370,9 @@ where S: crate::Encrypt + Clone, X: Indexes + Clone, { - fn lower<'s, K: 'static>(&self) -> Encryption<'s, S, Self::Output, K, CallerContext> + fn lower<'s, K: KeysetRegistry + 'static>( + &self, + ) -> Encryption<'s, S, Self::Output, K, CallerContext> where S: 's, { @@ -389,7 +395,7 @@ where T: EncryptFrom, CallerContext: Into, { - fn lower<'s, K: 'static>(&self) -> Encryption<'s, S, T, K, CallerContext> + fn lower<'s, K: KeysetRegistry + 'static>(&self) -> Encryption<'s, S, T, K, CallerContext> where S: 's, { @@ -517,7 +523,7 @@ impl ValuePlan { /// call names (for a plan built without one; `None` otherwise): /// the layout `.under(context)`. A context missing or given twice is a /// description that fails without I/O. - pub fn encryption<'s, K: 'static>( + pub fn encryption<'s, K: KeysetRegistry + 'static>( &self, context: Option

for Encrypted { type Context = AeadContext; - fn decryption(self, context: Self::Context) -> Decryption { + fn decryption(self, context: Self::Context) -> Decryption { open(self.ciphertext, context) } } @@ -467,7 +471,7 @@ where Terms: TermSet, { type Context = CallerContext; - fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + fn encryption<'s, K: KeysetRegistry + 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { @@ -487,7 +491,10 @@ where Self: DecryptInto

, Ctx: Into<>::Context>, { - fn decryption_field(self, context: Ctx) -> Option> { + fn decryption_field( + self, + context: Ctx, + ) -> Option> { Some(self.decryption(context.into())) } } @@ -578,7 +585,7 @@ pub fn indexed<'s, S, K, M, X>( ) -> Encryption<'s, S, Encrypted, K, CallerContext, M> where S: crate::Encrypt + 's, - K: 'static, + K: KeysetRegistry + 'static, M: ConsumeSource<'s, S> + ShareSource<'s, S>, X: Indexes, { diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index a19709a4f..e05a38c5b 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -146,7 +146,9 @@ enum Opening { Failed(Error), Open(Open), } -impl<'s, S: 's, T, K: KeysetRegistry, Ctx, M: SourceMode<'s, S>> fmt::Debug for Encryption<'s, S, T, K, Ctx, M> { +impl<'s, S: 's, T, K: KeysetRegistry, Ctx, M: SourceMode<'s, S>> fmt::Debug + for Encryption<'s, S, T, K, Ctx, M> +{ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Encryption").finish_non_exhaustive() } @@ -311,7 +313,7 @@ impl<'s, S: 's, T: 'static, K: KeysetRegistry + 'static, Ctx: 's, M: SourceMode< } } -impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { +impl<'s, S: 's, T: 'static, K: KeysetRegistry + 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { /// 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. /// @@ -389,8 +391,14 @@ where } } -impl<'s, S: 's, T: 'static, K: KeysetRegistry + 'static, Ctx: 's + Clone + MaybeSend + 'static, M> - Encryption<'s, S, T, K, Ctx, M> +impl< + 's, + S: 's, + T: 'static, + K: KeysetRegistry + 'static, + Ctx: 's + Clone + MaybeSend + 'static, + M, + > Encryption<'s, S, T, K, Ctx, M> where M: SourceMode<'s, S>, { @@ -426,8 +434,12 @@ where /// `Encrypt` consumes the plaintext. In [`Owned`](super::Owned) mode it is /// handed over, so `S` need not be `Clone`; in the default [`Borrowed`] mode /// it is cloned once, which is what `M: ConsumeSource<'s, S>` asks. -pub fn ciphertext<'s, S: crate::Encrypt + 's, K: KeysetRegistry + 'static, M: ConsumeSource<'s, S>>( -) -> Encryption<'s, S, StackCipherText, K, AeadContext, M> { +pub fn ciphertext< + 's, + S: crate::Encrypt + 's, + K: KeysetRegistry + 'static, + M: ConsumeSource<'s, S>, +>() -> Encryption<'s, S, StackCipherText, K, AeadContext, M> { Encryption { build: Box::new( move |source, cipher, cx: AeadContext| match cx.validated() { @@ -455,7 +467,7 @@ pub fn ciphertext<'s, S: crate::Encrypt + 's, K: KeysetRegistry + 'static, M: Co pub fn passthrough<'s, S, K, M, Ctx>() -> Encryption<'s, S, S, K, Ctx, M> where S: MaybeSend + 'static, - K: 'static, + K: KeysetRegistry + 'static, M: ConsumeSource<'s, S>, Ctx: 's, { @@ -476,7 +488,7 @@ macro_rules! term_operation { $function:ident, $output:ty, consume, [$($generics:tt)*], [$($bounds:tt)*] ) => { $(#[$doc])* - pub fn $function<'s, S, K: 'static, M: ConsumeSource<'s, S>, $($generics)*>( + pub fn $function<'s, S, K: KeysetRegistry + 'static, M: ConsumeSource<'s, S>, $($generics)*>( ) -> Encryption<'s, S, $output, K, CallerContext, M> where S: 's, @@ -656,12 +668,13 @@ impl KeysetCipher<'_, K> { /// /// ``` /// # async fn example() -> Result<(), stack_encrypt::Error> { - /// use stack_encrypt::kms::FakeDataKeySource; + /// use stack_encrypt::registry::fake::FakeKeysetRegistry; + /// use stack_encrypt::StackCipherBuilder; /// use stack_encrypt::target::{self, AeadContext, Owned}; /// use stack_encrypt::{nonempty, StackCipher, StackCipherText}; /// use vitaminc_protected::Protected; /// - /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; + /// let cipher = StackCipherBuilder::new().registry(FakeKeysetRegistry::new()).init().await?; /// let keyset = cipher.default_keyset(); /// // `Protected` is deliberately not `Clone`: it is moved in, and /// // the one copy is wiped once it is sealed. @@ -708,11 +721,12 @@ impl KeysetCipher<'_, K> { /// /// ``` /// # async fn example() -> Result<(), stack_encrypt::Error> { - /// use stack_encrypt::kms::FakeDataKeySource; + /// use stack_encrypt::registry::fake::FakeKeysetRegistry; + /// use stack_encrypt::StackCipherBuilder; /// use stack_encrypt::target::{self, AeadContext, Decryption}; /// use stack_encrypt::{nonempty, StackCipher, StackCipherText}; /// - /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; + /// let cipher = StackCipherBuilder::new().registry(FakeKeysetRegistry::new()).init().await?; /// let keyset = cipher.default_keyset(); /// let context = || AeadContext::from(nonempty!("users/email")); /// let sealed: StackCipherText = keyset.encrypt_as(&"bob@example.com".to_string(), context()).await?; diff --git a/packages/stack-encrypt/src/target/tuples.rs b/packages/stack-encrypt/src/target/tuples.rs index 059dde737..a5a114cad 100644 --- a/packages/stack-encrypt/src/target/tuples.rs +++ b/packages/stack-encrypt/src/target/tuples.rs @@ -13,8 +13,9 @@ use super::operations::{ DecryptField, DecryptInto, Decryptable, Decryption, EncryptFrom, Encryption, }; use super::IndexSpec; +use crate::registry::KeysetRegistry; use crate::Error; -use stack_kms::MaybeSend; +use vitaminc_kms::provider::MaybeSend; /// The one context two targets can be handed together: what a tuple of /// targets takes, so each element receives the same value (ADR-0004). @@ -88,7 +89,7 @@ macro_rules! tuple_of_targets { joined!(S; $first $(, $rest)+): Into<$first::Context> $(+ Into<$rest::Context>)+, { type Context = joined!(S; $first $(, $rest)+); - fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + fn encryption<'s, K: KeysetRegistry + 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { @@ -119,7 +120,7 @@ macro_rules! tuple_of_targets { $($rest: DecryptField,)+ { type Context = CallerContext; - fn decryption(self, context: CallerContext) -> Decryption { + fn decryption(self, context: CallerContext) -> Decryption { let ($first, $($rest),+) = self; $first .decryption_field(context.clone()) @@ -137,7 +138,7 @@ macro_rules! tuple_of_targets { Self: DecryptInto

+ Decryptable, Ctx: Into<>::Context>, { - fn decryption_field(self, context: Ctx) -> Option> { + fn decryption_field(self, context: Ctx) -> Option> { if !::DECRYPTABLE { return None; } diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 8380da12b..78e528431 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -6,8 +6,6 @@ mod common; -use stack_encrypt::KeysetRegistry; - use cllw_ore::CllwOreEncrypt; use common::{counting_cipher, recording_cipher, stack_cipher}; use stack_encrypt::sem::{EqualityTerm, MatchTerms, OreTerm}; @@ -242,7 +240,7 @@ where EqualityTerm: EncryptFrom, { type Context = >::Context; - fn encryption<'s, K: KeysetRegistry + 'static>( + fn encryption<'s, K: stack_encrypt::KeysetRegistry + 'static>( ) -> stack_encrypt::Encryption<'s, S, Self, K, Self::Context> where S: 's, @@ -300,7 +298,7 @@ impl Decryptable for Lying { } impl DecryptField for Lying { - fn decryption_field( + fn decryption_field( self, _context: Ctx, ) -> Option> { @@ -736,15 +734,14 @@ async fn a_struct_nests_in_a_struct_as_an_ordinary_field() { .unwrap(); assert_eq!(recovered, account); - // And not under another. The fake key source ignores descriptors, so the - // AEAD refuses; ZeroKMS would refuse the key retrieval first - // (`Error::Kms`). + // And not under another. The fake provider is bound, so the key + // retrieval is refused, as ZeroKMS refuses it. let row: EncryptedAccount = account .encrypt_into_with_context(&keyset, 9u64) .await .unwrap(); let other = Account::decrypt_from_with_context(row, &cipher, 8u64).await; - assert!(matches!(other, Err(Error::Aead)), "{other:?}"); + assert!(matches!(other, Err(Error::Provider(_))), "{other:?}"); } /// A tuple-struct plaintext is reached by index — inferred for a tuple @@ -860,7 +857,7 @@ mod the_plan_writes_what_the_derive_writes { } } - fn accounts_plan() -> Plan { + fn accounts_plan() -> Plan { Plan::context("accounts") .fields() .encrypt_into::(pick("user", |a: &Account| &a.user)) @@ -880,12 +877,12 @@ mod the_plan_writes_what_the_derive_writes { .using(&accounts_plan) .await .unwrap(); - let plan_sent = sent.lock().unwrap().generated(); - sent.lock().unwrap().generate.clear(); + let plan_sent = sent.calls().generated(); + sent.clear(); let derived: EncryptedAccount = account().encrypt_into(&keyset).await.unwrap(); assert_eq!( plan_sent, - sent.lock().unwrap().generated(), + sent.calls().generated(), "every data key under the same descriptor" ); @@ -947,10 +944,10 @@ mod the_plan_writes_what_the_derive_writes { .build() .unwrap(); let mut planned = cipher.encrypt(&user()).using(&users_plan).await.unwrap(); - let plan_sent = sent.lock().unwrap().generated(); - sent.lock().unwrap().generate.clear(); + let plan_sent = sent.calls().generated(); + sent.clear(); let derived: EncryptedUser = user().encrypt_into(&keyset).await.unwrap(); - assert_eq!(plan_sent, sent.lock().unwrap().generated()); + assert_eq!(plan_sent, sent.calls().generated()); let age: EncryptedAge = planned.take("age").unwrap(); let email: Encrypted<(EqualityTerm, MatchTerms)> = planned.take("email").unwrap(); assert_eq!(age.hm, derived.age.hm); @@ -993,7 +990,7 @@ mod the_plan_writes_what_the_derive_writes { } } - fn records_plan() -> Plan { + fn records_plan() -> Plan { Plan::fields() .context_field(pick("tenant", |r: &TenantRecord| &r.tenant)) .encrypt_into::<(StackCipherText, EqualityTerm), _>(pick( @@ -1024,7 +1021,7 @@ mod the_plan_writes_what_the_derive_writes { .await .unwrap(); assert_eq!( - sent.lock().unwrap().generated(), + sent.calls().generated(), ["tenants/acme/email", "tenants/acme/email"], "the field is sealed under the context the derive is handed" ); @@ -1057,7 +1054,7 @@ mod the_plan_writes_what_the_derive_writes { #[tokio::test] async fn both_refuse_a_mismatched_expected_context_before_any_key_request() { - let (cipher, _, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let keyset = cipher.default_keyset(); let records_plan = records_plan(); @@ -1087,6 +1084,6 @@ mod the_plan_writes_what_the_derive_writes { matches!(refused, Err(Error::ContextMismatch { .. })), "{refused:?}" ); - assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 0); + assert_eq!(provider.call_counts().1, 0); } } diff --git a/packages/stack-encrypt/tests/derive_plan.rs b/packages/stack-encrypt/tests/derive_plan.rs index 408543f82..77df5bf32 100644 --- a/packages/stack-encrypt/tests/derive_plan.rs +++ b/packages/stack-encrypt/tests/derive_plan.rs @@ -7,11 +7,9 @@ mod common; -use std::sync::atomic::Ordering as AtomicOrdering; - use common::{counting_cipher, recording_cipher, stack_cipher}; -use stack_encrypt::kms::FakeDataKeySource; use stack_encrypt::plan::{pick, FieldValues}; +use stack_encrypt::registry::fake::FakeKeysetRegistry; use stack_encrypt::sem::{EqualityTerm, MatchTerms, OpeTerm, OreTerm}; use stack_encrypt::target::{ AeadContext, CallerContext, DeclaredContext, DecryptField, DecryptFrom, Decryptable, @@ -38,7 +36,8 @@ struct LegacyEmail { impl EncryptFrom for LegacyEmail { type Context = CallerContext; - fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> + fn encryption<'s, K: stack_encrypt::KeysetRegistry + 'static>( + ) -> Encryption<'s, String, Self, K, Self::Context> where String: 's, { @@ -73,7 +72,7 @@ async fn a_plaintext_record_writes_what_the_old_chain_wrote() { let legacy: LegacyEmail = keyset.encrypt_as(&value, context.clone()).await.unwrap(); assert_eq!(derived.hm, legacy.hm, "the same term"); { - let sent = sent.lock().unwrap(); + let sent = sent.calls(); assert_eq!(sent.generate.len(), 2, "one request per record"); let generated = sent.generated(); assert_eq!(generated.len(), 2); @@ -118,7 +117,7 @@ async fn a_derived_record_as_a_typed_field_answers_its_queries() { email: String, } let cipher = stack_cipher().await; - let users_plan: Plan = Plan::context("users") + let users_plan: Plan = Plan::context("users") .fields() .encrypt_into::(pick("email", |u: &User| &u.email)) .build() @@ -205,7 +204,7 @@ struct TwoEqualities { /// before any key is requested. #[tokio::test] async fn two_outputs_declaring_one_index_are_refused_before_any_request() { - let (cipher, generates, _) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let keyset = cipher.default_keyset(); let result: Result = "x" .to_string() @@ -216,7 +215,7 @@ async fn two_outputs_declaring_one_index_are_refused_before_any_request() { "{:?}", result.err() ); - assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); + assert_eq!(provider.call_counts().0, 0); } // --- A record that stores its context ----------------------------------------- @@ -241,7 +240,8 @@ struct LegacyScoped { impl EncryptFrom for LegacyScoped { type Context = NonEmpty; - fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> + fn encryption<'s, K: stack_encrypt::KeysetRegistry + 'static>( + ) -> Encryption<'s, String, Self, K, Self::Context> where String: 's, { @@ -277,7 +277,7 @@ async fn a_context_field_record_writes_what_the_old_chain_wrote() { assert_eq!(derived.v, 3); assert_eq!(derived.hm, legacy.hm); { - let sent = sent.lock().unwrap(); + let sent = sent.calls(); let generated = sent.generated(); assert_eq!(generated.len(), 2); assert_eq!(generated[0], generated[1]); @@ -295,7 +295,7 @@ async fn a_context_field_record_writes_what_the_old_chain_wrote() { #[tokio::test] async fn a_context_field_plan_carries_and_checks_the_context() { - let (cipher, _, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let keyset = cipher.default_keyset(); let value = "bob@example.com".to_string(); let plan = Scoped::plan::().unwrap(); @@ -318,7 +318,7 @@ async fn a_context_field_plan_carries_and_checks_the_context() { "{:?}", refused.err() ); - assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 0); + assert_eq!(provider.call_counts().1, 0); // The expected one, and an unchecked one, open it. let record = seal().await.unwrap(); @@ -358,7 +358,7 @@ fn a_context_field_plan_has_one_context_source() { #[tokio::test] async fn a_one_value_plan_with_its_own_context_refuses_the_callers() { - let (cipher, generates, _) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let keyset = cipher.default_keyset(); let plan = Plan::value::() .context("users/email") @@ -380,7 +380,7 @@ async fn a_one_value_plan_with_its_own_context_refuses_the_callers() { "{:?}", refused.err() ); - assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); + assert_eq!(provider.call_counts().0, 0); } // --- A record of ciphertexts alone takes an AEAD-only context ----------------- @@ -404,7 +404,7 @@ async fn a_tuple_of_ciphertexts_takes_an_aead_context() { let pair: (StackCipherText, StackCipherText) = keyset.encrypt_as(&value, context()).await.unwrap(); assert_eq!( - sent.lock().unwrap().generated(), + sent.calls().generated(), ["names", "names", "names", "names"] ); let opened: String = keyset.decrypt_as(record.shadow, context()).await.unwrap(); @@ -451,7 +451,8 @@ struct LegacyUser { impl EncryptFrom for LegacyUser { type Context = DeclaredContext; - fn encryption<'s, K: 'static>() -> Encryption<'s, User, Self, K, Self::Context> + fn encryption<'s, K: stack_encrypt::KeysetRegistry + 'static>( + ) -> Encryption<'s, User, Self, K, Self::Context> where User: 's, { @@ -493,7 +494,7 @@ async fn a_struct_record_writes_what_the_old_chain_wrote() { assert_eq!(derived.email.terms, legacy.email.terms); assert_eq!(derived.age.ob, legacy.age.ob); { - let sent = sent.lock().unwrap(); + let sent = sent.calls(); assert_eq!(sent.generate.len(), 2, "one request per record"); assert_eq!( sent.generate[0], sent.generate[1], @@ -515,7 +516,7 @@ async fn a_struct_record_writes_what_the_old_chain_wrote() { } /// `EncryptedUser`'s plan, written by hand. -fn users_plan_by_hand() -> Plan { +fn users_plan_by_hand() -> Plan { Plan::context("users") .fields() .encrypt_into::, _>(pick("email", |u: &User| &u.email)) @@ -528,8 +529,8 @@ fn users_plan_by_hand() -> Plan { #[test] fn a_struct_record_plan_is_the_hand_written_chain() { - let derived = EncryptedUser::plan::().unwrap(); - let by_hand: Plan = users_plan_by_hand(); + let derived = EncryptedUser::plan::().unwrap(); + let by_hand: Plan = users_plan_by_hand(); assert_eq!(format!("{derived:?}"), format!("{by_hand:?}")); let labels: Vec<_> = derived .field_plans() @@ -558,7 +559,7 @@ struct TwoEqualitiesInAField { #[tokio::test] async fn a_field_declaring_one_index_twice_is_refused_before_any_request() { - let refused = TwoEqualitiesInAField::plan::(); + let refused = TwoEqualitiesInAField::plan::(); assert!( matches!( &refused, @@ -567,7 +568,7 @@ async fn a_field_declaring_one_index_twice_is_refused_before_any_request() { "{:?}", refused.err() ); - let (cipher, generates, _) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let keyset = cipher.default_keyset(); let result = keyset .encrypt_as::<_, TwoEqualitiesInAField>(&user(), DeclaredContext::default()) @@ -577,7 +578,7 @@ async fn a_field_declaring_one_index_twice_is_refused_before_any_request() { "{:?}", result.err() ); - assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); + assert_eq!(provider.call_counts().0, 0); } /// Every plan the derive emits builds, except where an output's type @@ -585,7 +586,7 @@ async fn a_field_declaring_one_index_twice_is_refused_before_any_request() { /// indexes they declare, and refused every other input at compile time. #[test] fn every_derived_plan_builds() { - assert!(EncryptedUser::plan::().is_ok()); + assert!(EncryptedUser::plan::().is_ok()); assert!(Email::plan::().is_ok()); assert!(Age::plan::().is_ok()); assert!(Wide::plan::().is_ok()); @@ -596,7 +597,7 @@ fn every_derived_plan_builds() { "two outputs of one index" ); assert!( - TwoEqualitiesInAField::plan::().is_err(), + TwoEqualitiesInAField::plan::().is_err(), "a field type of one index twice" ); } @@ -636,7 +637,7 @@ async fn a_struct_record_and_its_plan_written_by_hand_open_each_other() { .await .unwrap(); { - let sent = sent.lock().unwrap(); + let sent = sent.calls(); assert_eq!(sent.generate.len(), 2, "one request per record"); assert_eq!(sent.generate[0], sent.generate[1], "the same descriptors"); } @@ -733,7 +734,7 @@ async fn the_one_decryptable_output_opens_from_the_nested_tail() { #[tokio::test] async fn a_context_field_plan_refuses_an_empty_stored_context_before_any_request() { - let (cipher, _, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let keyset = cipher.default_keyset(); let value = "bob@example.com".to_string(); let plan = Scoped::plan::().unwrap(); @@ -749,7 +750,7 @@ async fn a_context_field_plan_refuses_an_empty_stored_context_before_any_request ) .await; assert!(refused.is_err(), "{:?}", refused.ok()); - assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 0); + assert_eq!(provider.call_counts().1, 0); } /// The derived `context_field` record's opener (written out by the derive) @@ -795,7 +796,8 @@ struct Tenanted { struct TenantTag(EqualityTerm); impl EncryptFrom for TenantTag { type Context = NonEmpty; - fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> { + fn encryption<'s, K: stack_encrypt::KeysetRegistry + 'static>( + ) -> Encryption<'s, String, Self, K, Self::Context> { >::encryption() .accepting::>() .map(TenantTag) @@ -808,7 +810,10 @@ impl Decryptable for TenantTag { const DECRYPTABLE: bool = false; } impl DecryptField for TenantTag { - fn decryption_field(self, _: Ctx) -> Option> { + fn decryption_field( + self, + _: Ctx, + ) -> Option> { None } } diff --git a/packages/stack-encrypt/tests/descriptor.rs b/packages/stack-encrypt/tests/descriptor.rs index 1f849a420..4736cc564 100644 --- a/packages/stack-encrypt/tests/descriptor.rs +++ b/packages/stack-encrypt/tests/descriptor.rs @@ -88,7 +88,7 @@ async fn a_label_is_sealed_under_the_descriptor_it_spells() -> Result<(), Error> .decrypt_into(&cipher, nonempty!("users").with("email").with(7u64)) .await?; - let sent = sent.lock().expect("lock").clone(); + let sent = sent.calls(); assert_eq!(sent.generated(), ["users/email", "(users/email)/7u64"]); assert_eq!(sent.retrieved(), ["users/email", "(users/email)/7u64"]); Ok(()) diff --git a/packages/stack-encrypt/tests/index.rs b/packages/stack-encrypt/tests/index.rs index 0489dda12..6707bf5fa 100644 --- a/packages/stack-encrypt/tests/index.rs +++ b/packages/stack-encrypt/tests/index.rs @@ -9,9 +9,8 @@ mod common; -use std::sync::atomic::Ordering; - use common::{counting_cipher, stack_cipher}; +use stack_encrypt::registry::fake::FakeKeysetRegistry; use stack_encrypt::sem::{ DefaultMatch, EqualityTerm, MatchConfig, MatchOptions, MatchTerms, OpeTerm, OreTerm, Tokenizer, }; @@ -21,7 +20,6 @@ use stack_encrypt::target::{ Indexes, Match, Ope, Ore, Owned, }; use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, Error, StackCipherText}; -use stack_kms::{FakeDataKeySource, IdentifiedBy}; use vitaminc_protected::{Controlled, Protected}; fn caller() -> CallerContext { @@ -103,7 +101,7 @@ mod given_equality_and_ore_over_a_string { #[tokio::test] async fn owned_mode_derives_the_same_terms_and_seals_from_one_key_request() { - let (cipher, generates, _) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let keyset = cipher.default_keyset(); let email = "bob@example.com".to_string(); @@ -115,7 +113,7 @@ mod given_equality_and_ore_over_a_string { ) .await .expect("owned"); - assert_eq!(generates.load(Ordering::SeqCst), 1, "one batched request"); + assert_eq!(provider.call_counts().0, 1, "one batched request"); let borrowed = keyset .run( indexed::((Equality, Ore)), @@ -409,7 +407,7 @@ mod given_a_passthrough { #[tokio::test] async fn borrowed_hands_back_a_copy_with_no_key_request_whatever_the_context() { - let (cipher, generates, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let keyset = cipher.default_keyset(); let id = "row-7".to_string(); // `()` is a context no operation can run under, so a passthrough @@ -419,8 +417,8 @@ mod given_a_passthrough { .await .expect("passthrough"); assert_eq!(out, id); - assert_eq!(generates.load(Ordering::SeqCst), 0, "nothing is sealed"); - assert_eq!(retrieves.load(Ordering::SeqCst), 0); + assert_eq!(provider.call_counts().0, 0, "nothing is sealed"); + assert_eq!(provider.call_counts().1, 0); } #[tokio::test] @@ -442,18 +440,14 @@ mod given_a_passthrough { /// ciphertext: one key request, and the carried value is the source. #[tokio::test] async fn beside_a_sealed_field_it_is_carried_unsealed() { - let (cipher, generates, _) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let keyset = cipher.default_keyset(); let value = "carried".to_string(); let both: Encryption<'_, String, (StackCipherText, String), _, AeadContext> = ciphertext::().zip(passthrough::()); let (sealed, carried) = keyset.run(both, &value, aead()).await.expect("run"); assert_eq!(carried, value); - assert_eq!( - generates.load(Ordering::SeqCst), - 1, - "one batch, for the ciphertext" - ); + assert_eq!(provider.call_counts().0, 1, "one batch, for the ciphertext"); let opened: String = keyset.decrypt_as(sealed, aead()).await.expect("open"); assert_eq!(opened, value); } @@ -475,10 +469,10 @@ mod given_a_decryption_held_in_a_variable { .await .unwrap(); - let opening: Decryption = + let opening: Decryption = stack_encrypt::target::open(sealed, aead()); assert_eq!(keyset.run_decryption(opening).await.unwrap(), "secret"); - let opening: Decryption = + let opening: Decryption = stack_encrypt::target::open(again, aead()); assert_eq!(cipher.run_decryption(opening).await.unwrap(), "secret"); } @@ -486,14 +480,8 @@ mod given_a_decryption_held_in_a_variable { #[tokio::test] async fn a_keyset_refuses_another_keysets_leaf_and_the_client_opens_it() { let cipher = stack_cipher().await; - let acme = cipher - .keyset(IdentifiedBy::Name("acme".to_string().into())) - .await - .unwrap(); - let globex = cipher - .keyset(IdentifiedBy::Name("globex".to_string().into())) - .await - .unwrap(); + let acme = cipher.keyset("acme").await.unwrap(); + let globex = cipher.keyset("globex").await.unwrap(); let sealed: StackCipherText = acme .encrypt_as(&"secret".to_string(), aead()) .await diff --git a/packages/stack-encrypt/tests/plan.rs b/packages/stack-encrypt/tests/plan.rs index 89aa4e20e..f6fcfea9c 100644 --- a/packages/stack-encrypt/tests/plan.rs +++ b/packages/stack-encrypt/tests/plan.rs @@ -7,10 +7,10 @@ mod common; use std::sync::atomic::Ordering; use common::{counting_cipher, loads_counting_cipher, recording_cipher, stack_cipher}; -use stack_encrypt::kms::{FakeDataKeySource, IdentifiedBy}; use stack_encrypt::plan::{ Field, FieldKind, FieldSchema, FieldValues, Fields, Operation, PlanError, }; +use stack_encrypt::registry::fake::FakeKeysetRegistry; use stack_encrypt::sem::{EqualityTerm, MatchTerms, OreTerm}; use stack_encrypt::target::{ self, indexed, passthrough, Borrowed, DeclaredContext, Encrypted, Encryption, IndexSpec, @@ -98,7 +98,7 @@ impl Field for Loose { type EmailTerms = (EqualityTerm, MatchTerms); type AgeTerms = (EqualityTerm, OreTerm); -fn users_plan() -> Plan { +fn users_plan() -> Plan { Plan::context("users") .fields() .encrypt_index::("email", (Equality, Match::default())) @@ -116,7 +116,7 @@ fn plan_error(result: Result) -> PlanError { } } -fn leaf_keyset(ciphertext: &StackCipherText) -> uuid::Uuid { +fn leaf_keyset(ciphertext: &StackCipherText) -> stack_encrypt::KeysetId { match ciphertext { CipherText::Single(leaf) => leaf.keyset_id(), other => panic!("expected a single leaf, got {other:?}"), @@ -132,7 +132,8 @@ type HandOut = ( ), u64, ); -fn hand_composed<'s, K: 'static>() -> Encryption<'s, User, HandOut, K, DeclaredContext> { +fn hand_composed<'s, K: stack_encrypt::KeysetRegistry + 'static>( +) -> Encryption<'s, User, HandOut, K, DeclaredContext> { indexed::((Equality, Match::default())) .under(nonempty!("users").with("email")) .project(|u: &User| &u.email) @@ -164,15 +165,15 @@ async fn the_chain_is_the_hand_composed_combinators_field_for_field() { .passthrough::("id") .await .unwrap(); - let chain_sent = sent.lock().unwrap().generated(); - sent.lock().unwrap().generate.clear(); + let chain_sent = sent.calls().generated(); + sent.clear(); let keyset = cipher.default_keyset(); let (((email, age), notes), id) = keyset .run(hand_composed(), &user, DeclaredContext::default()) .await .unwrap(); - let hand_sent = sent.lock().unwrap().generated(); + let hand_sent = sent.calls().generated(); assert_eq!( row.names().collect::>(), @@ -351,7 +352,7 @@ async fn a_query_extended_like_the_write_matches_it() { #[tokio::test] async fn a_query_against_an_undeclared_index_or_type_is_refused() { let cipher = stack_cipher().await; - let users_plan: Plan = users_plan(); + let users_plan: Plan = users_plan(); let age_plan = users_plan.field("age").unwrap(); let notes_plan = users_plan.field("notes").unwrap(); @@ -446,7 +447,7 @@ async fn the_one_tree_chain_is_the_cipher_directed_call_under_the_label() { .context("documents/v2/body") .await .unwrap(); - assert_eq!(sent.lock().unwrap().generated(), ["documents/v2/body"]); + assert_eq!(sent.calls().generated(), ["documents/v2/body"]); let label = Label::parse("documents/v2/body").unwrap(); let back: String = cipher.decrypt(ct, label.clone()).await.unwrap(); assert_eq!(back, body); @@ -466,7 +467,7 @@ async fn extend_extends_every_field_and_reading_back_needs_it() { .await .unwrap(); assert_eq!( - sent.lock().unwrap().generated(), + sent.calls().generated(), [ "(users/email)/7u64", "(users/age)/7u64", @@ -488,8 +489,11 @@ async fn extend_extends_every_field_and_reading_back_needs_it() { .await .unwrap(); assert!( - matches!(cipher.open(row).using(&users_plan).await, Err(Error::Aead)), - "the fake key source ignores descriptors, so a missing extension reaches the AEAD" + matches!( + cipher.open(row).using(&users_plan).await, + Err(Error::Provider(_)) + ), + "the fake provider is bound, so a missing extension is refused at the key retrieval, as ZeroKMS refuses it" ); } @@ -507,7 +511,7 @@ async fn a_one_value_plan_extended_on_write_needs_the_extension_to_query_and_ope let write = || cipher.encrypt(&34u32).using(&age_plan).extend(7u64); let out = write().await.unwrap(); - assert_eq!(sent.lock().unwrap().generated(), ["(users/age)/7u64"]); + assert_eq!(sent.calls().generated(), ["(users/age)/7u64"]); let query = |parts: Option| { let chain = cipher.query(&34u32).using(&age_plan).equality(); @@ -528,13 +532,16 @@ async fn a_one_value_plan_extended_on_write_needs_the_extension_to_query_and_ope .unwrap(); assert_eq!(back, 34); let unextended = cipher.open(write().await.unwrap()).using(&age_plan).await; - assert!(matches!(unextended, Err(Error::Aead)), "{unextended:?}"); + assert!( + matches!(unextended, Err(Error::Provider(_))), + "{unextended:?}" + ); let other = cipher .open(write().await.unwrap()) .using(&age_plan) .extend(8u64) .await; - assert!(matches!(other, Err(Error::Aead)), "{other:?}"); + assert!(matches!(other, Err(Error::Provider(_))), "{other:?}"); } /// The `index` verb writes terms alone: no data key is asked for, the @@ -553,7 +560,7 @@ async fn an_index_only_field_matches_its_query_and_mints_no_data_key() { .unwrap(); let mut row = cipher.encrypt(&user()).using(&tokens_plan).await.unwrap(); assert_eq!( - sent.lock().unwrap().generated(), + sent.calls().generated(), ["users/age", "users/notes"], "an index-only field asks for no data key" ); @@ -589,7 +596,7 @@ async fn a_second_extend_replaces_the_first() { .await .unwrap(); assert_eq!( - sent.lock().unwrap().generated(), + sent.calls().generated(), [ "(users/email)/7u64", "(users/age)/7u64", @@ -675,7 +682,7 @@ async fn decrypt_returns_what_can_come_back_and_refuses_a_mismatched_row() { #[tokio::test] async fn a_value_that_does_not_match_the_plan_is_refused_without_a_key_request() { - let (cipher, generates, _) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let loose_plan: Plan = Plan::context("users") .fields() .encrypt_index::("email", Equality) @@ -693,7 +700,7 @@ async fn a_value_that_does_not_match_the_plan_is_refused_without_a_key_request() .using(&loose_plan) .await .is_ok()); - generates.store(0, Ordering::SeqCst); + provider.clear(); assert_eq!( plan_error( @@ -717,7 +724,7 @@ async fn a_value_that_does_not_match_the_plan_is_refused_without_a_key_request() field: "age".into() } ); - assert_eq!(generates.load(Ordering::SeqCst), 0, "no key requested"); + assert_eq!(provider.call_counts().0, 0, "no key requested"); // A field the value names but cannot produce at the plan's type. let wrong_type: Plan = Plan::context("users") @@ -770,7 +777,7 @@ async fn a_value_that_does_not_match_the_plan_is_refused_without_a_key_request() expected: "alloc::string::String" } ); - assert_eq!(generates.load(Ordering::SeqCst), 0, "no key requested"); + assert_eq!(provider.call_counts().0, 0, "no key requested"); } /// A chain naming a keyset is checked before the keyset is loaded: a plan @@ -978,7 +985,7 @@ async fn a_passthrough_name_need_not_be_a_label_segment() { ); let sealed = Plan::context("users") - .fields::() + .fields::() .encrypt::("2fa_enabled") .build(); assert!(matches!( @@ -986,7 +993,7 @@ async fn a_passthrough_name_need_not_be_a_label_segment() { PlanError::FieldLabel { field, .. } if field == "2fa_enabled" )); let indexed = Plan::context("users") - .fields::() + .fields::() .index::("created(utc)", Equality) .build(); assert!(matches!( @@ -997,16 +1004,16 @@ async fn a_passthrough_name_need_not_be_a_label_segment() { #[tokio::test] async fn every_build_error_is_its_own() { - fn fields() -> stack_encrypt::plan::FieldsBuilder { + fn fields() -> stack_encrypt::plan::FieldsBuilder { Plan::context("users").fields() } let build = - |b: stack_encrypt::plan::FieldsBuilder| plan_error(b.build()); + |b: stack_encrypt::plan::FieldsBuilder| plan_error(b.build()); assert!(matches!( plan_error( Plan::context("users//x") - .fields::() + .fields::() .build() ), PlanError::ContextLabel(_) @@ -1129,7 +1136,7 @@ async fn every_build_error_is_its_own() { ); // The schema checks: a field the type lacks, one at the wrong type, and // one of the type's the plan leaves unnamed. - let complete = |b: stack_encrypt::plan::FieldsBuilder| { + let complete = |b: stack_encrypt::plan::FieldsBuilder| { b.encrypt_index::("email", Equality) .encrypt::("age") .encrypt::("notes") @@ -1225,7 +1232,7 @@ async fn match_options_must_agree_for_a_query_to_be_declared() { } } let cipher = stack_cipher().await; - let users_plan: Plan = users_plan(); + let users_plan: Plan = users_plan(); let email_plan = users_plan.field("email").unwrap(); let refused = plan_error( cipher @@ -1252,7 +1259,7 @@ async fn match_options_must_agree_for_a_query_to_be_declared() { #[tokio::test] async fn all_settles_several_chains_in_one_request() { - let (cipher, generates, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let users_plan: Plan = users_plan(); let email_plan = users_plan.field("email").unwrap(); let user = user(); @@ -1266,7 +1273,7 @@ async fn all_settles_several_chains_in_one_request() { )) .await .unwrap(); - assert_eq!(generates.load(Ordering::SeqCst), 1, "one generate call"); + assert_eq!(provider.call_counts().0, 1, "one generate call"); let email: &Encrypted = row.get("email").unwrap(); assert_eq!(email.terms.0, probe); @@ -1278,12 +1285,8 @@ async fn all_settles_several_chains_in_one_request() { )) .await .unwrap(); - assert_eq!( - generates.load(Ordering::SeqCst), - 2, - "one more generate call" - ); - assert_eq!(retrieves.load(Ordering::SeqCst), 1, "one retrieve call"); + assert_eq!(provider.call_counts().0, 2, "one more generate call"); + assert_eq!(provider.call_counts().1, 1, "one retrieve call"); assert_eq!(c.get::("notes").unwrap(), "likes cheese"); let (_, _, _, d) = stack_encrypt::all(( cipher.encrypt(&doc).context("docs"), @@ -1298,19 +1301,16 @@ async fn all_settles_several_chains_in_one_request() { )) .await .unwrap(); - assert_eq!(generates.load(Ordering::SeqCst), 3); - assert_eq!(retrieves.load(Ordering::SeqCst), 2); + assert_eq!(provider.call_counts().0, 3); + assert_eq!(provider.call_counts().1, 2); assert_eq!(d.len(), 4); let _ = b; } #[tokio::test] async fn all_runs_under_one_named_keyset_and_refuses_two() { - let (cipher, generates, _) = counting_cipher().await; - let tenant = cipher - .keyset(IdentifiedBy::Name("tenant".to_string().into())) - .await - .unwrap(); + let (cipher, provider) = counting_cipher().await; + let tenant = cipher.keyset("tenant").await.unwrap(); let doc = String::from("doc"); let (a, b) = stack_encrypt::all(( @@ -1332,7 +1332,10 @@ async fn all_runs_under_one_named_keyset_and_refuses_two() { tenant.keyset_id(), "one chain names it for all" ); - generates.store(0, Ordering::SeqCst); + provider.clear(); + for name in ["tenant", "other"] { + cipher.registry().keyset(name).expect("minted").1.clear(); + } let refused = stack_encrypt::all(( cipher.encrypt(&doc).context("docs").keyset("tenant"), @@ -1340,11 +1343,12 @@ async fn all_runs_under_one_named_keyset_and_refuses_two() { )) .await; assert!(matches!(refused, Err(Error::KeysetMismatch { .. }))); - assert_eq!( - generates.load(Ordering::SeqCst), - 0, - "refused before any request" - ); + // Each keyset has its own provider: none of the three was asked. + for name in ["tenant", "other"] { + let (_, named) = cipher.registry().keyset(name).expect("minted"); + assert_eq!(named.call_counts().0, 0, "refused before any request"); + } + assert_eq!(provider.call_counts().0, 0, "refused before any request"); } /// A batch settles through one client and resolves every keyset it names @@ -1402,11 +1406,15 @@ async fn all_refuses_chains_from_two_ciphers() { /// other chains mint under. #[tokio::test] async fn all_lets_an_unnamed_opening_read_any_keyset() { - let (cipher, generates, retrieves) = counting_cipher().await; - let tenant = cipher - .keyset(IdentifiedBy::Name("tenant".to_string().into())) - .await - .unwrap(); + let (cipher, provider) = counting_cipher().await; + let tenant = cipher.keyset("tenant").await.unwrap(); + // Each keyset has its own provider; the openings below retrieve from both. + let (_, tenant_provider) = cipher.registry().keyset("tenant").expect("minted"); + let counts = || { + let (g, r) = provider.call_counts(); + let (tg, tr) = tenant_provider.call_counts(); + (g + tg, r + tr) + }; let default = cipher.default_keyset().keyset_id(); let docs_plan = Plan::context("docs") .with::(Equality) @@ -1423,7 +1431,8 @@ async fn all_lets_an_unnamed_opening_read_any_keyset() { }; let (first, second, third) = (tenant_row().await, tenant_row().await, tenant_row().await); let default_row = cipher.encrypt(&doc).using(&docs_plan).await.unwrap(); - generates.store(0, Ordering::SeqCst); + provider.clear(); + tenant_provider.clear(); let (written, opened) = stack_encrypt::all(( cipher.encrypt(&doc).context("docs"), @@ -1433,8 +1442,8 @@ async fn all_lets_an_unnamed_opening_read_any_keyset() { .unwrap(); assert_eq!(leaf_keyset(&written), default, "minted under the default"); assert_eq!(opened, "doc", "a tenant row opens beside a default write"); - assert_eq!(generates.load(Ordering::SeqCst), 1); - assert_eq!(retrieves.load(Ordering::SeqCst), 1); + assert_eq!(counts().0, 1); + assert_eq!(counts().1, 1); let (written, opened, also) = stack_encrypt::all(( cipher.encrypt(&doc).context("docs").keyset("tenant"), @@ -1447,7 +1456,7 @@ async fn all_lets_an_unnamed_opening_read_any_keyset() { assert_eq!(opened, "doc", "a default row opens beside a tenant write"); assert_eq!(also, "doc"); assert_eq!( - retrieves.load(Ordering::SeqCst), + counts().1, 3, "the unnamed openings: one retrieve per keyset they read" ); @@ -1478,10 +1487,10 @@ async fn all_lets_an_unnamed_opening_read_any_keyset() { /// key is requested, however valid its siblings are. #[tokio::test] async fn all_refuses_a_bad_chain_beside_a_good_one_before_any_request() { - let (cipher, generates, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let users_plan: Plan = users_plan(); let row = cipher.encrypt(&user()).using(&users_plan).await.unwrap(); - generates.store(0, Ordering::SeqCst); + provider.clear(); let document = String::from("doc"); let result = stack_encrypt::all(( cipher.encrypt(&document).context("docs"), @@ -1497,17 +1506,14 @@ async fn all_refuses_a_bad_chain_beside_a_good_one_before_any_request() { plan_error(result), PlanError::NotInPlan { field } if field == "age" )); - assert_eq!(generates.load(Ordering::SeqCst), 0); - assert_eq!(retrieves.load(Ordering::SeqCst), 0); + assert_eq!(provider.call_counts().0, 0); + assert_eq!(provider.call_counts().1, 0); } #[tokio::test] async fn keyset_selects_the_minting_keyset_and_scopes_an_opening() { let cipher = stack_cipher().await; - let tenant = cipher - .keyset(IdentifiedBy::Name("tenant".to_string().into())) - .await - .unwrap(); + let tenant = cipher.keyset("tenant").await.unwrap(); let default = cipher.default_keyset().keyset_id(); let doc = String::from("doc"); @@ -1557,32 +1563,24 @@ async fn keyset_selects_the_minting_keyset_and_scopes_an_opening() { #[tokio::test] async fn a_collection_runs_in_one_request() { - let (cipher, generates, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let users_plan: Plan = users_plan(); let users = vec![user(), user(), user()]; let rows = cipher.encrypt(&users).using(&users_plan).await.unwrap(); assert_eq!(rows.len(), 3); - assert_eq!( - generates.load(Ordering::SeqCst), - 1, - "one generate for every row" - ); + assert_eq!(provider.call_counts().0, 1, "one generate for every row"); let rows = cipher .encrypt(&users[..2]) .using(&users_plan) .await .unwrap(); assert_eq!(rows.len(), 2); - assert_eq!(generates.load(Ordering::SeqCst), 2); + assert_eq!(provider.call_counts().0, 2); let back = cipher.open(rows).using(&users_plan).await.unwrap(); assert_eq!(back.len(), 2); - assert_eq!( - retrieves.load(Ordering::SeqCst), - 1, - "one retrieve for every row" - ); + assert_eq!(provider.call_counts().1, 1, "one retrieve for every row"); assert_eq!(back[1].get::("age"), Some(&34)); let ages = vec![1u32, 2, 3]; @@ -1594,18 +1592,20 @@ async fn a_collection_runs_in_one_request() { assert_eq!(out.len(), 3); let out = cipher.encrypt(&ages[1..]).using(&age_plan).await.unwrap(); assert_eq!(out.len(), 2); - assert_eq!(generates.load(Ordering::SeqCst), 4); + assert_eq!(provider.call_counts().0, 4); } #[tokio::test] async fn nothing_touches_a_key_before_the_await() { - let (cipher, generates, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let users_plan: Plan = users_plan(); let user = user(); let chain = cipher.encrypt(&user).using(&users_plan).keyset("tenant"); drop(chain); - assert_eq!(generates.load(Ordering::SeqCst), 0); + let (_, tenant) = cipher.registry().keyset("tenant").expect("minted"); + assert_eq!(tenant.call_counts().0, 0); + assert_eq!(provider.call_counts().0, 0); // A chain yields its Pending synchronously: terms derived, key requests // queued, nothing sent until it is awaited. @@ -1614,13 +1614,13 @@ async fn nothing_touches_a_key_before_the_await() { .encrypt(&user) .using(&users_plan) .prepare(&keyset, false); - assert_eq!(generates.load(Ordering::SeqCst), 0, "prepared, not sent"); + assert_eq!(provider.call_counts().0, 0, "prepared, not sent"); let row = pending.await.unwrap(); - assert_eq!(generates.load(Ordering::SeqCst), 1); + assert_eq!(provider.call_counts().0, 1); let pending = cipher.open(row).using(&users_plan).prepare(&keyset, true); - assert_eq!(retrieves.load(Ordering::SeqCst), 0); + assert_eq!(provider.call_counts().1, 0); assert!(pending.await.is_ok()); - assert_eq!(retrieves.load(Ordering::SeqCst), 1); + assert_eq!(provider.call_counts().1, 1); } #[tokio::test] @@ -1670,7 +1670,7 @@ async fn a_plan_lowers_to_an_encryption_and_a_decryption_held_by_value() { #[test] fn a_plan_is_data_its_fields_say_what_they_declare() { - let users_plan: Plan = users_plan(); + let users_plan: Plan = users_plan(); assert_eq!(users_plan.label().unwrap().to_string(), "users"); let fields: Vec<_> = users_plan.field_plans().collect(); assert_eq!(fields.len(), 4); @@ -1686,7 +1686,7 @@ fn a_plan_is_data_its_fields_say_what_they_declare() { assert_eq!(fields[3].kind(), FieldKind::Passthrough); assert_eq!(fields[3].label(), None, "a passthrough field has no label"); - let renamed: Plan = Plan::context("users") + let renamed: Plan = Plan::context("users") .fields() .encrypt::("email") .identity("contact") @@ -1700,7 +1700,7 @@ fn a_plan_is_data_its_fields_say_what_they_declare() { "users/contact", "a pinned identity keys the field, not its name" ); - let index_only: Plan = Plan::context("users") + let index_only: Plan = Plan::context("users") .fields() .index::("email", Equality) .encrypt::("age") @@ -1713,7 +1713,7 @@ fn a_plan_is_data_its_fields_say_what_they_declare() { let shown = format!("{users_plan:?}"); assert!(shown.starts_with(r#"Plan { context: "users", fields: [FieldPlan { name: "email""#)); let builder = Plan::context("users") - .fields::() + .fields::() .encrypt::("age"); assert_eq!( format!("{builder:?}"), @@ -1771,11 +1771,8 @@ async fn a_keyset_choice_names_what_it_holds() { let cipher = stack_cipher().await; let handle: stack_encrypt::plan::KeysetChoice<'_, _> = (&cipher.default_keyset()).into(); assert!(format!("{handle:?}").starts_with("Handle(KeysetCipher")); - let named: stack_encrypt::plan::KeysetChoice<'_, FakeDataKeySource> = "tenant".into(); - assert_eq!( - format!("{named:?}"), - r#"Named(Name(Name { inner: "tenant" }))"# - ); + let named: stack_encrypt::plan::KeysetChoice<'_, FakeKeysetRegistry> = "tenant".into(); + assert_eq!(format!("{named:?}"), r#"Named(Name("tenant"))"#); } /// A context or keyset name held in a `String` is passed by reference, as @@ -1783,10 +1780,7 @@ async fn a_keyset_choice_names_what_it_holds() { #[tokio::test] async fn a_borrowed_string_names_a_context_and_a_keyset() { let cipher = stack_cipher().await; - let tenant = cipher - .keyset(IdentifiedBy::Name("tenant".to_string().into())) - .await - .unwrap(); + let tenant = cipher.keyset("tenant").await.unwrap(); let context = String::from("users/age"); let tenant_name = String::from("tenant"); @@ -1822,10 +1816,7 @@ async fn a_borrowed_string_names_a_context_and_a_keyset() { #[tokio::test] async fn every_chain_honours_its_named_keyset() { let cipher = stack_cipher().await; - let tenant = cipher - .keyset(IdentifiedBy::Name("tenant".to_string().into())) - .await - .unwrap(); + let tenant = cipher.keyset("tenant").await.unwrap(); let id = tenant.keyset_id(); assert_ne!(id, cipher.default_keyset().keyset_id()); let user = user(); @@ -1890,4 +1881,4 @@ async fn every_chain_honours_its_named_keyset() { // `StackCipher` must stay nameable in this binary's imports. #[allow(dead_code)] -fn _cipher_type(_: &StackCipher) {} +fn _cipher_type(_: &StackCipher) {} diff --git a/packages/stack-encrypt/tests/plan_grammar.rs b/packages/stack-encrypt/tests/plan_grammar.rs index ee5fd3b84..7438d322e 100644 --- a/packages/stack-encrypt/tests/plan_grammar.rs +++ b/packages/stack-encrypt/tests/plan_grammar.rs @@ -11,8 +11,8 @@ use std::fmt::Debug; use std::sync::atomic::Ordering; use common::{counting_cipher, loads_counting_cipher, recording_cipher, stack_cipher}; -use stack_encrypt::kms::FakeDataKeySource; use stack_encrypt::plan::{pick, Field, FieldKind, FieldValues, Fields, Opens, PlanError, Runs}; +use stack_encrypt::registry::fake::FakeKeysetRegistry; use stack_encrypt::sem::{ EqualityTerm, MatchConfig, MatchOptions, MatchTerms, OpeTerm, OreTerm, Tokenizer, }; @@ -59,7 +59,7 @@ fn age(u: &User) -> &u32 { /// The picker fields plan every context-source test runs, without a /// context of its own. -fn contextless_plan() -> Plan { +fn contextless_plan() -> Plan { Plan::fields() .encrypt_index(("email", email), Equality) .encrypt(("age", age)) @@ -93,7 +93,7 @@ async fn both_starts_build_the_plan_the_context_start_builds() { let mut a = cipher.encrypt(&user()).using(&fields_first).await.unwrap(); let mut b = cipher.encrypt(&user()).using(&context_first).await.unwrap(); assert_eq!( - sent.lock().unwrap().generated(), + sent.calls().generated(), ["users/email", "users/age", "users/email", "users/age"] ); let a: Encrypted = a.take("email").unwrap(); @@ -134,7 +134,7 @@ async fn a_plan_built_without_a_context_takes_the_calls() { .await .unwrap(); assert_eq!( - sent.lock().unwrap().generated(), + sent.calls().generated(), ["tenants/acme/email", "tenants/acme/age"], "every field under /" ); @@ -173,7 +173,7 @@ async fn a_plan_built_without_a_context_takes_the_calls() { #[tokio::test] async fn a_plan_run_with_no_context_is_refused_before_any_key_request() { - let (cipher, generates, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let users_plan = Plan::fields() .encrypt_index(("email", email), Equality) .build() @@ -223,12 +223,8 @@ async fn a_plan_run_with_no_context_is_refused_before_any_key_request() { plan_error(cipher.open(age).using(&age_plan).await), PlanError::NoContext ); - assert_eq!( - generates.load(Ordering::SeqCst), - 2, - "only the two good writes" - ); - assert_eq!(retrieves.load(Ordering::SeqCst), 0); + assert_eq!(provider.call_counts().0, 2, "only the two good writes"); + assert_eq!(provider.call_counts().1, 0); assert!(PlanError::NoContext .to_string() .starts_with("the plan has no context")); @@ -237,19 +233,19 @@ async fn a_plan_run_with_no_context_is_refused_before_any_key_request() { #[tokio::test] async fn a_context_given_twice_is_refused_at_build() { let both = Plan::context("users") - .fields::() + .fields::() .context("people") .encrypt(("age", age)) .build(); assert_eq!(plan_error(both), two("the plan", "the plan")); let with_field = Plan::context("users") - .fields::() + .fields::() .context_field(("tenant", tenant)) .build(); assert_eq!(plan_error(with_field), two("the plan", "a context field")); - let field_first = Plan::fields::() + let field_first = Plan::fields::() .context_field(("tenant", tenant)) .context("users") .build(); @@ -270,7 +266,7 @@ async fn a_context_given_twice_is_refused_at_build() { #[tokio::test] async fn a_context_given_twice_is_refused_at_the_call_before_any_key_request() { - let (cipher, generates, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let users_plan: Plan = Plan::context("users") .fields() .encrypt_index(("email", email), Equality) @@ -347,8 +343,8 @@ async fn a_context_given_twice_is_refused_at_the_call_before_any_key_request() { ), two("the plan", "the call") ); - assert_eq!(generates.load(Ordering::SeqCst), 1, "only the good write"); - assert_eq!(retrieves.load(Ordering::SeqCst), 0); + assert_eq!(provider.call_counts().0, 1, "only the good write"); + assert_eq!(provider.call_counts().1, 0); } #[tokio::test] @@ -402,7 +398,7 @@ fn record_email(r: &TenantRecord) -> &String { &r.email } -fn records_plan() -> Plan { +fn records_plan() -> Plan { Plan::fields() .context_field(("tenant", tenant)) .encrypt_index(("email", record_email), Equality) @@ -723,10 +719,7 @@ async fn an_extension_reaches_every_field_of_a_context_field_plan() { .extend(7u64) .await .unwrap(); - assert_eq!( - sent.lock().unwrap().generated(), - ["(tenants/acme/email)/7u64"] - ); + assert_eq!(sent.calls().generated(), ["(tenants/acme/email)/7u64"]); let back = cipher .open(record) .using(&records_plan) @@ -743,7 +736,7 @@ async fn an_extension_reaches_every_field_of_a_context_field_plan() { .unwrap(); assert!(matches!( cipher.open(record).using(&records_plan).await, - Err(Error::Aead) + Err(Error::Provider(_)) )); } @@ -761,7 +754,7 @@ async fn a_vec_through_a_context_field_plan_seals_each_record_under_its_own_tena let records = vec![tenant_record(), globex]; let written = cipher.encrypt(&records).using(&records_plan).await.unwrap(); assert_eq!( - sent.lock().unwrap().generated(), + sent.calls().generated(), ["tenants/acme/email", "tenants/globex/email"], ); let back = cipher.open(written).using(&records_plan).await.unwrap(); @@ -808,7 +801,7 @@ async fn a_context_field_is_the_context_of_every_other_field() { .await .unwrap(); assert_eq!( - sent.lock().unwrap().generated(), + sent.calls().generated(), ["tenants/acme/email"], "the field is sealed under /" ); @@ -853,7 +846,7 @@ async fn a_context_field_is_the_context_of_every_other_field() { #[tokio::test] async fn opening_checks_the_context_field_against_the_expected_context() { - let (cipher, _, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let records_plan = records_plan(); let record = cipher @@ -868,7 +861,7 @@ async fn opening_checks_the_context_field_against_the_expected_context() { .await .unwrap(); assert_eq!(back.get::("email"), Some(&tenant_record().email)); - let before = retrieves.load(Ordering::SeqCst); + let before = provider.call_counts().1; let record = cipher .encrypt(&tenant_record()) @@ -885,7 +878,7 @@ async fn opening_checks_the_context_field_against_the_expected_context() { "{refused:?}" ); assert_eq!( - retrieves.load(Ordering::SeqCst), + provider.call_counts().1, before, "refused before any key request" ); @@ -899,12 +892,12 @@ async fn opening_checks_the_context_field_against_the_expected_context() { .unwrap(); moved.insert("tenant", String::from("tenants/globex")); let refused = cipher.open(moved).using(&records_plan).await; - assert!(matches!(refused, Err(Error::Aead)), "{refused:?}"); + assert!(matches!(refused, Err(Error::Provider(_))), "{refused:?}"); } #[tokio::test] async fn a_context_field_that_is_not_a_label_or_missing_is_refused() { - let (cipher, generates, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let records_plan = records_plan(); let bad = TenantRecord { tenant: "acme corp/(x)".into(), @@ -950,8 +943,8 @@ async fn a_context_field_that_is_not_a_label_or_missing_is_refused() { field: "tenant".into() } ); - assert_eq!(generates.load(Ordering::SeqCst), 3, "the three good writes"); - assert_eq!(retrieves.load(Ordering::SeqCst), 0); + assert_eq!(provider.call_counts().0, 3, "the three good writes"); + assert_eq!(provider.call_counts().1, 0); } // --- encrypt_into: a typed field is the data verbs' bytes ---------------------- @@ -993,7 +986,7 @@ where let one = One { v: value.clone() }; let mut typed_record = cipher.encrypt(&one).using(&typed).await.unwrap(); let mut data_record = cipher.encrypt(&one).using(&data).await.unwrap(); - assert_eq!(sent.lock().unwrap().generated(), ["t/v", "t/v"]); + assert_eq!(sent.calls().generated(), ["t/v", "t/v"]); let typed_out: Encrypted = typed_record.take("v").unwrap(); let data_out: Encrypted = data_record.take("v").unwrap(); @@ -1079,7 +1072,7 @@ fn a_five_index_field_is_refused_by_both_spellings() { index: "match", }; let typed = Plan::context("t") - .fields::, FakeDataKeySource>() + .fields::, FakeKeysetRegistry>() .encrypt_into::, OpeTerm, W)>, _>(pick( "v", |o: &One| &o.v, @@ -1087,7 +1080,7 @@ fn a_five_index_field_is_refused_by_both_spellings() { .build(); assert_eq!(plan_error(typed), refused()); let data = Plan::context("t") - .fields::, FakeDataKeySource>() + .fields::, FakeKeysetRegistry>() .encrypt_index( pick("v", |o: &One| &o.v), (Equality, Match::default(), Ore, Ope, Match::::new()), @@ -1199,7 +1192,8 @@ async fn a_typed_field_of_terms_alone_does_not_come_back() { struct Lying(StackCipherText); impl EncryptFrom for Lying { type Context = stack_encrypt::target::AeadContext; - fn encryption<'s, K: 'static>() -> Encryption<'s, u32, Self, K, Self::Context> { + fn encryption<'s, K: stack_encrypt::KeysetRegistry + 'static>( + ) -> Encryption<'s, u32, Self, K, Self::Context> { >::encryption().map(Lying) } } @@ -1207,7 +1201,10 @@ impl Decryptable for Lying { const DECRYPTABLE: bool = true; } impl DecryptField for Lying { - fn decryption_field(self, _: Ctx) -> Option> { + fn decryption_field( + self, + _: Ctx, + ) -> Option> { None } } @@ -1228,12 +1225,12 @@ async fn a_typed_field_that_opens_nothing_is_not_opened() { #[tokio::test] async fn a_field_is_a_target_or_data_verbs_never_both() { let target_first = Plan::context("users") - .fields::() + .fields::() .encrypt_into::(("email", email)) .encrypt(("email", email)) .build(); let verbs_first = Plan::context("users") - .fields::() + .fields::() .passthrough(("email", email)) .encrypt_into::(("email", email)) .build(); @@ -1246,7 +1243,7 @@ async fn a_field_is_a_target_or_data_verbs_never_both() { ); } let twice = Plan::context("users") - .fields::() + .fields::() .encrypt_into::(("email", email)) .encrypt_into::(("email", email)) .build(); @@ -1258,7 +1255,7 @@ async fn a_field_is_a_target_or_data_verbs_never_both() { "two targets on one field is a field named twice" ); let index_twice = Plan::context("users") - .fields::() + .fields::() .encrypt_into::, _>(("email", email)) .build(); assert_eq!( @@ -1298,10 +1295,7 @@ async fn a_one_value_typed_plan_is_the_plaintext_records_bytes() { ) .await .unwrap(); - assert_eq!( - sent.lock().unwrap().generated(), - ["users/email", "users/email"] - ); + assert_eq!(sent.calls().generated(), ["users/email", "users/email"]); assert_eq!(hm, derived.hm, "the same equality term"); // Each ciphertext opens through the other's reader. @@ -1382,7 +1376,8 @@ async fn a_one_value_plan_without_a_context_names_the_value_in_its_errors() { struct Tenanted(#[allow(dead_code)] EqualityTerm); impl EncryptFrom for Tenanted { type Context = NonEmpty; - fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> { + fn encryption<'s, K: stack_encrypt::KeysetRegistry + 'static>( + ) -> Encryption<'s, String, Self, K, Self::Context> { >::encryption() .accepting::>() .map(Tenanted) @@ -1417,7 +1412,7 @@ fn a_one_value_plan_builds_over_a_target_of_any_context() { #[tokio::test] async fn a_vec_written_through_a_one_value_plan_opens_through_it_in_one_request() { - let (cipher, generates, retrieves) = counting_cipher().await; + let (cipher, provider) = counting_cipher().await; let ages = vec![30u32, 31, 32]; let age_plan = Plan::context("users/age") @@ -1427,12 +1422,8 @@ async fn a_vec_written_through_a_one_value_plan_opens_through_it_in_one_request( let written = cipher.encrypt(&ages).using(&age_plan).await.unwrap(); let back: Vec = cipher.open(written).using(&age_plan).await.unwrap(); assert_eq!(back, ages); - assert_eq!(generates.load(Ordering::SeqCst), 1); - assert_eq!( - retrieves.load(Ordering::SeqCst), - 1, - "one retrieve for the Vec" - ); + assert_eq!(provider.call_counts().0, 1); + assert_eq!(provider.call_counts().1, 1, "one retrieve for the Vec"); let written = cipher.encrypt(&ages).using(&age_plan).await.unwrap(); let ciphertexts: Vec = written.into_iter().map(|e| e.ciphertext).collect(); @@ -1447,11 +1438,11 @@ async fn a_vec_written_through_a_one_value_plan_opens_through_it_in_one_request( .encrypt_into::<(StackCipherText, EqualityTerm)>() .build() .unwrap(); - let before = retrieves.load(Ordering::SeqCst); + let before = provider.call_counts().1; let written = cipher.encrypt(&ages).using(&typed_plan).await.unwrap(); let back: Vec = cipher.open(written).using(&typed_plan).await.unwrap(); assert_eq!(back, ages); - assert_eq!(retrieves.load(Ordering::SeqCst), before + 1); + assert_eq!(provider.call_counts().1, before + 1); } // --- Pickers ----------------------------------------------------------------- @@ -1492,7 +1483,7 @@ async fn a_picker_plan_needs_no_fields_impl_and_is_the_by_name_plans_bytes() { .unwrap(); let mut a = cipher.encrypt(&user()).using(&picked).await.unwrap(); let mut b = cipher.encrypt(&Named(user())).using(&named).await.unwrap(); - let sent = sent.lock().unwrap().generated(); + let sent = sent.calls().generated(); assert_eq!( sent, ["users/email", "users/age", "users/email", "users/age"] @@ -1591,7 +1582,7 @@ async fn a_pinned_identity_rescues_a_field_name_that_is_not_plain() { .await .unwrap(); assert_eq!( - sent.lock().unwrap().generated(), + sent.calls().generated(), ["users/totp_secret", "reading/value", "reading/unit"] ); let value: Encrypted = record.take("0").unwrap(); @@ -1610,7 +1601,7 @@ async fn a_pinned_identity_rescues_a_field_name_that_is_not_plain() { // Without an identity the name is the identity, and must be plain. let refused = Plan::context("users") - .fields::() + .fields::() .encrypt(pick("2fa_secret", |s: &Secret| &s.totp)) .build(); assert!(matches!( @@ -1618,7 +1609,7 @@ async fn a_pinned_identity_rescues_a_field_name_that_is_not_plain() { PlanError::FieldLabel { field, .. } if field == "2fa_secret" )); // And in a plan whose context comes later, likewise. - let refused = Plan::fields::() + let refused = Plan::fields::() .encrypt(pick("2fa_secret", |s: &Secret| &s.totp)) .build(); assert!(matches!( @@ -1646,7 +1637,7 @@ async fn a_pinned_identity_keys_a_call_context_plan_for_query_and_open() { .using(&readings_plan) .await .unwrap(); - assert_eq!(sent.lock().unwrap().generated(), ["reading/value"]); + assert_eq!(sent.calls().generated(), ["reading/value"]); let stored: Encrypted = record.take("0").unwrap(); let query_value = cipher .query(&21u32) @@ -1775,10 +1766,10 @@ async fn a_tuple_opens_through_its_ciphertext_wherever_it_sits() { // As a field of a derived record, through `DecryptField`. let pair: (StackCipherText, EqualityTerm) = keyset.encrypt_as(&7u32, context()).await.unwrap(); - let opening: Option> = pair.decryption_field(context()); + let opening: Option> = pair.decryption_field(context()); let opened = cipher.run_decryption(opening.unwrap()).await.unwrap(); assert_eq!(opened, 7); - let _ = <(StackCipherText, EqualityTerm) as DecryptInto>::decryption::; + let _ = <(StackCipherText, EqualityTerm) as DecryptInto>::decryption::; } /// A record whose terms-only tuple comes before its ciphertext. @@ -1800,7 +1791,7 @@ async fn a_terms_only_tuple_yields_to_the_ciphertext_beside_it() { let terms_alone: (EqualityTerm, OreTerm) = keyset.encrypt_as(&7u32, context()).await.unwrap(); - let opening: Option> = + let opening: Option> = terms_alone.decryption_field(context()); assert!(opening.is_none(), "a tuple of terms alone opens nothing"); @@ -1816,12 +1807,12 @@ async fn a_terms_only_tuple_yields_to_the_ciphertext_beside_it() { #[test] fn a_plan_says_where_its_context_comes_from() { - let records_plan = records_plan::(); + let records_plan = records_plan::(); assert!(format!("{records_plan:?}").starts_with(r#"Plan { context: """#)); - let users_plan = contextless_plan::(); + let users_plan = contextless_plan::(); assert!(format!("{users_plan:?}").starts_with(r#"Plan { context: """#)); let builder = - Plan::fields::().context_field(("tenant", tenant)); + Plan::fields::().context_field(("tenant", tenant)); assert!(format!("{builder:?}").starts_with(r#"FieldsBuilder { context: [Field("tenant")]"#)); let start = Plan::value::().context("a"); assert_eq!( diff --git a/packages/stack-encrypt/tests/source_mode.rs b/packages/stack-encrypt/tests/source_mode.rs index 0489248b0..1a7ac4e31 100644 --- a/packages/stack-encrypt/tests/source_mode.rs +++ b/packages/stack-encrypt/tests/source_mode.rs @@ -17,12 +17,12 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; +use stack_encrypt::registry::fake::FakeKeysetRegistry; use stack_encrypt::sem::{EqualityTerm, MatchTerms}; use stack_encrypt::target::{ ciphertext, equality, matching, AeadContext, Borrowed, CallerContext, Encryption, Owned, }; use stack_encrypt::{nonempty, Encrypt, NonEmpty, StackCipherText}; -use stack_kms::FakeDataKeySource; use vitaminc_aead::{Cipher, IntoAad}; use vitaminc_prf::{IntoPrfContext, Prf, PrfValue, PrfVisitor}; use vitaminc_protected::{Controlled, Protected}; @@ -235,7 +235,7 @@ async fn one_owned_operation_makes_no_copy() { /// A ciphertext beside an equality term, as a target composes them. fn sealed_and_indexed<'s, S, M>( -) -> Encryption<'s, S, (StackCipherText, EqualityTerm), FakeDataKeySource, CallerContext, M> +) -> Encryption<'s, S, (StackCipherText, EqualityTerm), FakeKeysetRegistry, CallerContext, M> where S: Encrypt + PrfValue + 's, M: stack_encrypt::target::ConsumeSource<'s, S> + stack_encrypt::target::ShareSource<'s, S>, diff --git a/packages/stack-encrypt/tests/ui/divergent_context_in_target.rs b/packages/stack-encrypt/tests/ui/divergent_context_in_target.rs index 8915bb9ff..989b8b266 100644 --- a/packages/stack-encrypt/tests/ui/divergent_context_in_target.rs +++ b/packages/stack-encrypt/tests/ui/divergent_context_in_target.rs @@ -21,7 +21,7 @@ struct Divergent { impl EncryptFrom for Divergent { type Context = DeclaredContext; - fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> + fn encryption<'s, K: stack_encrypt::KeysetRegistry + 'static>() -> Encryption<'s, String, Self, K, Self::Context> where String: 's, { diff --git a/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr b/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr index a29b6acd0..b532d4ea4 100644 --- a/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr +++ b/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr @@ -1,19 +1,3 @@ -error[E0277]: the trait bound `K: KeysetRegistry` is not satisfied - --> tests/ui/divergent_context_in_target.rs:24:40 - | -24 | fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `KeysetRegistry` is not implemented for `K` - | -note: required by a bound in `Encryption` - --> src/target/operations.rs - | - | pub struct Encryption<'s, S, T, K: KeysetRegistry, Ctx> { - | ^^^^^^^^^^^^^^ required by this bound in `Encryption` -help: consider further restricting type parameter `K` with trait `KeysetRegistry` - | -24 | fn encryption<'s, K: 'static + stack_encrypt::KeysetRegistry>() -> Encryption<'s, String, Self, K, Self::Context> - | +++++++++++++++++++++++++++++++ - error[E0308]: mismatched types --> tests/ui/divergent_context_in_target.rs:30:18 | @@ -29,57 +13,3 @@ note: method defined here | | pub fn zip( | ^^^ - -error[E0277]: the trait bound `K: KeysetRegistry` is not satisfied - --> tests/ui/divergent_context_in_target.rs:28:9 - | -28 | ciphertext() - | ^^^^^^^^^^^^ the trait `KeysetRegistry` is not implemented for `K` - | -note: required by a bound in `ciphertext` - --> src/target/operations.rs - | - | pub fn ciphertext<'s, S: crate::Encrypt + Clone + 's, K: KeysetRegistry + 'static>( - | ^^^^^^^^^^^^^^ required by this bound in `ciphertext` -help: consider further restricting type parameter `K` with trait `KeysetRegistry` - | -24 | fn encryption<'s, K: 'static + stack_encrypt::KeysetRegistry>() -> Encryption<'s, String, Self, K, Self::Context> - | +++++++++++++++++++++++++++++++ - -error[E0277]: the trait bound `K: KeysetRegistry` is not satisfied - --> tests/ui/divergent_context_in_target.rs:30:14 - | -30 | .zip(equality()) - | ^^^ the trait `KeysetRegistry` is not implemented for `K` - | -note: required by a bound in `Encryption::<'s, S, T, K, Ctx>::zip` - --> src/target/operations.rs - | - | impl<'s, S: 's, T: 'static, K: KeysetRegistry + 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { - | ^^^^^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx>::zip` -... - | pub fn zip( - | --- required by a bound in this associated function -help: consider further restricting type parameter `K` with trait `KeysetRegistry` - | -24 | fn encryption<'s, K: 'static + stack_encrypt::KeysetRegistry>() -> Encryption<'s, String, Self, K, Self::Context> - | +++++++++++++++++++++++++++++++ - -error[E0277]: the trait bound `K: KeysetRegistry` is not satisfied - --> tests/ui/divergent_context_in_target.rs:31:14 - | -31 | .map(|(c, hm)| Self { c, hm }) - | ^^^ the trait `KeysetRegistry` is not implemented for `K` - | -note: required by a bound in `Encryption::<'s, S, T, K, Ctx>::map` - --> src/target/operations.rs - | - | impl<'s, S: 's, T: 'static, K: KeysetRegistry + 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { - | ^^^^^^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx>::map` -... - | pub fn map(self, f: F) -> Encryption<'s, S, U, K, Ctx> - | --- required by a bound in this associated function -help: consider further restricting type parameter `K` with trait `KeysetRegistry` - | -24 | fn encryption<'s, K: 'static + stack_encrypt::KeysetRegistry>() -> Encryption<'s, String, Self, K, Self::Context> - | +++++++++++++++++++++++++++++++ diff --git a/packages/stack-encrypt/tests/ui/execution_callback.stderr b/packages/stack-encrypt/tests/ui/execution_callback.stderr index 5ede3f782..14048d4d6 100644 --- a/packages/stack-encrypt/tests/ui/execution_callback.stderr +++ b/packages/stack-encrypt/tests/ui/execution_callback.stderr @@ -5,7 +5,12 @@ error[E0277]: the trait bound `(): KeysetRegistry` is not satisfied | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `KeysetRegistry` is not implemented for `()` | help: the following other types implement trait `KeysetRegistry` - --> src/registry/fake.rs + --> src/registry.rs + | + | impl KeysetRegistry for NoRegistry { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `NoRegistry` + | + ::: src/registry/fake.rs | | impl KeysetRegistry for FakeKeysetRegistry { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `FakeKeysetRegistry` @@ -17,12 +22,12 @@ help: the following other types implement trait `KeysetRegistry` | | C: stack_auth::AuthStrategyBounds, | | for<'a> &'a C: stack_auth::AuthStrategy, | | Conn: stack_kms::ZeroKMSConnection + Send + Sync, - | |_____________________________________________________^ `Arc>` + | |_____________________________________________________^ `Arc>` note: required by a bound in `Encryption` --> src/target/operations.rs | - | pub struct Encryption<'s, S, T, K: KeysetRegistry, Ctx> { - | ^^^^^^^^^^^^^^ required by this bound in `Encryption` + | pub struct Encryption<'s, S: 's, T, K: KeysetRegistry, Ctx, M: SourceMode<'s, S> = Borrowed> { + | ^^^^^^^^^^^^^^ required by this bound in `Encryption` error[E0277]: the trait bound `(): KeysetRegistry` is not satisfied --> tests/ui/execution_callback.rs:6:16 @@ -34,7 +39,12 @@ error[E0277]: the trait bound `(): KeysetRegistry` is not satisfied | |__________^ the trait `KeysetRegistry` is not implemented for `()` | help: the following other types implement trait `KeysetRegistry` - --> src/registry/fake.rs + --> src/registry.rs + | + | impl KeysetRegistry for NoRegistry { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `NoRegistry` + | + ::: src/registry/fake.rs | | impl KeysetRegistry for FakeKeysetRegistry { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `FakeKeysetRegistry` @@ -46,12 +56,12 @@ help: the following other types implement trait `KeysetRegistry` | | C: stack_auth::AuthStrategyBounds, | | for<'a> &'a C: stack_auth::AuthStrategy, | | Conn: stack_kms::ZeroKMSConnection + Send + Sync, - | |_____________________________________________________^ `Arc>` + | |_____________________________________________________^ `Arc>` note: required by a bound in `Encryption` --> src/target/operations.rs | - | pub struct Encryption<'s, S, T, K: KeysetRegistry, Ctx> { - | ^^^^^^^^^^^^^^ required by this bound in `Encryption` + | pub struct Encryption<'s, S: 's, T, K: KeysetRegistry, Ctx, M: SourceMode<'s, S> = Borrowed> { + | ^^^^^^^^^^^^^^ required by this bound in `Encryption` error[E0277]: the trait bound `(): KeysetRegistry` is not satisfied --> tests/ui/execution_callback.rs:6:29 @@ -60,7 +70,12 @@ error[E0277]: the trait bound `(): KeysetRegistry` is not satisfied | ^^^^^^ the trait `KeysetRegistry` is not implemented for `()` | help: the following other types implement trait `KeysetRegistry` - --> src/registry/fake.rs + --> src/registry.rs + | + | impl KeysetRegistry for NoRegistry { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `NoRegistry` + | + ::: src/registry/fake.rs | | impl KeysetRegistry for FakeKeysetRegistry { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `FakeKeysetRegistry` @@ -72,7 +87,7 @@ help: the following other types implement trait `KeysetRegistry` | | C: stack_auth::AuthStrategyBounds, | | for<'a> &'a C: stack_auth::AuthStrategy, | | Conn: stack_kms::ZeroKMSConnection + Send + Sync, - | |_____________________________________________________^ `Arc>` + | |_____________________________________________________^ `Arc>` note: required by a bound in `KeysetCipher` --> src/keyset.rs | @@ -86,7 +101,12 @@ error[E0277]: the trait bound `(): KeysetRegistry` is not satisfied | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `KeysetRegistry` is not implemented for `()` | help: the following other types implement trait `KeysetRegistry` - --> src/registry/fake.rs + --> src/registry.rs + | + | impl KeysetRegistry for NoRegistry { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `NoRegistry` + | + ::: src/registry/fake.rs | | impl KeysetRegistry for FakeKeysetRegistry { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `FakeKeysetRegistry` @@ -98,7 +118,7 @@ help: the following other types implement trait `KeysetRegistry` | | C: stack_auth::AuthStrategyBounds, | | for<'a> &'a C: stack_auth::AuthStrategy, | | Conn: stack_kms::ZeroKMSConnection + Send + Sync, - | |_____________________________________________________^ `Arc>` + | |_____________________________________________________^ `Arc>` note: required by a bound in `stack_encrypt::Pending` --> src/target/pending.rs | diff --git a/packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr b/packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr index 385db4572..cac09ed1d 100644 --- a/packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr +++ b/packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr @@ -34,10 +34,10 @@ error[E0053]: method `keyset` has an incompatible type for trait --> tests/ui/foreign_cipher_scope.rs:16:25 | 16 | fn keyset(&self) -> Option { - | ^^^^^^^^^^^^ expected `KeysetId`, found `Uuid` + | ^^^^^^^^^^^^ expected `KeysetId`, found `uuid::Uuid` | = note: expected signature `fn(&AnyKeyset<'a, K>) -> Option` - found signature `fn(&AnyKeyset<'a, K>) -> Option` + found signature `fn(&AnyKeyset<'a, K>) -> Option` help: change the output type to match the trait | 16 - fn keyset(&self) -> Option { diff --git a/packages/stack-encrypt/tests/ui/indexes_empty_set.rs b/packages/stack-encrypt/tests/ui/indexes_empty_set.rs index f724dbc76..ff16fe578 100644 --- a/packages/stack-encrypt/tests/ui/indexes_empty_set.rs +++ b/packages/stack-encrypt/tests/ui/indexes_empty_set.rs @@ -1,8 +1,8 @@ //! `()` is not a set of indexes: an indexed field with no index would be a //! ciphertext-only field that says otherwise. That field is `ciphertext()`. use stack_encrypt::target::{indexed, Borrowed}; -use stack_kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; fn main() { - let _ = indexed::(()); + let _ = indexed::(()); } diff --git a/packages/stack-encrypt/tests/ui/indexes_empty_set.stderr b/packages/stack-encrypt/tests/ui/indexes_empty_set.stderr index 29718aad3..d15524e8e 100644 --- a/packages/stack-encrypt/tests/ui/indexes_empty_set.stderr +++ b/packages/stack-encrypt/tests/ui/indexes_empty_set.stderr @@ -1,8 +1,8 @@ error[E0277]: `()` is not a set of indexes over `u32` - --> tests/ui/indexes_empty_set.rs:7:57 + --> tests/ui/indexes_empty_set.rs:7:58 | -7 | let _ = indexed::(()); - | ^ expected one index, or a tuple of two to five, each defined over `u32` +7 | let _ = indexed::(()); + | ^ expected one index, or a tuple of two to five, each defined over `u32` | = help: the trait `Indexes` is not implemented for `()` = note: `()` is not a set of indexes: a field with no index is `ciphertext()` alone @@ -37,8 +37,8 @@ note: required by a bound in `indexed` error[E0277]: `()` is not a set of indexes over `u32` --> tests/ui/indexes_empty_set.rs:7:13 | -7 | let _ = indexed::(()); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected one index, or a tuple of two to five, each defined over `u32` +7 | let _ = indexed::(()); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected one index, or a tuple of two to five, each defined over `u32` | = help: the trait `Indexes` is not implemented for `()` = note: `()` is not a set of indexes: a field with no index is `ciphertext()` alone diff --git a/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.rs b/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.rs index 6b363cb74..cd237a59c 100644 --- a/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.rs +++ b/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.rs @@ -1,8 +1,8 @@ //! One inapplicable index makes the whole set inapplicable: equality is //! defined over `u32`, match is not. use stack_encrypt::target::{indexed, Borrowed, Equality, Match}; -use stack_kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; fn main() { - let _ = indexed::((Equality, Match::default())); + let _ = indexed::((Equality, Match::default())); } diff --git a/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.stderr b/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.stderr index cb38e177b..0a920449f 100644 --- a/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.stderr +++ b/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.stderr @@ -1,8 +1,8 @@ error[E0277]: the trait bound `u32: AsRef` is not satisfied --> tests/ui/match_in_a_tuple_on_integer.rs:7:13 | -7 | let _ = indexed::((Equality, Match::default())); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `AsRef` is not implemented for `u32` +7 | let _ = indexed::((Equality, Match::default())); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `AsRef` is not implemented for `u32` | help: the trait `stack_encrypt::Index` is implemented for `stack_encrypt::Match` --> src/target/index.rs diff --git a/packages/stack-encrypt/tests/ui/match_on_integer.rs b/packages/stack-encrypt/tests/ui/match_on_integer.rs index 37298c7ee..b3d9848a6 100644 --- a/packages/stack-encrypt/tests/ui/match_on_integer.rs +++ b/packages/stack-encrypt/tests/ui/match_on_integer.rs @@ -1,8 +1,8 @@ //! Match is defined over text, so a match index on an integer does not //! compile. use stack_encrypt::target::{indexed, Borrowed, Match}; -use stack_kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; fn main() { - let _ = indexed::(Match::default()); + let _ = indexed::(Match::default()); } diff --git a/packages/stack-encrypt/tests/ui/match_on_integer.stderr b/packages/stack-encrypt/tests/ui/match_on_integer.stderr index 309411cc5..1e9c82aa9 100644 --- a/packages/stack-encrypt/tests/ui/match_on_integer.stderr +++ b/packages/stack-encrypt/tests/ui/match_on_integer.stderr @@ -1,8 +1,8 @@ error[E0277]: the trait bound `u32: AsRef` is not satisfied --> tests/ui/match_on_integer.rs:7:13 | -7 | let _ = indexed::(Match::default()); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `AsRef` is not implemented for `u32` +7 | let _ = indexed::(Match::default()); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `AsRef` is not implemented for `u32` | help: the trait `stack_encrypt::Index` is implemented for `stack_encrypt::Match` --> src/target/index.rs diff --git a/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.rs b/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.rs index 8c808dca1..ec94b350d 100644 --- a/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.rs +++ b/packages/stack-encrypt/tests/ui/owned_fan_out_needs_clone.rs @@ -4,10 +4,10 @@ //! the value — and the refusal names `zip`, not the operations. use stack_encrypt::target::{ciphertext, equality, CallerContext, Owned}; use stack_encrypt::{nonempty, Encrypt, KeysetCipher}; -use stack_kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; fn seal_and_index( - keyset: &KeysetCipher<'_, FakeDataKeySource>, + keyset: &KeysetCipher<'_, FakeKeysetRegistry>, value: S, ) { let both = ciphertext::<_, _, Owned>() diff --git a/packages/stack-encrypt/tests/ui/pass/context_type_records.rs b/packages/stack-encrypt/tests/ui/pass/context_type_records.rs index 8ec27e5ae..c37ed4bab 100644 --- a/packages/stack-encrypt/tests/ui/pass/context_type_records.rs +++ b/packages/stack-encrypt/tests/ui/pass/context_type_records.rs @@ -4,7 +4,7 @@ use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::{DecryptField, Decryptable, Decryption, Encryption, IndexSpec}; use stack_encrypt::{DecryptInto, EncryptFrom, NonEmpty, StackCipher, StackCipherText}; -use stack_kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; #[derive(EncryptFrom, DecryptInto)] #[stash(plaintext = String, context_type = NonEmpty)] @@ -16,7 +16,7 @@ struct Sealed { struct Tag(#[allow(dead_code)] EqualityTerm); impl EncryptFrom for Tag { type Context = NonEmpty; - fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> { + fn encryption<'s, K: stack_encrypt::KeysetRegistry + 'static>() -> Encryption<'s, String, Self, K, Self::Context> { >::encryption() .accepting::>() .map(Tag) @@ -29,7 +29,7 @@ impl Decryptable for Tag { const DECRYPTABLE: bool = false; } impl DecryptField for Tag { - fn decryption_field(self, _: Ctx) -> Option> { + fn decryption_field(self, _: Ctx) -> Option> { None } } @@ -49,7 +49,7 @@ struct Pair { tag: Tag, } -async fn round_trips(cipher: &StackCipher) { +async fn round_trips(cipher: &StackCipher) { let keyset = cipher.default_keyset(); let value = String::from("bob@example.com"); let wrapped: Wrapped = keyset.encrypt_as(&value, NonEmpty::from(7u64)).await.unwrap(); diff --git a/packages/stack-encrypt/tests/ui/pass/indexes.rs b/packages/stack-encrypt/tests/ui/pass/indexes.rs index 4079dd413..5a71b06f2 100644 --- a/packages/stack-encrypt/tests/ui/pass/indexes.rs +++ b/packages/stack-encrypt/tests/ui/pass/indexes.rs @@ -3,7 +3,7 @@ //! defined for, in either source mode. use stack_encrypt::sem::{MatchConfig, MatchOptions}; use stack_encrypt::target::{indexed, Borrowed, Equality, Match, Ope, Ore, Owned}; -use stack_kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; struct Words; impl MatchConfig for Words { @@ -13,17 +13,17 @@ impl MatchConfig for Words { } fn main() { - let _ = indexed::(Equality); - let _ = indexed::((Equality, Ore)); - let _ = indexed::((Equality, Ore, Ope)); - let _ = indexed::(Match::default()); - let _ = indexed::(( + let _ = indexed::(Equality); + let _ = indexed::((Equality, Ore)); + let _ = indexed::((Equality, Ore, Ope)); + let _ = indexed::(Match::default()); + let _ = indexed::(( Equality, Match::::new(), Ore, Ope, )); - let _ = indexed::(( + let _ = indexed::(( Equality, Match::default(), Ore, diff --git a/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs b/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs index 4ae01d79f..0781f1d75 100644 --- a/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs +++ b/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs @@ -6,33 +6,33 @@ use stack_encrypt::target::{ ciphertext, equality, matching, ope, ore, CallerContext, Owned, Pending, }; use stack_encrypt::{nonempty, Encrypt, KeysetCipher, StackCipherText}; -use stack_kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; fn seal<'a, S: Encrypt>( - keyset: &'a KeysetCipher<'_, FakeDataKeySource>, + keyset: &'a KeysetCipher<'_, FakeKeysetRegistry>, value: S, -) -> Pending<'a, StackCipherText, FakeDataKeySource> { +) -> Pending<'a, StackCipherText, FakeKeysetRegistry> { keyset.run(ciphertext::<_, _, Owned>(), value, nonempty!("column").into()) } fn index<'a, S: vitaminc_prf::PrfValue>( - keyset: &'a KeysetCipher<'_, FakeDataKeySource>, + keyset: &'a KeysetCipher<'_, FakeKeysetRegistry>, value: S, -) -> Pending<'a, EqualityTerm, FakeDataKeySource> { +) -> Pending<'a, EqualityTerm, FakeKeysetRegistry> { keyset.run(equality::<_, _, Owned>(), value, CallerContext::from(nonempty!("column"))) } fn search<'a, S: AsRef>( - keyset: &'a KeysetCipher<'_, FakeDataKeySource>, + keyset: &'a KeysetCipher<'_, FakeKeysetRegistry>, value: S, -) -> Pending<'a, MatchTerms, FakeDataKeySource> { +) -> Pending<'a, MatchTerms, FakeKeysetRegistry> { keyset.run(matching::<_, _, Owned, _>(), value, CallerContext::from(nonempty!("column"))) } // Bounded only by the scheme's own trait: `CllwOreEncrypt` and // `CllwOpeEncrypt` do not imply `Clone`, so `Clone` returning to either // constructor's bounds fails this file. fn order<'a, S>( - keyset: &'a KeysetCipher<'_, FakeDataKeySource>, + keyset: &'a KeysetCipher<'_, FakeKeysetRegistry>, value: S, -) -> Pending<'a, OreTerm, FakeDataKeySource> +) -> Pending<'a, OreTerm, FakeKeysetRegistry> where S: CllwOreEncrypt + Send + 'static, S::Output: Send + 'static, @@ -40,9 +40,9 @@ where keyset.run(ore::<_, _, Owned>(), value, CallerContext::from(nonempty!("column"))) } fn order_preserving<'a, S>( - keyset: &'a KeysetCipher<'_, FakeDataKeySource>, + keyset: &'a KeysetCipher<'_, FakeKeysetRegistry>, value: S, -) -> Pending<'a, OpeTerm, FakeDataKeySource> +) -> Pending<'a, OpeTerm, FakeKeysetRegistry> where S: CllwOpeEncrypt + Send + 'static, S::Output: Send + 'static, diff --git a/packages/stack-encrypt/tests/ui/pass/plan_chain.rs b/packages/stack-encrypt/tests/ui/pass/plan_chain.rs index 16671c333..fdebd872d 100644 --- a/packages/stack-encrypt/tests/ui/pass/plan_chain.rs +++ b/packages/stack-encrypt/tests/ui/pass/plan_chain.rs @@ -1,7 +1,7 @@ //! The chain shapes that must compile: a value with a context, indexed, //! field by field, a saved plan over a value and a collection, a query and //! an opening, and several chains batched. -use stack_encrypt::kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; use stack_encrypt::plan::{Field, Fields}; use stack_encrypt::{Equality, Error, Match, Ore, Plan, StackCipher}; @@ -25,7 +25,7 @@ impl Field for Person { } } -async fn chains(cipher: &StackCipher, person: &Person) -> Result<(), Error> { +async fn chains(cipher: &StackCipher, person: &Person) -> Result<(), Error> { let doc = String::from("doc"); let _ = cipher.encrypt(&doc).context("docs").await?; let _ = cipher.encrypt(&34u32).context("users/age").with((Equality, Ore)).await?; diff --git a/packages/stack-encrypt/tests/ui/pass/plan_grammar.rs b/packages/stack-encrypt/tests/ui/pass/plan_grammar.rs index a41a85539..ff18ca350 100644 --- a/packages/stack-encrypt/tests/ui/pass/plan_grammar.rs +++ b/packages/stack-encrypt/tests/ui/pass/plan_grammar.rs @@ -1,7 +1,7 @@ //! The widened plan grammar compiles in each of its spellings: the two //! starts, a picker with a closure (through `pick`) and with a function, and //! `encrypt_into` in both positions. -use stack_encrypt::kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; use stack_encrypt::plan::pick; use stack_encrypt::sem::{EqualityTerm, MatchTerms}; use stack_encrypt::{Encrypted, Equality, Ore, Plan, StackCipherText}; @@ -16,7 +16,7 @@ fn age(u: &User) -> &u32 { } fn main() { - let _ = Plan::fields::() + let _ = Plan::fields::() .context("users") .encrypt_into::, _>(pick("email", |u: &User| &u.email)) .encrypt_index(("age", age), (Equality, Ore)) diff --git a/packages/stack-encrypt/tests/ui/pass/struct_over_borrowed_source.rs b/packages/stack-encrypt/tests/ui/pass/struct_over_borrowed_source.rs index d4e7e2cdb..f358560c6 100644 --- a/packages/stack-encrypt/tests/ui/pass/struct_over_borrowed_source.rs +++ b/packages/stack-encrypt/tests/ui/pass/struct_over_borrowed_source.rs @@ -8,7 +8,7 @@ use std::marker::PhantomData; use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::DeclaredContext; use stack_encrypt::{EncryptFrom, Encrypted, StackCipher, StackCipherText}; -use stack_kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; struct User<'a> { email: String, @@ -26,11 +26,11 @@ struct EncryptedUser<'a> { source: PhantomData<&'a ()>, } -async fn round_trip(cipher: &StackCipher) { +async fn round_trip(cipher: &StackCipher) { let keyset = cipher.default_keyset(); let user = User { email: "bob@example.com".into(), age: 34, note: "static" }; let record: EncryptedUser<'_> = keyset.encrypt_as(&user, DeclaredContext::default()).await.unwrap(); - let _ = EncryptedUser::plan::().unwrap(); + let _ = EncryptedUser::plan::().unwrap(); let _ = record; } diff --git a/packages/stack-encrypt/tests/ui/plan_empty_index_set.rs b/packages/stack-encrypt/tests/ui/plan_empty_index_set.rs index 25689c61c..35267fd3f 100644 --- a/packages/stack-encrypt/tests/ui/plan_empty_index_set.rs +++ b/packages/stack-encrypt/tests/ui/plan_empty_index_set.rs @@ -2,7 +2,7 @@ //! of indexes. A field with no index is `encrypt(name)`. use stack_encrypt::plan::{Field, Fields}; use stack_encrypt::Plan; -use stack_kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; struct User { age: u32, @@ -20,6 +20,6 @@ impl Field for User { fn main() { let _ = Plan::context("users") - .fields::() + .fields::() .encrypt_index::("age", ()); } diff --git a/packages/stack-encrypt/tests/ui/plan_match_on_integer.rs b/packages/stack-encrypt/tests/ui/plan_match_on_integer.rs index ca5ba34f3..458d00e8f 100644 --- a/packages/stack-encrypt/tests/ui/plan_match_on_integer.rs +++ b/packages/stack-encrypt/tests/ui/plan_match_on_integer.rs @@ -2,7 +2,7 @@ //! integer field. use stack_encrypt::plan::{Field, Fields}; use stack_encrypt::{Match, Plan}; -use stack_kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; struct User { age: u32, @@ -20,6 +20,6 @@ impl Field for User { fn main() { let _ = Plan::context("users") - .fields::() + .fields::() .encrypt_index::("age", Match::default()); } diff --git a/packages/stack-encrypt/tests/ui/plan_picker_wrong_type.rs b/packages/stack-encrypt/tests/ui/plan_picker_wrong_type.rs index bde54e26d..683d57c9d 100644 --- a/packages/stack-encrypt/tests/ui/plan_picker_wrong_type.rs +++ b/packages/stack-encrypt/tests/ui/plan_picker_wrong_type.rs @@ -1,6 +1,6 @@ //! A picker's accessor names the field's type, so a verb that names another //! type for the same field does not compile. -use stack_encrypt::kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; use stack_encrypt::plan::pick; use stack_encrypt::{Equality, Plan}; @@ -10,6 +10,6 @@ struct User { fn main() { let _ = Plan::context("users") - .fields::() + .fields::() .encrypt_index::(pick("email", |u: &User| &u.email), Equality); } diff --git a/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.rs b/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.rs index 9a53fb472..006df77d9 100644 --- a/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.rs +++ b/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.rs @@ -1,9 +1,9 @@ //! A one-value plan selects a query's index by type, so asking it for an //! index it does not hold does not compile. -use stack_encrypt::kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; use stack_encrypt::{Ore, Plan, StackCipher}; -async fn query(cipher: &StackCipher) { +async fn query(cipher: &StackCipher) { let age_plan = Plan::context("users/age").with::(Ore).build().unwrap(); let _ = cipher.query(&34u32).using(&age_plan).equality(); } diff --git a/packages/stack-encrypt/tests/ui/struct_field_must_open.rs b/packages/stack-encrypt/tests/ui/struct_field_must_open.rs index 5394b539f..4904c57bd 100644 --- a/packages/stack-encrypt/tests/ui/struct_field_must_open.rs +++ b/packages/stack-encrypt/tests/ui/struct_field_must_open.rs @@ -15,7 +15,7 @@ struct Profile { struct Opaque(#[allow(dead_code)] StackCipherText); impl EncryptFrom for Opaque { type Context = AeadContext; - fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> { + fn encryption<'s, K: stack_encrypt::KeysetRegistry + 'static>() -> Encryption<'s, String, Self, K, Self::Context> { >::encryption().map(Opaque) } } From 3e16a89e37332bb7273cf543fb43c3886edb182e Mon Sep 17 00:00:00 2001 From: James Sadler Date: Tue, 6 Oct 2026 10:33:55 +1100 Subject: [PATCH 4/8] fix(eql): eql-bindings' stack-encrypt feature runs over a keyset registry eql-bindings implements stack-encrypt's target traits by hand for its text payloads, and their `K` must now be a `KeysetRegistry`, as every `Encryption` and `Decryption` asks. The Rustdoc example and the eql-encryption-tests crate build their cipher over stack-encrypt's fake keyset registry, through its `test-support` feature, in place of stack-kms's deleted `FakeDataKeySource`. The test fixture records the descriptors through the fake provider, which is `Bound`: a descriptor mismatch now fails at the key retrieval. The `stack-encrypt` feature of eql-bindings has not been released yet (eql-bindings 3.0.6 on crates.io does not have it), so this changes no published API. --- packages/eql/Cargo.lock | 99 +------------------ .../eql/crates/eql-bindings/src/encryption.rs | 29 ++++-- .../eql-bindings/src/encryption/example.rs | 8 +- packages/eql/tests/encryption/Cargo.toml | 3 +- .../eql/tests/encryption/tests/common/mod.rs | 74 +++----------- .../eql/tests/encryption/tests/text_eq.rs | 12 +-- 6 files changed, 47 insertions(+), 178 deletions(-) diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index f0812b2f9..60c3f7999 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -585,7 +585,7 @@ dependencies = [ "ore-rs", "percent-encoding", "rand 0.8.6", - "recipher 0.2.3", + "recipher", "reqwest", "rmp-serde", "rust-stemmers", @@ -596,8 +596,8 @@ dependencies = [ "serde_json", "serdect", "sha2 0.10.9", - "stack-auth 0.42.2", - "stack-profile 0.42.2", + "stack-auth", + "stack-profile", "static_assertions", "thiserror 1.0.69", "tokio", @@ -1238,7 +1238,6 @@ dependencies = [ "serde", "serde_json", "stack-encrypt", - "stack-kms", "tokio", "uuid", ] @@ -3203,27 +3202,6 @@ dependencies = [ "zeroize", ] -[[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.16", - "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" @@ -4180,7 +4158,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "stack-profile 0.42.2", + "stack-profile", "thiserror 1.0.69", "tokio", "tracing", @@ -4193,32 +4171,6 @@ dependencies = [ "zerokms-protocol", ] -[[package]] -name = "stack-auth" -version = "0.43.0" -dependencies = [ - "aquamarine", - "base64", - "cts-common 0.43.0", - "jsonwebtoken", - "miette", - "open", - "serde", - "serde_json", - "serde_urlencoded", - "stack-profile 0.43.0", - "thiserror 1.0.69", - "tokio", - "tracing", - "url", - "uuid", - "vitaminc 0.5.1", - "vitaminc-protected 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", - "web-time", - "zeroize", - "zerokms-protocol", -] - [[package]] name = "stack-encrypt" version = "0.2.0" @@ -4248,36 +4200,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "stack-kms" -version = "0.1.0" -dependencies = [ - "async-mutex", - "base16ct", - "base64ct", - "blake3", - "futures", - "miette", - "opaque-debug", - "recipher 0.3.1", - "serde", - "serde_cbor", - "serde_json", - "serdect", - "sha2 0.10.9", - "stack-auth 0.43.0", - "thiserror 1.0.69", - "tracing", - "url", - "uuid", - "vitaminc 0.5.1", - "vitaminc-kms", - "vitaminc-protected 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", - "vitaminc-protected 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", - "zeroize", - "zerokms-protocol", -] - [[package]] name = "stack-profile" version = "0.42.2" @@ -4292,18 +4214,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "stack-profile" -version = "0.43.0" -dependencies = [ - "dirs", - "gethostname", - "serde", - "serde_json", - "thiserror 1.0.69", - "uuid", -] - [[package]] name = "static_assertions" version = "1.1.0" @@ -5143,6 +5053,7 @@ dependencies = [ "thiserror 2.0.20", "vitaminc-async-traits", "vitaminc-protected 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", + "vitaminc-random 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", "vitaminc-traits 0.5.1 (git+https://github.com/cipherstash/vitaminc?rev=b3bc6eb)", "zeroize", ] diff --git a/packages/eql/crates/eql-bindings/src/encryption.rs b/packages/eql/crates/eql-bindings/src/encryption.rs index df9a8b8d8..bc06e55e7 100644 --- a/packages/eql/crates/eql-bindings/src/encryption.rs +++ b/packages/eql/crates/eql-bindings/src/encryption.rs @@ -27,10 +27,10 @@ //! consumer names it with an `Identifier`, and the two interoperate. //! //! This complete example runs locally without credentials. It uses real -//! encryption with `stack_kms::FakeDataKeySource`, whose keys exist only for -//! this process. Add `stack-encrypt`, `stack-kms` (with `default-features = false` -//! and `features = ["test-support"]`), and `tokio` (with `features = ["rt", "macros"]`) -//! to your example's dependencies. +//! encryption with `stack_encrypt::registry::fake::FakeKeysetRegistry`, whose +//! keys exist only for this process. Add `stack-encrypt` (with +//! `default-features = false` and `features = ["test-support"]`) and `tokio` +//! (with `features = ["rt", "macros"]`) to your example's dependencies. //! //! The code below is executed by `cargo test -p eql-encryption-tests --test //! text_eq_example`. It is excluded from this crate's doctests so its optional @@ -169,7 +169,8 @@ impl Reader for Ciphertext { impl EncryptFrom for Ciphertext { type Context = AeadContext; - fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + fn encryption<'s, K: stack_encrypt::KeysetRegistry + 'static>( + ) -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { @@ -190,7 +191,10 @@ impl Visitor for NativeCiphertextVisitor { } impl + 'static> DecryptInto

for Ciphertext { type Context = AeadContext; - fn decryption(self, context: Self::Context) -> Decryption { + fn decryption( + self, + context: Self::Context, + ) -> Decryption { match self.read(NativeCiphertextVisitor) { Ok(native) => target::open(native, context), Err(error) => Decryption::failed(error), @@ -205,7 +209,10 @@ where Self: DecryptInto

, Ctx: Into<>::Context>, { - fn decryption_field(self, context: Ctx) -> Option> { + fn decryption_field( + self, + context: Ctx, + ) -> Option> { Some(self.decryption(context.into())) } } @@ -227,7 +234,8 @@ impl Transcode for Hmac256 { } impl EncryptFrom for Hmac256 { type Context = CallerContext; - fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + fn encryption<'s, K: stack_encrypt::KeysetRegistry + 'static>( + ) -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { @@ -238,7 +246,10 @@ impl Decryptable for Hmac256 { const DECRYPTABLE: bool = false; } impl DecryptField for Hmac256 { - fn decryption_field(self, _: Ctx) -> Option> { + fn decryption_field( + self, + _: Ctx, + ) -> Option> { None } } diff --git a/packages/eql/crates/eql-bindings/src/encryption/example.rs b/packages/eql/crates/eql-bindings/src/encryption/example.rs index 58a482b94..51559f7ff 100644 --- a/packages/eql/crates/eql-bindings/src/encryption/example.rs +++ b/packages/eql/crates/eql-bindings/src/encryption/example.rs @@ -2,14 +2,14 @@ use eql_bindings::{ v3::text::{TextEq, TextEqQuery}, Identifier, }; -use stack_encrypt::StackCipher; -use stack_kms::FakeDataKeySource; +use stack_encrypt::registry::fake::FakeKeysetRegistry; +use stack_encrypt::StackCipherBuilder; #[tokio::main(flavor = "current_thread")] async fn main() -> Result<(), Box> { // Local example only: these keys are held in memory, not in ZeroKMS. - let cipher = StackCipher::builder() - .kms(FakeDataKeySource::new()) + let cipher = StackCipherBuilder::new() + .registry(FakeKeysetRegistry::new()) .init() .await?; let keyset = cipher.default_keyset(); diff --git a/packages/eql/tests/encryption/Cargo.toml b/packages/eql/tests/encryption/Cargo.toml index 4b95f0f20..0d6bf32d9 100644 --- a/packages/eql/tests/encryption/Cargo.toml +++ b/packages/eql/tests/encryption/Cargo.toml @@ -14,8 +14,7 @@ harness = false # Keep crypto/Postgres test dependencies out of eql-bindings' default test build. [dev-dependencies] eql-bindings = { path = "../../crates/eql-bindings", features = ["stack-encrypt"] } -stack-encrypt = { path = "../../../stack-encrypt", default-features = false } -stack-kms = { path = "../../../stack-kms", default-features = false, features = ["test-support"] } +stack-encrypt = { path = "../../../stack-encrypt", default-features = false, features = ["test-support"] } serde = "1" serde_json = "1" uuid = "1" diff --git a/packages/eql/tests/encryption/tests/common/mod.rs b/packages/eql/tests/encryption/tests/common/mod.rs index e64883b5c..20b89e910 100644 --- a/packages/eql/tests/encryption/tests/common/mod.rs +++ b/packages/eql/tests/encryption/tests/common/mod.rs @@ -1,68 +1,16 @@ -use std::borrow::Cow; -use std::sync::{Arc, Mutex}; +use stack_encrypt::registry::fake::{FakeKeysetRegistry, FakeProvider}; +use stack_encrypt::{StackCipher, StackCipherBuilder}; -use stack_encrypt::StackCipher; -use stack_kms::{ - DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IdentifiedBy, - IndexKey, IndexKeySource, RetrieveKeyPayload, UnverifiedContext, -}; -use uuid::Uuid; - -#[derive(Default)] -pub struct Calls { - pub generate: Vec>, - pub retrieve: Vec>, -} - -pub struct Observed { - inner: FakeDataKeySource, - calls: Arc>, -} -impl DataKeySource for Observed { - async fn generate_keys( - &self, - payloads: Vec>, - keyset: Option, - context: Option>, - ) -> Result, stack_kms::Error> { - self.calls - .lock() - .unwrap() - .generate - .push(payloads.iter().map(|p| p.descriptor.to_owned()).collect()); - self.inner.generate_keys(payloads, keyset, context).await - } - async fn retrieve_keys( - &self, - payloads: Vec>, - keyset: Option, - context: Option<&UnverifiedContext>, - ) -> Result, stack_kms::Error> { - self.calls - .lock() - .unwrap() - .retrieve - .push(payloads.iter().map(|p| p.descriptor.to_owned()).collect()); - self.inner.retrieve_keys(payloads, keyset, context).await - } -} -impl IndexKeySource for Observed { - async fn load_index_key( - &self, - keyset: Option, - ) -> Result<(Uuid, IndexKey), stack_kms::Error> { - self.inner.load_index_key(keyset).await - } -} -pub async fn cipher() -> (StackCipher, Arc>) { - let calls = Arc::new(Mutex::new(Calls::default())); - let cipher = StackCipher::builder() - .kms(Observed { - inner: FakeDataKeySource::new(), - calls: calls.clone(), - }) +/// A cipher over the fake keyset registry, and the provider of its default +/// keyset. The provider records every binding (descriptor) it is sent, +/// grouped by call: `provider.calls().generate` and `.retrieve`. +pub async fn cipher() -> (StackCipher, FakeProvider) { + let registry = FakeKeysetRegistry::new(); + let (_, provider) = registry.default_keyset(); + let cipher = StackCipherBuilder::new() + .registry(registry) .init() .await .unwrap(); - (cipher, calls) + (cipher, provider) } diff --git a/packages/eql/tests/encryption/tests/text_eq.rs b/packages/eql/tests/encryption/tests/text_eq.rs index 86a2a2395..ced042baa 100644 --- a/packages/eql/tests/encryption/tests/text_eq.rs +++ b/packages/eql/tests/encryption/tests/text_eq.rs @@ -126,7 +126,7 @@ async fn independent_queries_match_native_terms_without_requesting_data_keys() { .unwrap(); assert_ne!(query.hm, probe.hm, "no added normalization or case folding"); } - let calls = calls.lock().unwrap(); + let calls = calls.calls(); assert!(calls.generate.is_empty()); assert!(calls.retrieve.is_empty()); } @@ -155,7 +155,7 @@ async fn identifier_components_are_nonempty_and_keep_their_boundaries() { } outputs.push(stored.hm); } - let calls = calls.lock().unwrap(); + let calls = calls.calls(); let descriptors: std::collections::HashSet<_> = calls.generate.iter().flatten().collect(); assert_eq!( descriptors.len(), @@ -220,7 +220,7 @@ async fn context_and_encoding_failures_are_rejected_before_retrieving_keys() { .await .is_err()); } - assert!(calls.lock().unwrap().retrieve.is_empty()); + assert!(calls.calls().retrieve.is_empty()); let mut moved = stored; moved.i = wrong.into_inner(); assert!( @@ -230,7 +230,7 @@ async fn context_and_encoding_failures_are_rejected_before_retrieving_keys() { .is_err(), "a different nonempty stored identifier fails authentication" ); - assert_eq!(calls.lock().unwrap().retrieve.len(), 1); + assert_eq!(calls.calls().retrieve.len(), 1); } #[tokio::test] @@ -258,7 +258,7 @@ async fn one_changed_byte_in_a_valid_body_fails_authentication() { .await .is_err()); assert_eq!( - calls.lock().unwrap().retrieve.len(), + calls.calls().retrieve.len(), 1, "the tampered body was decoded and parsed; the failure is authentication" ); @@ -279,7 +279,7 @@ async fn a_column_batches_generation_and_retrieval_under_the_same_descriptors() let records: Vec = serde_json::from_str(&json).unwrap(); let opened: Vec = cipher.decrypt_as(records, column().into()).await.unwrap(); assert_eq!(opened, values); - let calls = calls.lock().unwrap(); + let calls = calls.calls(); assert_eq!(calls.generate.len(), 1); assert_eq!(calls.retrieve.len(), 1); assert_eq!(calls.generate[0].len(), 3); From e1b97b0115432d5942f3ee7e2e814b40888c22f4 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Tue, 6 Oct 2026 10:34:17 +1100 Subject: [PATCH 5/8] chore(release): stack-kms 0.2.0 and stack-encrypt 0.3.0, the versions the registry needs Both breaks are to published crates. stack-kms 0.1.0 is on crates.io with DataKeySource and IndexKeySource, which this branch deletes, and without ZeroKmsKeyset, which stack-encrypt now needs. stack-encrypt 0.2.0 is on crates.io with StackCipher. So stack-kms moves to 0.2.0 and the stack-encrypt version group to 0.3.0, and each requirement on them follows. eql-bindings' stack-encrypt feature now asks for stack-encrypt 0.3.0. Its target impls take the KeysetRegistry bound, which 0.2.0 does not have, so a packaged eql-bindings that still named 0.2.0 would resolve it and fail to compile. AGENTS.md says the bump lands in the stack-encrypt PR first, because cargo refuses a requirement that the in-tree path dependency does not satisfy. Until 0.3.0 is on crates.io, test-eql.yml's dry run reports this as a warning. The versions are not published by this change. stack-kms and stack-encrypt pin vitaminc-kms to a git rev, so cargo cannot package them until vitaminc releases the key-provider traits. --- Cargo.lock | 6 +++--- languages/golang/stackencrypt/guest/Cargo.lock | 6 +++--- packages/eql/Cargo.lock | 4 ++-- packages/eql/crates/eql-bindings/Cargo.toml | 9 +++++---- packages/stack-encrypt-derive/Cargo.toml | 2 +- packages/stack-encrypt/Cargo.toml | 6 +++--- packages/stack-encrypt/fuzz/Cargo.lock | 4 ++-- packages/stack-kms/Cargo.toml | 2 +- packages/stack-kms/fuzz/Cargo.lock | 2 +- 9 files changed, 21 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1f273815c..ab9eee680 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3274,7 +3274,7 @@ dependencies = [ [[package]] name = "stack-encrypt" -version = "0.2.0" +version = "0.3.0" dependencies = [ "base64ct", "cllw-ore", @@ -3302,7 +3302,7 @@ dependencies = [ [[package]] name = "stack-encrypt-derive" -version = "0.2.0" +version = "0.3.0" dependencies = [ "proc-macro2", "quote", @@ -3324,7 +3324,7 @@ dependencies = [ [[package]] name = "stack-kms" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-mutex", "base16ct", diff --git a/languages/golang/stackencrypt/guest/Cargo.lock b/languages/golang/stackencrypt/guest/Cargo.lock index 9444134fd..cd75f52da 100644 --- a/languages/golang/stackencrypt/guest/Cargo.lock +++ b/languages/golang/stackencrypt/guest/Cargo.lock @@ -1978,7 +1978,7 @@ dependencies = [ [[package]] name = "stack-encrypt" -version = "0.2.0" +version = "0.3.0" dependencies = [ "base64ct", "cllw-ore", @@ -2001,7 +2001,7 @@ dependencies = [ [[package]] name = "stack-encrypt-derive" -version = "0.2.0" +version = "0.3.0" dependencies = [ "proc-macro2", "quote", @@ -2040,7 +2040,7 @@ dependencies = [ [[package]] name = "stack-kms" -version = "0.1.0" +version = "0.2.0" dependencies = [ "base16ct", "base64ct", diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index 60c3f7999..86f73cb8e 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -4173,7 +4173,7 @@ dependencies = [ [[package]] name = "stack-encrypt" -version = "0.2.0" +version = "0.3.0" dependencies = [ "base64ct", "cllw-ore 0.5.0", @@ -4193,7 +4193,7 @@ dependencies = [ [[package]] name = "stack-encrypt-derive" -version = "0.2.0" +version = "0.3.0" dependencies = [ "proc-macro2", "quote", diff --git a/packages/eql/crates/eql-bindings/Cargo.toml b/packages/eql/crates/eql-bindings/Cargo.toml index 4895d3e23..519e25ccd 100644 --- a/packages/eql/crates/eql-bindings/Cargo.toml +++ b/packages/eql/crates/eql-bindings/Cargo.toml @@ -37,13 +37,14 @@ schemars = "1" # builds against it in-tree; `version` is what crates.io resolves for the # PUBLISHED eql-bindings. So the stack-encrypt this names must be on crates.io # before eql-bindings is, and must carry the API `src/encryption.rs` compiles -# against (`Describe`, `Description`, `Label`, `target::transcode`). 0.1.0 did -# not: it shipped before they landed, and the path build cannot notice, -# because it compiles whatever the tree holds. Two guards compile this feature +# against (`Describe`, `Description`, `Label`, `target::transcode`, and the +# `KeysetRegistry` bound its target impls take). 0.1.0 did not: it shipped +# before they landed, and the path build cannot notice, because it compiles +# whatever the tree holds. 0.2.0 does not have the registry either. Two guards compile this feature # from the PACKAGED crate against the registry stack-encrypt instead: # `cargo publish --dry-run --all-features` in test-eql.yml at PR time, and # `publish_all_features` in ../../release-plz.toml at release time. -stack-encrypt = { path = "../../../stack-encrypt", version = "0.2.0", optional = true, default-features = false } +stack-encrypt = { path = "../../../stack-encrypt", version = "0.3.0", optional = true, default-features = false } # The vitaminc line stack-encrypt is built on. `PrfValue` is named from this # crate directly (stack-encrypt re-exports only `IntoPrfContext` and # `PrfContext`), and two vitaminc lines in one graph do not interoperate, so diff --git a/packages/stack-encrypt-derive/Cargo.toml b/packages/stack-encrypt-derive/Cargo.toml index 44a71aea0..cef5c410a 100644 --- a/packages/stack-encrypt-derive/Cargo.toml +++ b/packages/stack-encrypt-derive/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-encrypt-derive" description = "Derive macros for stack-encrypt's target-directed encryption" -version = "0.2.0" +version = "0.3.0" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 53b783f3c..1221d3b50 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-encrypt" description = "Encrypt Rust values under per-value ZeroKMS data keys via the vitaminc cipher traits" -version = "0.2.0" +version = "0.3.0" edition.workspace = true authors.workspace = true repository.workspace = true @@ -17,14 +17,14 @@ license-file = "LICENSE" # `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", version = "0.1.0", default-features = false, features = [ +stack-kms = { path = "../stack-kms", version = "0.2.0", default-features = false, features = [ "profile", ], optional = true } # `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", version = "0.2.0" } +stack-encrypt-derive = { path = "../stack-encrypt-derive", version = "0.3.0" } # The workspace vitaminc (0.5.1): one canonical context encoding shared by # the AEAD and PRF derivations (`IntoContext`, cipherstash/vitaminc#339), diff --git a/packages/stack-encrypt/fuzz/Cargo.lock b/packages/stack-encrypt/fuzz/Cargo.lock index f156fd826..d4a0ca536 100644 --- a/packages/stack-encrypt/fuzz/Cargo.lock +++ b/packages/stack-encrypt/fuzz/Cargo.lock @@ -858,7 +858,7 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "stack-encrypt" -version = "0.2.0" +version = "0.3.0" dependencies = [ "base64ct", "cllw-ore", @@ -879,7 +879,7 @@ dependencies = [ [[package]] name = "stack-encrypt-derive" -version = "0.2.0" +version = "0.3.0" dependencies = [ "proc-macro2", "quote", diff --git a/packages/stack-kms/Cargo.toml b/packages/stack-kms/Cargo.toml index d4a4a5bff..0a27b12ce 100644 --- a/packages/stack-kms/Cargo.toml +++ b/packages/stack-kms/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-kms" description = "Standalone client for ZeroKMS key generation and retrieval" -version = "0.1.0" +version = "0.2.0" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-kms/fuzz/Cargo.lock b/packages/stack-kms/fuzz/Cargo.lock index 2aa60014a..d000cb96e 100644 --- a/packages/stack-kms/fuzz/Cargo.lock +++ b/packages/stack-kms/fuzz/Cargo.lock @@ -1963,7 +1963,7 @@ dependencies = [ [[package]] name = "stack-kms" -version = "0.1.0" +version = "0.2.0" dependencies = [ "base16ct", "base64ct", From a391337e7691f3a9cf9db0eefc6f55a74fb2f1df Mon Sep 17 00:00:00 2001 From: James Sadler Date: Tue, 6 Oct 2026 10:46:20 +1100 Subject: [PATCH 6/8] feat(stack-encrypt)!: new leaves are format 2, and format-1 leaves from 0.2 still open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stack-encrypt 0.2.0 is on crates.io and wrote the format-1 leaf layout, `0x01 ‖ keyset_id ‖ iv ‖ tag_len ‖ tag ‖ ciphertext`, with the AAD PAE("stack-encrypt/leaf", 1, keyset_id, derived_aad, tag). The keyset registry changes the layout to an opaque key id, and the suite PR kept the version byte at 1 because nothing was published then. That is no longer true, so ADR 0002's own rule applies: the layout change bumps the version byte. * New leaves are format 2: `0x02 ‖ keyset_id ‖ key_id_len ‖ key_id ‖ ciphertext`, and the AAD binds version 2 and the whole key id. * A format-1 leaf still parses. Its key id is `iv ‖ tag`, which is exactly what the ZeroKMS provider mints and splits. It opens under the format-1 AAD, rebuilt as 0.2 computed it (the tag is bound, the IV is not), and `to_bytes` writes it back in the format-1 layout. `SealedValue::from_v1_parts` rebuilds one from 0.2's parts, and the serde form carries the format. * Only ZeroKMS ever wrote format 1, so a format-1 leaf fails closed on any other registry: `KeysetRegistry::READS_V1_LEAVES` (default false, true for `Arc`) gates it, and the dispatch refuses the batch with `Error::V1LeafNeedsZeroKms` before any key request. * Any other version byte is still `LeafBytesError::UnknownVersion`. Tests: in-crate pins for both layouts and both AAD derivations (the format-1 pin is 0.2's own pin, byte for byte). `tests/format_v1.rs` holds real format-1 ciphertext sealed by stack-encrypt at 327b4dbce under a fixed data key, and shows that the new code decrypts it, that it round-trips, that tampering fails, that a non-ZeroKMS registry refuses it before any request, and that it reaches ZeroKMS as its IV and tag. The same file shows the index terms derive exactly as 0.2 derived them under the same index key. The fuzz corpus gains format-2 seeds and the real format-1 leaf. ADR 0002 gains an amendment. BREAKING CHANGE: `SealedValue::FORMAT_VERSION` is 2. Leaves written by this version cannot be read by stack-encrypt 0.2. `LeafBytesError` gains `TagTooLong` and `UnknownSerdeVersion`, `Error` gains `V1LeafNeedsZeroKms`, and the serde form of `SealedValue` gains a `version` field. --- languages/golang/stackencrypt/guest_test.go | 22 +- languages/golang/stackencrypt/leaf.go | 5 +- ...n-the-leaf-and-names-as-bounded-lookups.md | 73 +++ .../valid-leaf-v1-real-from-0.2 | Bin 0 -> 97 bytes .../corpus/sealed_value_decode/valid-leaf-v2 | Bin 0 -> 49 bytes .../valid-leaf-v2-empty-key-id-and-body | Bin 0 -> 20 bytes .../fuzz/fuzz_targets/sealed_value_decode.rs | 4 +- packages/stack-encrypt/src/cipher.rs | 496 +++++++++++++++--- packages/stack-encrypt/src/registry.rs | 16 + .../stack-encrypt/src/registry/zerokms.rs | 4 + packages/stack-encrypt/src/target/core.rs | 6 +- packages/stack-encrypt/src/target/pending.rs | 15 + packages/stack-encrypt/src/target/request.rs | 24 + packages/stack-encrypt/tests/format_v1.rs | 473 +++++++++++++++++ packages/stack-encrypt/tests/frozen_bytes.rs | 26 +- 15 files changed, 1083 insertions(+), 81 deletions(-) create mode 100644 packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v1-real-from-0.2 create mode 100644 packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v2 create mode 100644 packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v2-empty-key-id-and-body create mode 100644 packages/stack-encrypt/tests/format_v1.rs diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 149b8df2b..1385053c7 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -598,10 +598,16 @@ func rawInstance(t *testing.T) *Client { return c } -// A structurally valid stack-encrypt leaf (the frozen layout: version, -// keyset id, key id length, key id, ciphertext) with no real key behind it. -// The same bytes the Rust side pins in `cipher.rs`, so the two cannot drift. -var fixtureLeaf = mustHex("016b65797365742d6669787475726531360300aabbccdeadbeef") +// A structurally valid stack-encrypt leaf (the frozen format-2 layout: +// version, keyset id, key id length, key id, ciphertext) with no real key +// behind it. The same bytes the Rust side pins in `tests/frozen_bytes.rs`, so +// the two cannot drift. +var fixtureLeaf = mustHex("026b65797365742d6669787475726531360300aabbccdeadbeef") + +// A real format-1 leaf, as stack-encrypt 0.2 wrote it (version, keyset id, +// IV, tag length, tag, ciphertext): the fixture `tests/format_v1.rs` decrypts +// on the Rust side. The guest still parses format 1. +var fixtureLeafV1 = mustHex("015e7f000000004000800000000000000101010101010101010101010101010101100076312d666978747572652d7461672d310153767116f8f0c7450ebd6cf71a4afd93d9d7d037b51a48c3ab9d68480cf3ba4dc5d6a986ccbafb60d629baf402") func mustHex(s string) []byte { b, err := hex.DecodeString(s) @@ -671,8 +677,12 @@ func TestGuestAcceptsEveryEncodingThisPackageBuilds(t *testing.T) { "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 }, + "DecryptElement any, format 1": func() error { + _, err := c.DecryptElement(ctx, Sealed(fixtureLeafV1), 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) diff --git a/languages/golang/stackencrypt/leaf.go b/languages/golang/stackencrypt/leaf.go index 702dbbb66..fcf85abfc 100644 --- a/languages/golang/stackencrypt/leaf.go +++ b/languages/golang/stackencrypt/leaf.go @@ -8,8 +8,9 @@ import ( ) // 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 +// (version, keyset id, key id, ciphertext), exactly what a database column +// holds. New leaves are format 2. A format-1 leaf (version, keyset id, IV, +// ZeroKMS tag, ciphertext), written by stack-encrypt 0.2, still decrypts. 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 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 index 6092dbd4a..8d52b5704 100644 --- 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 @@ -73,6 +73,79 @@ This is the second and last spend. The rule above is unchanged — the next layout change, after the first release that stores leaves, must bump the version byte. +### Amendment, 2026-10-06: the key-id layout is format 2, and format 1 is still read + +The amendment above was written in cipherstash-suite, when nothing was +published. That changed before the key-id layout landed: stack-encrypt 0.1.0 +and 0.2.0 shipped to crates.io on 2026-10-04 with the format-1 layout below, +and a deployment on 0.2 stores leaves in it. The reason the amendment gave for +keeping `0x01` — nothing is stored — is no longer true. So the rule applies, +and the key-id layout bumps the version byte. + +**Format 2** is the key-id layout, and every new leaf is written in it: + +```text +version(0x02) ‖ keyset_id:16 ‖ key_id_len:u16 LE ‖ key_id ‖ ciphertext +AAD = PAE("stack-encrypt/leaf", [0x02], keyset_id, derived_aad, key_id) +``` + +**Format 1** is what 0.1 and 0.2 wrote. It is still read, never written: + +```text +version(0x01) ‖ keyset_id:16 ‖ iv:16 ‖ tag_len:u16 LE ‖ tag ‖ ciphertext +AAD = PAE("stack-encrypt/leaf", [0x01], keyset_id, derived_aad, tag) +``` + +The format-1 AAD is the derivation 0.2 computed, byte for byte. It binds the +tag and not the IV. A wrong IV still fails, because it names another data key +and the AEAD refuses the result. + +How a format-1 leaf is read: + +- `SealedValue::from_bytes` parses the format-1 layout and forms the opaque + key id `iv ‖ tag`. That is exactly the key id a ZeroKMS provider + (`stack_kms::ZeroKmsKeyset`) mints and splits: a 16-byte IV, then the tag. + So the ZeroKMS provider retrieves the key with the same IV, tag and + descriptor that 0.2 sent, and nothing in `stack-kms` changes. +- The leaf remembers its format. It opens under the format-1 AAD, and + `to_bytes` writes it back in the format-1 layout, so the stored bytes + round-trip unchanged. `SealedValue::from_v1_parts` rebuilds one from the + `(keyset_id, iv, tag, ciphertext)` parts that 0.2's `into_parts` returned. + The serde form carries the format too. +- Any other version byte is `LeafBytesError::UnknownVersion`. + +**A format-1 leaf on a registry that is not ZeroKMS fails closed.** Only +ZeroKMS ever wrote format 1. Another backend would read `iv ‖ tag` as a key id +of its own: an AWS or Vault provider would send those bytes to its service, and +a fake might hand back some key. The AEAD would still refuse the result, but +the request should not be made, and the failure should say what is wrong. So +`KeysetRegistry` has a defaulted constant, `READS_V1_LEAVES = false`. Only +`impl KeysetRegistry for Arc` sets it to `true`. When a batch holds a +format-1 leaf and the registry does not read them, the dispatch refuses the +whole batch with `Error::V1LeafNeedsZeroKms { keyset_id }` before it resolves a +keyset or asks for a key. + +We put the signal on the registry, not on the provider, because the provider +traits are `vitaminc-kms`'s and know nothing of this crate's leaf formats. A +registry also answers for every keyset it resolves, and a format-1 leaf can +only come from a ZeroKMS keyset. We considered two other choices and rejected +them. Hand any registry the `iv ‖ tag` key id: this works for ZeroKMS, but on +any other backend it fails late, with a backend error or an AEAD error that +reads like tampering. Guess ZeroKMS from the provider's constants +(`ClientAndServer`, `PerValue`, `Bound`): another backend can declare the same +values. + +The tests that hold this are in `tests/format_v1.rs`. Two leaves there are real +format-1 ciphertext: stack-encrypt at cipherstash/stack `327b4dbce` (the 0.2.0 +line) sealed them under a fixed data key. The new code decrypts them, and the +same file shows a format-1 leaf reaching ZeroKMS as its IV and tag. The file +also shows that index terms derive exactly as 0.2 derived them under the same +index key. `src/cipher.rs` pins both AAD derivations and both layouts. + +The version byte is now spent for real. The next layout change must bump it to +`0x03`, and it must keep reading formats 1 and 2 for as long as stored data +can hold them. + ## 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 diff --git a/packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v1-real-from-0.2 b/packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v1-real-from-0.2 new file mode 100644 index 0000000000000000000000000000000000000000..0a3d69dc30b185e0e13d5e325ac4ceb014586165 GIT binary patch literal 97 zcmZRKt7iZL2Zja^#lXOb4g?s=40Y2oD@sa>Qgurb({&9QgUbrVetbCY%C|S?yOh`8 l$v3ZGFyAWWad`FI3=f{qyL^vcTiJGI*YAXDn!CO*0RRXSAhG}e literal 0 HcmV?d00001 diff --git a/packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v2 b/packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v2 new file mode 100644 index 0000000000000000000000000000000000000000..9a61c2ad881a3902d109a4d7754f17e2eaa8344b GIT binary patch literal 49 zcmZR|POU6XEzwQOtSBihN;NbSW~fRn%FoU%*3B%_P0UNtElEsQWGqfiP0`KE&r41P E0IgjSDF6Tf literal 0 HcmV?d00001 diff --git a/packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v2-empty-key-id-and-body b/packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v2-empty-key-id-and-body new file mode 100644 index 0000000000000000000000000000000000000000..66588ded557ed3cdc92097da7177279e1b4603f3 GIT binary patch literal 20 KcmZQ#zytsQJOBd# literal 0 HcmV?d00001 diff --git a/packages/stack-encrypt/fuzz/fuzz_targets/sealed_value_decode.rs b/packages/stack-encrypt/fuzz/fuzz_targets/sealed_value_decode.rs index a38487378..e542b985b 100644 --- a/packages/stack-encrypt/fuzz/fuzz_targets/sealed_value_decode.rs +++ b/packages/stack-encrypt/fuzz/fuzz_targets/sealed_value_decode.rs @@ -3,7 +3,9 @@ use libfuzzer_sys::fuzz_target; use stack_encrypt::SealedValue; -// Fuzz the frozen v1 leaf byte decoder. A stored ciphertext column comes back +// Fuzz the frozen leaf byte decoder: format 2, which this crate writes, and +// format 1, which stack-encrypt 0.2 wrote and this crate still reads (a +// format-1 leaf writes back in the format-1 layout). 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`. // diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 11f0b3d5e..964ce73cf 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -52,23 +52,24 @@ //! //! ## 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 +//! Each leaf ([`SealedValue`]) stores the keyset id and the provider's +//! opaque key id — 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 +//! `PAE("stack-encrypt/leaf", version, keyset_id, derived_aad, key_id)`, with +//! [`SealedValue::FORMAT_VERSION`] (2) — 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 +//! id bound under the key id, 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`]) -//! adds a further binding layer. +//! keyset for a key it never minted. The key id is always bound, so the +//! ciphertext is cryptographically tied to its data key (key binding); a +//! caller AAD (e.g. a [`ContextTag`]) adds a further binding layer. A +//! format-1 leaf, which stack-encrypt 0.2 wrote with ZeroKMS's `iv` and +//! `tag`, still opens under its own derivation (see [`SealedValue`]). //! 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 @@ -224,6 +225,20 @@ pub enum Error { /// from any keyset, decrypt through the [`StackCipher`]. #[error("leaf was sealed under keyset {found}, not the handle's keyset {expected}")] ForeignKeyset { expected: KeysetId, found: KeysetId }, + + /// A format-1 leaf ([`SealedValue::FORMAT_VERSION_V1`], written by + /// stack-encrypt 0.1 and 0.2) was opened through a registry that does + /// not read format-1 leaves ([`KeysetRegistry::READS_V1_LEAVES`]). + /// + /// Only ZeroKMS ever sealed a format-1 leaf: its key id is ZeroKMS's IV + /// and tag. Another backend would read those bytes as a key id of its + /// own, so the leaf is refused before any key is requested. Open it + /// through the ZeroKMS registry (`Arc`). + #[error( + "a format-1 leaf (keyset {keyset_id}) holds a ZeroKMS key id, and this keyset registry \ + does not read format-1 leaves; open it through ZeroKMS" + )] + V1LeafNeedsZeroKms { keyset_id: KeysetId }, /// 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 @@ -954,16 +969,40 @@ impl StackCipher { /// [`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: +/// Every new leaf is written in the format-2 layout: /// /// | offset | field | size | value | /// |--------------------|------------------|-----------------|-------| -/// | 0 | envelope version | 1 | [`FORMAT_VERSION`](Self::FORMAT_VERSION) (`0x01`) | +/// | 0 | envelope version | 1 | [`FORMAT_VERSION`](Self::FORMAT_VERSION) (`0x02`) | /// | 1 | keyset id | 16 | the keyset the data key was minted under, raw UUID bytes | /// | 17 | `key_id_len` | 2 | length of `key_id`, `u16` little-endian | /// | 19 | `key_id` | `key_id_len` | the provider's own handle for the data key, opaque to this crate | /// | 19 + `key_id_len` | local ciphertext | rest of buffer | the vitaminc `LocalCipherText` | /// +/// ## Format 1 (read only) +/// +/// stack-encrypt 0.1 and 0.2 wrote the format-1 layout, which carries +/// ZeroKMS's IV and key tag in place of an opaque key id: +/// +/// | offset | field | size | value | +/// |-----------------|------------------|------------|-------| +/// | 0 | envelope version | 1 | [`FORMAT_VERSION_V1`](Self::FORMAT_VERSION_V1) (`0x01`) | +/// | 1 | keyset id | 16 | raw UUID bytes | +/// | 17 | ZeroKMS IV | 16 | identifies the data key | +/// | 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 | the vitaminc `LocalCipherText` | +/// +/// [`from_bytes`](Self::from_bytes) still reads it. The leaf's key id is +/// then `iv ‖ tag`, which is exactly the key id a ZeroKMS provider mints, +/// and [`to_bytes`](Self::to_bytes) writes such a leaf back in the format-1 +/// layout, so the bytes round-trip unchanged. Its AAD is the format-1 +/// derivation, which binds the tag and not the IV. Only ZeroKMS ever wrote +/// format 1, so a format-1 leaf opens only through a registry that says it +/// reads them ([`KeysetRegistry::READS_V1_LEAVES`]); any other registry +/// refuses it with [`Error::V1LeafNeedsZeroKms`] before a key is requested. +/// This crate never writes format 1. +/// /// 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 @@ -976,7 +1015,7 @@ impl StackCipher { /// ```text /// ┌─ envelope (stack-encrypt, this table) ──────────────────────────────────────────────┐ /// │ version ‖ keyset_id ‖ key_id_len ‖ key_id ‖ ┌─ local ciphertext (vitaminc) ──────────┐ │ -/// │ 0x01 │ version ‖ nonce ‖ ciphertext ‖ gcm_tag │ │ +/// │ 0x02 │ version ‖ nonce ‖ ciphertext ‖ gcm_tag │ │ /// │ └────────────────────────────────────────┘ │ /// └─────────────────────────────────────────────────────────────────────────────────────┘ /// ``` @@ -984,7 +1023,8 @@ impl StackCipher { /// 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, key_id)`, and +/// `PAE("stack-encrypt/leaf", version, keyset_id, derived_aad, key_id)` +/// (format 1: `tag` in place of `key_id`), 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 @@ -1000,6 +1040,11 @@ impl StackCipher { #[derive(Debug, Serialize, Deserialize)] #[serde(try_from = "SealedValueRepr")] pub struct SealedValue { + /// The envelope format this leaf was sealed in: + /// [`FORMAT_VERSION`](Self::FORMAT_VERSION) for every leaf this crate + /// seals, [`FORMAT_VERSION_V1`](Self::FORMAT_VERSION_V1) for one read + /// from the format-1 layout. It selects the byte layout and the AAD. + version: u8, /// The keyset the data key was minted under. It routes the leaf to a /// provider, so it is the envelope's own field rather than something /// buried in `key_id`: decrypt must know which backend to ask *before* @@ -1007,7 +1052,8 @@ pub struct SealedValue { keyset_id: KeysetId, /// The provider's opaque handle for this leaf's data key, bound whole /// into the leaf's AAD. Its encoding is the backend's business — for - /// ZeroKMS it is the IV followed by the key tag. + /// ZeroKMS it is the IV followed by the key tag. A format-1 leaf always + /// holds `iv ‖ tag` here, so it is at least the IV long. key_id: Vec, /// The leaf sealed by [`vitaminc_encrypt::Aes256Cipher`] under the data /// key: `version ‖ nonce ‖ ciphertext ‖ gcm_tag`. @@ -1017,14 +1063,16 @@ pub struct SealedValue { /// 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. +/// 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.) + /// The leading version byte is not one this build knows how to parse: + /// neither [`SealedValue::FORMAT_VERSION`] nor + /// [`SealedValue::FORMAT_VERSION_V1`]. (A version this build *does* + /// know, stamped on bytes sealed under the other version, fails to parse + /// or 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 id @@ -1038,6 +1086,15 @@ pub enum LeafBytesError { /// failing the encrypt — so a live `SealedValue` always encodes. #[error("key id of {0} bytes exceeds the format's u16 length field")] KeyIdTooLong(usize), + /// A format-1 key tag does not fit that layout's `u16` length field + /// ([`SealedValue::from_v1_parts`], and `serde` deserialisation of a + /// format-1 leaf). + #[error("key tag of {0} bytes exceeds the format's u16 length field")] + TagTooLong(usize), + /// A `serde` form names a format version this build does not know. + /// (Bytes with an unknown version are [`UnknownVersion`](Self::UnknownVersion).) + #[error("unknown sealed-leaf format version {0}")] + UnknownSerdeVersion(u8), } impl SealedValue { @@ -1046,15 +1103,41 @@ impl SealedValue { /// 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::KeyIdTooLong`]), so a - /// value that exists always encodes. + /// Format 2 is the opaque-key-id layout. Format 1, the ZeroKMS IV and + /// tag layout that stack-encrypt 0.1 and 0.2 wrote, is + /// [`FORMAT_VERSION_V1`](Self::FORMAT_VERSION_V1): still read, never + /// written. + pub const FORMAT_VERSION: u8 = 2; + + /// The format-1 version byte. A leaf in this format is still read + /// (see the type-level docs); this crate never seals one. + pub const FORMAT_VERSION_V1: u8 = 1; + + /// The length of the ZeroKMS IV a format-1 leaf carries. A format-1 + /// leaf's key id is this IV followed by the tag. + const V1_IV_LEN: usize = 16; + + /// The envelope format this leaf is in: + /// [`FORMAT_VERSION`](Self::FORMAT_VERSION) for a leaf this crate + /// sealed, [`FORMAT_VERSION_V1`](Self::FORMAT_VERSION_V1) for one read + /// from the format-1 layout. + pub fn format_version(&self) -> u8 { + self.version + } + + /// Encode into the frozen byte layout of this leaf's format — see the + /// type-level docs. The inverse of [`from_bytes`](Self::from_bytes): + /// a format-1 leaf is written back in the format-1 layout. + /// + /// Infallible: every way of building a `SealedValue` rejects a key id + /// (or a format-1 tag) too long for the `u16` length field + /// ([`LeafBytesError::KeyIdTooLong`], [`LeafBytesError::TagTooLong`]), + /// so a value that exists always encodes. pub fn to_bytes(&self) -> Vec { + if self.version == Self::FORMAT_VERSION_V1 { + return self.to_v1_bytes(); + } // Exact by the `key_id_fits_length_field` check every construction // site applies — `from_parts`, serde deserialisation, and the seal // path (which guards against a provider returning an oversized id). @@ -1071,14 +1154,44 @@ impl SealedValue { 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. + /// The format-1 layout: `0x01 ‖ keyset_id ‖ iv ‖ tag_len ‖ tag ‖ + /// ciphertext`, with `iv ‖ tag` split back out of the key id. + fn to_v1_bytes(&self) -> Vec { + // A format-1 leaf is only ever built with a key id of `iv ‖ tag` + // (`from_bytes`, `from_v1_parts`, serde), so the split cannot fail + // and the tag fits the `u16` field; both fallbacks are unreachable. + let (iv, tag) = self + .key_id + .split_at_checked(Self::V1_IV_LEN) + .unwrap_or((&[0; Self::V1_IV_LEN], &[])); + let tag_len = u16::try_from(tag.len()).unwrap_or(u16::MAX); + debug_assert_eq!(usize::from(tag_len), tag.len()); + let ciphertext = self.ciphertext.as_ref(); + let mut out = Vec::with_capacity(1 + 16 + iv.len() + 2 + tag.len() + ciphertext.len()); + out.push(Self::FORMAT_VERSION_V1); + out.extend_from_slice(self.keyset_id.as_uuid().as_bytes()); + out.extend_from_slice(iv); + out.extend_from_slice(&tag_len.to_le_bytes()); + out.extend_from_slice(tag); + out.extend_from_slice(ciphertext); + out + } + + /// Decode the frozen byte layout — the inverse of + /// [`to_bytes`](Self::to_bytes). Reads format 2 and format 1; any other + /// version byte is [`LeafBytesError::UnknownVersion`]. Structural only: + /// a decoded leaf is untrusted bytes until it decrypts. pub fn from_bytes(bytes: &[u8]) -> Result { let (&version, rest) = bytes.split_first().ok_or(LeafBytesError::Truncated)?; - if version != Self::FORMAT_VERSION { - return Err(LeafBytesError::UnknownVersion(version)); + match version { + Self::FORMAT_VERSION => Self::from_v2_body(rest), + Self::FORMAT_VERSION_V1 => Self::from_v1_body(rest), + other => Err(LeafBytesError::UnknownVersion(other)), } + } + + /// The format-2 body, after the version byte. + fn from_v2_body(rest: &[u8]) -> Result { if rest.len() < 16 + 2 { return Err(LeafBytesError::Truncated); } @@ -1095,12 +1208,80 @@ impl SealedValue { } let (key_id, ciphertext) = rest.split_at(key_id_len); Ok(Self { + version: Self::FORMAT_VERSION, keyset_id, key_id: key_id.to_vec(), ciphertext: LocalCipherText::from(ciphertext.to_vec()), }) } + /// The format-1 body, after the version byte: `keyset_id ‖ iv ‖ tag_len + /// ‖ tag ‖ ciphertext`. The key id becomes `iv ‖ tag`. + fn from_v1_body(rest: &[u8]) -> Result { + if rest.len() < 16 + Self::V1_IV_LEN + 2 { + return Err(LeafBytesError::Truncated); + } + let (keyset_bytes, rest) = rest.split_at(16); + let keyset_id = + KeysetId::new(Uuid::from_slice(keyset_bytes).map_err(|_| LeafBytesError::Truncated)?); + let (iv, rest) = rest.split_at(Self::V1_IV_LEN); + 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); + let mut key_id = Vec::with_capacity(iv.len() + tag.len()); + key_id.extend_from_slice(iv); + key_id.extend_from_slice(tag); + Ok(Self { + version: Self::FORMAT_VERSION_V1, + keyset_id, + key_id, + ciphertext: LocalCipherText::from(ciphertext.to_vec()), + }) + } + + /// Rebuild a format-1 leaf from the parts stack-encrypt 0.2's + /// `SealedValue::into_parts` returned: `(keyset_id, iv, tag, + /// ciphertext)`. For a caller that stored those parts rather than the + /// bytes. The leaf opens as a format-1 leaf (see the type-level docs). + /// + /// Fails with [`LeafBytesError::TagTooLong`] if `tag` does not fit the + /// format-1 `u16` length field. Structural only. + pub fn from_v1_parts( + keyset_id: KeysetId, + iv: [u8; 16], + tag: Vec, + ciphertext: Vec, + ) -> Result { + if tag.len() > usize::from(u16::MAX) { + return Err(LeafBytesError::TagTooLong(tag.len())); + } + let mut key_id = Vec::with_capacity(iv.len() + tag.len()); + key_id.extend_from_slice(&iv); + key_id.extend_from_slice(&tag); + Ok(Self { + version: Self::FORMAT_VERSION_V1, + keyset_id, + key_id, + ciphertext: LocalCipherText::from(ciphertext), + }) + } + + /// The format-1 invariant on a key id: `iv ‖ tag`, the IV whole and the + /// tag within the `u16` length field. + fn v1_key_id_is_iv_and_tag(key_id: &[u8]) -> Result<(), LeafBytesError> { + let tag_len = key_id + .len() + .checked_sub(Self::V1_IV_LEN) + .ok_or(LeafBytesError::Truncated)?; + if tag_len > usize::from(u16::MAX) { + return Err(LeafBytesError::TagTooLong(tag_len)); + } + Ok(()) + } + /// The one invariant that makes [`to_bytes`](Self::to_bytes) infallible: /// the key id must fit the format's `u16` length field. fn key_id_fits_length_field(key_id: &[u8]) -> Result<(), LeafBytesError> { @@ -1110,8 +1291,8 @@ impl SealedValue { Ok(()) } - /// Rebuild a leaf from its persisted parts — the inverse of - /// [`into_parts`](Self::into_parts). + /// Rebuild a format-2 leaf from its persisted parts — the inverse of + /// [`into_parts`](Self::into_parts) for a leaf this crate sealed. /// /// Fails with [`LeafBytesError::KeyIdTooLong`] if `key_id` does not fit the /// byte format's `u16` length field. Structural only: nothing about the @@ -1123,6 +1304,7 @@ impl SealedValue { ) -> Result { Self::key_id_fits_length_field(&key_id)?; Ok(Self { + version: Self::FORMAT_VERSION, keyset_id, key_id, ciphertext: LocalCipherText::from(ciphertext), @@ -1130,6 +1312,11 @@ impl SealedValue { } /// Decompose into `(keyset_id, key_id, ciphertext)` for persistence. + /// + /// The parts do not carry the format: [`from_parts`](Self::from_parts) + /// rebuilds a format-2 leaf. Persist a format-1 leaf with + /// [`to_bytes`](Self::to_bytes), or rebuild it with + /// [`from_v1_parts`](Self::from_v1_parts). pub fn into_parts(self) -> (KeysetId, Vec, Vec) { ( self.keyset_id, @@ -1159,6 +1346,7 @@ impl SealedValue { impl Clone for SealedValue { fn clone(&self) -> Self { Self { + version: self.version, keyset_id: self.keyset_id, key_id: self.key_id.clone(), ciphertext: LocalCipherText::from(self.ciphertext.as_ref().to_vec()), @@ -1183,22 +1371,35 @@ impl TryFrom<&[u8]> for SealedValue { #[derive(Deserialize)] #[serde(rename = "SealedValue")] struct SealedValueRepr { + /// Absent means the current format: a leaf this crate sealed. + #[serde(default = "current_format")] + version: u8, keyset_id: KeysetId, key_id: Vec, ciphertext: LocalCipherText, } +fn current_format() -> u8 { + SealedValue::FORMAT_VERSION +} + impl TryFrom for SealedValue { type Error = LeafBytesError; fn try_from(repr: SealedValueRepr) -> Result { let SealedValueRepr { + version, keyset_id, key_id, ciphertext, } = repr; - Self::key_id_fits_length_field(&key_id)?; + match version { + Self::FORMAT_VERSION => Self::key_id_fits_length_field(&key_id)?, + Self::FORMAT_VERSION_V1 => Self::v1_key_id_is_iv_and_tag(&key_id)?, + other => return Err(LeafBytesError::UnknownSerdeVersion(other)), + } Ok(Self { + version, keyset_id, key_id, ciphertext, @@ -1453,23 +1654,50 @@ fn leaf_cipher(key: &ProviderKey) -> Result { /// The domain label deliberately carries no `/v1` suffix: the version is a /// *parameter* here, not part of the label. /// -/// # Breaking change +/// # Format versions +/// +/// The version is the leaf's own ([`SealedValue::format_version`]), so a +/// leaf opens under the derivation it was sealed with: +/// +/// - format 2: `PAE("stack-encrypt/leaf", [2], keyset_id, derived_aad, key_id)`; +/// - format 1: `PAE("stack-encrypt/leaf", [1], keyset_id, derived_aad, tag)`, +/// exactly as stack-encrypt 0.2 computed it. Format 1 bound the ZeroKMS +/// tag and not the IV; the tag is the key id after its 16-byte IV. /// -/// 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: KeysetId, key_id: &[u8]) -> Context<'static> { +/// Before 0.1 was published, this derivation changed twice without a +/// version bump (an unlabelled `PAE(aad, tag)` tuple; then a four-piece +/// labelled form without the keyset id). Those leaves were dev data only +/// and fail authentication. +fn leaf_aad(leaf: &SealedValue, aad: &Context<'_>) -> Result, Unspecified> { + leaf_aad_parts(leaf.version, aad, leaf.keyset_id, &leaf.key_id) +} + +/// [`leaf_aad`] over the parts, for the seal path, which has no leaf yet. +fn leaf_aad_parts( + version: u8, + aad: &Context<'_>, + keyset_id: KeysetId, + key_id: &[u8], +) -> Result, Unspecified> { const LEAF_AAD_DOMAIN: &[u8] = b"stack-encrypt/leaf"; - Context::pae(&[ + let bound: &[u8] = match version { + SealedValue::FORMAT_VERSION => key_id, + SealedValue::FORMAT_VERSION_V1 => { + // `iv ‖ tag`; the IV is not bound in format 1. + let (_iv, tag) = key_id + .split_at_checked(SealedValue::V1_IV_LEN) + .ok_or(Unspecified)?; + tag + } + _ => return Err(Unspecified), + }; + Ok(Context::pae(&[ LEAF_AAD_DOMAIN, - &[SealedValue::FORMAT_VERSION], + &[version], keyset_id.as_uuid().as_bytes(), aad.as_bytes(), - key_id, - ]) + bound, + ])) } /// Seal one plaintext leaf under a freshly generated data key. @@ -1491,8 +1719,10 @@ fn seal_leaf( let key_id = key.key_id.as_bytes().to_vec(); SealedValue::key_id_fits_length_field(&key_id).map_err(|_| Unspecified)?; let cipher = leaf_cipher(&key.plaintext)?; - match (&cipher).encrypt_bytes_vec(plaintext, leaf_aad(aad, keyset_id, &key_id))? { + let leaf_aad = leaf_aad_parts(SealedValue::FORMAT_VERSION, aad, keyset_id, &key_id)?; + match (&cipher).encrypt_bytes_vec(plaintext, leaf_aad)? { AesCipherText::Single(ciphertext) => Ok(SealedValue { + version: SealedValue::FORMAT_VERSION, keyset_id, key_id, ciphertext, @@ -1515,9 +1745,10 @@ fn open_leaf(keyed: KeyedLeaf, aad: &Context<'_>) -> Result>, let KeyedLeaf { leaf, key } = keyed; let cipher = leaf_cipher(&key)?; + let leaf_aad = leaf_aad(&leaf, aad)?; cipher .decipher(AesCipherText::Single(leaf.ciphertext)) - .decrypt_bytes(ProtectedBytes, leaf_aad(aad, leaf.keyset_id, &leaf.key_id)) + .decrypt_bytes(ProtectedBytes, leaf_aad) } /// Open one marker leaf (absent / empty-sequence / empty-map) and require the @@ -2174,24 +2405,171 @@ mod tests { ); } - /// 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. + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() + } + + /// Byte-level pin for the format-2 [`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 must come with a [`SealedValue::FORMAT_VERSION`] bump, + /// which this pin forces into view. #[test] fn leaf_aad_bytes_are_pinned() { let keyset = KeysetId::new(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(); + let leaf = SealedValue::from_parts(keyset, b"key-id".to_vec(), vec![]).unwrap(); + let aad = leaf_aad(&leaf, &Context::from_encoded(b"caller-aad")).unwrap(); // 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. + // being "stack-encrypt/leaf", [2], the keyset id's 16 bytes, the + // caller AAD, and the whole key id. + assert_eq!( + hex(aad.as_bytes()), + "05000000000000001200000000000000737461636b2d656e63727970742f6c65616601000000000000000210000000000000006b65797365742d6669787475726531360a0000000000000063616c6c65722d61616406000000000000006b65792d6964" + ); + } + + /// The format-1 derivation, pinned to the exact bytes stack-encrypt 0.2 + /// computed for the same inputs (its own `leaf_aad_bytes_are_pinned`, + /// with the tag `key-tag`). The IV is not bound: only the tag after it. + /// A format-1 leaf must open under precisely this, or no stored 0.2 + /// leaf opens. + #[test] + fn the_format_1_leaf_aad_is_the_0_2_derivation() { + let keyset = KeysetId::new(Uuid::from_bytes(*b"keyset-fixture16")); + let leaf = + SealedValue::from_v1_parts(keyset, [9; 16], b"key-tag".to_vec(), vec![]).unwrap(); + let aad = leaf_aad(&leaf, &Context::from_encoded(b"caller-aad")).unwrap(); assert_eq!( - hex, + hex(aad.as_bytes()), "05000000000000001200000000000000737461636b2d656e63727970742f6c65616601000000000000000110000000000000006b65797365742d6669787475726531360a0000000000000063616c6c65722d61616407000000000000006b65792d746167" ); + // A format-1 key id shorter than its IV has no tag to bind. + assert_eq!( + leaf_aad_parts(1, &Context::from_encoded(b"x"), keyset, &[0; 15]).map(|_| ()), + Err(Unspecified) + ); + assert_eq!( + leaf_aad_parts(3, &Context::from_encoded(b"x"), keyset, &[0; 16]).map(|_| ()), + Err(Unspecified), + "no derivation for a version this build does not know" + ); + } + + /// The format-2 byte layout, pinned whole: `0x02 ‖ keyset_id ‖ + /// key_id_len (u16 LE) ‖ key_id ‖ ciphertext`. + #[test] + fn the_format_2_layout_is_pinned() { + let keyset = KeysetId::new(Uuid::from_bytes(*b"keyset-fixture16")); + let leaf = SealedValue::from_parts(keyset, b"kid".to_vec(), b"ct".to_vec()).unwrap(); + assert_eq!(leaf.format_version(), SealedValue::FORMAT_VERSION); + assert_eq!(SealedValue::FORMAT_VERSION, 2); + assert_eq!( + hex(&leaf.to_bytes()), + // version ‖ "keyset-fixture16" ‖ 3u16 LE ‖ "kid" ‖ "ct" + "02\ + 6b65797365742d666978747572653136\ + 0300\ + 6b6964\ + 6374" + ); + } + + /// A format-1 leaf reads as `iv ‖ tag` and writes back byte for byte in + /// the format-1 layout. + #[test] + fn a_format_1_leaf_reads_as_iv_and_tag_and_round_trips() { + let mut bytes = vec![SealedValue::FORMAT_VERSION_V1]; + bytes.extend_from_slice(b"keyset-fixture16"); + bytes.extend_from_slice(&[7; 16]); + bytes.extend_from_slice(&3u16.to_le_bytes()); + bytes.extend_from_slice(b"tag"); + bytes.extend_from_slice(b"ciphertext"); + let leaf = SealedValue::from_bytes(&bytes).unwrap(); + assert_eq!(leaf.format_version(), SealedValue::FORMAT_VERSION_V1); + assert_eq!( + leaf.keyset_id(), + KeysetId::new(Uuid::from_bytes(*b"keyset-fixture16")) + ); + assert_eq!(leaf.key_id(), [&[7u8; 16][..], b"tag"].concat()); + assert_eq!(leaf.ciphertext(), b"ciphertext"); + assert_eq!( + leaf.to_bytes(), + bytes, + "format 1 is written back as format 1" + ); + assert_eq!(leaf.clone().to_bytes(), bytes, "a clone keeps the format"); + + let parts = SealedValue::from_v1_parts( + leaf.keyset_id(), + [7; 16], + b"tag".to_vec(), + b"ciphertext".to_vec(), + ) + .unwrap(); + assert_eq!( + parts.to_bytes(), + bytes, + "from_v1_parts builds the same leaf" + ); + + // The smallest format-1 leaf: keyset, IV, a zero tag length. One + // byte fewer is truncated, and so is a tag the length overstates. + let smallest = &bytes[..1 + 16 + 16 + 2]; + let mut smallest = smallest.to_vec(); + smallest[33..35].copy_from_slice(&0u16.to_le_bytes()); + assert!(SealedValue::from_bytes(&smallest).is_ok()); + assert_eq!( + SealedValue::from_bytes(&smallest[..smallest.len() - 1]).map(|_| ()), + Err(LeafBytesError::Truncated) + ); + assert_eq!( + SealedValue::from_bytes(&bytes[..1 + 16 + 16 + 2 + 2]).map(|_| ()), + Err(LeafBytesError::Truncated) + ); + assert_eq!( + SealedValue::from_v1_parts(leaf.keyset_id(), [0; 16], vec![0; 65_536], vec![]) + .map(|_| ()), + Err(LeafBytesError::TagTooLong(65_536)) + ); + } + + /// Unknown versions fail with a clear error, whichever way in. + #[test] + fn an_unknown_format_version_is_refused() { + for version in [0u8, 3, 0xff] { + let mut bytes = vec![version]; + bytes.extend_from_slice(&[0; 40]); + assert_eq!( + SealedValue::from_bytes(&bytes).map(|_| ()), + Err(LeafBytesError::UnknownVersion(version)) + ); + } + } + + /// The serde form carries the format, so a format-1 leaf survives a + /// serde round trip, and a form with no version is a format-2 leaf. + #[test] + fn the_serde_form_keeps_the_format() { + let keyset = KeysetId::new(Uuid::nil()); + let v1 = SealedValue::from_v1_parts(keyset, [1; 16], b"t".to_vec(), b"c".to_vec()).unwrap(); + let json = serde_json::to_string(&v1).unwrap(); + let back: SealedValue = serde_json::from_str(&json).unwrap(); + assert_eq!(back.to_bytes(), v1.to_bytes()); + + let mut value = serde_json::to_value(&v1).unwrap(); + let _ = value.as_object_mut().unwrap().remove("version"); + let unversioned: SealedValue = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(unversioned.format_version(), SealedValue::FORMAT_VERSION); + + value["version"] = 1.into(); + value["key_id"] = serde_json::to_value(vec![0u8; 15]).unwrap(); + assert!( + serde_json::from_value::(value.clone()).is_err(), + "a format-1 key id shorter than its IV is refused" + ); + value["version"] = 9.into(); + assert!(serde_json::from_value::(value).is_err()); } /// Both length checks in the byte format are boundary conditions, and a diff --git a/packages/stack-encrypt/src/registry.rs b/packages/stack-encrypt/src/registry.rs index 455a6cfc6..3151a1c06 100644 --- a/packages/stack-encrypt/src/registry.rs +++ b/packages/stack-encrypt/src/registry.rs @@ -156,6 +156,22 @@ pub trait KeysetRegistry { type Error: std::error::Error + Send + Sync + 'static; + /// Whether this registry's providers read format-1 leaves + /// ([`SealedValue::FORMAT_VERSION_V1`](crate::SealedValue::FORMAT_VERSION_V1)), + /// the layout stack-encrypt 0.1 and 0.2 wrote. + /// + /// A format-1 leaf carries ZeroKMS's IV and key tag, and the reader + /// hands its provider the key id `iv ‖ tag`: the encoding a ZeroKMS + /// provider mints and parses, and no other backend's. So only the + /// ZeroKMS registry (`Arc`) sets this. With the default, + /// `false`, a format-1 leaf fails closed with + /// [`Error::V1LeafNeedsZeroKms`](crate::Error::V1LeafNeedsZeroKms) + /// before any key is requested. + /// + /// Set it only for a registry whose providers parse a key id exactly as + /// `stack_kms::ZeroKmsKeyset` does: a 16-byte IV, then the tag. + const READS_V1_LEAVES: bool = false; + /// Resolve `keyset`, or report that this deployment has no such keyset. /// /// The three outcomes are deliberately distinct, because the name cache diff --git a/packages/stack-encrypt/src/registry/zerokms.rs b/packages/stack-encrypt/src/registry/zerokms.rs index 5df0be07f..0acfb78e5 100644 --- a/packages/stack-encrypt/src/registry/zerokms.rs +++ b/packages/stack-encrypt/src/registry/zerokms.rs @@ -36,6 +36,10 @@ where type Provider = ZeroKmsKeyset; type Error = stack_kms::Error; + /// Format-1 leaves are ZeroKMS leaves: their key id, `iv ‖ tag`, is the + /// one `ZeroKmsKeyset` mints and splits. + const READS_V1_LEAVES: bool = true; + /// One `load_keyset` round trip, which resolves the keyset *and* returns /// its index key — so loading the index key eagerly costs nothing extra /// here, and a keyset that cannot serve one fails now rather than at the diff --git a/packages/stack-encrypt/src/target/core.rs b/packages/stack-encrypt/src/target/core.rs index 0ae25dd83..4d2bf2702 100644 --- a/packages/stack-encrypt/src/target/core.rs +++ b/packages/stack-encrypt/src/target/core.rs @@ -133,11 +133,7 @@ fn collect_retrieve_requests( | CipherText::None(leaf) | CipherText::EmptySequence(leaf) | CipherText::EmptyMap(leaf) => { - out.push(Request::retrieve_under( - leaf.key_id().to_vec(), - descriptor.clone(), - leaf.keyset_id(), - )); + out.push(Request::retrieve_leaf(leaf, descriptor.clone())); } CipherText::Sequence(items) => { for item in items { diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index d3f15aead..d7c389c92 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -460,6 +460,7 @@ struct Retrieve { key_id: Vec, descriptor: Descriptor, keyset_id: KeysetId, + v1_leaf: bool, } /// Issue the batched ZeroKMS calls for `requests`: at most one @@ -481,10 +482,12 @@ async fn dispatch( key_id, descriptor, keyset_id, + v1_leaf, } => retrieves.push(Retrieve { key_id, descriptor, keyset_id, + v1_leaf, }), } } @@ -500,6 +503,18 @@ async fn dispatch( .chain(retrieves.iter().map(|retrieve| &retrieve.descriptor)) .try_for_each(Descriptor::check)?; + // A format-1 leaf's key id is ZeroKMS's `iv ‖ tag`, and only ZeroKMS ever + // sealed one. Another backend would read those bytes as its own key id, + // so the leaf is refused here, before any keyset is resolved or any key + // is asked for, unless the registry says it reads format-1 leaves. + if !K::READS_V1_LEAVES { + if let Some(retrieve) = retrieves.iter().find(|retrieve| retrieve.v1_leaf) { + return Err(Error::V1LeafNeedsZeroKms { + keyset_id: retrieve.keyset_id, + }); + } + } + let generated = if generates.is_empty() { Vec::new() } else { diff --git a/packages/stack-encrypt/src/target/request.rs b/packages/stack-encrypt/src/target/request.rs index e924d2540..4b47ddd8a 100644 --- a/packages/stack-encrypt/src/target/request.rs +++ b/packages/stack-encrypt/src/target/request.rs @@ -40,6 +40,12 @@ pub(super) enum RequestKind { key_id: Vec, descriptor: Descriptor, keyset_id: KeysetId, + /// Whether the leaf is format 1 ([`SealedValue::FORMAT_VERSION_V1`]): + /// its key id is ZeroKMS's `iv ‖ tag`, so only a registry that reads + /// format-1 leaves may be asked for it. + /// + /// [`SealedValue::FORMAT_VERSION_V1`]: crate::SealedValue::FORMAT_VERSION_V1 + v1_leaf: bool, }, } @@ -102,6 +108,19 @@ impl Request { key_id, descriptor, keyset_id, + v1_leaf: false, + }) + } + + /// The retrieve request for `leaf`'s data key, under an already rendered + /// descriptor. Carries the leaf's format, so that the dispatch can + /// refuse a format-1 leaf to a registry that does not read them. + pub(crate) fn retrieve_leaf(leaf: &crate::SealedValue, descriptor: Descriptor) -> Self { + Self(RequestKind::RetrieveDataKey { + key_id: leaf.key_id().to_vec(), + descriptor, + keyset_id: leaf.keyset_id(), + v1_leaf: leaf.format_version() == crate::SealedValue::FORMAT_VERSION_V1, }) } @@ -320,7 +339,12 @@ mod tests { key_id, descriptor, keyset_id, + v1_leaf, } => { + assert!( + !v1_leaf, + "a request built from parts is not a format-1 leaf" + ); assert_eq!(keyset_id, ks()); assert_eq!(key_id, vec![7, 8, 9]); assert_eq!(descriptor, d()); diff --git a/packages/stack-encrypt/tests/format_v1.rs b/packages/stack-encrypt/tests/format_v1.rs new file mode 100644 index 000000000..23c5ee311 --- /dev/null +++ b/packages/stack-encrypt/tests/format_v1.rs @@ -0,0 +1,473 @@ +//! Format-1 leaves, written by stack-encrypt 0.2, still open. +//! +//! stack-encrypt 0.2.0 is on crates.io and wrote the format-1 leaf layout +//! (`0x01 ‖ keyset_id ‖ iv ‖ tag_len ‖ tag ‖ ciphertext`, with the AAD +//! binding version 1 and the tag). This crate now writes format 2, and must +//! still read every format-1 leaf a 0.2 deployment stored. +//! +//! The two leaves below are REAL format-1 ciphertext. They were sealed by +//! stack-encrypt at cipherstash/stack 327b4dbce (the 0.2.0 line, before the +//! keyset registry) through its own `KeysetCipher::encrypt`, over a +//! `DataKeySource` that returned a fixed data key: every key is +//! [`DATA_KEY`], the n-th has IV `[n; 16]` and tag `v1-fixture-tag-n`, all +//! in keyset [`FIXTURE_KEYSET`]. The bytes are frozen here as 0.2 wrote +//! them. The AES-GCM nonce inside each is random, so the bytes cannot be +//! regenerated; they can only be read. +//! +//! The provider below answers for exactly those keys, and only under the +//! descriptor each was minted with, as ZeroKMS would. + +use stack_encrypt::registry::fake::FakeKeysetRegistry; +use stack_encrypt::registry::{ + Binding, BindingSupport, GeneratedDataKey, IndexKeyMaterial, IndexKeyProvider, KeyId, + KeyIsolation, KeyProvider, KeyReconstruction, KeysetRef, ProviderProtected, Resolved, +}; +use stack_encrypt::{ + nonempty, CipherText, Error, KeysetId, KeysetRegistry, SealedValue, StackCipher, + StackCipherBuilder, StackCipherText, +}; +use std::sync::{Arc, Mutex}; + +use uuid::Uuid; + +/// The keyset 0.2 sealed the fixtures under. +const FIXTURE_KEYSET: Uuid = Uuid::from_u128(0x5e7f_0000_0000_4000_8000_0000_0000_0001); +/// The data key 0.2 sealed the fixtures with. +const DATA_KEY: [u8; 32] = [0x42; 32]; + +/// `"alice@example.com"` (a `String`), sealed by 0.2 under the context +/// `"users/email"` (a `&str`). IV `[1; 16]`, tag `v1-fixture-tag-1`. +const EMAIL_V1: &str = "015e7f000000004000800000000000000101010101010101010101010101010101100076312d666978747572652d7461672d310153767116f8f0c7450ebd6cf71a4afd93d9d7d037b51a48c3ab9d68480cf3ba4dc5d6a986ccbafb60d629baf402"; +/// The descriptor 0.2 minted the email's key under. A single-part `&str` +/// context that holds a `/` renders base64-escaped. +const EMAIL_DESCRIPTOR: &str = "b64:dXNlcnMvZW1haWw="; + +/// `42u32`, sealed by 0.2 under `nonempty!("users").with("age")`. IV +/// `[2; 16]`, tag `v1-fixture-tag-2`. +const AGE_V1: &str = "015e7f000000004000800000000000000102020202020202020202020202020202100076312d666978747572652d7461672d320137b0c99a69fdf0bea484b519bbea74aa15fb59a03316bcb1cce19dea007f48af"; +const AGE_DESCRIPTOR: &str = "users/age"; + +fn unhex(hex: &str) -> Vec { + (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("hex")) + .collect() +} + +/// The key id 0.2's n-th key reads as: ZeroKMS's `iv ‖ tag`. +fn v1_key_id(n: u8) -> Vec { + let mut key_id = vec![n; 16]; + key_id.extend_from_slice(format!("v1-fixture-tag-{n}").as_bytes()); + key_id +} + +/// Answers for the fixture keys, as a ZeroKMS keyset would: the key id is +/// `iv ‖ tag`, and the key comes back only under the descriptor it was +/// minted with. New keys get fresh key ids under the same data key. +#[derive(Clone, Default)] +struct FixtureProvider { + /// The descriptors keys were minted under since the cipher was built. + minted: Arc>>>, +} + +#[derive(Debug, thiserror::Error)] +#[error("the fixture provider has no key {0:?} under that descriptor")] +struct NoSuchKey(Vec); + +impl KeyProvider<32> for FixtureProvider { + type Error = NoSuchKey; + + const RECONSTRUCTION: KeyReconstruction = KeyReconstruction::ClientAndServer; + const ISOLATION: KeyIsolation = KeyIsolation::PerValue; + const BINDING: BindingSupport = BindingSupport::Bound; + + async fn generate_keys( + &self, + bindings: &[Binding<'_>], + ) -> Result>, Self::Error> { + let mut minted = self.minted.lock().unwrap(); + Ok(bindings + .iter() + .map(|binding| { + minted.push(binding.as_bytes().to_vec()); + GeneratedDataKey { + plaintext: ProviderProtected::new(DATA_KEY), + key_id: KeyId::new(v1_key_id(0xff)), + } + }) + .collect()) + } + + async fn retrieve_keys( + &self, + keys: &[(KeyId, Binding<'_>)], + ) -> Result>, Self::Error> { + let minted = self.minted.lock().unwrap(); + let known: Vec<(Vec, &[u8])> = [ + (v1_key_id(1), EMAIL_DESCRIPTOR.as_bytes()), + (v1_key_id(2), AGE_DESCRIPTOR.as_bytes()), + ] + .into_iter() + .chain(minted.iter().map(|d| (v1_key_id(0xff), d.as_slice()))) + .collect(); + keys.iter() + .map(|(key_id, binding)| { + known + .iter() + .any(|(id, descriptor)| { + id.as_slice() == key_id.as_bytes() && *descriptor == binding.as_bytes() + }) + .then(|| ProviderProtected::new(DATA_KEY)) + .ok_or_else(|| NoSuchKey(key_id.as_bytes().to_vec())) + }) + .collect() + } +} + +impl IndexKeyProvider<32> for FixtureProvider { + type Error = NoSuchKey; + + async fn load_index_key(&self) -> Result, Self::Error> { + Ok(IndexKeyMaterial(ProviderProtected::new([0x11; 32]))) + } +} + +/// One keyset, [`FIXTURE_KEYSET`], served by [`FixtureProvider`]. It reads +/// format-1 leaves: it stands in for ZeroKMS, whose key id it shares. +#[derive(Default)] +struct FixtureRegistry(FixtureProvider); + +#[derive(Debug, thiserror::Error)] +#[error("unreachable")] +struct Never; + +impl KeysetRegistry for FixtureRegistry { + type Provider = FixtureProvider; + type Error = Never; + const READS_V1_LEAVES: bool = true; + + async fn resolve( + &self, + _keyset: &KeysetRef, + ) -> Result>, Self::Error> { + Ok(Some(Resolved { + id: KeysetId::new(FIXTURE_KEYSET), + name: None, + provider: self.0.clone(), + })) + } +} + +async fn fixture_cipher() -> StackCipher { + StackCipherBuilder::new() + .registry(FixtureRegistry::default()) + .init() + .await + .expect("build cipher") +} + +fn leaf(hex: &str) -> SealedValue { + SealedValue::from_bytes(&unhex(hex)).expect("a format-1 leaf parses") +} + +/// The most important test here: ciphertext 0.2 wrote opens under the new +/// code, to the plaintext 0.2 sealed. +#[tokio::test] +async fn real_format_1_ciphertext_from_0_2_still_decrypts() { + let cipher = fixture_cipher().await; + + let email: String = cipher + .decrypt(CipherText::Single(leaf(EMAIL_V1)), "users/email") + .await + .expect("a 0.2 leaf opens"); + assert_eq!(email, "alice@example.com"); + + let age: u32 = cipher + .decrypt( + CipherText::Single(leaf(AGE_V1)), + nonempty!("users").with("age"), + ) + .await + .expect("a 0.2 leaf opens"); + assert_eq!(age, 42); +} + +/// The context is still bound: a format-1 leaf opened under another +/// context fails, at the key retrieval (the descriptor differs) or at the +/// AEAD. +#[tokio::test] +async fn a_format_1_leaf_still_binds_its_context() { + let cipher = fixture_cipher().await; + let wrong: Result = cipher + .decrypt(CipherText::Single(leaf(EMAIL_V1)), "users/name") + .await; + assert!( + matches!(wrong, Err(Error::Provider(_) | Error::Aead)), + "{wrong:?}" + ); +} + +/// The frozen format-1 fixture decodes field by field, and writes back to +/// the same bytes. +#[test] +fn the_format_1_fixture_decodes_and_round_trips() { + let bytes = unhex(EMAIL_V1); + let leaf = SealedValue::from_bytes(&bytes).expect("parses"); + assert_eq!(leaf.format_version(), SealedValue::FORMAT_VERSION_V1); + assert_eq!(leaf.keyset_id(), KeysetId::new(FIXTURE_KEYSET)); + assert_eq!(leaf.key_id(), v1_key_id(1), "the key id is iv ‖ tag"); + assert_eq!( + leaf.ciphertext(), + &bytes[1 + 16 + 16 + 2 + "v1-fixture-tag-1".len()..] + ); + assert_eq!(leaf.to_bytes(), bytes); +} + +/// Every byte of the envelope is authenticated. Relabelling the version, +/// re-pointing the keyset, or changing the IV or the tag in storage stops +/// the leaf opening. +#[tokio::test] +async fn a_tampered_format_1_leaf_does_not_open() { + let cipher = fixture_cipher().await; + let original = unhex(EMAIL_V1); + // version byte, a keyset byte, an IV byte, a tag byte, a ciphertext byte + for offset in [0, 5, 20, 40, original.len() - 1] { + let mut bytes = original.clone(); + bytes[offset] ^= 0x01; + let opened = match SealedValue::from_bytes(&bytes) { + Ok(leaf) => { + cipher + .decrypt::(CipherText::Single(leaf), "users/email") + .await + } + Err(_) => continue, + }; + assert!(opened.is_err(), "byte {offset} flipped still opened"); + } +} + +/// A format-1 leaf whose keyset resolves through a registry that does not +/// read format 1 fails closed, with the error that says why, before any +/// key is requested. The fake registry stands in for any non-ZeroKMS +/// backend. +#[tokio::test] +async fn a_format_1_leaf_fails_closed_on_a_registry_that_does_not_read_it() { + const { assert!(!::READS_V1_LEAVES) }; + let registry = FakeKeysetRegistry::new(); + let (_, default) = registry.default_keyset(); + let cipher = StackCipherBuilder::new() + .registry(registry) + .init() + .await + .expect("build cipher"); + + let opened: Result = cipher + .decrypt(CipherText::Single(leaf(EMAIL_V1)), "users/email") + .await; + assert!( + matches!( + opened, + Err(Error::V1LeafNeedsZeroKms { keyset_id }) if keyset_id == KeysetId::new(FIXTURE_KEYSET) + ), + "{opened:?}" + ); + assert_eq!(default.call_counts(), (0, 0), "no key was asked for"); + let (_, fixture_keyset) = cipher + .registry() + .keyset(&FIXTURE_KEYSET.to_string()) + .expect("minted"); + assert_eq!(fixture_keyset.call_counts(), (0, 0)); + + // In a tree, one format-1 leaf beside format-2 leaves fails the batch. + let fresh: StackCipherText = cipher + .default_keyset() + .encrypt("new".to_string(), "users/email") + .await + .expect("seal"); + let tree: StackCipherText = + CipherText::Sequence(vec![fresh, CipherText::Single(leaf(EMAIL_V1))]); + let opened: Result, _> = cipher.decrypt(tree, "users/email").await; + assert!( + matches!(opened, Err(Error::V1LeafNeedsZeroKms { .. })), + "{opened:?}" + ); +} + +/// New leaves are format 2, and open; the same cipher opens a format-1 +/// leaf and a format-2 leaf under the same context. +#[tokio::test] +async fn new_leaves_are_format_2_beside_format_1() { + let cipher = fixture_cipher().await; + let sealed: StackCipherText = cipher + .default_keyset() + .encrypt("written now".to_string(), "users/note") + .await + .expect("seal"); + let CipherText::Single(new_leaf) = &sealed else { + panic!("a single leaf"); + }; + assert_eq!(new_leaf.format_version(), SealedValue::FORMAT_VERSION); + assert_eq!(new_leaf.to_bytes()[0], 0x02); + let back: String = cipher.decrypt(sealed, "users/note").await.expect("open"); + assert_eq!(back, "written now"); + + let fresh: StackCipherText = cipher + .default_keyset() + .encrypt("bob@example.com".to_string(), "users/email") + .await + .expect("seal"); + let new_email: String = cipher.decrypt(fresh, "users/email").await.expect("open"); + let old_email: String = cipher + .decrypt(CipherText::Single(leaf(EMAIL_V1)), "users/email") + .await + .expect("open"); + assert_eq!( + [old_email, new_email], + ["alice@example.com", "bob@example.com"], + "one cipher reads both formats" + ); +} + +/// Through ZeroKMS itself: a format-1 leaf's retrieval reaches ZeroKMS with +/// the IV and the tag 0.2 sent, split back out of the key id, under the +/// descriptor it was minted with. The stub cannot hand back the same data +/// key (that needs the client key 0.2 used), so the open itself fails at +/// the AEAD; what this pins is the request. +#[cfg(all(feature = "zerokms", not(target_arch = "wasm32")))] +#[tokio::test] +async fn a_format_1_leaf_reaches_zerokms_as_its_iv_and_tag() { + use stack_auth::StaticTokenStrategy; + use stack_kms::test_connection::{ + key_material, load_keyset_response, random_client_key, TestConnection, + TestConnectionBuilder, TEST_KEYSET_ID, + }; + use stack_kms::{ClientOpts, StackKms}; + use zerokms_protocol::{ + IdentifiedBy, LoadKeysetRequest, RetrieveKeyRequest, RetrieveKeyResponse, RetrievedKey, + }; + + type Registry = Arc>; + const { assert!(::READS_V1_LEAVES) }; + + /// What one retrieve spec sent: keyset, IV, descriptor, tag. + type Sent = (Option, Vec, String, Vec); + let seen: Arc>> = Arc::default(); + let record = Arc::clone(&seen); + let builder = TestConnectionBuilder::new() + .add_success_response::(load_keyset_response()) + .add_effect::(move |request| { + let keyset = match request.keyset_id { + Some(IdentifiedBy::Uuid(id)) => Some(id.to_string()), + _ => None, + }; + for spec in request.keys.iter() { + record.lock().unwrap().push(( + keyset.clone(), + spec.iv.as_ref().to_vec(), + spec.descriptor.to_string(), + spec.tag.to_vec(), + )); + } + }) + .add_success_response::(RetrieveKeyResponse { + keys: vec![RetrievedKey { + key_material: key_material(), + }], + }); + let kms: Registry = Arc::new( + StackKms::<_, TestConnection>::connect( + ClientOpts::new(builder), + StaticTokenStrategy::new("static-token"), + random_client_key(), + ) + .expect("connect over a test connection"), + ); + let cipher = StackCipherBuilder::new() + .registry(kms) + .init() + .await + .expect("build cipher"); + + // The real 0.2 leaf, re-pointed at the stub's keyset so the cipher + // needs no second keyset load. + let (_, key_id, ciphertext) = leaf(EMAIL_V1).into_parts(); + let (iv, tag) = key_id.split_at(16); + let leaf = SealedValue::from_v1_parts( + KeysetId::new(TEST_KEYSET_ID), + iv.try_into().expect("16 bytes"), + tag.to_vec(), + ciphertext, + ) + .expect("parts"); + + let opened: Result = cipher + .decrypt(CipherText::Single(leaf), "users/email") + .await; + assert!(matches!(opened, Err(Error::Aead)), "{opened:?}"); + assert_eq!( + *seen.lock().unwrap(), + vec![( + Some(TEST_KEYSET_ID.to_string()), + vec![1; 16], + EMAIL_DESCRIPTOR.to_owned(), + b"v1-fixture-tag-1".to_vec(), + )] + ); +} + +/// Index terms do not depend on the leaf format, and the keyset registry did +/// not change how they are derived. Under the same index key, the new code +/// derives exactly the terms 0.2 derived. These values were computed by +/// 0.2 (cipherstash/stack 327b4dbce) under the index key `[0x11; 32]`, the +/// one [`FixtureProvider`] serves, so a stored 0.2 term still matches a +/// query term the new code makes. +#[tokio::test] +async fn terms_derive_as_0_2_derived_them_under_the_same_index_key() { + use stack_encrypt::sem::DefaultMatch; + + let cipher = fixture_cipher().await; + let keyset = cipher.default_keyset(); + + let equality = keyset + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); + assert_eq!( + hex(equality.as_bytes()), + "74804bc352df939a17098372b1e2ca386d115c61e84e17648d15f90de0ff0424" + ); + + let matching = keyset + .match_terms::("alice smith", nonempty!("users/name")) + .await + .unwrap(); + assert_eq!( + matching.positions(), + [ + 17, 34, 43, 46, 47, 62, 79, 95, 106, 110, 134, 137, 139, 149, 159, 167, 173, 180, 199, + 213, 216, 224, 228, 238, 251, 255 + ] + ); + + let ore = keyset + .ore_term(42u32, nonempty!("users/age")) + .await + .unwrap(); + assert_eq!( + hex(ore.as_ref()), + "2202ee55c9365705cae9a0fb07a64040813bb4323dee587b0da617ee3b44678c" + ); + + let ope = keyset + .ope_term(42u32, nonempty!("users/age")) + .await + .unwrap(); + assert_eq!( + hex(ope.as_ref()), + "00871628f65496b8f8ba1ef74845303a0f1eb579b6a18e7a2bac586e3203ab3e4f" + ); +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} diff --git a/packages/stack-encrypt/tests/frozen_bytes.rs b/packages/stack-encrypt/tests/frozen_bytes.rs index eb34edf2c..49aef351e 100644 --- a/packages/stack-encrypt/tests/frozen_bytes.rs +++ b/packages/stack-encrypt/tests/frozen_bytes.rs @@ -1,10 +1,10 @@ //! 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 [`SealedValue`] leaf layout — format 2, +//! `version ‖ keyset_id ‖ key_id_len ‖ key_id ‖ local_ciphertext`, the +//! storage format a database column holds (format 1, which 0.2 wrote, is +//! pinned in `tests/format_v1.rs` against real 0.2 ciphertext), and //! * the index-term encodings (equality: raw 32 bytes; match: LE `u16` //! positions; ORE/OPE: raw CLLW ciphertext bytes). //! @@ -68,13 +68,14 @@ fn fixture_leaf() -> SealedValue { fn sealed_value_layout_is_pinned() { let bytes = fixture_leaf().to_bytes(); - // version(01) ‖ keyset_id(16 raw UUID bytes: ASCII "keyset-fixture16") ‖ + // version(02) ‖ keyset_id(16 raw UUID bytes: ASCII "keyset-fixture16") ‖ // key_id_len(0300 — 3, u16 LE) ‖ key_id(aabbcc) ‖ // local_ciphertext(deadbeef) assert_eq!( hex(&bytes), - "016b65797365742d6669787475726531360300aabbccdeadbeef" + "026b65797365742d6669787475726531360300aabbccdeadbeef" ); + assert_eq!(SealedValue::FORMAT_VERSION, 2); } #[test] @@ -96,10 +97,19 @@ fn sealed_value_from_bytes_inverts_to_bytes() { #[test] fn sealed_value_rejects_unknown_version() { let mut bytes = fixture_leaf().to_bytes(); - bytes[0] = 2; + for version in [0, 3, 0xff] { + bytes[0] = version; + assert!(matches!( + SealedValue::from_bytes(&bytes), + Err(LeafBytesError::UnknownVersion(v)) if v == version + )); + } + // Version 1 is known, and reads the format-1 layout. These format-2 + // bytes are too short for it. + bytes[0] = 1; assert!(matches!( SealedValue::from_bytes(&bytes), - Err(LeafBytesError::UnknownVersion(2)) + Err(LeafBytesError::Truncated) )); } From 07e1f2dfc3874c61392656d72e45e502e3788af6 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Tue, 6 Oct 2026 10:48:00 +1100 Subject: [PATCH 7/8] docs(stack-encrypt): changelog the keyset registry and the format-2 leaf The breaking and added entries for this change, appended to the Unreleased sections the earlier PRs wrote: in stack-encrypt's changelog, and in stack-kms's for the deleted DataKeySource, IndexKeySource and FakeDataKeySource. The Unreleased entry that named Error::Kms now names Error::Provider, its replacement. AGENTS.md says the stack_encrypt::kms re-export needs the zerokms feature, and a Go comment names StackCipherBuilder::new(). --- AGENTS.md | 2 +- languages/golang/stackencrypt/credentials.go | 2 +- packages/stack-encrypt/CHANGELOG.md | 64 +++++++++++++++++++- packages/stack-kms/CHANGELOG.md | 15 +++++ 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d159d229b..5e388ae00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ 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, one version group), `stack-kms` (published to crates.io from 0.1.0, its own version group, re-exported by `stack-encrypt` as `stack_encrypt::kms`), `stack-encrypt` and `stack-encrypt-derive` (published to crates.io from 0.1.0, one version group; `eql-bindings`' `stack-encrypt` feature depends on them from the registry), and `stack-guest-abi` (`publish = false`). They are the members of the **root Cargo workspace**, with the three node binding crates below. See "Working on the Rust crates". +- `packages/stack-auth`, `packages/stack-profile`, `packages/stack-kms`, `packages/stack-encrypt`, `packages/stack-encrypt-derive`, `packages/stack-guest-abi`: The Rust crates imported from `cipherstash/cipherstash-suite` with their history — `stack-auth` and `stack-profile` (published to crates.io, one version group), `stack-kms` (published to crates.io from 0.1.0, its own version group, re-exported by `stack-encrypt` as `stack_encrypt::kms` when its `zerokms` feature is on, which it is by default), `stack-encrypt` and `stack-encrypt-derive` (published to crates.io from 0.1.0, one version group; `eql-bindings`' `stack-encrypt` feature depends on them from the registry), and `stack-guest-abi` (`publish = false`). They are the members of the **root Cargo workspace**, with the three node binding crates below. See "Working on the Rust crates". - `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm from this repository by `release.yml` (`auth-artifacts`, `publish-auth`); a change to what it ships, the `stack-auth` crate included, needs an `@cipherstash/auth` changeset (`require-auth-npm-changeset.yml`). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. - `languages/golang`: The Go module (`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) diff --git a/languages/golang/stackencrypt/credentials.go b/languages/golang/stackencrypt/credentials.go index c1669d6c3..51b639276 100644 --- a/languages/golang/stackencrypt/credentials.go +++ b/languages/golang/stackencrypt/credentials.go @@ -168,7 +168,7 @@ 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 +// (stack-encrypt's StackCipherBuilder::new().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 diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 2c292ba83..b838190ac 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -35,7 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 keys the field under `("", "")` (`users/nickname`), not under the bare literal (`nickname`). Either way the field moves to a different context from the one 0.2.0 used: data written by 0.2.0 does not - decrypt under it (ZeroKMS refuses the key, `Error::Kms`) and its equality, + decrypt under it (ZeroKMS refuses the key, `Error::Provider`) and its equality, match and order terms do not match new query terms, so re-encrypt that data. Before the literal went, a non-plain literal such as `"readings/unit"` was already refused, for the same reason. @@ -81,6 +81,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (two `EqualityTerm`s), or a field type that declares one twice. 0.2.0 wrote the same term twice. +- **New sealed leaves are format 2, which 0.2.0 cannot read.** The leaf + layout carries the key provider's opaque key id in place of ZeroKMS's IV + and tag: `0x02 ‖ keyset_id ‖ key_id_len (u16 LE) ‖ key_id ‖ ciphertext`, + with the leaf AAD binding version 2 and the whole key id. + `SealedValue::FORMAT_VERSION` is `2`. Leaves 0.1.0 and 0.2.0 wrote + (format 1) still decrypt, through ZeroKMS: see Added. Upgrade every reader + before any writer, because a 0.2.0 reader refuses a format-2 leaf with + `LeafBytesError::UnknownVersion(2)`. +- **Keysets resolve through a `KeysetRegistry`.** `StackCipher` replaces `StackCipher`: a keyset + resolves to one `vitaminc-kms` key provider, so the cipher runs on ZeroKMS + or on another data key source without naming either. On ZeroKMS the + registry is `Arc`. `StackCipherBuilder::kms` is now `registry`, + and `StackCipher::builder()` is `StackCipherBuilder::new()`. +- `SealedValue`'s parts are `(KeysetId, key_id, ciphertext)`: `from_parts` + and `into_parts` take and return them, and `iv()` and `tag()` are + `key_id()`. `LeafBytesError::TagTooLong` is `KeyIdTooLong` for a format-2 + key id (`TagTooLong` remains for a format-1 tag). +- `KeysetId`, a newtype over `Uuid`, replaces `Uuid` wherever a keyset id + appears (`SealedValue::keyset_id`, `KeysetCipher::keyset_id`, + `Error::KeysetMismatch`, `Error::ForeignKeyset`). `KeysetRef { Default, + Id, Name }` replaces stack-kms's `IdentifiedBy` in this crate's API; a + `Uuid`, a `KeysetId` or a name converts into it. +- `Error::Kms(stack_kms::Error)` is gone. A key provider's failure is + `Error::Provider` and a registry's is `Error::Registry`, each boxed; on + ZeroKMS, downcast to `stack_kms::Error`. A keyset the registry does not + have is `Error::UnknownKeyset`. +- **Every `K` that names a cipher is now a `KeysetRegistry`.** + `EncryptFrom::encryption`, `DecryptInto::decryption`, + `DecryptField::decryption_field` and every function that returns an + `Encryption`, a `Decryption` or a `Pending` bound their `K` by it, so a + hand-written impl adds `K: KeysetRegistry`. Derived impls do it for you. + `plan::PlanKms` is now a `KeysetRegistry` whose provider is `Send + Sync`. +- `stack_encrypt::kms` (the stack-kms re-export) and `impl KeysetRegistry + for Arc` need the new `zerokms` feature. It is on by default, + and `http` turns it on. With it off, no ZeroKMS crate is in the graph. +- `test-support` now provides `registry::fake::FakeKeysetRegistry` (many + keysets, each with its own key material and index key) in place of + stack-kms's `FakeDataKeySource`, which is deleted. The fake provider is + `Bound`, so a wrong context fails at the key retrieval + (`Error::Provider`), as on ZeroKMS, and not at the AEAD. +- `Plan::context`, `Plan::fields` and `Plan::value` are associated + functions of `Plan<(), NoRegistry>` (was `Plan<(), ()>`). The calls do not + change. +- Requires stack-kms 0.2.0. + ### Added - `KeysetCipher::run`: run a description held in a variable over a value, @@ -189,6 +235,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the one every element accepts. A tuple of ciphertexts takes an `AeadContext`; a ciphertext beside a term takes a `CallerContext`. +- **Format-1 leaves, which 0.1.0 and 0.2.0 wrote, still decrypt.** + `SealedValue::from_bytes` reads the format-1 layout + (`0x01 ‖ keyset_id ‖ iv ‖ tag_len ‖ tag ‖ ciphertext`) and forms the key + id `iv ‖ tag`, which is what the ZeroKMS provider uses. The leaf opens + under the format-1 AAD and writes back in the format-1 layout, so the + stored bytes do not change. `SealedValue::FORMAT_VERSION_V1`, + `SealedValue::format_version` and `SealedValue::from_v1_parts` (for + parts stored from 0.2.0's `into_parts`) are new. +- `KeysetRegistry::READS_V1_LEAVES` and `Error::V1LeafNeedsZeroKms`: only + ZeroKMS wrote format-1 leaves, so a registry that is not ZeroKMS refuses + one before it asks for a key. `Arc` reads them. +- `registry`: `KeysetRegistry`, `Resolved`, `KeysetId`, `KeysetRef`, + `NoRegistry`, and the `vitaminc-kms` provider types an implementor names. +- `StackCipher::registry`, and `FakeKeysetRegistry::resolves` (a counter of + keyset lookups) for tests. + ## [0.2.0] - 2026-10-04 ### Breaking diff --git a/packages/stack-kms/CHANGELOG.md b/packages/stack-kms/CHANGELOG.md index 3bb7c1aba..5b14e0eb7 100644 --- a/packages/stack-kms/CHANGELOG.md +++ b/packages/stack-kms/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Breaking + +- **`DataKeySource`, `IndexKeySource` and `FakeDataKeySource` are deleted.** + A client library is generic over the `vitaminc-kms` key provider traits + instead (`KeyProvider`, `IndexKeyProvider`), which `ZeroKmsKeyset` + implements. stack-encrypt 0.3 resolves keysets through its own + `KeysetRegistry`, and its `test-support` feature provides the fake that + replaces `FakeDataKeySource` (`stack_encrypt::registry::fake`). +- The `test-support` feature no longer provides `FakeDataKeySource`. It + provides `test_connection` (see Added). + ### Added - `ZeroKmsKeyset`: one ZeroKMS keyset, resolved once over a shared @@ -17,6 +28,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 descriptor. Its `KeyId` is the IV followed by the tag. - `Error::BindingNotUtf8` and `Error::MalformedKeyId`, for a binding or a key id that `ZeroKmsKeyset` cannot use. +- `test_connection` (with `test-support`): the in-memory + `ZeroKMSConnection` the crate's own tests use, with its client-key and + load-keyset fixtures. stack-encrypt owns `impl KeysetRegistry for + Arc` and tests it through this, without a network. ### Changed From d87a809a46f595b435cad16c24a2a01ac9b6f403 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Tue, 6 Oct 2026 11:16:04 +1100 Subject: [PATCH 8/8] test(stack-encrypt): answer the mutants gate on the format-1 guard and NoRegistry The gate found nine surviving mutants. Two were in the format-1 tag-length guard on the serde path: no test fed it a tag at the u16 edge. The serde test now feeds a tag of exactly u16::MAX bytes, which is accepted, and one byte more, which is refused. Seven were in the bodies of NoRegistry's trait methods. NoRegistry is uninhabited, so those bodies cannot run and no test can kill a mutant there. They are excluded in .cargo/mutants.toml as unreachable, by name. The stale entry for StackCipher::builder goes too: that function no longer exists. --- .cargo/mutants.toml | 4 +++- packages/stack-encrypt/src/cipher.rs | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index c15952b75..f3278685f 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -68,8 +68,10 @@ exclude_re = [ # 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 — unreachable: `NoRegistry` is uninhabited (it only + # anchors `Plan::context`), so no value exists to call these on. + 'stack-encrypt/src/registry\.rs:\d+:\d+: replace |IndexKeyProvider<32>) for NoRegistry>::(resolve|generate_keys|retrieve_keys|load_index_key) ', # 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\(\)$', diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 964ce73cf..a0a40cad1 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -2568,6 +2568,16 @@ mod tests { serde_json::from_value::(value.clone()).is_err(), "a format-1 key id shorter than its IV is refused" ); + // The format-1 tag must fit its `u16` length field: a tag of exactly + // `u16::MAX` bytes does, one more does not. + value["key_id"] = serde_json::to_value(vec![0u8; 16 + usize::from(u16::MAX)]).unwrap(); + let at_limit: SealedValue = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(at_limit.format_version(), SealedValue::FORMAT_VERSION_V1); + value["key_id"] = serde_json::to_value(vec![0u8; 16 + usize::from(u16::MAX) + 1]).unwrap(); + assert!( + serde_json::from_value::(value.clone()).is_err(), + "a format-1 tag one byte over the length field is refused" + ); value["version"] = 9.into(); assert!(serde_json::from_value::(value).is_err()); }