From 6f0b092699e5e0e9bf552aa12308da8e3def3fa1 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 23:06:49 +1100 Subject: [PATCH 01/17] feat(stack-encrypt): encrypt and decrypt owned Vec collections as one batch `Vec` now implements `EncryptTarget>` and `DecryptSource>` for any owned element target, so a column of owned plaintexts reaches `EncryptInto` and `DecryptFrom` directly: `Vec` to `Vec` needs no caller context, and the column's `ContextInput` is the element's, so a `RequiredContext` target still cannot be omitted and a `NoCallerContext` target still cannot be handed one. `Default` plays no part in either. The element declaration runs once per element over a borrow of the guarded vector, so no `Clone` bound is added and the source is wiped before the pending is returned, including when the context is refused or its conversion panics. The elements' key requests merge into one logical batch in element order, with no request for an empty column; each element is sealed under its own key and authenticated on its own, so order and length are deliberately not bound (ADR-0010). A collection of recovered plaintext that fails after some elements were opened now wipes that prefix before returning the error: `Pending::collect_wiping` and `Decryption::all_wiping` sit beside the unchanged `collect` and `all`. Removing the wipe fails both the drop-witness test and the heap observer, so the guard is load-bearing. `Vec` stays a compile error: Vitamin C gives `u8` no source or target because it keeps `Vec` a byte leaf, so a byte vector is neither a column of bytes nor one ciphertext here; a whole byte vector is blob work. Nullable elements stay on `encrypt_tree`. Both refusals are pinned in `tests/ui`. Refs #1482 --- packages/stack-encrypt/CHANGELOG.md | 23 + packages/stack-encrypt/examples/scalar.rs | 13 + packages/stack-encrypt/src/lib.rs | 47 ++ packages/stack-encrypt/src/scalar.rs | 8 +- .../stack-encrypt/src/target/collections.rs | 71 +++ packages/stack-encrypt/src/target/mod.rs | 14 +- .../stack-encrypt/src/target/operations.rs | 53 ++- packages/stack-encrypt/src/target/pending.rs | 43 +- packages/stack-encrypt/tests/collections.rs | 415 ++++++++++++++++++ .../tests/ui/owned_collection_context.rs | 46 ++ .../tests/ui/owned_collection_context.stderr | 89 ++++ .../tests/ui/owned_collection_elements.rs | 12 + .../tests/ui/owned_collection_elements.stderr | 71 +++ .../tests/ui/pass/owned_collections.rs | 89 ++++ 14 files changed, 976 insertions(+), 18 deletions(-) create mode 100644 packages/stack-encrypt/src/target/collections.rs create mode 100644 packages/stack-encrypt/tests/collections.rs create mode 100644 packages/stack-encrypt/tests/ui/owned_collection_context.rs create mode 100644 packages/stack-encrypt/tests/ui/owned_collection_context.stderr create mode 100644 packages/stack-encrypt/tests/ui/owned_collection_elements.rs create mode 100644 packages/stack-encrypt/tests/ui/owned_collection_elements.stderr create mode 100644 packages/stack-encrypt/tests/ui/pass/owned_collections.rs diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 74b5d3df6..0fcd43e49 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -18,6 +18,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the final extension alone for both reads and queries of old data, or re-encrypt and re-index it under the appended context. This does not migrate stored data. +### Added + +- **Owned collections: `Vec` encrypts into `Vec` and back through + `EncryptInto` and `DecryptFrom`** (#1482). `Vec` implements + `EncryptTarget>` and `DecryptSource>` for any owned element + target, so `vec![29i32, 34].encrypt_into::, _>(&keyset)` + needs no caller context and `Vec::::decrypt_from(column, &keyset)` + opens it. The column's `ContextInput` is the element's: a column of a + `RequiredContext` target still cannot omit its context, a column of a + `NoCallerContext` target still cannot be given one, and `Default` grants + neither. Each element is sealed under its own data key and authenticated + on its own, in one logical batch per column (one `generate_keys` call, + one `retrieve_keys` call per keyset, element order kept, no call for an + empty column; the key source may still split a logical batch into several + provider requests). The vector is held in one guard and wiped before the + pending request is returned, including when the context is refused, with + no `Clone` bound; a column that fails after some elements were recovered + wipes those before returning the error. `Vec` is not a column of + scalars: `u8` is not `OpaqueEncode`, because Vitamin C keeps `Vec` a + byte leaf, so it compiles to neither a column of bytes nor one ciphertext. + Direct `keyset.encrypt` still takes one scalar; `encrypt_tree` remains the + structural path. + ## [0.3.0] - 2026-10-27 ### Breaking diff --git a/packages/stack-encrypt/examples/scalar.rs b/packages/stack-encrypt/examples/scalar.rs index 1d11733cd..20b30c8d8 100644 --- a/packages/stack-encrypt/examples/scalar.rs +++ b/packages/stack-encrypt/examples/scalar.rs @@ -29,5 +29,18 @@ async fn main() -> Result<(), Box> { .await?; let value = i32::decrypt_from_with_context(encrypted, &keyset, ("users", "age")).await?; assert_eq!(value, 42); + + // A column of scalars: one logical batch, each element its own ciphertext. + let ages: Vec = vec![29i32, 34, 41].encrypt_into(&keyset).await?; + let one: i32 = keyset.decrypt(ages[1].clone()).await?; + assert_eq!(one, 34); + let values = Vec::::decrypt_from(ages, &keyset).await?; + assert_eq!(values, [29, 34, 41]); + + let names: Vec = vec!["ada".to_owned(), "grace".to_owned()] + .encrypt_into_with_context(&keyset, ("users", "name")) + .await?; + let names = Vec::::decrypt_from_with_context(names, &keyset, ("users", "name")).await?; + assert_eq!(names, ["ada", "grace"]); Ok(()) } diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index fa25af1c8..5277378f8 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -239,6 +239,53 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); //! ``` //! +//! # Collections +//! +//! A `Vec` of owned plaintexts encrypts into a `Vec` of any owned target +//! through the same two traits, with or without a caller context, and back. +//! Each element is sealed under its own data key and authenticated on its +//! own: the elements are rows, so neither their order nor their number is +//! authenticated, and any one of them opens alone through +//! [`KeysetCipher::decrypt`]. The whole column is one logical batch: one +//! `generate_keys` call to seal it, one `retrieve_keys` call per keyset to +//! open it, in element order, and no call at all for an empty column. The +//! key source may still split a logical batch across several provider +//! requests. The column is held in one guard and wiped before the pending +//! request is returned, as a scalar is, and a column that fails after some +//! elements were recovered wipes those before reporting the error. +//! +//! ``` +//! # async fn example() -> Result<(), stack_encrypt::Error> { +//! use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto, StackCipher}; +//! let cipher = StackCipher::builder() +//! .kms(stack_encrypt::kms::FakeDataKeySource::new()).init().await?; +//! let keyset = cipher.default_keyset(); +//! +//! // No caller context: every element seals under the empty context. +//! let ages: Vec = vec![29i32, 34, 41].encrypt_into(&keyset).await?; +//! assert_eq!(Vec::::decrypt_from(ages.clone(), &keyset).await?, [29, 34, 41]); +//! // An element is an ordinary ciphertext. +//! assert_eq!(keyset.decrypt::(ages[1].clone()).await?, 34); +//! +//! // With a caller context, bound to every element. +//! let names: Vec = vec!["ada".to_owned(), "grace".to_owned()] +//! .encrypt_into_with_context(&keyset, ("users", "name")) +//! .await?; +//! let names = Vec::::decrypt_from_with_context(names, &keyset, ("users", "name")).await?; +//! assert_eq!(names, ["ada", "grace"]); +//! # Ok(()) +//! # } +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); +//! ``` +//! +//! The column takes the element target's caller-context capability as its +//! own, so a column of a target that requires a context cannot omit it, and +//! a column of a target that accepts none cannot be given one. A `Vec` +//! is not a column of scalars: `u8` is not an [`OpaqueEncode`] type, since +//! Vitamin C keeps `Vec` a byte leaf and gives `u8` no source of its +//! own, so `Vec` neither encrypts byte by byte nor as one `Ciphertext` +//! here. +//! //! Existing structural plans and derives still use [`StackCipherText`], a tree //! of sealed leaves. Their low-level direct entry points are //! [`KeysetCipher::encrypt_tree`] and [`KeysetCipher::decrypt_tree`]; their diff --git a/packages/stack-encrypt/src/scalar.rs b/packages/stack-encrypt/src/scalar.rs index b5aadf1f8..a31430311 100644 --- a/packages/stack-encrypt/src/scalar.rs +++ b/packages/stack-encrypt/src/scalar.rs @@ -23,9 +23,11 @@ mod sealed { /// Implemented for `bool`, the fixed-width signed integers, the unsigned /// integers from `u16` through `u128`, `f32`, `f64` and `String`. The trait is /// sealed: collections and borrowed values deliberately do not implement it. -/// `u8` waits on separating Vitamin C's byte-buffer and element -/// representations, and must not make `Vec` an implicit single -/// ciphertext. +/// A `Vec` of these encrypts element by element into a `Vec` +/// through [`EncryptInto`] instead; see the crate docs on collections. +/// `u8` is deliberately absent: Vitamin C keeps `Vec` a byte leaf and +/// gives `u8` no source of its own, so a byte vector is neither a column of +/// scalars nor an implicit single ciphertext here. /// Nullable `Option` values are also deferred in this scalar slice. Use /// [`KeysetCipher::encrypt_tree`] and [`KeysetCipher::decrypt_tree`] for them; /// fixed-width nullable values retain Vitamin C's padded-null representation. diff --git a/packages/stack-encrypt/src/target/collections.rs b/packages/stack-encrypt/src/target/collections.rs new file mode 100644 index 000000000..ea1e8056a --- /dev/null +++ b/packages/stack-encrypt/src/target/collections.rs @@ -0,0 +1,71 @@ +//! Owned collections: `Vec` encrypts into `Vec` as one logical batch, +//! and `Vec` decrypts back, through the element's own declaration. +//! +//! A collection declares nothing of its own. Its `ContextInput` is the +//! element's, so whether a caller may omit or must supply a context is +//! decided once, by the element target, and holds for the column: a +//! `Vec` is reached with no caller context, a column of a +//! `RequiredContext` target is not. `Default` plays no part in that. +//! +//! Each element is sealed under its own ZeroKMS data key and authenticated +//! on its own. Neither the collection's length nor the order of its +//! elements is authenticated: stored values are rows and columns, which +//! come back in any order and in part. The elements' key requests are +//! merged into one logical batch ([`Pending::collect`]), in element order, +//! and an empty collection makes no request at all. The key source may +//! still split one logical batch into several provider requests. +//! +//! A `Vec` is not a collection here: `u8` is not an opaque scalar, since +//! Vitamin C keeps `Vec` a byte leaf and gives `u8` no source of its own, +//! so neither `vec![1u8].encrypt_into::, _>(..)` nor a whole +//! byte vector as one `Ciphertext` compiles. A nullable element, `Option`, +//! is likewise not an opaque scalar in this slice. +use super::{DecryptSource, Decryption, EncryptTarget, Encryption, Pending}; +use stack_kms::MaybeSend; +use zeroize::Zeroize; + +impl> EncryptTarget> for Vec +where + T::ContextInput: Clone + MaybeSend + 'static, +{ + /// The element's: a column may omit or must supply exactly what one + /// element may or must. + type ContextInput = T::ContextInput; + fn encryption<'s, K: 'static>() -> Encryption<'s, Vec, Self, K, Self::ContextInput> + where + S: 's, + { + // Each element is handed by reference to the element declaration, + // which runs in its usual borrowed mode: the owned conversion holds + // the whole vector in its guard, and nothing is cloned. The + // collection stops declaring at the first element that fails, so a + // refused context is reported once rather than once per row. + Encryption::from_build( + move |source: &'s Vec, cipher, context: T::ContextInput| { + Pending::collect( + cipher, + source + .iter() + .map(|element| cipher.run(T::encryption(), element, context.clone())), + ) + }, + ) + } +} + +impl> DecryptSource> for Vec +where + T::ContextInput: Clone, +{ + /// The element's, as on the encrypt side. + type ContextInput = T::ContextInput; + fn decryption(self, context: Self::ContextInput) -> Decryption, K> { + // Every element's keys are retrieved in one batch and the results + // keep element order. An element that fails after its key arrived + // fails the column, and the elements recovered before it are wiped. + Decryption::all_wiping( + self.into_iter() + .map(|element| element.decryption(context.clone())), + ) + } +} diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 79df136cd..430db8f36 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -4,6 +4,9 @@ //! executed by [`EncryptInto`] and [`DecryptFrom`]. Their `ContextInput` //! capabilities explicitly allow omission or supply; `Default` does not //! determine either. [`scalar_ciphertext`] is the first owned operation. +//! A `Vec` of owned plaintexts encrypts into a `Vec` of any owned +//! target, under the element's `ContextInput`, as one logical batch of +//! independently authenticated elements; `Vec` decrypts back the same way. //! //! # Structural API during migration //! @@ -79,10 +82,12 @@ //! //! # Collections and authentication //! -//! `Vec` describes independently encrypted rows under the same context. -//! Encrypting a plaintext `Vec` into `StackCipherText` instead follows Vitamin -//! C's native sequence model, including its authenticated empty marker. The same -//! distinction applies to `Option` versus a native encrypted option. +//! `Vec` describes independently encrypted rows under the same context, +//! on the owned path (`Vec` into a `Vec` of [`EncryptTarget`]s) as on the +//! structural one. Encrypting a plaintext `Vec` into `StackCipherText` instead +//! follows Vitamin C's native sequence model, including its authenticated empty +//! marker. The same distinction applies to `Option` versus a native +//! encrypted option. //! Readers preserve marker ciphertext and map keys; changing a key changes the //! authenticated context when opening. Passthrough metadata and query terms are //! not presented as AEAD-authenticated values. @@ -95,6 +100,7 @@ //! `cipher.decrypt_as(record, context)`, or import [`legacy::EncryptInto`] and //! [`legacy::DecryptFrom`] while migrating these declarations. New scalar //! targets implement [`EncryptTarget`] and [`DecryptSource`]. +mod collections; mod context; mod conversion; pub(crate) mod core; diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index cc50c253e..3bfce0e98 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -165,6 +165,20 @@ impl fmt::Debug for Decryption { impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's, M: SourceMode<'s, S>> Encryption<'s, S, T, K, Ctx, M> { + /// A description from its execution closure. Crate-internal: this is + /// the constructor that hands a closure the source and the cipher + /// together, which the public constructors never do. The collection + /// declarations use it to run an element's description once per element. + pub(crate) fn from_build(build: F) -> Self + where + F: for<'a, 'k> FnOnce(M::Source, &'a KeysetCipher<'k, K>, Ctx) -> Pending<'a, T, K> + + MaybeSend + + 's, + { + Self { + build: Box::new(build), + } + } /// A description whose output is already known — metadata a record /// carries, or a declaration rejected before any key request. pub fn ready(result: Result) -> Self @@ -396,9 +410,7 @@ where + MaybeSend + 's, { - Encryption { - build: Box::new(choose), - } + Encryption::from_build(choose) } /// A description that only checks the borrowed source, yielding `()` or the @@ -808,19 +820,46 @@ impl Decryption { /// built. A `Vec` whose rows validate a stored context does not go on /// validating rows once one has been refused. pub fn all(items: I) -> Decryption, K> + where + I: IntoIterator, + { + match Self::openings(items) { + Err(error) => Decryption::failed(error), + Ok(opens) => Decryption::open(Box::new(move |cipher| { + Pending::collect(cipher, opens.into_iter().map(|open| open(cipher))) + })), + } + } + /// [`all`](Self::all) for recovered plaintext: when a later item fails + /// after its key arrived, the items recovered before it are wiped + /// rather than dropped as they are. The owned `Vec` conversions + /// open through here. + pub(crate) fn all_wiping(items: I) -> Decryption, K> + where + I: IntoIterator, + T: zeroize::Zeroize, + { + match Self::openings(items) { + Err(error) => Decryption::failed(error), + Ok(opens) => Decryption::open(Box::new(move |cipher| { + Pending::collect_wiping(cipher, opens.into_iter().map(|open| open(cipher))) + })), + } + } + /// The openings of `items`, up to the first failed description, which + /// is returned instead: the descriptions after it are never built. + fn openings(items: I) -> Result>, Error> where I: IntoIterator, { let mut opens = Vec::new(); for item in items { match item.inner { - Opening::Failed(error) => return Decryption::failed(error), + Opening::Failed(error) => return Err(error), Opening::Open(open) => opens.push(open), } } - Decryption::open(Box::new(move |cipher| { - Pending::collect(cipher, opens.into_iter().map(|open| open(cipher))) - })) + Ok(opens) } /// An absent item recovers as `None` without I/O. fn optional(item: Option) -> Decryption, K> diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index b62935126..e9eff44d7 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -339,6 +339,34 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { pub(crate) fn collect( scope: impl CipherScope<'a, K>, items: impl IntoIterator>, + ) -> Pending<'a, Vec, K> { + Self::collect_releasing(scope, items, drop) + } + + /// [`collect`](Self::collect) for a collection of recovered plaintext. + /// When a later item fails at fulfilment (an authentication or type + /// failure after its key arrived), the items recovered before it are + /// wiped before the error is returned, not dropped as they are. A + /// failure before fulfilment recovers nothing, so there is nothing to + /// wipe. + pub(crate) fn collect_wiping( + scope: impl CipherScope<'a, K>, + items: impl IntoIterator>, + ) -> Pending<'a, Vec, K> + where + T: zeroize::Zeroize, + { + Self::collect_releasing(scope, items, |mut recovered| { + zeroize::Zeroize::zeroize(&mut recovered) + }) + } + + /// The one collection builder. `release` is handed the items fulfilled + /// before a later item failed; it is not called when every item succeeds. + fn collect_releasing( + scope: impl CipherScope<'a, K>, + items: impl IntoIterator>, + release: impl FnOnce(Vec) + MaybeSend + 'a, ) -> Pending<'a, Vec, K> { let items = items.into_iter(); let cipher = scope.cipher(); @@ -365,10 +393,17 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { requests, failed: None, fulfil: Box::new(move |responses| { - fulfils - .into_iter() - .map(|fulfil| fulfil(responses)) - .collect() + let mut recovered = Vec::with_capacity(fulfils.len()); + for fulfil in fulfils { + match fulfil(responses) { + Ok(item) => recovered.push(item), + Err(error) => { + release(recovered); + return Err(error); + } + } + } + Ok(recovered) }), } } diff --git a/packages/stack-encrypt/tests/collections.rs b/packages/stack-encrypt/tests/collections.rs new file mode 100644 index 000000000..84743ae3f --- /dev/null +++ b/packages/stack-encrypt/tests/collections.rs @@ -0,0 +1,415 @@ +//! Owned collections: `Vec` encrypts into `Vec` and back as one +//! logical batch of independently authenticated elements. No service +//! credentials are needed. +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use stack_encrypt::target::{ + AcceptsContext, DecryptSource, Decryption, EncryptTarget, Encryption, NoCallerContext, + OptionalContext, RequiredContext, ScalarContext, +}; +use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto, Error}; +mod common; + +#[tokio::test] +async fn a_column_round_trips_without_caller_context_in_one_batch() { + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let ages = vec![3i32, -1, 42, 0, 7]; + + let column: Vec = ages.clone().encrypt_into(&keyset).await.unwrap(); + assert_eq!(column.len(), ages.len()); + assert_eq!( + generates.load(Ordering::SeqCst), + 1, + "five elements share one generate_keys call" + ); + + let opened = Vec::::decrypt_from(column.clone(), &keyset) + .await + .unwrap(); + assert_eq!(opened, ages, "order is preserved"); + assert_eq!( + retrieves.load(Ordering::SeqCst), + 1, + "five elements share one retrieve_keys call" + ); + + // Every element is an ordinary opaque ciphertext: the scalar API opens + // it on its own, under the same empty context. + for (element, age) in column.into_iter().zip(ages) { + assert_eq!(keyset.decrypt::(element).await.unwrap(), age); + } + + let names = vec!["ada".to_owned(), "grace".to_owned()]; + let column: Vec = names.clone().encrypt_into(&keyset).await.unwrap(); + assert_eq!( + Vec::::decrypt_from(column, &cipher).await.unwrap(), + names + ); +} + +#[tokio::test] +async fn a_column_round_trips_with_caller_context() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let ages = vec![29i32, 34, 41]; + + let column: Vec = ages + .clone() + .encrypt_into_with_context(&keyset, ("users", "age")) + .await + .unwrap(); + assert_eq!( + Vec::::decrypt_from_with_context(column.clone(), &keyset, ("users", "age")) + .await + .unwrap(), + ages + ); + // The context is each element's own AAD, as the scalar API binds it. + assert_eq!( + keyset + .decrypt_with_context::(column[1].clone(), ("users", "age")) + .await + .unwrap(), + 34 + ); + assert!(matches!( + Vec::::decrypt_from(column.clone(), &keyset).await, + Err(Error::Aead) + )); + assert!(matches!( + Vec::::decrypt_from_with_context(column, &keyset, ("users", "name")).await, + Err(Error::Aead) + )); +} + +#[tokio::test] +async fn an_empty_column_makes_no_key_request() { + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let column: Vec = Vec::::new().encrypt_into(&keyset).await.unwrap(); + assert!(column.is_empty()); + let opened = Vec::::decrypt_from(column, &keyset).await.unwrap(); + assert!(opened.is_empty()); + assert_eq!(generates.load(Ordering::SeqCst), 0); + assert_eq!(retrieves.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn elements_are_authenticated_on_their_own_not_as_a_sequence() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let column: Vec = vec![1i32, 2, 3].encrypt_into(&keyset).await.unwrap(); + + // Neither order nor length is authenticated: stored values are rows, + // which come back in any order and in part. + let mut reordered = column.clone(); + reordered.reverse(); + assert_eq!( + Vec::::decrypt_from(reordered, &keyset).await.unwrap(), + [3, 2, 1] + ); + assert_eq!( + Vec::::decrypt_from(column[..2].to_vec(), &keyset) + .await + .unwrap(), + [1, 2] + ); + + // Each element is: one altered byte fails the column. + let mut bytes = column[1].to_bytes(); + *bytes.last_mut().unwrap() ^= 1; + let mut tampered = column.clone(); + tampered[1] = Ciphertext::from_bytes(&bytes).unwrap(); + assert!(matches!( + Vec::::decrypt_from(tampered, &keyset).await, + Err(Error::Aead) + )); + + // The element's type is its own too: an element of another type in + // the column is a shape refusal, not tampering. + let mut mixed = column.clone(); + mixed[2] = keyset.encrypt("three".to_owned()).await.unwrap(); + assert!(matches!( + Vec::::decrypt_from(mixed, &keyset).await, + Err(Error::OpenedType { .. }) + )); +} + +#[tokio::test] +async fn a_foreign_keyset_element_is_refused_before_retrieval() { + let (cipher, _, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let other = cipher + .keyset(stack_encrypt::kms::IdentifiedBy::Uuid(uuid::Uuid::new_v4())) + .await + .unwrap(); + let column: Vec = vec![1i32, 2].encrypt_into(&keyset).await.unwrap(); + assert!(matches!( + Vec::::decrypt_from(column, &other).await, + Err(Error::ForeignKeyset { .. }) + )); + assert_eq!(retrieves.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn nested_columns_settle_in_one_batch_and_keep_their_shape() { + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let rows = vec![vec![1i32, 2], vec![], vec![3]]; + let column: Vec> = rows.clone().encrypt_into(&keyset).await.unwrap(); + assert_eq!(column.iter().map(Vec::len).collect::>(), [2, 0, 1]); + assert_eq!(generates.load(Ordering::SeqCst), 1); + assert_eq!( + Vec::>::decrypt_from(column, &keyset) + .await + .unwrap(), + rows + ); + assert_eq!(retrieves.load(Ordering::SeqCst), 1); +} + +// A target that requires a caller context, even though `u64: Default`. +struct BoundInteger(Ciphertext); +impl EncryptTarget for BoundInteger { + type ContextInput = RequiredContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, i32, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext() + .map(Self) + .accepting() + } +} +impl DecryptSource for BoundInteger { + type ContextInput = RequiredContext; + fn decryption(self, context: Self::ContextInput) -> Decryption { + self.0 + .decryption(OptionalContext::::supplied( + context.into_inner().into(), + )) + } +} +#[tokio::test] +async fn a_column_of_a_required_context_target_takes_the_elements_requirement() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let column: Vec = vec![1i32, 2, 3] + .encrypt_into_with_context(&keyset, 7u64) + .await + .unwrap(); + assert_eq!( + keyset + .decrypt_with_context::(column[0].0.clone(), 7u64) + .await + .unwrap(), + 1 + ); + let wrong: Vec = column.iter().map(|b| BoundInteger(b.0.clone())).collect(); + assert!(matches!( + Vec::::decrypt_from_with_context(wrong, &keyset, 8u64).await, + Err(Error::Aead) + )); + assert_eq!( + Vec::::decrypt_from_with_context(column, &keyset, 7u64) + .await + .unwrap(), + [1, 2, 3] + ); +} + +// A target that declares its whole context and accepts none from the caller. +struct FixedInteger(Ciphertext); +impl EncryptTarget for FixedInteger { + type ContextInput = NoCallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, i32, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext() + .map(Self) + .under(stack_encrypt::nonempty!("users/fixed-age")) + .accepting() + } +} +impl DecryptSource for FixedInteger { + type ContextInput = NoCallerContext; + fn decryption(self, _: Self::ContextInput) -> Decryption { + self.0 + .decryption(OptionalContext::::supplied( + "users/fixed-age".into(), + )) + } +} +#[tokio::test] +async fn a_column_of_a_no_caller_context_target_keeps_the_declared_context() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let column: Vec = vec![5i32, 6].encrypt_into(&keyset).await.unwrap(); + assert_eq!( + keyset + .decrypt_with_context::(column[1].0.clone(), "users/fixed-age") + .await + .unwrap(), + 6 + ); + assert_eq!( + Vec::::decrypt_from(column, &keyset).await.unwrap(), + [5, 6] + ); +} + +// Deliberately not Clone: a column of these is held by the conversion's +// guard and handed to each element's declaration by reference. +struct Tracked { + value: String, + wiped: Arc, +} +impl zeroize::Zeroize for Tracked { + fn zeroize(&mut self) { + self.value.zeroize(); + self.wiped.store(true, Ordering::SeqCst); + } +} +impl Drop for Tracked { + fn drop(&mut self) { + assert!( + self.value.is_empty(), + "owned plaintext dropped without wiping" + ); + } +} +fn tracked(value: &str) -> (Tracked, Arc) { + let wiped = Arc::new(AtomicBool::new(false)); + ( + Tracked { + value: value.to_owned(), + wiped: wiped.clone(), + }, + wiped, + ) +} +fn tracked_column(values: &[&str]) -> (Vec, Vec>) { + values.iter().map(|value| tracked(value)).unzip() +} +fn all_wiped(flags: &[Arc]) -> bool { + flags.iter().all(|flag| flag.load(Ordering::SeqCst)) +} +struct TrackedCiphertext { + ciphertext: Ciphertext, + /// Set when the plaintext this opens to is wiped. + wiped: Arc, +} +impl TrackedCiphertext { + fn new(ciphertext: Ciphertext) -> Self { + Self { + ciphertext, + wiped: Arc::default(), + } + } +} +impl EncryptTarget for TrackedCiphertext { + type ContextInput = OptionalContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, Tracked, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext::() + .project(|source: &Tracked| &source.value) + .map(Self::new) + } +} +/// Opens to a [`Tracked`], so wiping of recovered plaintext is observable. +impl DecryptSource for TrackedCiphertext { + type ContextInput = OptionalContext; + fn decryption(self, context: Self::ContextInput) -> Decryption { + let wiped = self.wiped; + self.ciphertext + .decryption(context) + .map(move |value: String| Tracked { value, wiped }) + } +} + +#[tokio::test] +async fn owned_elements_are_wiped_before_the_pending_is_returned() { + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + + let (column, wiped) = tracked_column(&["one", "two", "three"]); + let pending = column.encrypt_into::, _>(&keyset); + assert!(all_wiped(&wiped), "every element is wiped before any await"); + assert_eq!(generates.load(Ordering::SeqCst), 0); + drop(pending); + assert_eq!(generates.load(Ordering::SeqCst), 0); + + let (column, wiped) = tracked_column(&["one", "two", "three"]); + let pending = column.encrypt_into::, _>(&keyset); + assert!(all_wiped(&wiped)); + let encrypted = pending.await.unwrap(); + assert_eq!(generates.load(Ordering::SeqCst), 1); + let opened: Vec = Vec::::decrypt_from( + encrypted + .into_iter() + .map(|t| t.ciphertext) + .collect::>(), + &keyset, + ) + .await + .unwrap(); + assert_eq!(opened, ["one", "two", "three"]); +} + +#[tokio::test] +async fn owned_elements_are_wiped_when_the_context_is_refused() { + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let (column, wiped) = tracked_column(&["one", "two"]); + let pending = + column.encrypt_into_with_context::, _>(&keyset, "x".repeat(65536)); + assert!(all_wiped(&wiped)); + assert!(pending.await.is_err()); + assert_eq!(generates.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn owned_elements_are_guarded_during_context_conversion() { + struct Panics; + impl From for ScalarContext { + fn from(_: Panics) -> Self { + panic!("conversion failed") + } + } + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let (column, wiped) = tracked_column(&["one", "two"]); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + column.encrypt_into_with_context::, _>(&keyset, Panics) + })); + assert!(result.is_err()); + assert!(all_wiped(&wiped)); +} + +#[tokio::test] +async fn a_late_element_failure_wipes_the_plaintext_recovered_before_it() { + let (cipher, _, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let first = keyset.encrypt("recovered first".to_owned()).await.unwrap(); + // The second element is sealed under another context, so it fails + // authentication only once its key has arrived: the first element has + // been recovered by then. + let second = keyset + .encrypt_with_context("never recovered".to_owned(), "elsewhere") + .await + .unwrap(); + let column = vec![ + TrackedCiphertext::new(first), + TrackedCiphertext::new(second), + ]; + let wiped: Vec<_> = column.iter().map(|t| t.wiped.clone()).collect(); + // `Tracked::drop` also fails the test if the recovered prefix is + // dropped unwiped. + let result = Vec::::decrypt_from(column, &keyset).await; + assert!(matches!(result, Err(Error::Aead)), "{:?}", result.err()); + assert!( + wiped[0].load(Ordering::SeqCst), + "the recovered prefix is wiped" + ); + assert!( + !wiped[1].load(Ordering::SeqCst), + "nothing was recovered to wipe" + ); + assert_eq!(retrieves.load(Ordering::SeqCst), 1, "both keys in one call"); +} diff --git a/packages/stack-encrypt/tests/ui/owned_collection_context.rs b/packages/stack-encrypt/tests/ui/owned_collection_context.rs new file mode 100644 index 000000000..7ecf08457 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_collection_context.rs @@ -0,0 +1,46 @@ +//! A column takes exactly the element's caller-context capability: a column +//! of a `RequiredContext` target cannot omit the context, even for a `u64` +//! that implements `Default`, and a column of a `NoCallerContext` target +//! cannot be handed one. +use stack_encrypt::target::{ + DecryptSource, Decryption, EncryptTarget, Encryption, NoCallerContext, RequiredContext, +}; +use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto}; + +struct Required(Ciphertext); +impl EncryptTarget for Required { + type ContextInput = RequiredContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, i32, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext().map(Self).accepting() + } +} +impl DecryptSource for Required { + type ContextInput = RequiredContext; + fn decryption(self, _: Self::ContextInput) -> Decryption { + Decryption::failed(stack_encrypt::Error::Aead) + } +} +struct Fixed; +impl EncryptTarget for Fixed { + type ContextInput = NoCallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, i32, Self, K, Self::ContextInput> { + Encryption::failed(stack_encrypt::Error::Aead) + } +} +impl DecryptSource for Fixed { + type ContextInput = NoCallerContext; + fn decryption(self, _: Self::ContextInput) -> Decryption { + Decryption::failed(stack_encrypt::Error::Aead) + } +} +fn capabilities( + keyset: &stack_encrypt::KeysetCipher<'_, K>, + required: Vec, + fixed: Vec, +) { + let _ = vec![1i32, 2].encrypt_into::, _>(keyset); + let _ = Vec::::decrypt_from(required, keyset); + let _ = vec![1i32, 2].encrypt_into_with_context::, _>(keyset, ()); + let _ = Vec::::decrypt_from_with_context(fixed, keyset, ()); +} +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/owned_collection_context.stderr b/packages/stack-encrypt/tests/ui/owned_collection_context.stderr new file mode 100644 index 000000000..286be41fa --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_collection_context.stderr @@ -0,0 +1,89 @@ +error[E0277]: the trait bound `RequiredContext: AllowsNoContext` is not satisfied + --> tests/ui/owned_collection_context.rs:41:13 + | +41 | let _ = vec![1i32, 2].encrypt_into::, _>(keyset); + | ^^^^^^^^^^^^^ ------------ required by a bound introduced by this call + | | + | the trait `AllowsNoContext` is not implemented for `RequiredContext` + | +help: the following other types implement trait `AllowsNoContext` + --> src/target/input.rs + | + | impl AllowsNoContext for NoCallerContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `NoCallerContext` +... + | impl AllowsNoContext for OptionalContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext` +note: required by a bound in `stack_encrypt::EncryptInto::encrypt_into` + --> src/target/conversion.rs + | + | fn encrypt_into<'a, T, K: 'static>(self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> + | ------------ required by a bound in this associated function +... + | T::ContextInput: AllowsNoContext, + | ^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` + +error[E0277]: the trait bound `RequiredContext: AllowsNoContext` is not satisfied + --> tests/ui/owned_collection_context.rs:42:38 + | +42 | let _ = Vec::::decrypt_from(required, keyset); + | ------------------------ ^^^^^^^^ the trait `AllowsNoContext` is not implemented for `RequiredContext` + | | + | required by a bound introduced by this call + | +help: the following other types implement trait `AllowsNoContext` + --> src/target/input.rs + | + | impl AllowsNoContext for NoCallerContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `NoCallerContext` +... + | impl AllowsNoContext for OptionalContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext` +note: required by a bound in `stack_encrypt::DecryptFrom::decrypt_from` + --> src/target/conversion.rs + | + | fn decrypt_from<'a, S, K: 'static>( + | ------------ required by a bound in this associated function +... + | S::ContextInput: AllowsNoContext, + | ^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from` + +error[E0277]: the trait bound `NoCallerContext: AcceptsContext` is not satisfied + --> tests/ui/owned_collection_context.rs:43:27 + | +43 | let _ = vec![1i32, 2].encrypt_into_with_context::, _>(keyset, ()); + | ^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `AcceptsContext` is not implemented for `NoCallerContext` + | +help: the following other types implement trait `AcceptsContext` + --> src/target/input.rs + | + | impl AcceptsContext for RequiredContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `RequiredContext` +... + | impl AcceptsContext for OptionalContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext` + +error[E0277]: the trait bound `NoCallerContext: AcceptsContext` is not satisfied + --> tests/ui/owned_collection_context.rs:44:51 + | +44 | let _ = Vec::::decrypt_from_with_context(fixed, keyset, ()); + | ------------------------------------- ^^^^^ the trait `AcceptsContext` is not implemented for `NoCallerContext` + | | + | required by a bound introduced by this call + | +help: the following other types implement trait `AcceptsContext` + --> src/target/input.rs + | + | impl AcceptsContext for RequiredContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `RequiredContext` +... + | impl AcceptsContext for OptionalContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext` +note: required by a bound in `stack_encrypt::DecryptFrom::decrypt_from_with_context` + --> src/target/conversion.rs + | + | fn decrypt_from_with_context<'a, S, K: 'static>( + | ------------------------- required by a bound in this associated function +... + | S::ContextInput: AcceptsContext, + | ^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from_with_context` diff --git a/packages/stack-encrypt/tests/ui/owned_collection_elements.rs b/packages/stack-encrypt/tests/ui/owned_collection_elements.rs new file mode 100644 index 000000000..c08f22756 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_collection_elements.rs @@ -0,0 +1,12 @@ +//! A collection's elements must be opaque scalars. `u8` is not one: Vitamin +//! C keeps `Vec` a byte leaf and gives `u8` no source of its own, so a +//! byte vector is neither a column of bytes nor one ciphertext here (a whole +//! byte vector is a blob, a separate representation). A nullable element and +//! a borrowed element are not opaque scalars either. +use stack_encrypt::{Ciphertext, EncryptInto}; +fn unsupported(keyset: &stack_encrypt::KeysetCipher<'_, K>) { + let _ = vec![1u8, 2].encrypt_into::, _>(keyset); + let _ = vec![Some(1i32), None].encrypt_into::, _>(keyset); + let _ = vec!["borrowed", "plaintext"].encrypt_into::, _>(keyset); +} +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr b/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr new file mode 100644 index 000000000..f326cd210 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr @@ -0,0 +1,71 @@ +error[E0277]: the trait bound `u8: OpaqueEncode` is not satisfied + --> tests/ui/owned_collection_elements.rs:8:26 + | +8 | let _ = vec![1u8, 2].encrypt_into::, _>(keyset); + | ^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `u8` + | + = help: the following other types implement trait `OpaqueEncode`: + f32 + f64 + i128 + i16 + i32 + i64 + i8 + u128 + and $N others + = note: required for `stack_encrypt::Ciphertext` to implement `EncryptTarget` + = note: 1 redundant requirement hidden + = note: required for `Vec` to implement `EncryptTarget>` + +error[E0277]: the trait bound `Option: OpaqueEncode` is not satisfied + --> tests/ui/owned_collection_elements.rs:9:36 + | +9 | let _ = vec![Some(1i32), None].encrypt_into::, _>(keyset); + | ^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `Option` + | + = help: the following other types implement trait `OpaqueEncode`: + bool + f32 + f64 + i128 + i16 + i32 + i64 + i8 + and $N others + = note: required for `stack_encrypt::Ciphertext` to implement `EncryptTarget>` + = note: 1 redundant requirement hidden + = note: required for `Vec` to implement `EncryptTarget>>` + +error[E0599]: the method `encrypt_into` exists for struct `Vec<&str>`, but its trait bounds were not satisfied + --> tests/ui/owned_collection_elements.rs:10:43 + | +10 | let _ = vec!["borrowed", "plaintext"].encrypt_into::, _>(keyset); + | ^^^^^^^^^^^^ + | + ::: $RUST/alloc/src/vec/mod.rs + | + | pub struct Vec { + | ------------------------------------------------------------------------------------------------ doesn't satisfy `Vec<&str>: stack_encrypt::EncryptInto` or `Vec<&str>: zeroize::Zeroize` + | + = note: the following trait bounds were not satisfied: + `Vec<&str>: zeroize::Zeroize` + which is required by `Vec<&str>: stack_encrypt::EncryptInto` + `&Vec<&str>: zeroize::Zeroize` + which is required by `&Vec<&str>: stack_encrypt::EncryptInto` + `&mut Vec<&str>: zeroize::Zeroize` + which is required by `&mut Vec<&str>: stack_encrypt::EncryptInto` + `[&str]: Sized` + which is required by `[&str]: stack_encrypt::EncryptInto` + `[&str]: zeroize::Zeroize` + which is required by `[&str]: stack_encrypt::EncryptInto` + `&[&str]: zeroize::Zeroize` + which is required by `&[&str]: stack_encrypt::EncryptInto` + `&mut [&str]: zeroize::Zeroize` + which is required by `&mut [&str]: stack_encrypt::EncryptInto` + = help: items from traits can only be used if the trait is in scope +help: trait `LegacyEncryptInto` which provides `encrypt_into` is implemented but not in scope; perhaps you want to import it + | + 6 + use stack_encrypt::target::legacy::EncryptInto; + | diff --git a/packages/stack-encrypt/tests/ui/pass/owned_collections.rs b/packages/stack-encrypt/tests/ui/pass/owned_collections.rs new file mode 100644 index 000000000..7cf0cfdb9 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/pass/owned_collections.rs @@ -0,0 +1,89 @@ +//! The owned collection forms that must compile: a `Vec` of opaque scalars +//! into a `Vec` of any owned target, with the element's caller-context +//! capability, and back. `tests/ui/owned_collection_elements.rs` and +//! `tests/ui/owned_collection_context.rs` pin the forms that must not. +use stack_encrypt::target::{ + AcceptsContext, DecryptSource, Decryption, EncryptTarget, Encryption, NoCallerContext, + OptionalContext, RequiredContext, ScalarContext, +}; +use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto, KeysetCipher, StackCipher}; +use stack_kms::FakeDataKeySource; + +struct Required(Ciphertext); +impl EncryptTarget for Required { + type ContextInput = RequiredContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, i32, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext().map(Self).accepting() + } +} +impl DecryptSource for Required { + type ContextInput = RequiredContext; + fn decryption(self, context: Self::ContextInput) -> Decryption { + self.0.decryption(OptionalContext::::supplied( + context.into_inner().into(), + )) + } +} +struct Fixed(Ciphertext); +impl EncryptTarget for Fixed { + type ContextInput = NoCallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, i32, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext() + .map(Self) + .under(stack_encrypt::nonempty!("users/fixed")) + .accepting() + } +} +impl DecryptSource for Fixed { + type ContextInput = NoCallerContext; + fn decryption(self, _: Self::ContextInput) -> Decryption { + self.0.decryption(OptionalContext::::supplied( + "users/fixed".into(), + )) + } +} + +/// `Tracked` is deliberately not `Clone`: a column of owned values is held +/// by the conversion's guard and handed to each element by reference. +struct Tracked(String); +impl zeroize::Zeroize for Tracked { + fn zeroize(&mut self) { + self.0.zeroize(); + } +} +struct TrackedCiphertext(Ciphertext); +impl EncryptTarget for TrackedCiphertext { + type ContextInput = OptionalContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, Tracked, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext::() + .project(|source: &Tracked| &source.0) + .map(Self) + } +} + +async fn columns( + cipher: &StackCipher, + keyset: &KeysetCipher<'_, FakeDataKeySource>, +) -> Result<(), stack_encrypt::Error> { + // Context-free, with a context, and through the client scope. + let ages: Vec = vec![1i32, 2].encrypt_into(keyset).await?; + let _: Vec = Vec::decrypt_from(ages, cipher).await?; + let names: Vec = vec![String::from("a")] + .encrypt_into_with_context(keyset, ("users", "name")) + .await?; + let _: Vec = Vec::decrypt_from_with_context(names, keyset, ("users", "name")).await?; + // Nested columns, and a column of a non-`Clone` owned plaintext. + let nested: Vec> = vec![vec![1i32], vec![]].encrypt_into(keyset).await?; + let _: Vec> = Vec::decrypt_from(nested, keyset).await?; + let _: Vec = vec![Tracked(String::from("x"))].encrypt_into(keyset).await?; + // The element's capability is the column's. + let required: Vec = vec![1i32].encrypt_into_with_context(keyset, 7u64).await?; + let _: Vec = Vec::decrypt_from_with_context(required, keyset, 7u64).await?; + let fixed: Vec = vec![1i32].encrypt_into(keyset).await?; + let _: Vec = Vec::decrypt_from(fixed, keyset).await?; + Ok(()) +} + +fn main() { + let _ = columns; +} From c2d7c7c4f3b3c114de1b6b56303942a4d2649b5a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 23:06:50 +1100 Subject: [PATCH 02/17] test(stack-encrypt): enroll owned collection lifecycle evidence The owned `Vec` to `Vec` conversion gets the same lifecycle probes a record operation has: an unpolled drop releases captures without key I/O, an in-flight cancellation drops the provider future and captures, a column is one logical batch whose captures release once on success and provider failure, and an empty column makes no provider call. The heap observer watches a `Vec` column sealed through its guard, and a late type failure in `Vec` to `Vec`, for unwiped frees. All six are enrolled in `lifecycle-profiles.json` under the existing requirement IDs, as the lifecycle contract asks, and the contract and coverage documents name the owned conversion in those rows. No Go change: the conversion is a Rust-only surface, and Go reaches batching through its record interface, which the runtime profile already instruments. Refs #1482 --- conformance/stack-encrypt/coverage.md | 5 ++ .../stack-encrypt/lifecycle-contract.md | 10 +-- .../v1/lifecycle-v1.enrollment.json | 17 ++++- packages/stack-encrypt/tests/heap_hygiene.rs | 46 ++++++++++++ .../tests/lifecycle_conformance.rs | 71 ++++++++++++++++++- 5 files changed, 140 insertions(+), 9 deletions(-) diff --git a/conformance/stack-encrypt/coverage.md b/conformance/stack-encrypt/coverage.md index 39b356b78..6080d4e74 100644 --- a/conformance/stack-encrypt/coverage.md +++ b/conformance/stack-encrypt/coverage.md @@ -129,6 +129,11 @@ The [lifecycle contract](lifecycle-contract.md) enrolls native pending/KMS tests heap observations, guest ABI cleanup, Go cancellation/memory tests and public SDK diagnostics. New probes cover unpolled and in-flight drops, provider failure, cleanup after a late authenticated type failure, and chunk concurrency/drop. +The owned `Vec` to `Vec` conversion is enrolled under the same +requirements: unpolled and in-flight drops, one logical batch per column with +none for an empty one, source wiping through its guard, and wiping of the +recovered prefix after a late element failure. It is a native Rust surface; Go +reaches batching through its record interface, which the runtime profile covers. Existing runtime/read/context vectors retain their provider-call counters. This adds required execution and explicit observation limits; it removes no diff --git a/conformance/stack-encrypt/lifecycle-contract.md b/conformance/stack-encrypt/lifecycle-contract.md index 2cf10ab12..e9cb8e04c 100644 --- a/conformance/stack-encrypt/lifecycle-contract.md +++ b/conformance/stack-encrypt/lifecycle-contract.md @@ -10,13 +10,13 @@ process. Python and TypeScript are explicitly not enrolled. | Requirement | Native evidence | Go evidence and limits | |---|---|---| -| `lifecycle-ownership-v1` | Actual record operations release fulfilment captures exactly once on success, provider failure and unpolled drop; an unpolled encrypt or decrypt makes no key request. Zipped fulfilments do not share key material. | Client keys are consumed/redacted; explicit close and unreachable-client cleanup release guest memory; calls on a closed client fail. GC cleanup is eventual, so callers should close explicitly. | -| `lifecycle-cancellation-v1` | Dropping an encrypt future paused in key generation, or a decrypt future paused in key retrieval, drops the provider future and operation captures; subsequent work on the cipher succeeds. Guest ABI abort spends the handle and drops the operation. | Cancellation before a call sends no request; cancellation between steps or during token fetch leaves unrelated operations usable. A guest step that overruns cancellation closes the client. Discarded late output is wiped. | -| `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue. Existing context cases reject late authentication failures without returning partial output. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | -| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths and has a negative control. Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | +| `lifecycle-ownership-v1` | Actual record operations and owned `Vec` to `Vec` conversions release fulfilment captures exactly once on success, provider failure and unpolled drop; an unpolled encrypt or decrypt makes no key request. Zipped fulfilments do not share key material. | Client keys are consumed/redacted; explicit close and unreachable-client cleanup release guest memory; calls on a closed client fail. GC cleanup is eventual, so callers should close explicitly. | +| `lifecycle-cancellation-v1` | Dropping an encrypt future paused in key generation (for a record operation or an owned column), or a decrypt future paused in key retrieval, drops the provider future and operation captures; subsequent work on the cipher succeeds. Guest ABI abort spends the handle and drops the operation. | Cancellation before a call sends no request; cancellation between steps or during token fetch leaves unrelated operations usable. A guest step that overruns cancellation closes the client. Discarded late output is wiped. | +| `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue, through the dynamic record path and the owned `Vec` to `Vec` conversion alike. Existing context cases reject late authentication failures without returning partial output. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | +| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths, including an owned `Vec` column sealed through its guard, and has a negative control. Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | | `lifecycle-diagnostics-v1` | Guest errors discard untrusted foreign cause messages; failed/successful exports do not hand over stale diagnostics. | Structured errors remain available after failures; SDK errors and redacted display/logging expose types or fields rather than plaintext values. | | `lifecycle-validation-before-io-v1` | Existing runtime/read/context cases count provider calls. Wrong shape, independently detectable context mismatch and oversized descriptors fail before key I/O; lazy assembly stops after local failure. | Host refusals happen before entering the guest, and shared runtime/read cases exercise the uninitialised guest's preflight boundary. Go does not yet expose native provider counters. | -| `lifecycle-logical-batching-v1` | Instrumented `DataKeySource` calls establish one logical generation batch, retrieval grouping by keyset, descriptor ordering and no work for empty batches. | Go uses the same engine, but this profile does not claim independently instrumented Go key-request counts. | +| `lifecycle-logical-batching-v1` | Instrumented `DataKeySource` calls establish one logical generation batch, retrieval grouping by keyset, descriptor ordering and no work for empty batches, for record operations and for an owned `Vec` column of any length including zero. | Go uses the same engine, but this profile does not claim independently instrumented Go key-request counts. | | `lifecycle-provider-chunks-v1` | The actual KMS chunking helper preserves order, handles a final short chunk, fails the whole call on a chunk error, bounds in-flight chunks and releases their futures on cancellation. | This is a native algorithm observation, not a measurement of live HTTP request counts. | The drop witnesses belong to fulfilment closures and provider futures in real diff --git a/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json b/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json index 12b35f883..913fdbab1 100644 --- a/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json +++ b/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json @@ -90,7 +90,9 @@ "lifecycle": [ "dropping_unpolled_work_releases_captures_without_key_io", "dropping_unpolled_decrypt_releases_captures_without_key_io", - "success_and_provider_failure_release_captures_once" + "success_and_provider_failure_release_captures_once", + "dropping_an_unpolled_owned_column_releases_captures_without_key_io", + "an_owned_column_is_one_logical_batch_and_releases_captures_once" ], "pending": [ "target::pending::tests::zipped_fulfilments_never_share_key_material" @@ -108,7 +110,8 @@ "evidence": { "lifecycle": [ "cancelling_in_flight_work_drops_provider_and_captures", - "cancelling_in_flight_decrypt_drops_provider_and_captures" + "cancelling_in_flight_decrypt_drops_provider_and_captures", + "cancelling_an_owned_column_in_flight_drops_provider_and_captures" ], "guest-abi": [ "step::tests::an_aborted_operation_is_dropped_and_its_handle_spent", @@ -127,7 +130,10 @@ { "id": "lifecycle-partial-output-v1", "evidence": { - "heap": ["a_late_read_type_failure_leaves_no_plaintext_on_the_heap"], + "heap": [ + "a_late_read_type_failure_leaves_no_plaintext_on_the_heap", + "a_late_type_failure_in_an_owned_column_leaves_no_plaintext_on_the_heap" + ], "contexts": ["shared_context_boundaries"], "go": [ "TestContextBoundaries", @@ -142,6 +148,7 @@ "evidence": { "heap": [ "a_record_encrypted_from_a_value_frame_leaves_no_plaintext_on_the_heap", + "an_owned_column_encrypted_leaves_no_plaintext_on_the_heap", "a_record_decrypted_with_a_nested_field_leaves_no_plaintext_on_the_heap", "a_json_document_read_from_a_frame_leaves_no_plaintext_on_the_heap", "the_spy_sees_an_unwiped_free" @@ -205,6 +212,10 @@ "id": "lifecycle-logical-batching-v1", "evidence": { "runtime": ["shared_record_runtime_profile"], + "lifecycle": [ + "an_owned_column_is_one_logical_batch_and_releases_captures_once", + "an_empty_owned_column_makes_no_provider_call" + ], "pending": [ "target::pending::tests::one_pending_asking_for_many_keys_is_one_call", "target::pending::tests::all_merges_a_column_into_one_call_preserving_order", diff --git a/packages/stack-encrypt/tests/heap_hygiene.rs b/packages/stack-encrypt/tests/heap_hygiene.rs index e229d412d..4312e3f0e 100644 --- a/packages/stack-encrypt/tests/heap_hygiene.rs +++ b/packages/stack-encrypt/tests/heap_hygiene.rs @@ -369,6 +369,52 @@ fn a_late_read_type_failure_leaves_no_plaintext_on_the_heap() { }); } +/// The owned collection conversion: a column of owned strings is held by +/// the conversion's guard, handed to each element's declaration by +/// reference, and wiped before the pending is returned. Nothing it frees +/// may still hold a value. +#[test] +fn an_owned_column_encrypted_leaves_no_plaintext_on_the_heap() { + use stack_encrypt::{Ciphertext, EncryptInto}; + let rt = runtime(); + let cipher = rt.block_on(common::stack_cipher()); + let keyset = cipher.default_keyset(); + let column = vec![marked("first"), marked("second"), marked("third")]; + watched("Vec -> Vec", || { + let sealed: Vec = rt + .block_on(async { column.encrypt_into(&keyset).await }) + .unwrap(); + assert_eq!(sealed.len(), 3); + }); +} + +/// A column whose later element fails after its key arrived, here with a +/// type the destination does not accept: the element recovered before it is +/// wiped rather than dropped as it is. +#[test] +fn a_late_type_failure_in_an_owned_column_leaves_no_plaintext_on_the_heap() { + use stack_encrypt::DecryptFrom; + let rt = runtime(); + let cipher = rt.block_on(common::stack_cipher()); + let keyset = cipher.default_keyset(); + let column = rt.block_on(async { + vec![ + keyset.encrypt(marked("first-row")).await.unwrap(), + keyset.encrypt(true).await.unwrap(), + ] + }); + watched( + "late type failure in Vec -> Vec", + || { + let result = rt.block_on(async { Vec::::decrypt_from(column, &keyset).await }); + assert!(matches!( + result, + Err(stack_encrypt::Error::OpenedType { .. }) + )); + }, + ); +} + #[test] fn a_query_term_leaves_no_plaintext_on_the_heap() { let rt = runtime(); diff --git a/packages/stack-encrypt/tests/lifecycle_conformance.rs b/packages/stack-encrypt/tests/lifecycle_conformance.rs index 85ec8a79a..aaab35e1a 100644 --- a/packages/stack-encrypt/tests/lifecycle_conformance.rs +++ b/packages/stack-encrypt/tests/lifecycle_conformance.rs @@ -13,7 +13,7 @@ use std::task::{Context, Waker}; use common::frames::frame; use stack_encrypt::dynamic::{record, Scope, ValueFrame}; -use stack_encrypt::{Pending, StackCipher, StackCipherText}; +use stack_encrypt::{Ciphertext, EncryptInto, Pending, StackCipher, StackCipherText}; use stack_kms::{ DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyError, GenerateKeyPayload, IdentifiedBy, IndexKey, IndexKeySource, RetrieveKeyPayload, @@ -250,3 +250,72 @@ async fn cancelling_in_flight_decrypt_drops_provider_and_captures() { assert_eq!(*calls.retrievals.lock().unwrap(), vec![3, 3]); assert_eq!(calls.retrieve_drops.load(Ordering::SeqCst), 2); } + +// The owned collection conversion, `Vec` to `Vec`, runs the element +// declaration once per element and merges the results into one pending. The +// probes below hold it to the same lifecycle as a record operation. +fn owned_column<'a>( + keyset: &'a stack_encrypt::KeysetCipher<'_, Source>, + count: usize, +) -> Pending<'a, Vec, Source> { + vec![String::from("lifecycle-input"); count].encrypt_into(keyset) +} + +#[tokio::test] +async fn dropping_an_unpolled_owned_column_releases_captures_without_key_io() { + let (cipher, calls) = cipher().await; + let keyset = cipher.default_keyset(); + let drops = Arc::new(AtomicUsize::new(0)); + let pending = observed(owned_column(&keyset, 2), drops.clone()); + assert_eq!(drops.load(Ordering::SeqCst), 0); + assert!(calls.batches.lock().unwrap().is_empty()); + drop(pending); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert!(calls.batches.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn cancelling_an_owned_column_in_flight_drops_provider_and_captures() { + let (cipher, calls) = cipher().await; + calls.block.store(true, Ordering::SeqCst); + let keyset = cipher.default_keyset(); + let drops = Arc::new(AtomicUsize::new(0)); + let mut future = observed(owned_column(&keyset, 3), drops.clone()).into_future(); + assert!(future + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + .is_pending()); + assert_eq!(*calls.batches.lock().unwrap(), vec![3]); + assert_eq!(drops.load(Ordering::SeqCst), 0); + assert_eq!(calls.provider_drops.load(Ordering::SeqCst), 0); + drop(future); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert_eq!(calls.provider_drops.load(Ordering::SeqCst), 1); + calls.block.store(false, Ordering::SeqCst); + owned_column(&keyset, 1).await.unwrap(); + assert_eq!(*calls.batches.lock().unwrap(), vec![3, 1]); +} + +#[tokio::test] +async fn an_owned_column_is_one_logical_batch_and_releases_captures_once() { + for fail in [false, true] { + let (cipher, calls) = cipher().await; + calls.fail.store(fail, Ordering::SeqCst); + let keyset = cipher.default_keyset(); + let drops = Arc::new(AtomicUsize::new(0)); + let result = observed(owned_column(&keyset, 4), drops.clone()).await; + assert_eq!(result.is_err(), fail); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert_eq!(calls.provider_drops.load(Ordering::SeqCst), 1); + assert_eq!(*calls.batches.lock().unwrap(), vec![4]); + } +} + +#[tokio::test] +async fn an_empty_owned_column_makes_no_provider_call() { + let (cipher, calls) = cipher().await; + let keyset = cipher.default_keyset(); + let sealed = owned_column(&keyset, 0).await.unwrap(); + assert!(sealed.is_empty()); + assert!(calls.batches.lock().unwrap().is_empty()); +} From 585f6a8b0ca511fb7ebcf3517943637f74cea413 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 23:13:46 +1100 Subject: [PATCH 03/17] fix(stack-encrypt): wipe a column's recovered prefix when an element conversion panics --- packages/stack-encrypt/CHANGELOG.md | 3 +- packages/stack-encrypt/src/lib.rs | 3 +- packages/stack-encrypt/src/target/pending.rs | 52 +++++++++++----- packages/stack-encrypt/tests/collections.rs | 65 ++++++++++++++++++++ 4 files changed, 107 insertions(+), 16 deletions(-) diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 0fcd43e49..cc3ac27e6 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -35,7 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 provider requests). The vector is held in one guard and wiped before the pending request is returned, including when the context is refused, with no `Clone` bound; a column that fails after some elements were recovered - wipes those before returning the error. `Vec` is not a column of + wipes those before returning the error, and before unwinding when an + element's conversion panics. `Vec` is not a column of scalars: `u8` is not `OpaqueEncode`, because Vitamin C keeps `Vec` a byte leaf, so it compiles to neither a column of bytes nor one ciphertext. Direct `keyset.encrypt` still takes one scalar; `encrypt_tree` remains the diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 5277378f8..497e2b024 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -252,7 +252,8 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! key source may still split a logical batch across several provider //! requests. The column is held in one guard and wiped before the pending //! request is returned, as a scalar is, and a column that fails after some -//! elements were recovered wipes those before reporting the error. +//! elements were recovered wipes those before reporting the error (or +//! before unwinding, when an element's conversion panics). //! //! ``` //! # async fn example() -> Result<(), stack_encrypt::Error> { diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index e9eff44d7..df6da3735 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -345,10 +345,10 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { /// [`collect`](Self::collect) for a collection of recovered plaintext. /// When a later item fails at fulfilment (an authentication or type - /// failure after its key arrived), the items recovered before it are - /// wiped before the error is returned, not dropped as they are. A - /// failure before fulfilment recovers nothing, so there is nothing to - /// wipe. + /// failure after its key arrived, or a panic in its conversion), the + /// items recovered before it are wiped before the error is returned or + /// the panic unwinds, not dropped as they are. A failure before + /// fulfilment recovers nothing, so there is nothing to wipe. pub(crate) fn collect_wiping( scope: impl CipherScope<'a, K>, items: impl IntoIterator>, @@ -362,7 +362,8 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { } /// The one collection builder. `release` is handed the items fulfilled - /// before a later item failed; it is not called when every item succeeds. + /// before a later item failed or panicked; it is not called when every + /// item succeeds. fn collect_releasing( scope: impl CipherScope<'a, K>, items: impl IntoIterator>, @@ -393,22 +394,45 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { requests, failed: None, fulfil: Box::new(move |responses| { - let mut recovered = Vec::with_capacity(fulfils.len()); + // Held in a guard so that an item whose fulfilment panics + // releases the prefix during the unwind, as an error does. + let mut recovered = Recovered { + items: Vec::with_capacity(fulfils.len()), + release: Some(release), + }; for fulfil in fulfils { - match fulfil(responses) { - Ok(item) => recovered.push(item), - Err(error) => { - release(recovered); - return Err(error); - } - } + recovered.items.push(fulfil(responses)?); } - Ok(recovered) + Ok(recovered.keep()) }), } } } +/// The items a collection has recovered so far. Dropped before +/// [`keep`](Self::keep), by an early return or an unwind, it hands them to +/// `release`. +struct Recovered)> { + items: Vec, + release: Option, +} + +impl)> Recovered { + /// Every item succeeded: the collection is the output, not released. + fn keep(mut self) -> Vec { + self.release = None; + std::mem::take(&mut self.items) + } +} + +impl)> Drop for Recovered { + fn drop(&mut self) { + if let Some(release) = self.release.take() { + release(std::mem::take(&mut self.items)); + } + } +} + impl<'a, T: 'a, K> Pending<'a, T, K> where K: DataKeySource + IndexKeySource, diff --git a/packages/stack-encrypt/tests/collections.rs b/packages/stack-encrypt/tests/collections.rs index 84743ae3f..c3bce3ba9 100644 --- a/packages/stack-encrypt/tests/collections.rs +++ b/packages/stack-encrypt/tests/collections.rs @@ -413,3 +413,68 @@ async fn a_late_element_failure_wipes_the_plaintext_recovered_before_it() { ); assert_eq!(retrieves.load(Ordering::SeqCst), 1, "both keys in one call"); } + +// Opens to a `Flagged`, and panics in its conversion when told to. `Flagged` +// has no drop check of its own, so an unwiped drop during the unwind is seen +// through the flag rather than as an abort. +struct Flagged { + value: String, + wiped: Arc, +} +impl zeroize::Zeroize for Flagged { + fn zeroize(&mut self) { + self.value.zeroize(); + self.wiped.store(true, Ordering::SeqCst); + } +} +struct PanickingCiphertext { + ciphertext: Ciphertext, + panics: bool, + wiped: Arc, +} +impl DecryptSource for PanickingCiphertext { + type ContextInput = OptionalContext; + fn decryption(self, context: Self::ContextInput) -> Decryption { + let (panics, wiped) = (self.panics, self.wiped); + self.ciphertext + .decryption(context) + .map(move |value: String| { + assert!(!panics, "element conversion failed"); + Flagged { value, wiped } + }) + } +} + +#[test] +fn a_panicking_element_conversion_wipes_the_plaintext_recovered_before_it() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let cipher = rt.block_on(common::stack_cipher()); + let keyset = cipher.default_keyset(); + let column: Vec<_> = rt + .block_on(async { + vec![ + keyset.encrypt("recovered first".to_owned()).await.unwrap(), + keyset.encrypt("never recovered".to_owned()).await.unwrap(), + ] + }) + .into_iter() + .enumerate() + .map(|(row, ciphertext)| PanickingCiphertext { + ciphertext, + panics: row == 1, + wiped: Arc::default(), + }) + .collect(); + let wiped: Vec<_> = column.iter().map(|c| c.wiped.clone()).collect(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + rt.block_on(async { Vec::::decrypt_from(column, &keyset).await }) + })); + assert!(result.is_err(), "the element conversion panicked"); + assert!( + wiped[0].load(Ordering::SeqCst), + "the prefix recovered before the panic is wiped during the unwind" + ); +} From 34120f5e8d81d1be1e4b202dfe497ecd7b697087 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 23:14:54 +1100 Subject: [PATCH 04/17] test(stack-encrypt): pin that no vector encrypts into one ciphertext and the decrypt side refuses byte and nullable elements --- .../tests/ui/owned_collection_elements.rs | 12 +- .../tests/ui/owned_collection_elements.stderr | 154 +++++++++++++----- 2 files changed, 124 insertions(+), 42 deletions(-) diff --git a/packages/stack-encrypt/tests/ui/owned_collection_elements.rs b/packages/stack-encrypt/tests/ui/owned_collection_elements.rs index c08f22756..c0cec89e2 100644 --- a/packages/stack-encrypt/tests/ui/owned_collection_elements.rs +++ b/packages/stack-encrypt/tests/ui/owned_collection_elements.rs @@ -2,11 +2,17 @@ //! C keeps `Vec` a byte leaf and gives `u8` no source of its own, so a //! byte vector is neither a column of bytes nor one ciphertext here (a whole //! byte vector is a blob, a separate representation). A nullable element and -//! a borrowed element are not opaque scalars either. -use stack_encrypt::{Ciphertext, EncryptInto}; -fn unsupported(keyset: &stack_encrypt::KeysetCipher<'_, K>) { +//! a borrowed element are not opaque scalars either. No vector, of bytes or +//! of scalars, encrypts into one `Ciphertext`, and the decrypt side refuses +//! the same element types. +use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto}; +fn unsupported(keyset: &stack_encrypt::KeysetCipher<'_, K>, column: Vec) { let _ = vec![1u8, 2].encrypt_into::, _>(keyset); let _ = vec![Some(1i32), None].encrypt_into::, _>(keyset); let _ = vec!["borrowed", "plaintext"].encrypt_into::, _>(keyset); + let _ = vec![1u8, 2].encrypt_into::(keyset); + let _ = vec![1i32, 2].encrypt_into::(keyset); + let _ = Vec::::decrypt_from(column.clone(), keyset); + let _ = Vec::>::decrypt_from(column, keyset); } fn main() {} diff --git a/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr b/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr index f326cd210..41c3c0cdd 100644 --- a/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr +++ b/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr @@ -1,47 +1,47 @@ error[E0277]: the trait bound `u8: OpaqueEncode` is not satisfied - --> tests/ui/owned_collection_elements.rs:8:26 - | -8 | let _ = vec![1u8, 2].encrypt_into::, _>(keyset); - | ^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `u8` - | - = help: the following other types implement trait `OpaqueEncode`: - f32 - f64 - i128 - i16 - i32 - i64 - i8 - u128 - and $N others - = note: required for `stack_encrypt::Ciphertext` to implement `EncryptTarget` - = note: 1 redundant requirement hidden - = note: required for `Vec` to implement `EncryptTarget>` + --> tests/ui/owned_collection_elements.rs:10:26 + | +10 | let _ = vec![1u8, 2].encrypt_into::, _>(keyset); + | ^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `u8` + | + = help: the following other types implement trait `OpaqueEncode`: + f32 + f64 + i128 + i16 + i32 + i64 + i8 + u128 + and $N others + = note: required for `stack_encrypt::Ciphertext` to implement `EncryptTarget` + = note: 1 redundant requirement hidden + = note: required for `Vec` to implement `EncryptTarget>` error[E0277]: the trait bound `Option: OpaqueEncode` is not satisfied - --> tests/ui/owned_collection_elements.rs:9:36 - | -9 | let _ = vec![Some(1i32), None].encrypt_into::, _>(keyset); - | ^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `Option` - | - = help: the following other types implement trait `OpaqueEncode`: - bool - f32 - f64 - i128 - i16 - i32 - i64 - i8 - and $N others - = note: required for `stack_encrypt::Ciphertext` to implement `EncryptTarget>` - = note: 1 redundant requirement hidden - = note: required for `Vec` to implement `EncryptTarget>>` + --> tests/ui/owned_collection_elements.rs:11:36 + | +11 | let _ = vec![Some(1i32), None].encrypt_into::, _>(keyset); + | ^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `Option` + | + = help: the following other types implement trait `OpaqueEncode`: + bool + f32 + f64 + i128 + i16 + i32 + i64 + i8 + and $N others + = note: required for `stack_encrypt::Ciphertext` to implement `EncryptTarget>` + = note: 1 redundant requirement hidden + = note: required for `Vec` to implement `EncryptTarget>>` error[E0599]: the method `encrypt_into` exists for struct `Vec<&str>`, but its trait bounds were not satisfied - --> tests/ui/owned_collection_elements.rs:10:43 + --> tests/ui/owned_collection_elements.rs:12:43 | -10 | let _ = vec!["borrowed", "plaintext"].encrypt_into::, _>(keyset); +12 | let _ = vec!["borrowed", "plaintext"].encrypt_into::, _>(keyset); | ^^^^^^^^^^^^ | ::: $RUST/alloc/src/vec/mod.rs @@ -67,5 +67,81 @@ error[E0599]: the method `encrypt_into` exists for struct `Vec<&str>`, but its t = help: items from traits can only be used if the trait is in scope help: trait `LegacyEncryptInto` which provides `encrypt_into` is implemented but not in scope; perhaps you want to import it | - 6 + use stack_encrypt::target::legacy::EncryptInto; + 8 + use stack_encrypt::target::legacy::EncryptInto; + | + +error[E0277]: the trait bound `Vec: OpaqueEncode` is not satisfied + --> tests/ui/owned_collection_elements.rs:13:26 + | +13 | let _ = vec![1u8, 2].encrypt_into::(keyset); + | ^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `Vec` + | + = help: the following other types implement trait `OpaqueEncode`: + bool + f32 + f64 + i128 + i16 + i32 + i64 + i8 + and $N others + = note: required for `stack_encrypt::Ciphertext` to implement `EncryptTarget>` + +error[E0277]: the trait bound `Vec: OpaqueEncode` is not satisfied + --> tests/ui/owned_collection_elements.rs:14:27 + | +14 | let _ = vec![1i32, 2].encrypt_into::(keyset); + | ^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `Vec` + | + = help: the following other types implement trait `OpaqueEncode`: + bool + f32 + f64 + i128 + i16 + i32 + i64 + i8 + and $N others + = note: required for `stack_encrypt::Ciphertext` to implement `EncryptTarget>` + +error[E0277]: the trait bound `u8: OpaqueDecode` is not satisfied + --> tests/ui/owned_collection_elements.rs:15:13 + | +15 | let _ = Vec::::decrypt_from(column.clone(), keyset); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `OpaqueDecode` is not implemented for `u8` + | + = help: the following other types implement trait `OpaqueDecode`: + f32 + f64 + i128 + i16 + i32 + i64 + i8 + u128 + and $N others + = note: required for `stack_encrypt::Ciphertext` to implement `DecryptSource` + = note: 1 redundant requirement hidden + = note: required for `Vec` to implement `DecryptSource>` + +error[E0277]: the trait bound `Option: OpaqueDecode` is not satisfied + --> tests/ui/owned_collection_elements.rs:16:13 + | +16 | let _ = Vec::>::decrypt_from(column, keyset); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `OpaqueDecode` is not implemented for `Option` | + = help: the following other types implement trait `OpaqueDecode`: + bool + f32 + f64 + i128 + i16 + i32 + i64 + i8 + and $N others + = note: required for `stack_encrypt::Ciphertext` to implement `DecryptSource>` + = note: 1 redundant requirement hidden + = note: required for `Vec` to implement `DecryptSource>>` From c762f4067167cee54994e84f6dd411f2500e1263 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 23:16:11 +1100 Subject: [PATCH 05/17] test(stack-encrypt): enroll the owned-column drop witnesses, which see a leak the heap observer cannot --- conformance/stack-encrypt/lifecycle-contract.md | 10 +++++++--- .../v1/lifecycle-v1.enrollment.json | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/conformance/stack-encrypt/lifecycle-contract.md b/conformance/stack-encrypt/lifecycle-contract.md index e9cb8e04c..68f913e6c 100644 --- a/conformance/stack-encrypt/lifecycle-contract.md +++ b/conformance/stack-encrypt/lifecycle-contract.md @@ -12,8 +12,8 @@ process. Python and TypeScript are explicitly not enrolled. |---|---|---| | `lifecycle-ownership-v1` | Actual record operations and owned `Vec` to `Vec` conversions release fulfilment captures exactly once on success, provider failure and unpolled drop; an unpolled encrypt or decrypt makes no key request. Zipped fulfilments do not share key material. | Client keys are consumed/redacted; explicit close and unreachable-client cleanup release guest memory; calls on a closed client fail. GC cleanup is eventual, so callers should close explicitly. | | `lifecycle-cancellation-v1` | Dropping an encrypt future paused in key generation (for a record operation or an owned column), or a decrypt future paused in key retrieval, drops the provider future and operation captures; subsequent work on the cipher succeeds. Guest ABI abort spends the handle and drops the operation. | Cancellation before a call sends no request; cancellation between steps or during token fetch leaves unrelated operations usable. A guest step that overruns cancellation closes the client. Discarded late output is wiped. | -| `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue, through the dynamic record path and the owned `Vec` to `Vec` conversion alike. Existing context cases reject late authentication failures without returning partial output. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | -| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths, including an owned `Vec` column sealed through its guard, and has a negative control. Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | +| `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue, through the dynamic record path and the owned `Vec` to `Vec` conversion alike; owned-column drop witnesses show that prefix is wiped, not leaked, after a late failure or a panicking element conversion. Existing context cases reject late authentication failures without returning partial output. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | +| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths, including an owned `Vec` column sealed through its guard, and has a negative control. Drop witnesses show an owned column's source is wiped before its pending is returned, including on a refused or panicking context. Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | | `lifecycle-diagnostics-v1` | Guest errors discard untrusted foreign cause messages; failed/successful exports do not hand over stale diagnostics. | Structured errors remain available after failures; SDK errors and redacted display/logging expose types or fields rather than plaintext values. | | `lifecycle-validation-before-io-v1` | Existing runtime/read/context cases count provider calls. Wrong shape, independently detectable context mismatch and oversized descriptors fail before key I/O; lazy assembly stops after local failure. | Host refusals happen before entering the guest, and shared runtime/read cases exercise the uninitialised guest's preflight boundary. Go does not yet expose native provider counters. | | `lifecycle-logical-batching-v1` | Instrumented `DataKeySource` calls establish one logical generation batch, retrieval grouping by keyset, descriptor ordering and no work for empty batches, for record operations and for an owned `Vec` column of any length including zero. | Go uses the same engine, but this profile does not claim independently instrumented Go key-request counts. | @@ -59,7 +59,11 @@ These entries in the index are limitations, not successful requirements: sealing frame by backtrace and reports excluded frees. It scrubs stack residue before observing heap frees; it does not prove stack erasure. Its JSON query paths use borrowed/static path text because owned path copies have separate - cleanup limitations. The lifecycle runner preserves the observer's output. + cleanup limitations. It sees only blocks that are freed: plaintext leaked and + never freed is not observed. For the owned `Vec` conversion, drop + witnesses in `packages/stack-encrypt/tests/collections.rs` observe the wipe + itself, so a leaked source or recovered prefix fails there. The lifecycle + runner preserves the observer's output. - **`caller-owned-host-values` — outside the guarantee.** Caller-owned strings, bytes, objects, returned plaintext, aliases and copies remain the caller's responsibility. Ownership transfer or protected borrowing must be stated by diff --git a/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json b/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json index 913fdbab1..b9a0e39d6 100644 --- a/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json +++ b/conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json @@ -27,6 +27,14 @@ "binary": "heap_hygiene", "success_output": "immediate" }, + "collections": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "features": ["--all-features"], + "binary": "collections" + }, "runtime": { "language": "rust", "lane": "fast", @@ -134,6 +142,10 @@ "a_late_read_type_failure_leaves_no_plaintext_on_the_heap", "a_late_type_failure_in_an_owned_column_leaves_no_plaintext_on_the_heap" ], + "collections": [ + "a_late_element_failure_wipes_the_plaintext_recovered_before_it", + "a_panicking_element_conversion_wipes_the_plaintext_recovered_before_it" + ], "contexts": ["shared_context_boundaries"], "go": [ "TestContextBoundaries", @@ -153,6 +165,11 @@ "a_json_document_read_from_a_frame_leaves_no_plaintext_on_the_heap", "the_spy_sees_an_unwiped_free" ], + "collections": [ + "owned_elements_are_wiped_before_the_pending_is_returned", + "owned_elements_are_wiped_when_the_context_is_refused", + "owned_elements_are_guarded_during_context_conversion" + ], "guest-abi": [ "buffers::tests::wipe_all_leaves_no_live_buffer", "last_error::tests::clearing_wipes_and_frees_the_registered_buffer", From 4b4a5b4eeaec29cf8b3c0ba5c1f2f8d30b4df11d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 23:52:32 +1100 Subject: [PATCH 06/17] test(stack-encrypt): pin the borrowed element refusal on the trait, not the method Calling `encrypt_into` as a method on `Vec<&str>` fails method resolution (E0599), and rustc then quotes the `Vec` definition from the standard library source when the `rust-src` component is installed. CI's toolchain does not install it, so the recorded expectation matched locally and not in CI. Calling the trait function directly fails on the element bound instead (`&str: OpaqueEncode`), which names the actual refusal and prints the same text with or without `rust-src`. --- .../tests/ui/owned_collection_elements.rs | 2 +- .../tests/ui/owned_collection_elements.stderr | 74 +++++++++++-------- 2 files changed, 45 insertions(+), 31 deletions(-) diff --git a/packages/stack-encrypt/tests/ui/owned_collection_elements.rs b/packages/stack-encrypt/tests/ui/owned_collection_elements.rs index c0cec89e2..8db547ebd 100644 --- a/packages/stack-encrypt/tests/ui/owned_collection_elements.rs +++ b/packages/stack-encrypt/tests/ui/owned_collection_elements.rs @@ -9,7 +9,7 @@ use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto}; fn unsupported(keyset: &stack_encrypt::KeysetCipher<'_, K>, column: Vec) { let _ = vec![1u8, 2].encrypt_into::, _>(keyset); let _ = vec![Some(1i32), None].encrypt_into::, _>(keyset); - let _ = vec!["borrowed", "plaintext"].encrypt_into::, _>(keyset); + let _ = EncryptInto::encrypt_into::, _>(vec!["borrowed", "plaintext"], keyset); let _ = vec![1u8, 2].encrypt_into::(keyset); let _ = vec![1i32, 2].encrypt_into::(keyset); let _ = Vec::::decrypt_from(column.clone(), keyset); diff --git a/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr b/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr index 41c3c0cdd..1130a9e9c 100644 --- a/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr +++ b/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr @@ -38,37 +38,51 @@ error[E0277]: the trait bound `Option: OpaqueEncode` is not satisfied = note: 1 redundant requirement hidden = note: required for `Vec` to implement `EncryptTarget>>` -error[E0599]: the method `encrypt_into` exists for struct `Vec<&str>`, but its trait bounds were not satisfied - --> tests/ui/owned_collection_elements.rs:12:43 - | -12 | let _ = vec!["borrowed", "plaintext"].encrypt_into::, _>(keyset); - | ^^^^^^^^^^^^ - | - ::: $RUST/alloc/src/vec/mod.rs - | - | pub struct Vec { - | ------------------------------------------------------------------------------------------------ doesn't satisfy `Vec<&str>: stack_encrypt::EncryptInto` or `Vec<&str>: zeroize::Zeroize` - | - = note: the following trait bounds were not satisfied: - `Vec<&str>: zeroize::Zeroize` - which is required by `Vec<&str>: stack_encrypt::EncryptInto` - `&Vec<&str>: zeroize::Zeroize` - which is required by `&Vec<&str>: stack_encrypt::EncryptInto` - `&mut Vec<&str>: zeroize::Zeroize` - which is required by `&mut Vec<&str>: stack_encrypt::EncryptInto` - `[&str]: Sized` - which is required by `[&str]: stack_encrypt::EncryptInto` - `[&str]: zeroize::Zeroize` - which is required by `[&str]: stack_encrypt::EncryptInto` - `&[&str]: zeroize::Zeroize` - which is required by `&[&str]: stack_encrypt::EncryptInto` - `&mut [&str]: zeroize::Zeroize` - which is required by `&mut [&str]: stack_encrypt::EncryptInto` - = help: items from traits can only be used if the trait is in scope -help: trait `LegacyEncryptInto` which provides `encrypt_into` is implemented but not in scope; perhaps you want to import it - | - 8 + use stack_encrypt::target::legacy::EncryptInto; +error[E0277]: the trait bound `&str: OpaqueEncode` is not satisfied + --> tests/ui/owned_collection_elements.rs:12:41 | +12 | let _ = EncryptInto::encrypt_into::, _>(vec!["borrowed", "plaintext"], keyset); + | ^^^^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `&str` + | +help: the trait `OpaqueEncode` is implemented for `std::string::String` + --> src/scalar.rs + | + | impl OpaqueEncode for $ty {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +... + | opaque!(bool, i8, i16, i32, i64, i128, u16, u32, u64, u128, f32, f64, String); + | ----------------------------------------------------------------------------- in this macro invocation + = note: required for `stack_encrypt::Ciphertext` to implement `EncryptTarget<&str>` + = note: 1 redundant requirement hidden + = note: required for `Vec` to implement `EncryptTarget>` +note: required by a bound in `stack_encrypt::EncryptInto::encrypt_into` + --> src/target/conversion.rs + | + | fn encrypt_into<'a, T, K: 'static>(self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> + | ------------ required by a bound in this associated function + | where + | T: EncryptTarget, + | ^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` + = note: this error originates in the macro `opaque` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: the trait bound `&str: zeroize::Zeroize` is not satisfied + --> tests/ui/owned_collection_elements.rs:12:13 + | +12 | let _ = EncryptInto::encrypt_into::, _>(vec!["borrowed", "plaintext"], keyset); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `zeroize::DefaultIsZeroes` is not implemented for `&str` + | + = help: the following other types implement trait `zeroize::DefaultIsZeroes`: + () + PhantomPinned + bool + char + f32 + f64 + i128 + i16 + and $N others + = note: required for `&str` to implement `zeroize::Zeroize` + = note: required for `Vec` to implement `EncryptTarget>` error[E0277]: the trait bound `Vec: OpaqueEncode` is not satisfied --> tests/ui/owned_collection_elements.rs:13:26 From 8c4e9384757631e431eab540d0c52a298f63a14e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 5 Nov 2026 22:23:58 +1100 Subject: [PATCH 07/17] build(stack-encrypt)!: move to vitaminc 0.9 vitaminc 0.9 adds the encrypt-side Scalar marker (vitaminc#595) this PR's owned API needs. Its frame codec now writes into vitaminc_protected::Buffer, which wipes every allocation it outgrows, instead of a Vec, and a map's repeated key is found when the map ends, unnamed, so no key is copied out of the frame. stack-encrypt's frame writers write through a Buffer and move the finished bytes into the Protected> a ValueFrame holds, so ValueFrame's surface is unchanged. Buffer is re-exported beside Protected, since the re-exported codec returns it. A record source still reports Reason::RepeatedKey for a repeated key, now recognised from the writer's unnamed refusal. The Go guest and the EQL encryption tests, which build stack-encrypt by path, move with it. The order schemes move with it, to ore-rs 0.11 and clww-ore 0.7, their releases against vitaminc 0.9. Neither changes a stored order term. The EQL block ORE timing harness's ciphertext generator, which pins the same ore-rs as the root lockfile, moves too. --- Cargo.lock | 166 ++++++++--------- Cargo.toml | 24 +-- languages/golang/encrypt/guest/Cargo.lock | 160 ++++++++--------- languages/golang/encrypt/guest/src/io.rs | 6 +- languages/golang/encrypt/guest/src/ops.rs | 10 +- languages/golang/encrypt/guest/src/targets.rs | 3 +- .../golang/encrypt/guest/src/test_frame.rs | 4 +- .../golang/encrypt/guest/tests/support/mod.rs | 6 +- packages/eql/Cargo.lock | 170 +++++++++--------- packages/eql/tests/encryption/Cargo.toml | 2 +- .../encryption/tests/json_tree/elements.rs | 15 +- .../encryption/tests/json_tree/predicate.rs | 7 +- .../encryption/tests/json_tree_data_plan.rs | 28 ++- .../timing/ore_block_v2/ctgen/Cargo.lock | 48 ++--- .../timing/ore_block_v2/ctgen/Cargo.toml | 8 +- packages/stack-encrypt/CHANGELOG.md | 16 ++ packages/stack-encrypt/src/dynamic/context.rs | 14 +- packages/stack-encrypt/src/dynamic/query.rs | 30 ++-- packages/stack-encrypt/src/dynamic/record.rs | 22 +-- packages/stack-encrypt/src/dynamic/term.rs | 9 +- .../stack-encrypt/src/dynamic/test_frame.rs | 6 +- .../stack-encrypt/src/dynamic/value_frame.rs | 85 +++++---- packages/stack-encrypt/src/lib.rs | 5 +- packages/stack-encrypt/tests/conformance.rs | 8 +- packages/stack-encrypt/tests/declarations.rs | 15 +- packages/stack-encrypt/tests/heap_hygiene.rs | 9 +- packages/stack-encrypt/tests/json_index.rs | 21 ++- .../stack-encrypt/tests/json_operators.rs | 33 +++- .../tests/lifecycle_conformance.rs | 4 +- .../stack-encrypt/tests/query_operators.rs | 4 +- .../stack-encrypt/tests/read_conformance.rs | 8 +- .../stack-encrypt/tests/record_lowering.rs | 5 +- .../tests/runtime_conformance.rs | 10 +- packages/stack-encrypt/tests/stored_rows.rs | 8 +- packages/stack-encrypt/tests/value_kinds.rs | 13 +- 35 files changed, 564 insertions(+), 418 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e14762064..f24c7111a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -556,16 +556,16 @@ dependencies = [ [[package]] name = "clww-ore" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95747f5724666cb973b2ca0659e10b6083c11f99877c4727b4c9f534a591c74" +checksum = "9527fcb65fbebc5a8f8987063c171bb37bb37fb628de1293e07c1b6e0a21a24f" dependencies = [ "blake3", "hex", "subtle", "thiserror 1.0.69", "vitaminc-ore", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", ] @@ -1519,7 +1519,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "tokio", "tower-service", "tracing", @@ -2299,9 +2299,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "ore-rs" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bd65962ec1409c09d20e1e016f4fe1b4ae2e2e58dba38c155538b2c0801a185" +checksum = "b274b7a0bf081d51815ccc7b20fa3b023e5769e3446fe4de1e8e8044a4413201" dependencies = [ "aes", "block-modes", @@ -2311,12 +2311,12 @@ dependencies = [ "rand 0.10.1", "subtle-ng", "thiserror 1.0.69", - "vitaminc-context 0.8.0", + "vitaminc-context 0.9.0", "vitaminc-hmac", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] @@ -2542,7 +2542,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.18", "tokio", "tracing", @@ -2581,7 +2581,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", "windows-sys 0.52.0", ] @@ -3361,8 +3361,8 @@ dependencies = [ "tracing-subscriber", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.9.0", + "vitaminc-protected 0.9.0", "web-time", "zeroize", "zerokms-protocol", @@ -3384,7 +3384,7 @@ dependencies = [ "tempfile", "tokio", "url", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", ] @@ -3433,15 +3433,15 @@ dependencies = [ "tokio", "trybuild", "uuid", - "vitaminc 0.8.0", - "vitaminc-aead 0.8.0", + "vitaminc 0.9.0", + "vitaminc-aead 0.9.0", "vitaminc-aead-frame", - "vitaminc-encrypt 0.8.0", + "vitaminc-encrypt 0.9.0", "vitaminc-hmac", "vitaminc-normalize", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", "zerokms-protocol", ] @@ -3467,9 +3467,9 @@ dependencies = [ "proptest", "serde_json", "thiserror 1.0.69", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.9.0", "vitaminc-aead-frame", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", ] @@ -3502,8 +3502,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.9.0", + "vitaminc-protected 0.9.0", "zeroize", "zerokms-protocol", ] @@ -4265,16 +4265,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "e7aa53d696a9e1af61b36a60b92cc0dde7421f8f58787333d3c425a176af241c" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-context 0.9.0", + "vitaminc-encrypt 0.9.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", + "vitaminc-traits 0.9.0", ] [[package]] @@ -4294,9 +4294,9 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "efba1dac1efc04ffc09cba49e343e0fe656c88c32472a4206f2a6df284add33d" dependencies = [ "bytes", "chrono", @@ -4304,10 +4304,10 @@ dependencies = [ "rust_decimal", "serde", "thiserror 2.0.18", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.9.0", + "vitaminc-context 0.9.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] @@ -4324,9 +4324,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "a3c6575dac7701e578b375d16a0b693294d73847ed275c9b9962cf20c423ea10" dependencies = [ "proc-macro2", "quote", @@ -4335,13 +4335,13 @@ dependencies = [ [[package]] name = "vitaminc-aead-frame" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0299777255171712a296d59164691dc1e93d10bddaab6e248fede1790dfd2e" +checksum = "ba4a3e8a2057ce32724b0d42f71e87ee348d13c818fc54a16c443b86c4fe8d9a" dependencies = [ "serde", - "vitaminc-aead 0.8.0", - "zeroize", + "vitaminc-aead 0.9.0", + "vitaminc-protected 0.9.0", ] [[package]] @@ -4356,12 +4356,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "5b407b3127be198a8da7d4d914c11215e1eae1b467cdf89eca8a7a296055e863" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", ] [[package]] @@ -4380,72 +4380,72 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "7fa0a3493624afd1490085448d6d09d22bf061954e9ab0db08837f423db3a58d" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] [[package]] name = "vitaminc-hmac" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5fa0ae8bbad85ae30fb5c91b0b72b694d59bf2c2c1e7ad799eda66fdbd67e7c" +checksum = "ada61349172294efe0a71bf2af367969b4e196437b46c20c688aaf15f692bc2d" dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", ] [[package]] name = "vitaminc-normalize" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a2d8875e0b8b35b7a4f9dd698bbd13c989837cea564b5a4cbb5371b78fa937" +checksum = "f1d354f4ee3764be072eb02f4f8db79ed0ed467a7c6d9f2c6143823867bde347" dependencies = [ "thiserror 2.0.18", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-protected 0.9.0", ] [[package]] name = "vitaminc-ore" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05518b4d35cb0c592dad59afb11b8e883ea252449b9ae47ff5ddeff0ed49f75" +checksum = "9a812b8e245352094c3c2113312cf86d0804b83f1e315bacd15571cd01703c8b" dependencies = [ "thiserror 2.0.18", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-context 0.9.0", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916688478273ca3e5c6805b355534ce9263688391cd4ab23b08bef760039d325" +checksum = "d1703452d837ef964ec1f20cc601265857245d72ac14426276d49c3106dd39b3" dependencies = [ "mutants", "thiserror 2.0.18", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-context 0.9.0", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", ] [[package]] @@ -4467,9 +4467,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "44e61d6e58132793aace6898403ffa949bc06e388c1fc89c08d48ad0303133c3" dependencies = [ "bitvec", "digest 0.11.3", @@ -4478,7 +4478,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.18", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.9.0", "zeroize", ] @@ -4495,9 +4495,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "a60e915eb16c115882ea4754cecc99d7bcd3d8a1bfb9b8e512af4b6f7dd14801" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -4522,16 +4522,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "71e32850b739b58a7064b50eb3db655de323f57f21c1fe74d5ccd4d308560563" dependencies = [ "chacha20", "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.9.0", + "vitaminc-random-derives 0.9.0", "zeroize", ] @@ -4548,9 +4548,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "1863fe02de3945047dc9094d3cadbde06becafa49aa95d5e475bf903c8d83981" dependencies = [ "proc-macro2", "quote", @@ -4575,17 +4575,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a715ef503b1b41801a6c23e9756ed3d5a1201fa632609bccfa2a7a41ab5386cf" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] @@ -4843,7 +4843,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d3234daee..19339fc61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,15 +82,15 @@ cts-common = { version = "0.43.0", default-features = false } zerokms-protocol = "0.12.31" recipher = "=0.3.1" # The two order-preserving schemes Stack Encrypt's order terms encrypt -# through. Both implement vitaminc-ore's `OrderScheme` against vitaminc 0.8: +# through. Both implement vitaminc-ore's `OrderScheme` against vitaminc 0.9: # `clww-ore` (cllw-ore renamed) for CLWW ORE and OPE, and ore-rs's # `BlockOre` for block ORE, which routes a value to its 6-bit fixed-width # engine (up to ten bytes) or its chained one (wider, or variable length), # the terms EQL v4's `_ord_ore` domains store. # vitaminc is a plain dependency of both from here, not a feature. Both are # pinned exactly: a new release can change every stored order term. -clww-ore = { version = "=0.6.0", default-features = false } -ore-rs = { version = "=0.10.0", default-features = false } +clww-ore = { version = "=0.7.0", default-features = false } +ore-rs = { version = "=0.11.0", default-features = false } # External dependencies, with the suite's feature lists. base64 = "0.22.0" @@ -139,18 +139,18 @@ web-time = "1.1" zeroize = { version = "1.8.1", features = ["derive"] } # This is needed because lock_api 0.4.6 was breaking things and the branch forces the use of 0.4.12 temp-env = { git = "https://github.com/cipherstash/temp-env", branch = "main" } -vitaminc = { version = "0.8.0", features = ["random", "protected", "encrypt"] } -vitaminc-aead = "0.8.0" +vitaminc = { version = "0.9.0", features = ["random", "protected", "encrypt"] } +vitaminc-aead = "0.9.0" # The transport frame and ciphertext codec every language binding carries # across its FFI boundary. Optional in stack-encrypt (the `dynamic` feature). -vitaminc-aead-frame = "0.8.0" -vitaminc-encrypt = "0.8.0" -vitaminc-hmac = "0.8.0" +vitaminc-aead-frame = "0.9.0" +vitaminc-encrypt = "0.9.0" +vitaminc-hmac = "0.9.0" # Normalized equality terms (Unicode 16 NFC text, one integer encoding for # every width), which `vitaminc-prf`'s `normalize` feature derives over. -vitaminc-normalize = "0.8.0" +vitaminc-normalize = "0.9.0" # The text order key: the system ADR-0002's fold, pinned to Unicode 16, # which text ORE and OPE terms are derived over (ADR-0009). -vitaminc-ore = "0.8.0" -vitaminc-prf = { version = "0.8.0", features = ["normalize"] } -vitaminc-protected = "0.8.0" +vitaminc-ore = "0.9.0" +vitaminc-prf = { version = "0.9.0", features = ["normalize"] } +vitaminc-protected = "0.9.0" diff --git a/languages/golang/encrypt/guest/Cargo.lock b/languages/golang/encrypt/guest/Cargo.lock index 81cbcb3c3..df1645103 100644 --- a/languages/golang/encrypt/guest/Cargo.lock +++ b/languages/golang/encrypt/guest/Cargo.lock @@ -357,16 +357,16 @@ dependencies = [ [[package]] name = "clww-ore" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95747f5724666cb973b2ca0659e10b6083c11f99877c4727b4c9f534a591c74" +checksum = "9527fcb65fbebc5a8f8987063c171bb37bb37fb628de1293e07c1b6e0a21a24f" dependencies = [ "blake3", "hex", "subtle", "thiserror 1.0.69", "vitaminc-ore", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", ] @@ -752,7 +752,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1458,9 +1458,9 @@ dependencies = [ [[package]] name = "ore-rs" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bd65962ec1409c09d20e1e016f4fe1b4ae2e2e58dba38c155538b2c0801a185" +checksum = "b274b7a0bf081d51815ccc7b20fa3b023e5769e3446fe4de1e8e8044a4413201" dependencies = [ "aes", "block-modes", @@ -1470,12 +1470,12 @@ dependencies = [ "rand 0.10.2", "subtle-ng", "thiserror 1.0.69", - "vitaminc-context 0.8.0", + "vitaminc-context 0.9.0", "vitaminc-hmac", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] @@ -2110,8 +2110,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.9.0", + "vitaminc-protected 0.9.0", "web-time", "zeroize", "zerokms-protocol", @@ -2136,15 +2136,15 @@ dependencies = [ "stack-kms", "thiserror 1.0.69", "uuid", - "vitaminc 0.8.0", - "vitaminc-aead 0.8.0", + "vitaminc 0.9.0", + "vitaminc-aead 0.9.0", "vitaminc-aead-frame", - "vitaminc-encrypt 0.8.0", + "vitaminc-encrypt 0.9.0", "vitaminc-hmac", "vitaminc-normalize", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", ] @@ -2184,9 +2184,9 @@ dependencies = [ "miette", "serde_json", "thiserror 1.0.69", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.9.0", "vitaminc-aead-frame", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", ] @@ -2212,8 +2212,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.9.0", + "vitaminc-protected 0.9.0", "zeroize", "zerokms-protocol", ] @@ -2688,16 +2688,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "e7aa53d696a9e1af61b36a60b92cc0dde7421f8f58787333d3c425a176af241c" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-context 0.9.0", + "vitaminc-encrypt 0.9.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", + "vitaminc-traits 0.9.0", ] [[package]] @@ -2717,18 +2717,18 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "efba1dac1efc04ffc09cba49e343e0fe656c88c32472a4206f2a6df284add33d" dependencies = [ "bytes", "futures-util", "serde", "thiserror 2.0.20", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.9.0", + "vitaminc-context 0.9.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] @@ -2745,9 +2745,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "a3c6575dac7701e578b375d16a0b693294d73847ed275c9b9962cf20c423ea10" dependencies = [ "proc-macro2", "quote", @@ -2756,13 +2756,13 @@ dependencies = [ [[package]] name = "vitaminc-aead-frame" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0299777255171712a296d59164691dc1e93d10bddaab6e248fede1790dfd2e" +checksum = "ba4a3e8a2057ce32724b0d42f71e87ee348d13c818fc54a16c443b86c4fe8d9a" dependencies = [ "serde", - "vitaminc-aead 0.8.0", - "zeroize", + "vitaminc-aead 0.9.0", + "vitaminc-protected 0.9.0", ] [[package]] @@ -2777,12 +2777,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "5b407b3127be198a8da7d4d914c11215e1eae1b467cdf89eca8a7a296055e863" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", ] [[package]] @@ -2801,72 +2801,72 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "7fa0a3493624afd1490085448d6d09d22bf061954e9ab0db08837f423db3a58d" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] [[package]] name = "vitaminc-hmac" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5fa0ae8bbad85ae30fb5c91b0b72b694d59bf2c2c1e7ad799eda66fdbd67e7c" +checksum = "ada61349172294efe0a71bf2af367969b4e196437b46c20c688aaf15f692bc2d" dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", ] [[package]] name = "vitaminc-normalize" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a2d8875e0b8b35b7a4f9dd698bbd13c989837cea564b5a4cbb5371b78fa937" +checksum = "f1d354f4ee3764be072eb02f4f8db79ed0ed467a7c6d9f2c6143823867bde347" dependencies = [ "thiserror 2.0.20", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-protected 0.9.0", ] [[package]] name = "vitaminc-ore" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05518b4d35cb0c592dad59afb11b8e883ea252449b9ae47ff5ddeff0ed49f75" +checksum = "9a812b8e245352094c3c2113312cf86d0804b83f1e315bacd15571cd01703c8b" dependencies = [ "thiserror 2.0.20", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-context 0.9.0", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916688478273ca3e5c6805b355534ce9263688391cd4ab23b08bef760039d325" +checksum = "d1703452d837ef964ec1f20cc601265857245d72ac14426276d49c3106dd39b3" dependencies = [ "mutants", "thiserror 2.0.20", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-context 0.9.0", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", ] [[package]] @@ -2888,9 +2888,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "44e61d6e58132793aace6898403ffa949bc06e388c1fc89c08d48ad0303133c3" dependencies = [ "bitvec", "digest 0.11.3", @@ -2899,7 +2899,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.20", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.9.0", "zeroize", ] @@ -2916,9 +2916,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "a60e915eb16c115882ea4754cecc99d7bcd3d8a1bfb9b8e512af4b6f7dd14801" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2943,16 +2943,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "71e32850b739b58a7064b50eb3db655de323f57f21c1fe74d5ccd4d308560563" dependencies = [ "chacha20", "getrandom 0.4.3", "rand 0.10.2", "thiserror 2.0.20", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.9.0", + "vitaminc-random-derives 0.9.0", "zeroize", ] @@ -2969,9 +2969,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "1863fe02de3945047dc9094d3cadbde06becafa49aa95d5e475bf903c8d83981" dependencies = [ "proc-macro2", "quote", @@ -2996,17 +2996,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a715ef503b1b41801a6c23e9756ed3d5a1201fa632609bccfa2a7a41ab5386cf" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.20", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] @@ -3093,7 +3093,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/languages/golang/encrypt/guest/src/io.rs b/languages/golang/encrypt/guest/src/io.rs index 29206fd83..d71fcd50f 100644 --- a/languages/golang/encrypt/guest/src/io.rs +++ b/languages/golang/encrypt/guest/src/io.rs @@ -29,6 +29,7 @@ use serde::{Deserialize, Serialize, Serializer}; use stack_encrypt::dynamic::frame::serde::{from_slice, to_vec}; +use stack_encrypt::Controlled as _; use stack_guest_abi::last_error::malformed; use stack_guest_abi::step::{Request, Response}; use zeroize::Zeroizing; @@ -63,13 +64,14 @@ pub fn encode_step(handle: u64, requests: &[Request]) -> Result, u32> { }) .collect(), }; - let frame = Zeroizing::new(to_vec(&wire).map_err(|_| STATUS_ENCODING)?); + // A `Buffer`, which wipes on drop and as it grows. + let frame = to_vec(&wire).map_err(|_| STATUS_ENCODING)?; // Sized exactly, as every output is: the registry would otherwise // reallocate a buffer with spare capacity and leave the old one unwiped. let mut out = Vec::new(); out.try_reserve_exact(frame.len()) .map_err(|_| stack_guest_abi::last_error::internal("no memory for a step's requests"))?; - out.extend_from_slice(&frame); + out.extend_from_slice(frame.risky_ref()); Ok(out) } diff --git a/languages/golang/encrypt/guest/src/ops.rs b/languages/golang/encrypt/guest/src/ops.rs index 3b2c65e25..d69536b0f 100644 --- a/languages/golang/encrypt/guest/src/ops.rs +++ b/languages/golang/encrypt/guest/src/ops.rs @@ -49,10 +49,10 @@ use stack_encrypt::dynamic::frame::{decode_ciphertext, encode_ciphertext, Frame} use stack_encrypt::dynamic::{self, Scope, ValueFrame}; use stack_encrypt::sem::MatchOptions; use stack_encrypt::target::IndexSpec; +use stack_encrypt::Controlled as _; use stack_encrypt::ErrorPayload; use stack_encrypt::{CipherText, KeysetCipher, Label, Node, SealedValue, StackCipherText}; use stack_kms::{DataKeySource, IndexKeySource}; -use zeroize::Zeroizing; use stack_guest_abi::last_error; @@ -621,11 +621,11 @@ fn not_a_frame() -> u32 { /// is sized as every other output is. A query term's search tokens are /// derived from plaintext, so the staging buffer is wiped once copied. fn encode_answer(answer: &T) -> Result, u32> { - let frame = Zeroizing::new( - stack_encrypt::dynamic::frame::serde::to_vec(answer).map_err(|_| STATUS_ENCODING)?, - ); + // A `Buffer`, which wipes on drop and as it grows. + let frame = + stack_encrypt::dynamic::frame::serde::to_vec(answer).map_err(|_| STATUS_ENCODING)?; let mut out = exact_buffer(Some(frame.len()))?; - out.extend_from_slice(&frame); + out.extend_from_slice(frame.risky_ref()); Ok(out) } diff --git a/languages/golang/encrypt/guest/src/targets.rs b/languages/golang/encrypt/guest/src/targets.rs index c61b7083e..630332bda 100644 --- a/languages/golang/encrypt/guest/src/targets.rs +++ b/languages/golang/encrypt/guest/src/targets.rs @@ -321,7 +321,8 @@ mod tests { } }); let frame = dynamic::frame::serde::to_vec(&wire).expect("plan frame"); - let plan = record::plan_with(&frame, &resolver()).expect("plan"); + let plan = record::plan_with(stack_encrypt::Controlled::risky_ref(&frame), &resolver()) + .expect("plan"); dynamic::field_operators(&plan, "profile", &resolver()) .expect("operators") .into_iter() diff --git a/languages/golang/encrypt/guest/src/test_frame.rs b/languages/golang/encrypt/guest/src/test_frame.rs index 58e492663..3f4693ae9 100644 --- a/languages/golang/encrypt/guest/src/test_frame.rs +++ b/languages/golang/encrypt/guest/src/test_frame.rs @@ -27,7 +27,9 @@ impl Serialize for RawBytes<'_> { /// The frame bytes of `v`. pub(crate) fn frame(v: &V) -> Vec { - let scalar = |r: Result, _>| r.expect("a scalar frames"); + let scalar = |r: Result| { + stack_encrypt::Controlled::risky_unwrap(r.expect("a scalar frames")) + }; let container = |tag: u8, len: usize| { let mut out = vec![tag]; out.extend_from_slice(&u32::try_from(len).expect("short").to_le_bytes()); diff --git a/languages/golang/encrypt/guest/tests/support/mod.rs b/languages/golang/encrypt/guest/tests/support/mod.rs index e21c00bcb..57f0fd7d7 100644 --- a/languages/golang/encrypt/guest/tests/support/mod.rs +++ b/languages/golang/encrypt/guest/tests/support/mod.rs @@ -15,7 +15,7 @@ use stack_encrypt::dynamic::plaintext::{ visit_map, visit_seq, Error as _, FixedType, Float, Int, Logical, MapSink, Physical, SeqSink, Sink, SourceError, }; -use stack_encrypt::{Controlled, Protected}; +use stack_encrypt::Controlled; use stack_encrypt::{DecodeError, Decrypt, Encrypt}; /// A value as a test spells it: each scalar at its own width, a logical @@ -80,7 +80,7 @@ impl PartialEq for Tree { impl Tree { /// The tree's frame, written by vitaminc's own frame writer, which /// refuses a repeated key. - pub(crate) fn to_frame(&self) -> Result>, FrameWriteError> { + pub(crate) fn to_frame(&self) -> Result { to_frame(self) } @@ -141,7 +141,7 @@ fn write(value: &Tree, out: &mut Vec) { /// read back. pub(crate) fn value_of(answer: &T) -> Tree { let frame = stack_encrypt::dynamic::frame::serde::to_vec(answer).expect("frames"); - Tree::from_frame(&frame).expect("a value") + Tree::from_frame(frame.risky_ref()).expect("a value") } fn scalar(sink: S, ty: Option, value: &Tree) -> Result { diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index a71c1d4e3..12f96145e 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -631,16 +631,16 @@ dependencies = [ [[package]] name = "clww-ore" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95747f5724666cb973b2ca0659e10b6083c11f99877c4727b4c9f534a591c74" +checksum = "9527fcb65fbebc5a8f8987063c171bb37bb37fb628de1293e07c1b6e0a21a24f" dependencies = [ "blake3", "hex", "subtle", "thiserror 1.0.69", "vitaminc-ore", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", ] @@ -1223,7 +1223,7 @@ dependencies = [ "stack-kms", "tokio", "uuid", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.9.0", ] [[package]] @@ -1272,7 +1272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2655,9 +2655,9 @@ dependencies = [ [[package]] name = "ore-rs" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bd65962ec1409c09d20e1e016f4fe1b4ae2e2e58dba38c155538b2c0801a185" +checksum = "b274b7a0bf081d51815ccc7b20fa3b023e5769e3446fe4de1e8e8044a4413201" dependencies = [ "aes", "block-modes", @@ -2667,12 +2667,12 @@ dependencies = [ "rand 0.10.1", "subtle-ng", "thiserror 1.0.69", - "vitaminc-context 0.8.0", + "vitaminc-context 0.9.0", "vitaminc-hmac", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] @@ -3037,7 +3037,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3465,7 +3465,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3522,7 +3522,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4148,8 +4148,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.9.0", + "vitaminc-protected 0.9.0", "web-time", "zeroize", "zerokms-protocol", @@ -4165,7 +4165,7 @@ dependencies = [ "clww-ore", "futures", "miette", - "ore-rs 0.10.0", + "ore-rs 0.11.0", "rand 0.10.1", "rust_decimal", "serde", @@ -4176,15 +4176,15 @@ dependencies = [ "stack-kms", "thiserror 1.0.69", "uuid", - "vitaminc 0.8.0", - "vitaminc-aead 0.8.0", + "vitaminc 0.9.0", + "vitaminc-aead 0.9.0", "vitaminc-aead-frame", - "vitaminc-encrypt 0.8.0", + "vitaminc-encrypt 0.9.0", "vitaminc-hmac", "vitaminc-normalize", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", ] @@ -4219,8 +4219,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.9.0", + "vitaminc-protected 0.9.0", "zeroize", "zerokms-protocol", ] @@ -4369,10 +4369,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4975,16 +4975,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "e7aa53d696a9e1af61b36a60b92cc0dde7421f8f58787333d3c425a176af241c" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-context 0.9.0", + "vitaminc-encrypt 0.9.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", + "vitaminc-traits 0.9.0", ] [[package]] @@ -5017,9 +5017,9 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "efba1dac1efc04ffc09cba49e343e0fe656c88c32472a4206f2a6df284add33d" dependencies = [ "bytes", "chrono", @@ -5027,10 +5027,10 @@ dependencies = [ "rust_decimal", "serde", "thiserror 2.0.20", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.9.0", + "vitaminc-context 0.9.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] @@ -5047,9 +5047,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "a3c6575dac7701e578b375d16a0b693294d73847ed275c9b9962cf20c423ea10" dependencies = [ "proc-macro2", "quote", @@ -5058,13 +5058,13 @@ dependencies = [ [[package]] name = "vitaminc-aead-frame" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0299777255171712a296d59164691dc1e93d10bddaab6e248fede1790dfd2e" +checksum = "ba4a3e8a2057ce32724b0d42f71e87ee348d13c818fc54a16c443b86c4fe8d9a" dependencies = [ "serde", - "vitaminc-aead 0.8.0", - "zeroize", + "vitaminc-aead 0.9.0", + "vitaminc-protected 0.9.0", ] [[package]] @@ -5079,12 +5079,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "5b407b3127be198a8da7d4d914c11215e1eae1b467cdf89eca8a7a296055e863" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", ] [[package]] @@ -5117,72 +5117,72 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "7fa0a3493624afd1490085448d6d09d22bf061954e9ab0db08837f423db3a58d" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] [[package]] name = "vitaminc-hmac" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5fa0ae8bbad85ae30fb5c91b0b72b694d59bf2c2c1e7ad799eda66fdbd67e7c" +checksum = "ada61349172294efe0a71bf2af367969b4e196437b46c20c688aaf15f692bc2d" dependencies = [ "hmac 0.13.0", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", ] [[package]] name = "vitaminc-normalize" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a2d8875e0b8b35b7a4f9dd698bbd13c989837cea564b5a4cbb5371b78fa937" +checksum = "f1d354f4ee3764be072eb02f4f8db79ed0ed467a7c6d9f2c6143823867bde347" dependencies = [ "thiserror 2.0.20", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-protected 0.9.0", ] [[package]] name = "vitaminc-ore" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05518b4d35cb0c592dad59afb11b8e883ea252449b9ae47ff5ddeff0ed49f75" +checksum = "9a812b8e245352094c3c2113312cf86d0804b83f1e315bacd15571cd01703c8b" dependencies = [ "thiserror 2.0.20", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-context 0.9.0", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916688478273ca3e5c6805b355534ce9263688391cd4ab23b08bef760039d325" +checksum = "d1703452d837ef964ec1f20cc601265857245d72ac14426276d49c3106dd39b3" dependencies = [ "mutants", "thiserror 2.0.20", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-context 0.9.0", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", ] [[package]] @@ -5219,9 +5219,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "44e61d6e58132793aace6898403ffa949bc06e388c1fc89c08d48ad0303133c3" dependencies = [ "bitvec", "digest 0.11.3", @@ -5230,7 +5230,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.20", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.9.0", "zeroize", ] @@ -5258,9 +5258,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "a60e915eb16c115882ea4754cecc99d7bcd3d8a1bfb9b8e512af4b6f7dd14801" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -5299,16 +5299,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "71e32850b739b58a7064b50eb3db655de323f57f21c1fe74d5ccd4d308560563" dependencies = [ "chacha20", "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.20", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.9.0", + "vitaminc-random-derives 0.9.0", "zeroize", ] @@ -5336,9 +5336,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "1863fe02de3945047dc9094d3cadbde06becafa49aa95d5e475bf903c8d83981" dependencies = [ "proc-macro2", "quote", @@ -5379,17 +5379,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a715ef503b1b41801a6c23e9756ed3d5a1201fa632609bccfa2a7a41ab5386cf" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.20", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] @@ -5658,7 +5658,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/packages/eql/tests/encryption/Cargo.toml b/packages/eql/tests/encryption/Cargo.toml index 787f19abc..9ab1fea1c 100644 --- a/packages/eql/tests/encryption/Cargo.toml +++ b/packages/eql/tests/encryption/Cargo.toml @@ -31,7 +31,7 @@ stack-encrypt = { path = "../../../stack-encrypt", default-features = false, fea # `plaintext::Sink`, for a plaintext type with its own `Encrypt` impl; and # chrono's and rust_decimal's own sources, which seal the leaf a date, # timestamp or decimal from any other producer seals. -vitaminc-aead = { version = "0.8.0", features = ["chrono", "rust_decimal"] } +vitaminc-aead = { version = "0.9.0", features = ["chrono", "rust_decimal"] } chrono = { version = "0.4.44", default-features = false, features = ["std"] } rust_decimal = { version = "1", default-features = false, features = ["std"] } stack-kms = { path = "../../../stack-kms", default-features = false, features = ["test-support"] } diff --git a/packages/eql/tests/encryption/tests/json_tree/elements.rs b/packages/eql/tests/encryption/tests/json_tree/elements.rs index 5e4ea4359..f3abe9389 100644 --- a/packages/eql/tests/encryption/tests/json_tree/elements.rs +++ b/packages/eql/tests/encryption/tests/json_tree/elements.rs @@ -29,6 +29,7 @@ use eql_bindings::encryption::targets::{self as eql_targets, Target}; use stack_encrypt::dynamic::{ self, record, OpSpec, Operand, TargetDescriptor, TargetError, TargetResolver, ValueFrame, }; +use stack_encrypt::Controlled as _; use stack_encrypt::{KeysetCipher, Label, Pending, StackCipher}; /// eql-bindings' table as the engine's resolver, naming each type's leaf, @@ -122,11 +123,15 @@ fn contact_plan() -> record::Plan { "target": {"name": "JsonSearch", "options": {"ordered": [RELATIONSHIP, CALLS]}} } }); - record::plan_with(&dynamic::frame::serde::to_vec(&wire).unwrap(), &Eql).unwrap() + record::plan_with( + &dynamic::frame::serde::to_vec(&wire).unwrap().risky_unwrap(), + &Eql, + ) + .unwrap() } pub(super) fn frame_of(value: &Value) -> ValueFrame { - ValueFrame::from_bytes(&dynamic::frame::serde::to_vec(value).unwrap()).unwrap() + ValueFrame::from_bytes(&dynamic::frame::serde::to_vec(value).unwrap().risky_unwrap()).unwrap() } /// The operand of `operator` on `profile` for `input`, as the SQL binds @@ -189,7 +194,11 @@ fn layout_plan(sealed: bool) -> record::Plan { }} } }); - record::plan_with(&dynamic::frame::serde::to_vec(&wire).unwrap(), &Eql).unwrap() + record::plan_with( + &dynamic::frame::serde::to_vec(&wire).unwrap().risky_unwrap(), + &Eql, + ) + .unwrap() } /// A contact with each field present when given. diff --git a/packages/eql/tests/encryption/tests/json_tree/predicate.rs b/packages/eql/tests/encryption/tests/json_tree/predicate.rs index 202937053..2fd8666ea 100644 --- a/packages/eql/tests/encryption/tests/json_tree/predicate.rs +++ b/packages/eql/tests/encryption/tests/json_tree/predicate.rs @@ -10,6 +10,7 @@ use super::elements::{derive, eql_text, frame_of, Eql}; use super::*; use stack_encrypt::dynamic::{self, record, OpSpec, Operand}; +use stack_encrypt::Controlled as _; use stack_encrypt::KeysetCipher; /// The paths the documents order: what the predicates' ranges ask at. @@ -35,7 +36,11 @@ fn people_plan() -> record::Plan { "target": {"name": "JsonSearch", "options": {"ordered": ORDERED}} } }); - record::plan_with(&dynamic::frame::serde::to_vec(&wire).unwrap(), &Eql).unwrap() + record::plan_with( + &dynamic::frame::serde::to_vec(&wire).unwrap().risky_unwrap(), + &Eql, + ) + .unwrap() } /// Documents with every field absent, `null` or a value of the one type its diff --git a/packages/eql/tests/encryption/tests/json_tree_data_plan.rs b/packages/eql/tests/encryption/tests/json_tree_data_plan.rs index a52ab565a..b81315518 100644 --- a/packages/eql/tests/encryption/tests/json_tree_data_plan.rs +++ b/packages/eql/tests/encryption/tests/json_tree_data_plan.rs @@ -29,6 +29,7 @@ use stack_encrypt::dynamic::{ }; use stack_encrypt::json::{JsonDocument, JsonOptions, JsonQuery}; use stack_encrypt::target::{ExpectedContext, Layout}; +use stack_encrypt::Controlled as _; use stack_encrypt::{CipherText, Encrypt, KeysetCipher, Label, NonEmpty, Pending, StackCipher}; /// eql-bindings' table as the engine's resolver, as the Go guest installs @@ -122,7 +123,11 @@ fn plan() -> record::Plan { } } }); - record::plan_with(&dynamic::frame::serde::to_vec(&wire).unwrap(), &Eql).unwrap() + record::plan_with( + &dynamic::frame::serde::to_vec(&wire).unwrap().risky_unwrap(), + &Eql, + ) + .unwrap() } fn frame(value: T) -> ValueFrame { @@ -411,7 +416,8 @@ async fn a_document_does_not_open_under_another_column() { } } })) - .unwrap(), + .unwrap() + .risky_unwrap(), &Eql, ) .unwrap(); @@ -488,7 +494,12 @@ fn input(entries: &[(&str, Value)]) -> ValueFrame { .iter() .map(|(k, v)| ((*k).to_owned(), v.clone())) .collect(); - ValueFrame::from_bytes(&dynamic::frame::serde::to_vec(&object).unwrap()).unwrap() + ValueFrame::from_bytes( + &dynamic::frame::serde::to_vec(&object) + .unwrap() + .risky_unwrap(), + ) + .unwrap() } /// The data path's term for one operator and input on `profile`. @@ -560,7 +571,8 @@ fn a_document_field_answers_the_json_operators() { &dynamic::frame::serde::to_vec(&json!({ "profile": {"context": ["users", "profile"], "target": "JsonSearch"} })) - .unwrap(), + .unwrap() + .risky_unwrap(), &Eql, ) .unwrap(); @@ -904,8 +916,12 @@ async fn open( request: Value, groups: Vec>, ) -> Result>, stack_encrypt::Error> { - let request = - ValueFrame::from_bytes(&dynamic::frame::serde::to_vec(&request).unwrap()).unwrap(); + let request = ValueFrame::from_bytes( + &dynamic::frame::serde::to_vec(&request) + .unwrap() + .risky_unwrap(), + ) + .unwrap(); let rows = frame(&groups); let opened = dynamic::json_open(keyset, plan, "profile", request, rows, &Eql) .unwrap() diff --git a/packages/eql/tests/timing/ore_block_v2/ctgen/Cargo.lock b/packages/eql/tests/timing/ore_block_v2/ctgen/Cargo.lock index a600d1a03..a743e844b 100644 --- a/packages/eql/tests/timing/ore_block_v2/ctgen/Cargo.lock +++ b/packages/eql/tests/timing/ore_block_v2/ctgen/Cargo.lock @@ -407,9 +407,9 @@ dependencies = [ [[package]] name = "ore-rs" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bd65962ec1409c09d20e1e016f4fe1b4ae2e2e58dba38c155538b2c0801a185" +checksum = "b274b7a0bf081d51815ccc7b20fa3b023e5769e3446fe4de1e8e8044a4413201" dependencies = [ "aes", "block-modes", @@ -743,9 +743,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "efba1dac1efc04ffc09cba49e343e0fe656c88c32472a4206f2a6df284add33d" dependencies = [ "bytes", "futures-util", @@ -760,9 +760,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "a3c6575dac7701e578b375d16a0b693294d73847ed275c9b9962cf20c423ea10" dependencies = [ "proc-macro2", "quote", @@ -771,9 +771,9 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "5b407b3127be198a8da7d4d914c11215e1eae1b467cdf89eca8a7a296055e863" dependencies = [ "mutants", "vitaminc-protected", @@ -781,9 +781,9 @@ dependencies = [ [[package]] name = "vitaminc-hmac" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5fa0ae8bbad85ae30fb5c91b0b72b694d59bf2c2c1e7ad799eda66fdbd67e7c" +checksum = "ada61349172294efe0a71bf2af367969b4e196437b46c20c688aaf15f692bc2d" dependencies = [ "hmac", "sha2", @@ -794,9 +794,9 @@ dependencies = [ [[package]] name = "vitaminc-normalize" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a2d8875e0b8b35b7a4f9dd698bbd13c989837cea564b5a4cbb5371b78fa937" +checksum = "f1d354f4ee3764be072eb02f4f8db79ed0ed467a7c6d9f2c6143823867bde347" dependencies = [ "thiserror 2.0.21", "unicode-general-category", @@ -807,9 +807,9 @@ dependencies = [ [[package]] name = "vitaminc-ore" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05518b4d35cb0c592dad59afb11b8e883ea252449b9ae47ff5ddeff0ed49f75" +checksum = "9a812b8e245352094c3c2113312cf86d0804b83f1e315bacd15571cd01703c8b" dependencies = [ "thiserror 2.0.21", "unicode-general-category", @@ -823,9 +823,9 @@ dependencies = [ [[package]] name = "vitaminc-prf" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916688478273ca3e5c6805b355534ce9263688391cd4ab23b08bef760039d325" +checksum = "d1703452d837ef964ec1f20cc601265857245d72ac14426276d49c3106dd39b3" dependencies = [ "mutants", "thiserror 2.0.21", @@ -835,9 +835,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "44e61d6e58132793aace6898403ffa949bc06e388c1fc89c08d48ad0303133c3" dependencies = [ "bitvec", "digest", @@ -852,9 +852,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "a60e915eb16c115882ea4754cecc99d7bcd3d8a1bfb9b8e512af4b6f7dd14801" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -864,9 +864,9 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "71e32850b739b58a7064b50eb3db655de323f57f21c1fe74d5ccd4d308560563" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -879,9 +879,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "1863fe02de3945047dc9094d3cadbde06becafa49aa95d5e475bf903c8d83981" dependencies = [ "proc-macro2", "quote", diff --git a/packages/eql/tests/timing/ore_block_v2/ctgen/Cargo.toml b/packages/eql/tests/timing/ore_block_v2/ctgen/Cargo.toml index b5e23206e..586ce92b7 100644 --- a/packages/eql/tests/timing/ore_block_v2/ctgen/Cargo.toml +++ b/packages/eql/tests/timing/ore_block_v2/ctgen/Cargo.toml @@ -14,11 +14,11 @@ publish = false [dependencies] # ore-rs's `BlockOre`, whose 6-bit fixed-width and chained engines are what # EQL v4's `ore_block_v2` compares; the same version as the root Cargo.lock. -# Since 0.10 a value reaches the engines only through vitaminc-ore, as it +# Since ore-rs 0.10 a value reaches the engines only through vitaminc-ore, as it # does in stack-encrypt. -ore-rs = { version = "=0.10.0", default-features = false } -vitaminc-ore = "=0.8.0" -vitaminc-protected = "=0.8.0" +ore-rs = { version = "=0.11.0", default-features = false } +vitaminc-ore = "=0.9.0" +vitaminc-protected = "=0.9.0" rand = "0.8" hex = "0.4" diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index cc3ac27e6..3f533705c 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -17,6 +17,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 different terms and silently miss those old rows: they return no error. Keep the final extension alone for both reads and queries of old data, or re-encrypt and re-index it under the appended context. This does not migrate stored data. +- **stack-encrypt requires the vitaminc 0.9 crates, clww-ore 0.7 and + ore-rs 0.11.** A crate that names a vitaminc type + stack-encrypt takes or returns moves to 0.9 with it. No sealed byte, term + or stored format changes. Two things in this crate's surface change. + The re-exported frame codec, `stack_encrypt::dynamic::frame`: its + `serde::to_vec` and `FrameWriter` now produce and write into a + `vitaminc_protected::Buffer`, which wipes each allocation it outgrows, + rather than a `Vec`. Read the bytes with `risky_ref`, or take them + with `risky_unwrap`; `Buffer` is re-exported as `stack_encrypt::Buffer` + beside `Protected`. `ValueFrame::into_inner` still returns + `Protected>`. And a repeated key in a value written to a frame + (`ValueFrame::of`, outside a passthrough) is now refused unnamed, as a + `DecodeError::Source` wrapping `FrameWriteError::DuplicateKey`, rather + than as `DecodeError::DuplicateKey(key)`: the 0.9 writer copies no key + out of the frame. A record source still reports `Reason::RepeatedKey` + for the field. ### Added diff --git a/packages/stack-encrypt/src/dynamic/context.rs b/packages/stack-encrypt/src/dynamic/context.rs index 3b2d1493b..3c16472d3 100644 --- a/packages/stack-encrypt/src/dynamic/context.rs +++ b/packages/stack-encrypt/src/dynamic/context.rs @@ -74,11 +74,11 @@ use crate::{ContextPiece, NonEmpty}; /// /// ``` /// use stack_encrypt::dynamic::context; -/// use stack_encrypt::{nonempty, IntoAad}; +/// use stack_encrypt::{nonempty, Controlled, IntoAad}; /// /// // `[["users", "age"], 7u64]`, as a binding writes it. /// let frame = vitaminc_aead_frame::serde::to_vec(&(("users", "age"), 7u64))?; -/// let parsed = context(&frame)?; +/// let parsed = context(frame.risky_ref())?; /// let typed = nonempty!("users").with("age").with(7u64); /// assert_eq!( /// parsed.into_inner().into_aad().as_bytes(), @@ -546,7 +546,9 @@ mod tests { /// refused rather than widened. #[test] fn an_integer_is_read_at_its_own_width() { - let read = |frame: Vec| context(&frame).map(NonEmpty::into_inner); + let read = |frame: vitaminc_protected::Buffer| { + context(vitaminc_protected::Controlled::risky_ref(&frame)).map(NonEmpty::into_inner) + }; assert_eq!(read(to_vec(&7i32).unwrap()).unwrap(), ContextPiece::I32(7)); assert_eq!(read(to_vec(&7i64).unwrap()).unwrap(), ContextPiece::I64(7)); assert_eq!(read(to_vec(&7u32).unwrap()).unwrap(), ContextPiece::U32(7)); @@ -602,7 +604,11 @@ mod tests { /// Bytes that are not one frame are not a context. #[test] fn bytes_that_are_not_a_frame_are_refused() { - for bytes in [&[][..], &[0xff, 1, 2], &to_vec(&"a").unwrap()[..2]] { + for bytes in [ + &[][..], + &[0xff, 1, 2], + &vitaminc_protected::Controlled::risky_ref(&to_vec(&"a").unwrap())[..2], + ] { assert!(matches!( context(bytes), Err(Error::Context { diff --git a/packages/stack-encrypt/src/dynamic/query.rs b/packages/stack-encrypt/src/dynamic/query.rs index e678d0a63..3e9cfccc4 100644 --- a/packages/stack-encrypt/src/dynamic/query.rs +++ b/packages/stack-encrypt/src/dynamic/query.rs @@ -184,6 +184,7 @@ pub enum OpSpec { /// ``` /// use serde_json::json; /// use stack_encrypt::dynamic::{check_query, frame, record, OpSpec, Reason, ValueFrame}; + /// use stack_encrypt::Controlled; /// # use stack_encrypt::dynamic::{TargetDescriptor, TargetError, TargetOptions, TargetResolver, ValueKind}; /// # use stack_encrypt::target::IndexSpec; /// # use stack_encrypt::{KeysetCipher, Label, Pending, StackCipher}; @@ -209,17 +210,18 @@ pub enum OpSpec { /// # } /// // A document field that orders `$.age`. /// let plan = record::plan_with( - /// &frame::serde::to_vec(&json!({"profile": { + /// frame::serde::to_vec(&json!({"profile": { /// "context": ["users", "profile"], "type": "json", "null": "sql", /// "target": {"name": "JsonSearch", "options": {"ordered": ["$.age"]}} - /// }}))?, + /// }}))? + /// .risky_ref(), /// &Documents, /// )?; /// // What `jsonb_path_match(col, $1, $2)` takes: the predicate, and its /// // variables as the JSON text of one object. /// let input = |predicate: &str| -> ValueFrame { /// let input = json!({"predicate": predicate, "vars": r#"{"min": 18, "r": "sibling"}"#}); - /// ValueFrame::from_bytes(&frame::serde::to_vec(&input).unwrap()).unwrap() + /// ValueFrame::from_bytes(frame::serde::to_vec(&input).unwrap().risky_ref()).unwrap() /// }; /// let check = |predicate: &str| { /// check_query(&plan, "profile", OpSpec::Match, input(predicate), &Documents) @@ -787,10 +789,11 @@ fn answers( /// ``` /// use stack_encrypt::dynamic::{field_operators, record, NoTargets, OpSpec}; /// use stack_encrypt::target::IndexSpec; +/// use stack_encrypt::Controlled; /// -/// let plan = record::plan(&vitaminc_aead_frame::serde::to_vec(&serde_json::json!({ +/// let plan = record::plan(vitaminc_aead_frame::serde::to_vec(&serde_json::json!({ /// "age": { "context": ["users", "age"], "outputs": ["c", "eq", "ore"], "type": "uint32" }, -/// }))?)?; +/// }))?.risky_ref())?; /// /// let operators = field_operators(&plan, "age", &NoTargets)?; /// assert_eq!(operators[0], (OpSpec::Eq, IndexSpec::Equality)); @@ -1191,14 +1194,14 @@ where /// ``` /// use stack_encrypt::dynamic::{query_term, record, NoTargets, OpSpec, Operand}; /// use stack_encrypt::kms::FakeDataKeySource; -/// use stack_encrypt::{op, Ore, Plan, StackCipher}; +/// use stack_encrypt::{op, Controlled, Ore, Plan, StackCipher}; /// /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; /// let keyset = cipher.default_keyset(); -/// let plan = record::plan(&vitaminc_aead_frame::serde::to_vec(&serde_json::json!({ +/// let plan = record::plan(vitaminc_aead_frame::serde::to_vec(&serde_json::json!({ /// "age": { "context": ["users", "age"], "outputs": ["c", "ore"], "type": "uint32" }, -/// }))?)?; +/// }))?.risky_ref())?; /// /// let term = query_term(&keyset, &plan, "age", OpSpec::Lt, 40u32, &NoTargets)?.await?; /// @@ -2202,9 +2205,9 @@ mod tests { tokenizer: crate::sem::Tokenizer::Ngram { length: 0 }, ..MatchOptions::default() }; - let context = crate::dynamic::context( + let context = crate::dynamic::context(vitaminc_protected::Controlled::risky_ref( &vitaminc_aead_frame::serde::to_vec(&("users", "name")).expect("frames"), - ) + )) .expect("context"); let field = FieldPlan::new( "name", @@ -2508,9 +2511,9 @@ mod tests { let stored = |value: &ValueFrame, name: &'static str| { let keyset = &keyset; - let ctx = super::super::context( + let ctx = super::super::context(vitaminc_protected::Controlled::risky_ref( &vitaminc_aead_frame::serde::to_vec(&("users", name)).expect("frames"), - ) + )) .expect("context"); let value = value.clone(); async move { @@ -3173,8 +3176,7 @@ mod wire_bytes { use vitaminc_aead_frame::serde::to_vec; fn hex(value: &T) -> String { - to_vec(value) - .expect("frames") + vitaminc_protected::Controlled::risky_ref(&to_vec(value).expect("frames")) .iter() .map(|b| format!("{b:02x}")) .collect() diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 3baeebf32..c9ddec132 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -1186,6 +1186,7 @@ pub fn plan(frame: &[u8]) -> Result { /// ``` /// use stack_encrypt::dynamic::{record, NoTargets, Output}; /// use stack_encrypt::target::IndexSpec; +/// use stack_encrypt::Controlled; /// /// // As a binding sends it: seal `age` under the label users/age and index /// // it for equality. @@ -1196,7 +1197,7 @@ pub fn plan(frame: &[u8]) -> Result { /// "type": "uint32", /// }, /// }))?; -/// let plan = record::plan_with(&frame, &NoTargets)?; +/// let plan = record::plan_with(frame.risky_ref(), &NoTargets)?; /// /// assert_eq!(plan.fields().len(), 1); /// assert_eq!(plan.fields()[0].name(), "age"); @@ -1587,7 +1588,7 @@ fn plan_refusal(error: &vitaminc_aead_frame::serde::Error, frame: &[u8]) -> Erro /// /// ``` /// use stack_encrypt::dynamic::{record, Scope, ValueFrame}; -/// use stack_encrypt::StackCipher; +/// use stack_encrypt::{Controlled, StackCipher}; /// use stack_encrypt::kms::FakeDataKeySource; /// /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { @@ -1598,9 +1599,9 @@ fn plan_refusal(error: &vitaminc_aead_frame::serde::Error, frame: &[u8]) -> Erro /// let keyset = cipher.default_keyset(); /// /// // Seal `age` under users/age with an equality term beside it. -/// let plan = record::plan(&vitaminc_aead_frame::serde::to_vec(&serde_json::json!({ +/// let plan = record::plan(vitaminc_aead_frame::serde::to_vec(&serde_json::json!({ /// "age": { "context": ["users", "age"], "outputs": ["c", "eq"], "type": "uint32" }, -/// }))?)?; +/// }))?.risky_ref())?; /// /// // The row as a binding hands it over: one transport frame. Here a Rust /// // map writes it; a binding's codec writes the same bytes. @@ -2318,6 +2319,7 @@ fn split_refusal(error: DecodeError) -> Error { }; let reason = match error { DecodeError::DuplicateKey(_) => Reason::RepeatedKey, + error if super::value_frame::is_unnamed_repeated_key(error) => Reason::RepeatedKey, DecodeError::Expected(_) => Reason::FieldType, _ => Reason::Unframeable, }; @@ -2624,8 +2626,8 @@ fn write_record(rows: Rows>) -> Result writer .map(Some(fields.len())) @@ -8193,7 +8195,7 @@ mod tests { Some(vitaminc_aead::plaintext::Logical::Uuid), vitaminc_aead::plaintext::Physical::Bytes(&[7; 16]), ); - let mut frame = Vec::new(); + let mut frame = vitaminc_protected::Buffer::new(); { let mut map = vitaminc_aead::FrameWriter::new(&mut frame) .map(Some(1)) @@ -8202,12 +8204,12 @@ mod tests { .expect("written"); map.end().expect("ended"); } - let source = vitaminc_aead::Frame::parse(&frame).expect("a frame"); + let source = vitaminc_aead::Frame::parse(frame.risky_ref()).expect("a frame"); let error = check_source(source, &plan).expect_err("a uuid field"); expect(&error, "Source", Some("email"), FieldType); // The same inside a passthrough subtree: the field is named, and // the cause is the value's type, not an unframeable source. - let mut frame = Vec::new(); + let mut frame = vitaminc_protected::Buffer::new(); { let mut map = vitaminc_aead::FrameWriter::new(&mut frame) .map(Some(1)) @@ -8218,7 +8220,7 @@ mod tests { .expect("written"); map.end().expect("ended"); } - let source = vitaminc_aead::Frame::parse(&frame).expect("a frame"); + let source = vitaminc_aead::Frame::parse(frame.risky_ref()).expect("a frame"); let error = check_source(source, &plan).expect_err("a uuid in a passthrough"); expect(&error, "Source", Some("id"), FieldType); // Each row of a batch is split the same way, and the first fault diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs index 756cd567b..f7cd79498 100644 --- a/packages/stack-encrypt/src/dynamic/term.rs +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -415,7 +415,7 @@ impl TryFrom for MatchWire { /// ``` /// use stack_encrypt::dynamic::{context, term}; /// use stack_encrypt::target::IndexSpec; -/// use stack_encrypt::StackCipher; +/// use stack_encrypt::{Controlled, StackCipher}; /// use stack_encrypt::kms::FakeDataKeySource; /// /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { @@ -426,7 +426,7 @@ impl TryFrom for MatchWire { /// let keyset = cipher.default_keyset(); /// /// // `["users", "age"]`, as a binding writes it. -/// let ctx = context(&vitaminc_aead_frame::serde::to_vec(&("users", "age"))?)?; +/// let ctx = context(vitaminc_aead_frame::serde::to_vec(&("users", "age"))?.risky_ref())?; /// let probe = term(&keyset, &34u32, &IndexSpec::Equality, ctx.clone()).await?; /// let typed = keyset.equality_term(34u32, ctx).await?; /// assert_eq!(probe, typed.into_bytes().to_vec()); @@ -1091,7 +1091,10 @@ mod tests { /// A one-part context, read from its frame as a binding sends it. fn ctx(label: &str) -> NonEmpty> { - context(&vitaminc_aead_frame::serde::to_vec(&label).expect("frames")).expect("context") + context(vitaminc_protected::Controlled::risky_ref( + &vitaminc_aead_frame::serde::to_vec(&label).expect("frames"), + )) + .expect("context") } fn s(value: &str) -> Tree { diff --git a/packages/stack-encrypt/src/dynamic/test_frame.rs b/packages/stack-encrypt/src/dynamic/test_frame.rs index 2f57c7f06..1bc329f70 100644 --- a/packages/stack-encrypt/src/dynamic/test_frame.rs +++ b/packages/stack-encrypt/src/dynamic/test_frame.rs @@ -13,7 +13,7 @@ use vitaminc_aead::plaintext::{ Sink, SourceError, }; use vitaminc_aead::{to_frame, DecodeError, Decrypt, Encrypt, Frame}; -use vitaminc_protected::{Controlled, Protected}; +use vitaminc_protected::Controlled; /// A value as a test spells it: each scalar at its own width, a logical /// scalar under its type. Equality is structural and variable-time, which @@ -77,7 +77,7 @@ impl PartialEq for Tree { impl Tree { /// The tree's frame, written by vitaminc's own frame writer, which /// refuses a repeated key. - pub(crate) fn to_frame(&self) -> Result>, FrameWriteError> { + pub(crate) fn to_frame(&self) -> Result { to_frame(self) } @@ -138,7 +138,7 @@ fn write(value: &Tree, out: &mut Vec) { /// read back. pub(crate) fn value_of(answer: &T) -> Tree { let frame = vitaminc_aead_frame::serde::to_vec(answer).expect("frames"); - Tree::from_frame(&frame).expect("a value") + Tree::from_frame(frame.risky_ref()).expect("a value") } fn scalar(sink: S, ty: Option, value: &Tree) -> Result { diff --git a/packages/stack-encrypt/src/dynamic/value_frame.rs b/packages/stack-encrypt/src/dynamic/value_frame.rs index 11ff10a61..681ce80d9 100644 --- a/packages/stack-encrypt/src/dynamic/value_frame.rs +++ b/packages/stack-encrypt/src/dynamic/value_frame.rs @@ -37,7 +37,7 @@ use vitaminc_aead::plaintext::{ TimeUnit, Width, }; use vitaminc_aead::{DecodeError, Decrypt, Encrypt}; -use vitaminc_protected::{Controlled, OpaqueDebug, Protected}; +use vitaminc_protected::{Buffer, Controlled, OpaqueDebug, Protected}; use super::ValueKind; use crate::plan::IntoLabel; @@ -92,9 +92,12 @@ impl ValueFrame { /// /// [`DecodeError::Expected`] for a scalar outside the model (a uuid, a /// logical type this build does not know, a date, decimal or timestamp - /// outside vitaminc's range), [`DecodeError::DuplicateKey`] for a map with a - /// repeated key, and [`DecodeError::Source`] for a source that fails or - /// a value deeper or longer than a frame carries. + /// outside vitaminc's range), [`DecodeError::DuplicateKey`] for a map in a + /// passthrough with a repeated key, and [`DecodeError::Source`] for a + /// source that fails, a value deeper or longer than a frame carries, or + /// a repeated key anywhere else. That last is the frame writer's + /// [`FrameWriteError::DuplicateKey`], which names no key: the key is + /// plaintext, and the writer copies none out of the frame. pub fn of(value: P) -> Result { write(|writer| value.visit(Modeled::new(writer))) } @@ -141,9 +144,16 @@ impl ValueFrame { /// A frame this module's writers wrote from frames already in the value /// model: the record a decrypt assembles. Checked to be one frame. - pub(super) fn from_written(buffer: Protected>) -> Result { + pub(super) fn from_written(buffer: Buffer) -> Result { let _ = Frame::parse(buffer.risky_ref()).map_err(|_| crate::Error::ResponseShape)?; - Ok(Self(buffer)) + Ok(Self::from_buffer(buffer)) + } + + /// A frame a writer finished writing. The bytes move out of the + /// [`Buffer`] they grew in, which wiped every allocation it outgrew, + /// into the [`Protected`] the frame holds; nothing is copied. + fn from_buffer(buffer: Buffer) -> Self { + Self(Protected::new(buffer.risky_unwrap())) } /// The null, as a frame: what an absent value opens to. @@ -286,9 +296,9 @@ impl MapSink for Check { fn write( f: impl FnOnce(FrameWriter<'_>) -> Result<(), DecodeError>, ) -> Result { - let mut buffer = Protected::new(Vec::new()); - f(FrameWriter::new(buffer.risky_inner_mut()))?; - Ok(ValueFrame(buffer)) + let mut buffer = Buffer::new(); + f(FrameWriter::new(&mut buffer))?; + Ok(ValueFrame::from_buffer(buffer)) } /// Walks the frame in place: no scalar leaves the buffer except to go @@ -441,12 +451,29 @@ pub(super) struct FieldFault { pub(super) error: DecodeError, } -/// A frame writer's refusal as a decode error. A repeated key is reported -/// where the key is known ([`Modeled::entry`]). A value the model does not -/// hold, or a repeated key, inside a passthrough subtree reaches here wrapped -/// as the writer's source error ([`ModelOnly`] can only return the writer's -/// error type), and is unwrapped, so it is refused as the same `Expected` or -/// `DuplicateKey` a sealed value is. +/// A frame writer's refusal as a decode error. A value the model does not +/// hold inside a passthrough subtree reaches here wrapped as the writer's +/// source error ([`ModelOnly`] can only return the writer's error type), and +/// is unwrapped, so it is refused as the same `Expected` a sealed value is. +/// +/// A repeated key stays the writer's own unnamed +/// [`FrameWriteError::DuplicateKey`], in or out of a passthrough: the writer +/// finds it when the map ends, among the keys already in the frame, and +/// names none, since naming it would copy plaintext out of the frame where +/// it would be freed unwiped. [`is_unnamed_repeated_key`] recognises it. +/// Whether `error` is the frame writer's refusal of a repeated map key, which +/// reaches a caller unnamed (see [`decode_error`]). +pub(super) fn is_unnamed_repeated_key(error: &DecodeError) -> bool { + matches!( + error, + DecodeError::Source(source) + if matches!( + source.inner().downcast_ref::(), + Some(FrameWriteError::DuplicateKey) + ) + ) +} + fn decode_error(error: FrameWriteError) -> DecodeError { match error { FrameWriteError::Source(error) => match error.inner().downcast_ref::() { @@ -553,9 +580,6 @@ impl MapSink for Modeled> { inner: writer, field, }), - Err(FrameWriteError::DuplicateKey) => { - Err(Modeled { inner: (), field }.fault(DecodeError::duplicate_key(key))) - } Err(error) => Err(Modeled { inner: (), field }.written(error)), } } @@ -736,7 +760,7 @@ type Finish = fn(bool, Vec<(Option, ValueFrame)>) -> Result { map: bool, - children: Vec<(Option, Protected>)>, + children: Vec<(Option, Buffer)>, finish: Finish, } @@ -750,21 +774,20 @@ impl Children { } fn next(&mut self, key: Option<&str>) -> Modeled> { - self.children - .push((key.map(str::to_owned), Protected::new(Vec::new()))); + self.children.push((key.map(str::to_owned), Buffer::new())); let buffer = match self.children.last_mut() { Some((_, buffer)) => buffer, // Pushed on the line above. None => unreachable!(), }; - Modeled::new(FrameWriter::new(buffer.risky_inner_mut())) + Modeled::new(FrameWriter::new(buffer)) } fn finish(self) -> Result { let children = self .children .into_iter() - .map(|(key, buffer)| (key, ValueFrame(buffer))) + .map(|(key, buffer)| (key, ValueFrame::from_buffer(buffer))) .collect(); (self.finish)(self.map, children) } @@ -1195,7 +1218,7 @@ impl SeqSink for NotARow<'_> { /// names the entry's key in any refusal. pub(super) struct RowMap { out: O, - entries: Vec<(String, Protected>)>, + entries: Vec<(String, Buffer)>, } impl RowMap { @@ -1216,24 +1239,20 @@ impl MapSink for RowMap { Self: 's; fn entry(&mut self, key: &str) -> Result>, DecodeError> { - self.entries - .push((key.to_owned(), Protected::new(Vec::new()))); + self.entries.push((key.to_owned(), Buffer::new())); let buffer = match self.entries.last_mut() { Some((_, buffer)) => buffer, // Pushed on the line above. None => unreachable!(), }; - Ok(Modeled::in_field( - FrameWriter::new(buffer.risky_inner_mut()), - key, - )) + Ok(Modeled::in_field(FrameWriter::new(buffer), key)) } fn end(self) -> Result { let row = self .entries .into_iter() - .map(|(key, buffer)| (key, ValueFrame(buffer))) + .map(|(key, buffer)| (key, ValueFrame::from_buffer(buffer))) .collect(); Ok(self.out.put(Some(row))) } @@ -1610,10 +1629,12 @@ mod tests { ValueFrame::of(hidden), Err(DecodeError::Expected(_)) )); + // A repeated key is refused when its map ends, unnamed: the writer + // copies no key out of the frame to say which one. let repeated = Tree::Object(vec![("k".into(), Tree::Null), ("k".into(), Tree::Null)]); assert!(matches!( ValueFrame::of(&repeated), - Err(DecodeError::DuplicateKey(key)) if key == "k" + Err(ref error) if super::is_unnamed_repeated_key(error) )); assert!(matches!( ValueFrame::from_bytes(&[]), diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 497e2b024..fc8fdaaa6 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -410,5 +410,6 @@ pub use vitaminc_prf::PrfContext; pub use vitaminc_protected::{nonempty, nonempty_bytes, EmptyError, MaybeEmpty, NonEmpty}; // And the wrapper plaintext comes back in (`ValueFrame::into_inner`, a -// decrypted `Protected`), with the trait that reads it. -pub use vitaminc_protected::{Controlled, Protected}; +// decrypted `Protected`), with the trait that reads it, and the wiping +// buffer the re-exported frame writer writes into (`dynamic::frame`). +pub use vitaminc_protected::{Buffer, Controlled, Protected}; diff --git a/packages/stack-encrypt/tests/conformance.rs b/packages/stack-encrypt/tests/conformance.rs index f2875aa01..1ce6b06b6 100644 --- a/packages/stack-encrypt/tests/conformance.rs +++ b/packages/stack-encrypt/tests/conformance.rs @@ -19,6 +19,7 @@ use stack_encrypt::target::IndexSpec; use stack_encrypt::{ CipherText, Ciphertext, Error, KeysetCipher, Label, SealedValue, StackCipherText, }; +use vitaminc_protected::Controlled as _; const PROFILE: &str = "record-scalars-v1"; @@ -77,7 +78,12 @@ fn plan(corpus: &Corpus, case: &Case) -> record::Plan { let declaration = json!({corpus.field.clone(): { "context": context, "outputs": ["c", "eq"], "type": case.kind, }}); - record::plan(&vitaminc_aead_frame::serde::to_vec(&declaration).unwrap()).unwrap() + record::plan( + &vitaminc_aead_frame::serde::to_vec(&declaration) + .unwrap() + .risky_unwrap(), + ) + .unwrap() } fn stored(corpus: &Corpus, artifact: &Artifact) -> StackCipherText { diff --git a/packages/stack-encrypt/tests/declarations.rs b/packages/stack-encrypt/tests/declarations.rs index d56eac509..8219802ca 100644 --- a/packages/stack-encrypt/tests/declarations.rs +++ b/packages/stack-encrypt/tests/declarations.rs @@ -12,6 +12,7 @@ use serde_json::value::RawValue; use serde_json::{json, Value}; use stack_encrypt::dynamic::record::{self, NullMode, Output}; use stack_encrypt::{ContextPiece, ErrorPayload}; +use vitaminc_protected::Controlled as _; #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -113,9 +114,9 @@ fn shared_declaration_grammar() { case.id ); let frame = match case.input { - Input::Json(value) => { - vitaminc_aead_frame::serde::to_vec(&projection(&value).unwrap()).unwrap() - } + Input::Json(value) => vitaminc_aead_frame::serde::to_vec(&projection(&value).unwrap()) + .unwrap() + .risky_unwrap(), Input::FrameHex(text) => unhex(&text), }; if let Some(boundary) = case.boundary_error { @@ -211,11 +212,15 @@ fn projection(raw: &RawValue) -> Result> fn json_projection_is_lexical_and_lossless() { fn encoded(text: &str) -> Vec { let raw = serde_json::from_str::>(text).unwrap(); - vitaminc_aead_frame::serde::to_vec(&projection(&raw).unwrap()).unwrap() + vitaminc_aead_frame::serde::to_vec(&projection(&raw).unwrap()) + .unwrap() + .risky_unwrap() } assert_eq!( encoded("-0"), - vitaminc_aead_frame::serde::to_vec(&0i64).unwrap() + vitaminc_aead_frame::serde::to_vec(&0i64) + .unwrap() + .risky_unwrap() ); assert_eq!(encoded(r#"{"z":1,"a":2}"#), encoded(r#"{"a":2,"z":1}"#)); for invalid in ["18446744073709551616", "-9223372036854775809", "1e400"] { diff --git a/packages/stack-encrypt/tests/heap_hygiene.rs b/packages/stack-encrypt/tests/heap_hygiene.rs index 4312e3f0e..684675ee6 100644 --- a/packages/stack-encrypt/tests/heap_hygiene.rs +++ b/packages/stack-encrypt/tests/heap_hygiene.rs @@ -50,6 +50,7 @@ use stack_encrypt::dynamic::{query_term, record, verify, NoTargets, OpSpec, Scop use stack_encrypt::nonempty; use vitaminc_aead::plaintext::{Physical, Raw}; use vitaminc_aead::Frame; +use vitaminc_protected::Controlled as _; /// What every plaintext under test carries. const MARK: &[u8] = b"zq-heap-marker-7f3a9c"; @@ -548,7 +549,9 @@ fn a_json_operators_input_leaves_no_plaintext_on_the_heap() { "target": {"name": "JsonSearch", "options": {"ordered": ["$.age"]}}} }); let plan = record::plan_with( - &stack_encrypt::dynamic::frame::serde::to_vec(&wire).unwrap(), + &stack_encrypt::dynamic::frame::serde::to_vec(&wire) + .unwrap() + .risky_unwrap(), &Documents, ) .unwrap(); @@ -916,7 +919,9 @@ mod json_index { "target": {"name": "JsonSearch", "options": {"ordered": ["$.age", "$.visits[*].at"]}}} }); let plan = record::plan_with( - &stack_encrypt::dynamic::frame::serde::to_vec(&wire).unwrap(), + &stack_encrypt::dynamic::frame::serde::to_vec(&wire) + .unwrap() + .risky_unwrap(), &Documents, ) .unwrap(); diff --git a/packages/stack-encrypt/tests/json_index.rs b/packages/stack-encrypt/tests/json_index.rs index 2fef7e9d3..58fe635da 100644 --- a/packages/stack-encrypt/tests/json_index.rs +++ b/packages/stack-encrypt/tests/json_index.rs @@ -32,6 +32,7 @@ use stack_encrypt::target::{ }; use stack_encrypt::{nonempty, Error, Plan, PlanError, StackCipher}; use stack_kms::FakeDataKeySource; +use vitaminc_protected::Controlled as _; // ============================================================================= // Setup @@ -2958,14 +2959,17 @@ mod given_the_json_index_spec { json!({"json": {"sealed_root": false, "ordered": []}}), "every option is written when any is not the default" ); - let frame = - vitaminc_aead_frame::serde::to_vec(&json!({"json": {"sealed_root": false}})).unwrap(); + let frame = vitaminc_aead_frame::serde::to_vec(&json!({"json": {"sealed_root": false}})) + .unwrap() + .risky_unwrap(); assert_eq!( IndexSpec::from_frame(&frame).unwrap(), IndexSpec::Json(open_root()), "an option read from a frame" ); - let frame = vitaminc_aead_frame::serde::to_vec(&json!({"json": {"nope": true}})).unwrap(); + let frame = vitaminc_aead_frame::serde::to_vec(&json!({"json": {"nope": true}})) + .unwrap() + .risky_unwrap(); assert!( IndexSpec::from_frame(&frame).is_err(), "an unknown option refuses the index" @@ -2982,14 +2986,16 @@ mod given_the_json_index_spec { let frame = vitaminc_aead_frame::serde::to_vec( &json!({"json": {"ordered": ["$.age", "$.orders[*].total"]}}), ) - .unwrap(); + .unwrap() + .risky_unwrap(); assert_eq!( IndexSpec::from_frame(&frame).unwrap(), ordered, "ordered paths read back parsed" ); - let frame = - vitaminc_aead_frame::serde::to_vec(&json!({"json": {"ordered": ["age"]}})).unwrap(); + let frame = vitaminc_aead_frame::serde::to_vec(&json!({"json": {"ordered": ["age"]}})) + .unwrap() + .risky_unwrap(); assert!( IndexSpec::from_frame(&frame).is_err(), "a text that is not a path refuses the index" @@ -2998,7 +3004,8 @@ mod given_the_json_index_spec { // can reach the leaf it names. let frame = vitaminc_aead_frame::serde::to_vec(&json!({"json": {"ordered": ["$.scores[2]"]}})) - .unwrap(); + .unwrap() + .risky_unwrap(); assert!( IndexSpec::from_frame(&frame).is_err(), "a position in an ordered path refuses the index" diff --git a/packages/stack-encrypt/tests/json_operators.rs b/packages/stack-encrypt/tests/json_operators.rs index 52cbc540d..a326aac49 100644 --- a/packages/stack-encrypt/tests/json_operators.rs +++ b/packages/stack-encrypt/tests/json_operators.rs @@ -25,6 +25,7 @@ use stack_encrypt::json::{JsonDocument, Tag}; use stack_encrypt::target::{Borrowed, CallerContext, IndexSpec}; use stack_encrypt::{CipherText, Encrypt, KeysetCipher, Label, NonEmpty, Pending, StackCipher}; use stack_kms::{DataKeySource, IndexKeySource}; +use vitaminc_protected::Controlled as _; /// What the helpers below run over: any key source a test builds its /// cipher on. @@ -116,7 +117,11 @@ fn plan_of(target: Value) -> record::Plan { let wire = json!({ "profile": {"context": ["users", "profile"], "type": "json", "null": "sql", "target": target} }); - record::plan_with(&dynamic::frame::serde::to_vec(&wire).unwrap(), &Documents).unwrap() + record::plan_with( + &dynamic::frame::serde::to_vec(&wire).unwrap().risky_unwrap(), + &Documents, + ) + .unwrap() } fn frame(value: T) -> ValueFrame { @@ -125,7 +130,7 @@ fn frame(value: T) -> ValueFrame { /// A JSON value as a frame: what a binding sends as an input or a request. fn json_frame(value: &Value) -> ValueFrame { - ValueFrame::from_bytes(&dynamic::frame::serde::to_vec(value).unwrap()).unwrap() + ValueFrame::from_bytes(&dynamic::frame::serde::to_vec(value).unwrap().risky_unwrap()).unwrap() } async fn term( @@ -743,7 +748,8 @@ async fn rows_that_are_not_the_reads_are_refused() { "prefs": {"context": ["users", "prefs"], "type": "json", "null": "sql", "target": "JsonSearch"} })) - .unwrap(), + .unwrap() + .risky_unwrap(), &Documents, ) .unwrap(); @@ -912,8 +918,11 @@ fn a_malformed_read_is_refused_before_any_key() { // A field that does not store a document has no reads. let wire = json!({"email": {"context": ["users", "email"], "type": "string", "outputs": ["c"]}}); - let scalar = - record::plan_with(&dynamic::frame::serde::to_vec(&wire).unwrap(), &Documents).unwrap(); + let scalar = record::plan_with( + &dynamic::frame::serde::to_vec(&wire).unwrap().risky_unwrap(), + &Documents, + ) + .unwrap(); let error = dynamic::check_json_open( &scalar, "email", @@ -1581,8 +1590,11 @@ mod element_match { "profile": {"context": ["users", "profile"], "type": "json", "null": "sql", "target": {"name": "JsonSearch", "options": {"ordered": ["$.a[*].b"]}}} }); - let plan = record::plan_with(&dynamic::frame::serde::to_vec(&wire).unwrap(), &Unnamed) - .unwrap(); + let plan = record::plan_with( + &dynamic::frame::serde::to_vec(&wire).unwrap().risky_unwrap(), + &Unnamed, + ) + .unwrap(); let names = names(&plan, &Unnamed); assert!(names.contains(&"lt_at"), "the orderings still: {names:?}"); assert!(!names.contains(&"element_match"), "{names:?}"); @@ -2373,8 +2385,11 @@ mod compiled_match { "profile": {"context": ["users", "profile"], "type": "json", "null": "sql", "target": "JsonSearch"} }); - let plan = record::plan_with(&dynamic::frame::serde::to_vec(&wire).unwrap(), &Unnamed) - .unwrap(); + let plan = record::plan_with( + &dynamic::frame::serde::to_vec(&wire).unwrap().risky_unwrap(), + &Unnamed, + ) + .unwrap(); let names = names(&plan, &Unnamed); assert!(names.contains(&"eq_at"), "the others still: {names:?}"); assert!(!names.contains(&"match"), "{names:?}"); diff --git a/packages/stack-encrypt/tests/lifecycle_conformance.rs b/packages/stack-encrypt/tests/lifecycle_conformance.rs index aaab35e1a..763dfad96 100644 --- a/packages/stack-encrypt/tests/lifecycle_conformance.rs +++ b/packages/stack-encrypt/tests/lifecycle_conformance.rs @@ -20,6 +20,7 @@ use stack_kms::{ UnverifiedContext, }; use uuid::Uuid; +use vitaminc_protected::Controlled as _; #[derive(Default)] struct Observations { @@ -108,7 +109,8 @@ fn plan() -> record::Plan { &vitaminc_aead_frame::serde::to_vec(&serde_json::json!({ "value": {"context": ["lifecycle", "value"], "outputs": ["c"], "type": "string"} })) - .unwrap(), + .unwrap() + .risky_unwrap(), ) .unwrap() } diff --git a/packages/stack-encrypt/tests/query_operators.rs b/packages/stack-encrypt/tests/query_operators.rs index 9103a2674..848ec9d1e 100644 --- a/packages/stack-encrypt/tests/query_operators.rs +++ b/packages/stack-encrypt/tests/query_operators.rs @@ -77,7 +77,9 @@ fn stats_cases() -> Vec<(Json, Stats)> { /// A plan written as JSON, as frame bytes: what a binding sends. fn frame_of_json(value: &Json) -> Vec { - vitaminc_aead_frame::serde::to_vec(value).expect("frames") + vitaminc_protected::Controlled::risky_unwrap( + vitaminc_aead_frame::serde::to_vec(value).expect("frames"), + ) } /// A JSON value as the frame a binding sends: a number is a `u64` when it diff --git a/packages/stack-encrypt/tests/read_conformance.rs b/packages/stack-encrypt/tests/read_conformance.rs index c8749ed5d..2572ce807 100644 --- a/packages/stack-encrypt/tests/read_conformance.rs +++ b/packages/stack-encrypt/tests/read_conformance.rs @@ -14,6 +14,7 @@ use std::{ collections::{BTreeMap, BTreeSet}, sync::atomic::Ordering, }; +use vitaminc_protected::Controlled as _; type Row = BTreeMap; #[derive(Deserialize)] @@ -107,7 +108,12 @@ fn plan(decl: &Declaration, context_field: bool, context_name: &str) -> record:: // Match Go Plan.Wire's declaration order explicitly, regardless of the // serde_json map backend or the context field's lexical position. let framed = |v: &Value| { - ValueFrame::from_bytes(&vitaminc_aead_frame::serde::to_vec(v).unwrap()).unwrap() + ValueFrame::from_bytes( + &vitaminc_aead_frame::serde::to_vec(v) + .unwrap() + .risky_unwrap(), + ) + .unwrap() }; let mut fields = Vec::new(); if context_field { diff --git a/packages/stack-encrypt/tests/record_lowering.rs b/packages/stack-encrypt/tests/record_lowering.rs index 3fa04934a..a1335bc70 100644 --- a/packages/stack-encrypt/tests/record_lowering.rs +++ b/packages/stack-encrypt/tests/record_lowering.rs @@ -36,6 +36,7 @@ use stack_encrypt::sem::MatchOptions; use stack_encrypt::target::{Encrypted, IndexSpec}; use stack_encrypt::{CipherText, Error, Label, Plan, SealedValue, StackCipher, StackCipherText}; use vitaminc_aead_frame::serde::from_slice; +use vitaminc_protected::Controlled as _; const SEED: [u8; 32] = *b"stack-encrypt record fixture v1 "; @@ -94,7 +95,9 @@ fn plaintext_json() -> Json { /// A plan written as JSON, as frame bytes: what a binding sends. fn frame_of_json(value: &Json) -> Vec { - vitaminc_aead_frame::serde::to_vec(value).expect("frames") + vitaminc_aead_frame::serde::to_vec(value) + .expect("frames") + .risky_unwrap() } /// The plaintext as the binding sends it: each field at the kind the plan diff --git a/packages/stack-encrypt/tests/runtime_conformance.rs b/packages/stack-encrypt/tests/runtime_conformance.rs index c9faf2db0..e6b5e4389 100644 --- a/packages/stack-encrypt/tests/runtime_conformance.rs +++ b/packages/stack-encrypt/tests/runtime_conformance.rs @@ -14,6 +14,7 @@ use std::{ collections::{BTreeMap, BTreeSet}, sync::atomic::Ordering, }; +use vitaminc_protected::Controlled as _; type Row = BTreeMap; @@ -102,9 +103,12 @@ async fn shared_record_runtime_profile() { if !case.null.is_empty() { spec["null"] = json!(case.null); } - let plan = - record::plan(&vitaminc_aead_frame::serde::to_vec(&json!({"value":spec})).unwrap()) - .unwrap(); + let plan = record::plan( + &vitaminc_aead_frame::serde::to_vec(&json!({"value":spec})) + .unwrap() + .risky_unwrap(), + ) + .unwrap(); let keyset = cipher.default_keyset(); match case.operation { Operation::Seal { diff --git a/packages/stack-encrypt/tests/stored_rows.rs b/packages/stack-encrypt/tests/stored_rows.rs index bc5aba38a..fdeb442a4 100644 --- a/packages/stack-encrypt/tests/stored_rows.rs +++ b/packages/stack-encrypt/tests/stored_rows.rs @@ -218,7 +218,9 @@ fn refreeze_order_terms(stored: &mut Json, resealed: &Json) { /// A plan written as JSON, as frame bytes: what a binding sends. fn frame_of_json(value: &Json) -> Vec { - vitaminc_aead_frame::serde::to_vec(value).expect("frames") + vitaminc_protected::Controlled::risky_unwrap( + vitaminc_aead_frame::serde::to_vec(value).expect("frames"), + ) } fn plan_of(json: &Json) -> DataPlan { @@ -386,9 +388,9 @@ impl Value { } fn frame(&self) -> Vec { - let mut bytes = Vec::new(); + let mut bytes = vitaminc_protected::Buffer::new(); self.write(FrameWriter::new(&mut bytes)).expect("frames"); - bytes + vitaminc_protected::Controlled::risky_unwrap(bytes) } } diff --git a/packages/stack-encrypt/tests/value_kinds.rs b/packages/stack-encrypt/tests/value_kinds.rs index cb55e3290..94fa122cf 100644 --- a/packages/stack-encrypt/tests/value_kinds.rs +++ b/packages/stack-encrypt/tests/value_kinds.rs @@ -33,6 +33,7 @@ use stack_encrypt::{CipherText, KeysetCipher, SealedValue, StackCipher, StackCip use vitaminc_aead::plaintext::{Int, Logical, Physical, Raw, TimeUnit}; use vitaminc_aead::plaintext::{MapSink, Sink}; use vitaminc_aead::{Encrypt, FrameWriter}; +use vitaminc_protected::Controlled as _; const SEED: [u8; 32] = *b"stack-encrypt value kinds v1 "; @@ -191,7 +192,7 @@ fn data_plan() -> DataPlan { /// The source as a binding hands it over: one frame, a map of the fields in /// plan order. fn source() -> ValueFrame { - let mut bytes = Vec::new(); + let mut bytes = vitaminc_protected::Buffer::new(); { let kinds = kinds(); let mut map = FrameWriter::new(&mut bytes) @@ -204,7 +205,7 @@ fn source() -> ValueFrame { } map.end().expect("ended"); } - ValueFrame::from_bytes(&bytes).expect("in the value model") + ValueFrame::from_bytes(bytes.risky_ref()).expect("in the value model") } fn hex(bytes: &[u8]) -> String { @@ -366,7 +367,9 @@ async fn every_new_kind_seals_opens_and_queries_as_the_typed_path() { kind.field ); let ctx = context( - &vitaminc_aead_frame::serde::to_vec(&("value_kinds", kind.field)).expect("frames"), + &vitaminc_aead_frame::serde::to_vec(&("value_kinds", kind.field)) + .expect("frames") + .risky_unwrap(), ) .expect("context"); let typed_eq = keyset @@ -416,7 +419,7 @@ async fn a_value_of_another_width_is_refused_when_sealed() { let cipher = deterministic_cipher(SEED).await; let plan = data_plan(); // The source with the `int8` field's value sent at 32 bits. - let mut bytes = Vec::new(); + let mut bytes = vitaminc_protected::Buffer::new(); { let kinds = kinds(); let mut map = FrameWriter::new(&mut bytes) @@ -433,7 +436,7 @@ async fn a_value_of_another_width_is_refused_when_sealed() { map.end().expect("ended"); } let keyset = cipher.default_keyset(); - let source = vitaminc_aead::Frame::parse(&bytes).expect("a frame"); + let source = vitaminc_aead::Frame::parse(bytes.risky_ref()).expect("a frame"); let refused = record::encrypt(&keyset, source, &plan); assert!( matches!(&refused, Err(error) if error.field() == Some("i8") && error.reason() == Some(Reason::FieldType)), From 1f81e22259b79ab7e1f719bb8d1876eae5cbdc09 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 5 Nov 2026 22:24:08 +1100 Subject: [PATCH 08/17] feat(stack-encrypt): byte strings are opaque scalars, through vitaminc's Scalar marker OpaqueEncode was a hand-kept list of types in this crate, and it had already drifted from the leaves vitaminc defines: Vec and [u8; N] are one byte leaf in vitaminc, sealed fine by the structural API and the Go SDK, but neither encrypted into one Ciphertext here (review on #1501). OpaqueEncode is now every owned Zeroize type whose borrow is a vitaminc plaintext::Scalar, and OpaqueDecode every Zeroize FromScalar target. So vec![1u8, 2] and [7u8; 16] encrypt into one Ciphertext and decrypt back, and a Vec> is a column. u8 is still not a scalar, so a Vec is never a column of bytes, and a Vec of any other scalar still does not encrypt into one Ciphertext; the compile-fail fixtures keep pinning both. The sealed Encode bridge stays: it is the supertrait that lets a generic caller holding &S walk it. --- packages/stack-encrypt/CHANGELOG.md | 18 +- packages/stack-encrypt/src/lib.rs | 11 +- packages/stack-encrypt/src/scalar.rs | 70 ++++--- .../stack-encrypt/src/target/collections.rs | 11 +- packages/stack-encrypt/tests/collections.rs | 47 +++++ .../tests/ui/owned_collection_elements.rs | 12 +- .../tests/ui/owned_collection_elements.stderr | 184 ++++++++---------- .../tests/ui/pass/owned_collections.rs | 10 +- .../ui/scalar_collections_and_borrows.rs | 1 - .../ui/scalar_collections_and_borrows.stderr | 72 +++---- .../tests/ui/scalar_nullable.stderr | 46 ++--- 11 files changed, 258 insertions(+), 224 deletions(-) diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 3f533705c..cd4ba377e 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -53,10 +53,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 no `Clone` bound; a column that fails after some elements were recovered wipes those before returning the error, and before unwinding when an element's conversion panics. `Vec` is not a column of - scalars: `u8` is not `OpaqueEncode`, because Vitamin C keeps `Vec` a - byte leaf, so it compiles to neither a column of bytes nor one ciphertext. - Direct `keyset.encrypt` still takes one scalar; `encrypt_tree` remains the - structural path. + scalars: `u8` is not `OpaqueEncode`, because Vitamin C keeps `Vec` one + byte leaf. Direct `keyset.encrypt` still takes one scalar; `encrypt_tree` + remains the structural path. +- **Byte strings are opaque scalars: `Vec` and `[u8; N]` encrypt into + one `Ciphertext`, and a `Vec>` into a column.** `OpaqueEncode` is + now every owned `Zeroize` type whose borrow is a Vitamin C + `plaintext::Scalar` (one leaf), and `OpaqueDecode` every `Zeroize` + `FromScalar` target, in place of a hand-kept list that had already left + out the byte leaves Vitamin C defines. So + `keyset.encrypt(vec![1u8, 2, 3])` and + `Vec::::decrypt_from(ciphertext, &keyset)` now compile, as the + structural API and the Go SDK already sealed them. A type of your own + joins by implementing `Scalar` for its borrow and `Zeroize`. A `Vec` of + any other scalar, and `u8` on its own, still do not. ## [0.3.0] - 2026-10-27 diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index fc8fdaaa6..a5dd0b24a 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -282,17 +282,16 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! The column takes the element target's caller-context capability as its //! own, so a column of a target that requires a context cannot omit it, and //! a column of a target that accepts none cannot be given one. A `Vec` -//! is not a column of scalars: `u8` is not an [`OpaqueEncode`] type, since -//! Vitamin C keeps `Vec` a byte leaf and gives `u8` no source of its -//! own, so `Vec` neither encrypts byte by byte nor as one `Ciphertext` -//! here. +//! is not a column of scalars: Vitamin C keeps `Vec` one byte leaf and +//! gives `u8` no source of its own, so `u8` is not an [`OpaqueEncode`] type +//! and a `Vec` encrypts as one `Ciphertext`, never byte by byte. A +//! `Vec>` is a column of byte strings. //! //! Existing structural plans and derives still use [`StackCipherText`], a tree //! of sealed leaves. Their low-level direct entry points are //! [`KeysetCipher::encrypt_tree`] and [`KeysetCipher::decrypt_tree`]; their //! borrowed conversion helpers live in [`target::legacy`]. They are separate -//! from the owned scalar API. There is no generic `Serialize` fallback and -//! no implicit `Vec` blob. +//! from the owned scalar API. There is no generic `Serialize` fallback. //! //! # What is authenticated //! diff --git a/packages/stack-encrypt/src/scalar.rs b/packages/stack-encrypt/src/scalar.rs index a31430311..a589883df 100644 --- a/packages/stack-encrypt/src/scalar.rs +++ b/packages/stack-encrypt/src/scalar.rs @@ -5,7 +5,8 @@ use crate::target::{ OptionalContext, Pending, ScalarContext, }; use crate::{CipherText, Decrypt, Error, KeysetCipher, LeafBytesError, SealedValue}; -use vitaminc_aead::plaintext::{Encrypt, Sink}; +use vitaminc_aead::plaintext::{Encrypt, Scalar, Sink}; +use vitaminc_aead::FromScalar; use vitaminc_protected::OpaqueDebug; use zeroize::Zeroize; @@ -20,43 +21,56 @@ mod sealed { /// A plaintext type that encrypts as one opaque [`Ciphertext`]. /// -/// Implemented for `bool`, the fixed-width signed integers, the unsigned -/// integers from `u16` through `u128`, `f32`, `f64` and `String`. The trait is -/// sealed: collections and borrowed values deliberately do not implement it. -/// A `Vec` of these encrypts element by element into a `Vec` -/// through [`EncryptInto`] instead; see the crate docs on collections. -/// `u8` is deliberately absent: Vitamin C keeps `Vec` a byte leaf and -/// gives `u8` no source of its own, so a byte vector is neither a column of -/// scalars nor an implicit single ciphertext here. +/// Implemented for every owned type that Vitamin C walks as exactly one +/// leaf and that can be wiped: a type `S` where `&S` is a +/// [`Scalar`](vitaminc_aead::plaintext::Scalar) and `S` is [`Zeroize`]. +/// That is `bool`, the fixed-width signed integers, the unsigned integers +/// from `u16` through `u128`, `f32`, `f64`, `String`, and the byte strings +/// `Vec` and `[u8; N]`, each one byte leaf; and any type of your own +/// whose borrow implements `Scalar` and which implements `Zeroize`. The +/// trait is sealed: implement Vitamin C's marker, and this follows. +/// +/// A sequence is not a `Scalar`, so no `Vec` other than `Vec` encrypts +/// into one `Ciphertext`. A `Vec` of these encrypts element by element into +/// a `Vec` through [`EncryptInto`] instead; see the crate docs +/// on collections. `u8` is absent, as it is from Vitamin C's sources: +/// `Vec` is one byte leaf, not a column of bytes. /// Nullable `Option` values are also deferred in this scalar slice. Use /// [`KeysetCipher::encrypt_tree`] and [`KeysetCipher::decrypt_tree`] for them; /// fixed-width nullable values retain Vitamin C's padded-null representation. pub trait OpaqueEncode: sealed::Encode + Zeroize + Send + 'static {} -/// A plaintext type that one opaque [`Ciphertext`] decrypts to: the types -/// that implement [`OpaqueEncode`]. Sealed, like it. +/// A plaintext type that one opaque [`Ciphertext`] decrypts to: every +/// [`FromScalar`] target that can be wiped, which covers the built-in +/// [`OpaqueEncode`] types. Sealed, like it. pub trait OpaqueDecode: sealed::Decode + Decrypt + Zeroize + Send + 'static {} -macro_rules! opaque { - ($($ty:ty),* $(,)?) => {$( - impl sealed::Encode for $ty { - const DEPTH: Option = <&$ty as Encrypt>::DEPTH; - const FIXED: Option = <&$ty as Encrypt>::FIXED; - fn visit(&self, sink: S) -> Result { - Encrypt::visit(self, sink) - } - } - impl sealed::Decode for $ty {} - impl OpaqueEncode for $ty {} - impl OpaqueDecode for $ty {} - )*}; +// The bridge from an owned `S` to its borrow's walk: `OpaqueEncode`'s +// callers hold `&S`, and only this impl's where clause knows `&S` encrypts. +impl sealed::Encode for S +where + S: Zeroize + Send + 'static, + for<'a> &'a S: Scalar, +{ + const DEPTH: Option = <&S as Encrypt>::DEPTH; + const FIXED: Option = <&S as Encrypt>::FIXED; + fn visit(&self, sink: V) -> Result { + Encrypt::visit(self, sink) + } +} +impl OpaqueEncode for S +where + S: Zeroize + Send + 'static, + for<'a> &'a S: Scalar, +{ } -opaque!(bool, i8, i16, i32, i64, i128, u16, u32, u64, u128, f32, f64, String); +impl sealed::Decode for P {} +impl OpaqueDecode for P {} pub(crate) struct ScalarRef<'a, S>(pub &'a S); -// Vitamin C 0.8 implements Encrypt for &Protected, but not ProtectedRef. -// This bridge preserves its encoding metadata while the owned conversion keeps -// the source in Protected. Replace it when the encoder accepts protected borrows. +// The owned conversion keeps the source in Protected and hands this a borrow. +// It carries the scalar's encoding metadata through `sealed::Encode`, whose +// supertrait bound is what lets a generic caller walk `&S` at all. impl Encrypt for ScalarRef<'_, S> { const DEPTH: Option = ::DEPTH; const FIXED: Option = ::FIXED; diff --git a/packages/stack-encrypt/src/target/collections.rs b/packages/stack-encrypt/src/target/collections.rs index ea1e8056a..b5d94ca03 100644 --- a/packages/stack-encrypt/src/target/collections.rs +++ b/packages/stack-encrypt/src/target/collections.rs @@ -15,11 +15,12 @@ //! and an empty collection makes no request at all. The key source may //! still split one logical batch into several provider requests. //! -//! A `Vec` is not a collection here: `u8` is not an opaque scalar, since -//! Vitamin C keeps `Vec` a byte leaf and gives `u8` no source of its own, -//! so neither `vec![1u8].encrypt_into::, _>(..)` nor a whole -//! byte vector as one `Ciphertext` compiles. A nullable element, `Option`, -//! is likewise not an opaque scalar in this slice. +//! A `Vec` is not a collection here: Vitamin C keeps `Vec` one byte +//! leaf and gives `u8` no source of its own, so `u8` is not an opaque scalar +//! and `vec![1u8].encrypt_into::, _>(..)` does not compile. +//! The byte vector is one `Ciphertext`, and a `Vec>` is a column of +//! them. A nullable element, `Option`, is not an opaque scalar in this +//! slice. use super::{DecryptSource, Decryption, EncryptTarget, Encryption, Pending}; use stack_kms::MaybeSend; use zeroize::Zeroize; diff --git a/packages/stack-encrypt/tests/collections.rs b/packages/stack-encrypt/tests/collections.rs index c3bce3ba9..50c6568a8 100644 --- a/packages/stack-encrypt/tests/collections.rs +++ b/packages/stack-encrypt/tests/collections.rs @@ -153,6 +153,53 @@ async fn a_foreign_keyset_element_is_refused_before_retrieval() { assert_eq!(retrieves.load(Ordering::SeqCst), 0); } +#[tokio::test] +async fn a_byte_string_is_one_ciphertext_and_a_column_of_them_is_a_column() { + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + + // One byte leaf: one ciphertext, one key. + let blob: Ciphertext = vec![0u8, 1, 255].encrypt_into(&keyset).await.unwrap(); + assert_eq!( + Vec::::decrypt_from(blob.clone(), &keyset) + .await + .unwrap(), + [0, 1, 255] + ); + // A byte leaf opened as a `String` is a type mismatch, not tampering. + assert!(matches!( + keyset.decrypt::(blob).await, + Err(Error::OpenedType { .. }) + )); + assert_eq!(generates.load(Ordering::SeqCst), 1); + assert_eq!(retrieves.load(Ordering::SeqCst), 2); + + // A column of byte strings, the empty one included, is one batch. + let blobs = vec![vec![1u8, 2, 3], vec![], vec![9]]; + let column: Vec = blobs.clone().encrypt_into(&keyset).await.unwrap(); + assert_eq!(column.len(), 3); + assert_eq!(generates.load(Ordering::SeqCst), 2); + assert_eq!( + Vec::>::decrypt_from(column.clone(), &keyset) + .await + .unwrap(), + blobs + ); + assert_eq!(retrieves.load(Ordering::SeqCst), 3); + // Each element is an ordinary byte-string ciphertext. + assert_eq!( + keyset.decrypt::>(column[2].clone()).await.unwrap(), + [9] + ); + + // A fixed-width byte array is a byte leaf too. + let key: Ciphertext = [7u8; 16].encrypt_into(&keyset).await.unwrap(); + assert_eq!( + <[u8; 16]>::decrypt_from(key, &keyset).await.unwrap(), + [7u8; 16] + ); +} + #[tokio::test] async fn nested_columns_settle_in_one_batch_and_keep_their_shape() { let (cipher, generates, retrieves) = common::counting_cipher().await; diff --git a/packages/stack-encrypt/tests/ui/owned_collection_elements.rs b/packages/stack-encrypt/tests/ui/owned_collection_elements.rs index 8db547ebd..554d75ecd 100644 --- a/packages/stack-encrypt/tests/ui/owned_collection_elements.rs +++ b/packages/stack-encrypt/tests/ui/owned_collection_elements.rs @@ -1,16 +1,14 @@ //! A collection's elements must be opaque scalars. `u8` is not one: Vitamin -//! C keeps `Vec` a byte leaf and gives `u8` no source of its own, so a -//! byte vector is neither a column of bytes nor one ciphertext here (a whole -//! byte vector is a blob, a separate representation). A nullable element and -//! a borrowed element are not opaque scalars either. No vector, of bytes or -//! of scalars, encrypts into one `Ciphertext`, and the decrypt side refuses -//! the same element types. +//! C keeps `Vec` one byte leaf and gives `u8` no source of its own, so a +//! byte vector is one `Ciphertext` (`tests/ui/pass/owned_collections.rs`), +//! never a column of bytes. A nullable element and a borrowed element are +//! not opaque scalars either. No vector of scalars encrypts into one +//! `Ciphertext`, and the decrypt side refuses the same element types. use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto}; fn unsupported(keyset: &stack_encrypt::KeysetCipher<'_, K>, column: Vec) { let _ = vec![1u8, 2].encrypt_into::, _>(keyset); let _ = vec![Some(1i32), None].encrypt_into::, _>(keyset); let _ = EncryptInto::encrypt_into::, _>(vec!["borrowed", "plaintext"], keyset); - let _ = vec![1u8, 2].encrypt_into::(keyset); let _ = vec![1i32, 2].encrypt_into::(keyset); let _ = Vec::::decrypt_from(column.clone(), keyset); let _ = Vec::>::decrypt_from(column, keyset); diff --git a/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr b/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr index 1130a9e9c..5f5bb3578 100644 --- a/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr +++ b/packages/stack-encrypt/tests/ui/owned_collection_elements.stderr @@ -1,74 +1,49 @@ -error[E0277]: the trait bound `u8: OpaqueEncode` is not satisfied - --> tests/ui/owned_collection_elements.rs:10:26 - | -10 | let _ = vec![1u8, 2].encrypt_into::, _>(keyset); - | ^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `u8` - | - = help: the following other types implement trait `OpaqueEncode`: - f32 - f64 - i128 - i16 - i32 - i64 - i8 - u128 - and $N others - = note: required for `stack_encrypt::Ciphertext` to implement `EncryptTarget` - = note: 1 redundant requirement hidden - = note: required for `Vec` to implement `EncryptTarget>` +error[E0277]: the trait bound `for<'a> &'a u8: Scalar` is not satisfied + --> tests/ui/owned_collection_elements.rs:9:26 + | +9 | let _ = vec![1u8, 2].encrypt_into::, _>(keyset); + | ^^^^^^^^^^^^ the trait `for<'a> Scalar` is not implemented for `&'a u8` + | + = help: the following other types implement trait `Scalar`: + &f32 + &f64 + &i128 + &i16 + &i32 + &i64 + &i8 + &u128 + and $N others + = note: required for `u8` to implement `OpaqueEncode` + = note: required for `stack_encrypt::Ciphertext` to implement `EncryptTarget` + = note: 1 redundant requirement hidden + = note: required for `Vec` to implement `EncryptTarget>` -error[E0277]: the trait bound `Option: OpaqueEncode` is not satisfied - --> tests/ui/owned_collection_elements.rs:11:36 - | -11 | let _ = vec![Some(1i32), None].encrypt_into::, _>(keyset); - | ^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `Option` - | - = help: the following other types implement trait `OpaqueEncode`: - bool - f32 - f64 - i128 - i16 - i32 - i64 - i8 +error[E0277]: the trait bound `for<'a> &'a Option: Scalar` is not satisfied + --> tests/ui/owned_collection_elements.rs:10:36 + | +10 | let _ = vec![Some(1i32), None].encrypt_into::, _>(keyset); + | ^^^^^^^^^^^^ the trait `for<'a> Scalar` is not implemented for `&'a Option` + | + = help: the following other types implement trait `Scalar`: + &&'b T + &'a Protected + &Raw<'_> + &Vec + &[u8; N] + &[u8] + &bool + &chrono::datetime::DateTime and $N others + = note: required for `Option` to implement `OpaqueEncode` = note: required for `stack_encrypt::Ciphertext` to implement `EncryptTarget>` = note: 1 redundant requirement hidden = note: required for `Vec` to implement `EncryptTarget>>` -error[E0277]: the trait bound `&str: OpaqueEncode` is not satisfied - --> tests/ui/owned_collection_elements.rs:12:41 - | -12 | let _ = EncryptInto::encrypt_into::, _>(vec!["borrowed", "plaintext"], keyset); - | ^^^^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `&str` - | -help: the trait `OpaqueEncode` is implemented for `std::string::String` - --> src/scalar.rs - | - | impl OpaqueEncode for $ty {} - | ^^^^^^^^^^^^^^^^^^^^^^^^^ -... - | opaque!(bool, i8, i16, i32, i64, i128, u16, u32, u64, u128, f32, f64, String); - | ----------------------------------------------------------------------------- in this macro invocation - = note: required for `stack_encrypt::Ciphertext` to implement `EncryptTarget<&str>` - = note: 1 redundant requirement hidden - = note: required for `Vec` to implement `EncryptTarget>` -note: required by a bound in `stack_encrypt::EncryptInto::encrypt_into` - --> src/target/conversion.rs - | - | fn encrypt_into<'a, T, K: 'static>(self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> - | ------------ required by a bound in this associated function - | where - | T: EncryptTarget, - | ^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` - = note: this error originates in the macro `opaque` (in Nightly builds, run with -Z macro-backtrace for more info) - error[E0277]: the trait bound `&str: zeroize::Zeroize` is not satisfied - --> tests/ui/owned_collection_elements.rs:12:13 + --> tests/ui/owned_collection_elements.rs:11:13 | -12 | let _ = EncryptInto::encrypt_into::, _>(vec!["borrowed", "plaintext"], keyset); +11 | let _ = EncryptInto::encrypt_into::, _>(vec!["borrowed", "plaintext"], keyset); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `zeroize::DefaultIsZeroes` is not implemented for `&str` | = help: the following other types implement trait `zeroize::DefaultIsZeroes`: @@ -84,49 +59,40 @@ error[E0277]: the trait bound `&str: zeroize::Zeroize` is not satisfied = note: required for `&str` to implement `zeroize::Zeroize` = note: required for `Vec` to implement `EncryptTarget>` -error[E0277]: the trait bound `Vec: OpaqueEncode` is not satisfied - --> tests/ui/owned_collection_elements.rs:13:26 +error[E0277]: the trait bound `for<'a> &'a Vec: Scalar` is not satisfied + --> tests/ui/owned_collection_elements.rs:12:27 | -13 | let _ = vec![1u8, 2].encrypt_into::(keyset); - | ^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `Vec` - | - = help: the following other types implement trait `OpaqueEncode`: - bool - f32 - f64 - i128 - i16 - i32 - i64 - i8 - and $N others - = note: required for `stack_encrypt::Ciphertext` to implement `EncryptTarget>` - -error[E0277]: the trait bound `Vec: OpaqueEncode` is not satisfied - --> tests/ui/owned_collection_elements.rs:14:27 +12 | let _ = vec![1i32, 2].encrypt_into::(keyset); + | ^^^^^^^^^^^^ the trait `for<'a> Scalar` is not implemented for `&'a Vec` | -14 | let _ = vec![1i32, 2].encrypt_into::(keyset); - | ^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `Vec` +help: the following other types implement trait `Scalar` + --> $CARGO/vitaminc-aead-$VERSION/src/plaintext/sources.rs | - = help: the following other types implement trait `OpaqueEncode`: - bool - f32 - f64 - i128 - i16 - i32 - i64 - i8 - and $N others + | impl Scalar for $ty {} + | ^^^^^^^^^^^^^^^^^^^ + | | + | `&Vec` + | `Vec` +... + | / scalar! { + | | bool, &bool, f32, &f32, f64, &f64, + | | i8, &i8, i16, &i16, i32, &i32, i64, &i64, i128, &i128, + | | u16, &u16, u32, &u32, u64, &u64, u128, &u128, +... | + | | Bytes<'_>, &Bytes<'_>, Raw<'_>, &Raw<'_>, + | | } + | |_- in this macro invocation + = note: required for `Vec` to implement `OpaqueEncode` = note: required for `stack_encrypt::Ciphertext` to implement `EncryptTarget>` + = note: this error originates in the macro `scalar` (in Nightly builds, run with -Z macro-backtrace for more info) -error[E0277]: the trait bound `u8: OpaqueDecode` is not satisfied - --> tests/ui/owned_collection_elements.rs:15:13 +error[E0277]: the trait bound `u8: vitaminc_aead::typed::FromScalar` is not satisfied + --> tests/ui/owned_collection_elements.rs:13:13 | -15 | let _ = Vec::::decrypt_from(column.clone(), keyset); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `OpaqueDecode` is not implemented for `u8` +13 | let _ = Vec::::decrypt_from(column.clone(), keyset); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `vitaminc_aead::typed::FromScalar` is not implemented for `u8` | - = help: the following other types implement trait `OpaqueDecode`: + = help: the following other types implement trait `vitaminc_aead::typed::FromScalar`: f32 f64 i128 @@ -136,26 +102,28 @@ error[E0277]: the trait bound `u8: OpaqueDecode` is not satisfied i8 u128 and $N others + = note: required for `u8` to implement `OpaqueDecode` = note: required for `stack_encrypt::Ciphertext` to implement `DecryptSource` = note: 1 redundant requirement hidden = note: required for `Vec` to implement `DecryptSource>` -error[E0277]: the trait bound `Option: OpaqueDecode` is not satisfied - --> tests/ui/owned_collection_elements.rs:16:13 +error[E0277]: the trait bound `Option: vitaminc_aead::typed::FromScalar` is not satisfied + --> tests/ui/owned_collection_elements.rs:14:13 | -16 | let _ = Vec::>::decrypt_from(column, keyset); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `OpaqueDecode` is not implemented for `Option` +14 | let _ = Vec::>::decrypt_from(column, keyset); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `vitaminc_aead::typed::FromScalar` is not implemented for `Option` | - = help: the following other types implement trait `OpaqueDecode`: + = help: the following other types implement trait `vitaminc_aead::typed::FromScalar`: + Vec + [u8; N] bool + chrono::datetime::DateTime + chrono::naive::date::NaiveDate f32 f64 i128 - i16 - i32 - i64 - i8 and $N others + = note: required for `Option` to implement `OpaqueDecode` = note: required for `stack_encrypt::Ciphertext` to implement `DecryptSource>` = note: 1 redundant requirement hidden = note: required for `Vec` to implement `DecryptSource>>` diff --git a/packages/stack-encrypt/tests/ui/pass/owned_collections.rs b/packages/stack-encrypt/tests/ui/pass/owned_collections.rs index 7cf0cfdb9..70caf47ad 100644 --- a/packages/stack-encrypt/tests/ui/pass/owned_collections.rs +++ b/packages/stack-encrypt/tests/ui/pass/owned_collections.rs @@ -1,6 +1,7 @@ //! The owned collection forms that must compile: a `Vec` of opaque scalars //! into a `Vec` of any owned target, with the element's caller-context -//! capability, and back. `tests/ui/owned_collection_elements.rs` and +//! capability, and back. A `Vec` is one byte leaf, so it is one +//! `Ciphertext`, and a `Vec>` is a column of them. `tests/ui/owned_collection_elements.rs` and //! `tests/ui/owned_collection_context.rs` pin the forms that must not. use stack_encrypt::target::{ AcceptsContext, DecryptSource, Decryption, EncryptTarget, Encryption, NoCallerContext, @@ -72,6 +73,13 @@ async fn columns( .encrypt_into_with_context(keyset, ("users", "name")) .await?; let _: Vec = Vec::decrypt_from_with_context(names, keyset, ("users", "name")).await?; + // A byte string is one leaf, and a column of byte strings is a column. + let blob: Ciphertext = vec![1u8, 2].encrypt_into(keyset).await?; + let _: Vec = Vec::decrypt_from(blob, keyset).await?; + let fixed_bytes: Ciphertext = [7u8; 4].encrypt_into(keyset).await?; + let _: [u8; 4] = <[u8; 4]>::decrypt_from(fixed_bytes, keyset).await?; + let blobs: Vec = vec![vec![1u8], vec![]].encrypt_into(keyset).await?; + let _: Vec> = Vec::decrypt_from(blobs, keyset).await?; // Nested columns, and a column of a non-`Clone` owned plaintext. let nested: Vec> = vec![vec![1i32], vec![]].encrypt_into(keyset).await?; let _: Vec> = Vec::decrypt_from(nested, keyset).await?; diff --git a/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.rs b/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.rs index b44585ce1..7ff064c5d 100644 --- a/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.rs +++ b/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.rs @@ -1,6 +1,5 @@ fn unsupported(keyset: &stack_encrypt::KeysetCipher<'_, K>) { let _ = keyset.encrypt(vec![1i32, 2]); - let _ = keyset.encrypt(vec![1u8, 2]); let _ = keyset.encrypt("borrowed plaintext"); } fn main() {} diff --git a/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr b/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr index f8bde2374..ab5de3568 100644 --- a/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr +++ b/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr @@ -1,70 +1,58 @@ -error[E0277]: the trait bound `Vec: OpaqueEncode` is not satisfied +error[E0277]: the trait bound `for<'a> &'a Vec: Scalar` is not satisfied --> tests/ui/scalar_collections_and_borrows.rs:2:28 | 2 | let _ = keyset.encrypt(vec![1i32, 2]); - | ------- ^^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `Vec` + | ------- ^^^^^^^^^^^^^ the trait `for<'a> Scalar` is not implemented for `&'a Vec` | | | required by a bound introduced by this call | - = help: the following other types implement trait `OpaqueEncode`: - bool - f32 - f64 - i128 - i16 - i32 - i64 - i8 - and $N others +help: the following other types implement trait `Scalar` + --> $CARGO/vitaminc-aead-$VERSION/src/plaintext/sources.rs + | + | impl Scalar for $ty {} + | ^^^^^^^^^^^^^^^^^^^ + | | + | `&Vec` + | `Vec` +... + | / scalar! { + | | bool, &bool, f32, &f32, f64, &f64, + | | i8, &i8, i16, &i16, i32, &i32, i64, &i64, i128, &i128, + | | u16, &u16, u32, &u32, u64, &u64, u128, &u128, +... | + | | Bytes<'_>, &Bytes<'_>, Raw<'_>, &Raw<'_>, + | | } + | |_- in this macro invocation + = note: required for `Vec` to implement `OpaqueEncode` note: required by a bound in `stack_encrypt::scalar::>::encrypt` --> src/scalar.rs | | pub fn encrypt(&self, value: S) -> Pending<'_, Ciphertext, K> { | ^^^^^^^^^^^^ required by this bound in `stack_encrypt::scalar::>::encrypt` + = note: this error originates in the macro `scalar` (in Nightly builds, run with -Z macro-backtrace for more info) -error[E0277]: the trait bound `Vec: OpaqueEncode` is not satisfied +error[E0277]: the trait bound `&str: OpaqueEncode` is not satisfied --> tests/ui/scalar_collections_and_borrows.rs:3:28 | -3 | let _ = keyset.encrypt(vec![1u8, 2]); - | ------- ^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `Vec` +3 | let _ = keyset.encrypt("borrowed plaintext"); + | ------- ^^^^^^^^^^^^^^^^^^^^ the trait `zeroize::DefaultIsZeroes` is not implemented for `&str` | | | required by a bound introduced by this call | - = help: the following other types implement trait `OpaqueEncode`: + = help: the following other types implement trait `zeroize::DefaultIsZeroes`: + () + PhantomPinned bool + char f32 f64 i128 i16 - i32 - i64 - i8 and $N others + = note: required for `&str` to implement `zeroize::Zeroize` + = note: required for `&str` to implement `OpaqueEncode` note: required by a bound in `stack_encrypt::scalar::>::encrypt` --> src/scalar.rs | | pub fn encrypt(&self, value: S) -> Pending<'_, Ciphertext, K> { | ^^^^^^^^^^^^ required by this bound in `stack_encrypt::scalar::>::encrypt` - -error[E0277]: the trait bound `&str: OpaqueEncode` is not satisfied - --> tests/ui/scalar_collections_and_borrows.rs:4:28 - | -4 | let _ = keyset.encrypt("borrowed plaintext"); - | ------- ^^^^^^^^^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `&str` - | | - | required by a bound introduced by this call - | -help: the trait `OpaqueEncode` is implemented for `std::string::String` - --> src/scalar.rs - | - | impl OpaqueEncode for $ty {} - | ^^^^^^^^^^^^^^^^^^^^^^^^^ -... - | opaque!(bool, i8, i16, i32, i64, i128, u16, u32, u64, u128, f32, f64, String); - | ----------------------------------------------------------------------------- in this macro invocation -note: required by a bound in `stack_encrypt::scalar::>::encrypt` - --> src/scalar.rs - | - | pub fn encrypt(&self, value: S) -> Pending<'_, Ciphertext, K> { - | ^^^^^^^^^^^^ required by this bound in `stack_encrypt::scalar::>::encrypt` - = note: this error originates in the macro `opaque` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/packages/stack-encrypt/tests/ui/scalar_nullable.stderr b/packages/stack-encrypt/tests/ui/scalar_nullable.stderr index c4647de38..c3ffacddf 100644 --- a/packages/stack-encrypt/tests/ui/scalar_nullable.stderr +++ b/packages/stack-encrypt/tests/ui/scalar_nullable.stderr @@ -1,45 +1,47 @@ -error[E0277]: the trait bound `Option: OpaqueEncode` is not satisfied +error[E0277]: the trait bound `for<'a> &'a Option: Scalar` is not satisfied --> tests/ui/scalar_nullable.rs:2:28 | 2 | let _ = keyset.encrypt(Some(42i32)); - | ------- ^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `Option` + | ------- ^^^^^^^^^^^ the trait `for<'a> Scalar` is not implemented for `&'a Option` | | | required by a bound introduced by this call | - = help: the following other types implement trait `OpaqueEncode`: - bool - f32 - f64 - i128 - i16 - i32 - i64 - i8 + = help: the following other types implement trait `Scalar`: + &&'b T + &'a Protected + &Raw<'_> + &Vec + &[u8; N] + &[u8] + &bool + &chrono::datetime::DateTime and $N others + = note: required for `Option` to implement `OpaqueEncode` note: required by a bound in `stack_encrypt::scalar::>::encrypt` --> src/scalar.rs | | pub fn encrypt(&self, value: S) -> Pending<'_, Ciphertext, K> { | ^^^^^^^^^^^^ required by this bound in `stack_encrypt::scalar::>::encrypt` -error[E0277]: the trait bound `Option: OpaqueEncode` is not satisfied +error[E0277]: the trait bound `for<'a> &'a Option: Scalar` is not satisfied --> tests/ui/scalar_nullable.rs:3:28 | 3 | let _ = keyset.encrypt(None::); - | ------- ^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `Option` + | ------- ^^^^^^^^^^^ the trait `for<'a> Scalar` is not implemented for `&'a Option` | | | required by a bound introduced by this call | - = help: the following other types implement trait `OpaqueEncode`: - bool - f32 - f64 - i128 - i16 - i32 - i64 - i8 + = help: the following other types implement trait `Scalar`: + &&'b T + &'a Protected + &Raw<'_> + &Vec + &[u8; N] + &[u8] + &bool + &chrono::datetime::DateTime and $N others + = note: required for `Option` to implement `OpaqueEncode` note: required by a bound in `stack_encrypt::scalar::>::encrypt` --> src/scalar.rs | From f4e5ba2084ad5904370e965e44dbf13d0924ae92 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 6 Nov 2026 10:47:17 +1100 Subject: [PATCH 09/17] build(stack-encrypt): move the fuzz crates to vitaminc 0.9, and import Controlled where json_index uses it The three detached fuzz workspaces still locked vitaminc 0.8, and the stack-encrypt one named vitaminc-aead and vitaminc-protected 0.8 directly, so check_record's Encrypt impl met two copies of the traits. They move to 0.9 with the rest of the tree; only vitaminc and the order schemes change in their lockfiles. json_index.rs imported Controlled at the top, but only its feature = "dynamic" module reads a frame, so the --no-default-features build the WASI guest tests failed on an unused import. The import moves into that module. --- packages/stack-auth/fuzz/Cargo.lock | 82 +++++------ packages/stack-encrypt/fuzz/Cargo.lock | 156 ++++++++++----------- packages/stack-encrypt/fuzz/Cargo.toml | 4 +- packages/stack-encrypt/tests/json_index.rs | 2 +- packages/stack-kms/fuzz/Cargo.lock | 86 ++++++------ 5 files changed, 165 insertions(+), 165 deletions(-) diff --git a/packages/stack-auth/fuzz/Cargo.lock b/packages/stack-auth/fuzz/Cargo.lock index b24b8daa9..da725f822 100644 --- a/packages/stack-auth/fuzz/Cargo.lock +++ b/packages/stack-auth/fuzz/Cargo.lock @@ -2626,8 +2626,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.9.0", + "vitaminc-protected 0.9.0", "web-time", "zeroize", "zerokms-protocol", @@ -3181,16 +3181,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "e7aa53d696a9e1af61b36a60b92cc0dde7421f8f58787333d3c425a176af241c" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-context 0.9.0", + "vitaminc-encrypt 0.9.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", + "vitaminc-traits 0.9.0", ] [[package]] @@ -3210,18 +3210,18 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "efba1dac1efc04ffc09cba49e343e0fe656c88c32472a4206f2a6df284add33d" dependencies = [ "bytes", "futures-util", "serde", "thiserror 2.0.18", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.9.0", + "vitaminc-context 0.9.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] @@ -3238,9 +3238,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "a3c6575dac7701e578b375d16a0b693294d73847ed275c9b9962cf20c423ea10" dependencies = [ "proc-macro2", "quote", @@ -3259,12 +3259,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "5b407b3127be198a8da7d4d914c11215e1eae1b467cdf89eca8a7a296055e863" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", ] [[package]] @@ -3283,15 +3283,15 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "7fa0a3493624afd1490085448d6d09d22bf061954e9ab0db08837f423db3a58d" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] @@ -3314,9 +3314,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "44e61d6e58132793aace6898403ffa949bc06e388c1fc89c08d48ad0303133c3" dependencies = [ "bitvec", "digest 0.11.3", @@ -3325,7 +3325,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.18", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.9.0", "zeroize", ] @@ -3342,9 +3342,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "a60e915eb16c115882ea4754cecc99d7bcd3d8a1bfb9b8e512af4b6f7dd14801" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -3369,16 +3369,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "71e32850b739b58a7064b50eb3db655de323f57f21c1fe74d5ccd4d308560563" dependencies = [ "chacha20", "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.9.0", + "vitaminc-random-derives 0.9.0", "zeroize", ] @@ -3395,9 +3395,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "1863fe02de3945047dc9094d3cadbde06becafa49aa95d5e475bf903c8d83981" dependencies = [ "proc-macro2", "quote", @@ -3422,17 +3422,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a715ef503b1b41801a6c23e9756ed3d5a1201fa632609bccfa2a7a41ab5386cf" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] diff --git a/packages/stack-encrypt/fuzz/Cargo.lock b/packages/stack-encrypt/fuzz/Cargo.lock index a4dd39004..3b897fa47 100644 --- a/packages/stack-encrypt/fuzz/Cargo.lock +++ b/packages/stack-encrypt/fuzz/Cargo.lock @@ -366,16 +366,16 @@ dependencies = [ [[package]] name = "clww-ore" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95747f5724666cb973b2ca0659e10b6083c11f99877c4727b4c9f534a591c74" +checksum = "9527fcb65fbebc5a8f8987063c171bb37bb37fb628de1293e07c1b6e0a21a24f" dependencies = [ "blake3", "hex", "subtle", "thiserror 1.0.69", "vitaminc-ore", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", ] @@ -1475,9 +1475,9 @@ dependencies = [ [[package]] name = "ore-rs" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bd65962ec1409c09d20e1e016f4fe1b4ae2e2e58dba38c155538b2c0801a185" +checksum = "b274b7a0bf081d51815ccc7b20fa3b023e5769e3446fe4de1e8e8044a4413201" dependencies = [ "aes", "block-modes", @@ -1487,12 +1487,12 @@ dependencies = [ "rand 0.10.1", "subtle-ng", "thiserror 1.0.69", - "vitaminc-context 0.8.0", + "vitaminc-context 0.9.0", "vitaminc-hmac", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] @@ -2066,8 +2066,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.9.0", + "vitaminc-protected 0.9.0", "web-time", "zeroize", "zerokms-protocol", @@ -2092,15 +2092,15 @@ dependencies = [ "stack-kms", "thiserror 1.0.69", "uuid", - "vitaminc 0.8.0", - "vitaminc-aead 0.8.0", + "vitaminc 0.9.0", + "vitaminc-aead 0.9.0", "vitaminc-aead-frame", - "vitaminc-encrypt 0.8.0", + "vitaminc-encrypt 0.9.0", "vitaminc-hmac", "vitaminc-normalize", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", ] @@ -2122,8 +2122,8 @@ dependencies = [ "serde_json", "stack-encrypt", "uuid", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-protected 0.9.0", ] [[package]] @@ -2148,8 +2148,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.9.0", + "vitaminc-protected 0.9.0", "zeroize", "zerokms-protocol", ] @@ -2594,16 +2594,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "e7aa53d696a9e1af61b36a60b92cc0dde7421f8f58787333d3c425a176af241c" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-context 0.9.0", + "vitaminc-encrypt 0.9.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", + "vitaminc-traits 0.9.0", ] [[package]] @@ -2623,18 +2623,18 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "efba1dac1efc04ffc09cba49e343e0fe656c88c32472a4206f2a6df284add33d" dependencies = [ "bytes", "futures-util", "serde", "thiserror 2.0.18", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.9.0", + "vitaminc-context 0.9.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] @@ -2651,9 +2651,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "a3c6575dac7701e578b375d16a0b693294d73847ed275c9b9962cf20c423ea10" dependencies = [ "proc-macro2", "quote", @@ -2662,13 +2662,13 @@ dependencies = [ [[package]] name = "vitaminc-aead-frame" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0299777255171712a296d59164691dc1e93d10bddaab6e248fede1790dfd2e" +checksum = "ba4a3e8a2057ce32724b0d42f71e87ee348d13c818fc54a16c443b86c4fe8d9a" dependencies = [ "serde", - "vitaminc-aead 0.8.0", - "zeroize", + "vitaminc-aead 0.9.0", + "vitaminc-protected 0.9.0", ] [[package]] @@ -2683,12 +2683,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "5b407b3127be198a8da7d4d914c11215e1eae1b467cdf89eca8a7a296055e863" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", ] [[package]] @@ -2707,72 +2707,72 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "7fa0a3493624afd1490085448d6d09d22bf061954e9ab0db08837f423db3a58d" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] [[package]] name = "vitaminc-hmac" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5fa0ae8bbad85ae30fb5c91b0b72b694d59bf2c2c1e7ad799eda66fdbd67e7c" +checksum = "ada61349172294efe0a71bf2af367969b4e196437b46c20c688aaf15f692bc2d" dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", ] [[package]] name = "vitaminc-normalize" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a2d8875e0b8b35b7a4f9dd698bbd13c989837cea564b5a4cbb5371b78fa937" +checksum = "f1d354f4ee3764be072eb02f4f8db79ed0ed467a7c6d9f2c6143823867bde347" dependencies = [ "thiserror 2.0.18", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-protected 0.9.0", ] [[package]] name = "vitaminc-ore" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05518b4d35cb0c592dad59afb11b8e883ea252449b9ae47ff5ddeff0ed49f75" +checksum = "9a812b8e245352094c3c2113312cf86d0804b83f1e315bacd15571cd01703c8b" dependencies = [ "thiserror 2.0.18", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-context 0.9.0", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916688478273ca3e5c6805b355534ce9263688391cd4ab23b08bef760039d325" +checksum = "d1703452d837ef964ec1f20cc601265857245d72ac14426276d49c3106dd39b3" dependencies = [ "mutants", "thiserror 2.0.18", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-context 0.9.0", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", ] [[package]] @@ -2794,9 +2794,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "44e61d6e58132793aace6898403ffa949bc06e388c1fc89c08d48ad0303133c3" dependencies = [ "bitvec", "digest 0.11.3", @@ -2805,7 +2805,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.18", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.9.0", "zeroize", ] @@ -2822,9 +2822,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "a60e915eb16c115882ea4754cecc99d7bcd3d8a1bfb9b8e512af4b6f7dd14801" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2849,16 +2849,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "71e32850b739b58a7064b50eb3db655de323f57f21c1fe74d5ccd4d308560563" dependencies = [ "chacha20", "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.9.0", + "vitaminc-random-derives 0.9.0", "zeroize", ] @@ -2875,9 +2875,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "1863fe02de3945047dc9094d3cadbde06becafa49aa95d5e475bf903c8d83981" dependencies = [ "proc-macro2", "quote", @@ -2902,17 +2902,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a715ef503b1b41801a6c23e9756ed3d5a1201fa632609bccfa2a7a41ab5386cf" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] diff --git a/packages/stack-encrypt/fuzz/Cargo.toml b/packages/stack-encrypt/fuzz/Cargo.toml index 8b47f231a..7cafc607e 100644 --- a/packages/stack-encrypt/fuzz/Cargo.toml +++ b/packages/stack-encrypt/fuzz/Cargo.toml @@ -17,9 +17,9 @@ cargo-fuzz = true [dependencies] # `Controlled`, to take a passthrough node's frame out of its wrapper: the # same line stack-encrypt builds on. -vitaminc-protected = "0.8.0" +vitaminc-protected = "0.9.0" # The sinks the record harness writes its plans and passthroughs through. -vitaminc-aead = "0.8.0" +vitaminc-aead = "0.9.0" libfuzzer-sys = "0.4" # Structure-aware inputs for `check_record` and `plan_build`: the mirror # types derive `Arbitrary`, so the fuzzer mutates trees and plans, not bytes. diff --git a/packages/stack-encrypt/tests/json_index.rs b/packages/stack-encrypt/tests/json_index.rs index 58fe635da..69c89d93e 100644 --- a/packages/stack-encrypt/tests/json_index.rs +++ b/packages/stack-encrypt/tests/json_index.rs @@ -32,7 +32,6 @@ use stack_encrypt::target::{ }; use stack_encrypt::{nonempty, Error, Plan, PlanError, StackCipher}; use stack_kms::FakeDataKeySource; -use vitaminc_protected::Controlled as _; // ============================================================================= // Setup @@ -2933,6 +2932,7 @@ mod given_a_laid_out_plan { #[cfg(feature = "dynamic")] mod given_the_json_index_spec { use super::*; + use vitaminc_protected::Controlled as _; /// The spec's wire form is a fixed contract with stored data and the Go /// guest, so its strings are pinned literally. diff --git a/packages/stack-kms/fuzz/Cargo.lock b/packages/stack-kms/fuzz/Cargo.lock index 049141916..d49e6c138 100644 --- a/packages/stack-kms/fuzz/Cargo.lock +++ b/packages/stack-kms/fuzz/Cargo.lock @@ -1964,8 +1964,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.9.0", + "vitaminc-protected 0.9.0", "web-time", "zeroize", "zerokms-protocol", @@ -1992,8 +1992,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.9.0", + "vitaminc-protected 0.9.0", "zeroize", "zerokms-protocol", ] @@ -2411,16 +2411,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "e7aa53d696a9e1af61b36a60b92cc0dde7421f8f58787333d3c425a176af241c" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-context 0.9.0", + "vitaminc-encrypt 0.9.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", + "vitaminc-traits 0.9.0", ] [[package]] @@ -2440,18 +2440,18 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "efba1dac1efc04ffc09cba49e343e0fe656c88c32472a4206f2a6df284add33d" dependencies = [ "bytes", "futures-util", "serde", "thiserror 2.0.18", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.9.0", + "vitaminc-context 0.9.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] @@ -2468,9 +2468,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "a3c6575dac7701e578b375d16a0b693294d73847ed275c9b9962cf20c423ea10" dependencies = [ "proc-macro2", "quote", @@ -2489,12 +2489,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "5b407b3127be198a8da7d4d914c11215e1eae1b467cdf89eca8a7a296055e863" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.9.0", ] [[package]] @@ -2513,15 +2513,15 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "7fa0a3493624afd1490085448d6d09d22bf061954e9ab0db08837f423db3a58d" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.9.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] @@ -2544,9 +2544,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "44e61d6e58132793aace6898403ffa949bc06e388c1fc89c08d48ad0303133c3" dependencies = [ "bitvec", "digest 0.11.3", @@ -2555,7 +2555,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.18", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.9.0", "zeroize", ] @@ -2572,9 +2572,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "a60e915eb16c115882ea4754cecc99d7bcd3d8a1bfb9b8e512af4b6f7dd14801" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2599,16 +2599,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "71e32850b739b58a7064b50eb3db655de323f57f21c1fe74d5ccd4d308560563" dependencies = [ "chacha20", "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.9.0", + "vitaminc-random-derives 0.9.0", "zeroize", ] @@ -2625,9 +2625,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "1863fe02de3945047dc9094d3cadbde06becafa49aa95d5e475bf903c8d83981" dependencies = [ "proc-macro2", "quote", @@ -2652,17 +2652,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a715ef503b1b41801a6c23e9756ed3d5a1201fa632609bccfa2a7a41ab5386cf" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.9.0", + "vitaminc-random 0.9.0", "zeroize", ] From 348a92e2070bd949901d0425d23ad62423a0b173 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 6 Nov 2026 11:05:58 +1100 Subject: [PATCH 10/17] docs(stack-encrypt): plain-language pass over the owned collections docs Rewrites the rustdoc, CHANGELOG entries and conformance prose this branch adds, in plain language: one idea per sentence, lists for parallel points, and terms explained where they first appear. In the docs, a Vec of ciphertexts is now defined as a column. Two corrections, beyond wording: - decode_error's doc comment had become attached to is_unnamed_repeated_key, which was inserted between the two. Each function now has its own doc. - That doc said a repeated key stays unnamed inside a passthrough as well as outside. Inside a passthrough, ModelOnlyMap names the key, and decode_error passes it through, as ValueFrame::of's docs already said. The doc now says so. --- conformance/stack-encrypt/coverage.md | 15 ++- .../stack-encrypt/lifecycle-contract.md | 17 +-- packages/stack-encrypt/CHANGELOG.md | 102 ++++++++++-------- .../stack-encrypt/src/dynamic/value_frame.rs | 55 ++++++---- packages/stack-encrypt/src/lib.rs | 60 +++++++---- packages/stack-encrypt/src/scalar.rs | 50 +++++---- .../stack-encrypt/src/target/collections.rs | 50 +++++---- packages/stack-encrypt/src/target/mod.rs | 12 ++- .../stack-encrypt/src/target/operations.rs | 22 ++-- packages/stack-encrypt/src/target/pending.rs | 31 +++--- 10 files changed, 242 insertions(+), 172 deletions(-) diff --git a/conformance/stack-encrypt/coverage.md b/conformance/stack-encrypt/coverage.md index 6080d4e74..000b3e21c 100644 --- a/conformance/stack-encrypt/coverage.md +++ b/conformance/stack-encrypt/coverage.md @@ -130,10 +130,17 @@ heap observations, guest ABI cleanup, Go cancellation/memory tests and public SD diagnostics. New probes cover unpolled and in-flight drops, provider failure, cleanup after a late authenticated type failure, and chunk concurrency/drop. The owned `Vec` to `Vec` conversion is enrolled under the same -requirements: unpolled and in-flight drops, one logical batch per column with -none for an empty one, source wiping through its guard, and wiping of the -recovered prefix after a late element failure. It is a native Rust surface; Go -reaches batching through its record interface, which the runtime profile covers. +requirements. The conversion's tests cover: + +- dropping a column before it is polled +- dropping a column while its key request is running +- one logical batch of key requests for each column, and none for an empty + column +- wiping the source `Vec` through its guard +- wiping the elements already decrypted, when a later element fails + +The owned conversion is a native Rust API. Go reaches batching through its +record interface, and the runtime profile covers that interface. Existing runtime/read/context vectors retain their provider-call counters. This adds required execution and explicit observation limits; it removes no diff --git a/conformance/stack-encrypt/lifecycle-contract.md b/conformance/stack-encrypt/lifecycle-contract.md index 68f913e6c..62945fa32 100644 --- a/conformance/stack-encrypt/lifecycle-contract.md +++ b/conformance/stack-encrypt/lifecycle-contract.md @@ -11,12 +11,12 @@ process. Python and TypeScript are explicitly not enrolled. | Requirement | Native evidence | Go evidence and limits | |---|---|---| | `lifecycle-ownership-v1` | Actual record operations and owned `Vec` to `Vec` conversions release fulfilment captures exactly once on success, provider failure and unpolled drop; an unpolled encrypt or decrypt makes no key request. Zipped fulfilments do not share key material. | Client keys are consumed/redacted; explicit close and unreachable-client cleanup release guest memory; calls on a closed client fail. GC cleanup is eventual, so callers should close explicitly. | -| `lifecycle-cancellation-v1` | Dropping an encrypt future paused in key generation (for a record operation or an owned column), or a decrypt future paused in key retrieval, drops the provider future and operation captures; subsequent work on the cipher succeeds. Guest ABI abort spends the handle and drops the operation. | Cancellation before a call sends no request; cancellation between steps or during token fetch leaves unrelated operations usable. A guest step that overruns cancellation closes the client. Discarded late output is wiped. | -| `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue, through the dynamic record path and the owned `Vec` to `Vec` conversion alike; owned-column drop witnesses show that prefix is wiped, not leaked, after a late failure or a panicking element conversion. Existing context cases reject late authentication failures without returning partial output. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | -| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths, including an owned `Vec` column sealed through its guard, and has a negative control. Drop witnesses show an owned column's source is wiped before its pending is returned, including on a refused or panicking context. Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | +| `lifecycle-cancellation-v1` | Dropping an encrypt future paused in key generation, or a decrypt future paused in key retrieval, drops the provider future and operation captures, for a record operation or an owned column; subsequent work on the cipher succeeds. Guest ABI abort spends the handle and drops the operation. | Cancellation before a call sends no request; cancellation between steps or during token fetch leaves unrelated operations usable. A guest step that overruns cancellation closes the client. Discarded late output is wiped. | +| `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue. Tests check this for the dynamic record path and for the owned `Vec` to `Vec` conversion. For owned columns, drop witnesses are test values that record whether they were wiped. They show that the prefix is wiped, not leaked, after a late failure or an element's panic. Existing context cases reject late authentication failures without returning partial output. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | +| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths and has a negative control. One of those paths encrypts an owned `Vec` column through its guard. Drop witnesses show that an owned column's source is wiped before its pending request is returned. That includes a refused context, and a context conversion that panics. Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | | `lifecycle-diagnostics-v1` | Guest errors discard untrusted foreign cause messages; failed/successful exports do not hand over stale diagnostics. | Structured errors remain available after failures; SDK errors and redacted display/logging expose types or fields rather than plaintext values. | | `lifecycle-validation-before-io-v1` | Existing runtime/read/context cases count provider calls. Wrong shape, independently detectable context mismatch and oversized descriptors fail before key I/O; lazy assembly stops after local failure. | Host refusals happen before entering the guest, and shared runtime/read cases exercise the uninitialised guest's preflight boundary. Go does not yet expose native provider counters. | -| `lifecycle-logical-batching-v1` | Instrumented `DataKeySource` calls establish one logical generation batch, retrieval grouping by keyset, descriptor ordering and no work for empty batches, for record operations and for an owned `Vec` column of any length including zero. | Go uses the same engine, but this profile does not claim independently instrumented Go key-request counts. | +| `lifecycle-logical-batching-v1` | Instrumented `DataKeySource` calls establish one logical generation batch, retrieval grouping by keyset, descriptor ordering and no work for empty batches, for record operations and for an owned `Vec` column of any length, including an empty one. | Go uses the same engine, but this profile does not claim independently instrumented Go key-request counts. | | `lifecycle-provider-chunks-v1` | The actual KMS chunking helper preserves order, handles a final short chunk, fails the whole call on a chunk error, bounds in-flight chunks and releases their futures on cancellation. | This is a native algorithm observation, not a measurement of live HTTP request counts. | The drop witnesses belong to fulfilment closures and provider futures in real @@ -59,10 +59,11 @@ These entries in the index are limitations, not successful requirements: sealing frame by backtrace and reports excluded frees. It scrubs stack residue before observing heap frees; it does not prove stack erasure. Its JSON query paths use borrowed/static path text because owned path copies have separate - cleanup limitations. It sees only blocks that are freed: plaintext leaked and - never freed is not observed. For the owned `Vec` conversion, drop - witnesses in `packages/stack-encrypt/tests/collections.rs` observe the wipe - itself, so a leaked source or recovered prefix fails there. The lifecycle + cleanup limitations. The observer only scans blocks that are freed, so it + misses plaintext that is leaked and never freed. For the owned `Vec` + conversion, the drop witnesses in `packages/stack-encrypt/tests/collections.rs` + record the wipe itself. A test there fails if a source or a decrypted prefix + is leaked. The lifecycle runner preserves the observer's output. - **`caller-owned-host-values` — outside the guarantee.** Caller-owned strings, bytes, objects, returned plaintext, aliases and copies remain the caller's diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index cd4ba377e..4e30ac674 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -18,55 +18,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the final extension alone for both reads and queries of old data, or re-encrypt and re-index it under the appended context. This does not migrate stored data. - **stack-encrypt requires the vitaminc 0.9 crates, clww-ore 0.7 and - ore-rs 0.11.** A crate that names a vitaminc type - stack-encrypt takes or returns moves to 0.9 with it. No sealed byte, term - or stored format changes. Two things in this crate's surface change. - The re-exported frame codec, `stack_encrypt::dynamic::frame`: its - `serde::to_vec` and `FrameWriter` now produce and write into a - `vitaminc_protected::Buffer`, which wipes each allocation it outgrows, - rather than a `Vec`. Read the bytes with `risky_ref`, or take them - with `risky_unwrap`; `Buffer` is re-exported as `stack_encrypt::Buffer` - beside `Protected`. `ValueFrame::into_inner` still returns - `Protected>`. And a repeated key in a value written to a frame - (`ValueFrame::of`, outside a passthrough) is now refused unnamed, as a - `DecodeError::Source` wrapping `FrameWriteError::DuplicateKey`, rather - than as `DecodeError::DuplicateKey(key)`: the 0.9 writer copies no key - out of the frame. A record source still reports `Reason::RepeatedKey` - for the field. + ore-rs 0.11.** If your crate names a vitaminc type that stack-encrypt takes + or returns, move it to vitaminc 0.9 too. No sealed bytes, terms, or stored + formats change. Two parts of this crate's API change: + - The re-exported frame codec, `stack_encrypt::dynamic::frame`, now writes + into a `vitaminc_protected::Buffer`, not a `Vec`. A `Buffer` wipes each + allocation it outgrows. `serde::to_vec` returns a `Buffer`, and + `FrameWriter` writes into one. Read the bytes with `risky_ref`, or take + them with `risky_unwrap`. `Buffer` is re-exported as + `stack_encrypt::Buffer`. `ValueFrame::into_inner` still returns + `Protected>`. + - `ValueFrame::of` no longer names a repeated map key outside a + passthrough. `ValueFrame::of` returns `DecodeError::Source` in place of + `DecodeError::DuplicateKey(key)`, and the source error is + `FrameWriteError::DuplicateKey`. + The vitaminc 0.9 writer does not copy the key out of the frame, because + the key is plaintext. A record source still reports `Reason::RepeatedKey` + for the field. ### Added -- **Owned collections: `Vec` encrypts into `Vec` and back through - `EncryptInto` and `DecryptFrom`** (#1482). `Vec` implements +- **Owned collections: `EncryptInto` encrypts a `Vec` into a `Vec`, + and `DecryptFrom` decrypts it back** (#1482). `Vec` implements `EncryptTarget>` and `DecryptSource>` for any owned element - target, so `vec![29i32, 34].encrypt_into::, _>(&keyset)` - needs no caller context and `Vec::::decrypt_from(column, &keyset)` - opens it. The column's `ContextInput` is the element's: a column of a - `RequiredContext` target still cannot omit its context, a column of a - `NoCallerContext` target still cannot be given one, and `Default` grants - neither. Each element is sealed under its own data key and authenticated - on its own, in one logical batch per column (one `generate_keys` call, - one `retrieve_keys` call per keyset, element order kept, no call for an - empty column; the key source may still split a logical batch into several - provider requests). The vector is held in one guard and wiped before the - pending request is returned, including when the context is refused, with - no `Clone` bound; a column that fails after some elements were recovered - wipes those before returning the error, and before unwinding when an - element's conversion panics. `Vec` is not a column of - scalars: `u8` is not `OpaqueEncode`, because Vitamin C keeps `Vec` one - byte leaf. Direct `keyset.encrypt` still takes one scalar; `encrypt_tree` - remains the structural path. -- **Byte strings are opaque scalars: `Vec` and `[u8; N]` encrypt into - one `Ciphertext`, and a `Vec>` into a column.** `OpaqueEncode` is - now every owned `Zeroize` type whose borrow is a Vitamin C - `plaintext::Scalar` (one leaf), and `OpaqueDecode` every `Zeroize` - `FromScalar` target, in place of a hand-kept list that had already left - out the byte leaves Vitamin C defines. So + target. For example, `vec![29i32, 34].encrypt_into::, _>(&keyset)` + needs no caller context, and `Vec::::decrypt_from(column, &keyset)` + decrypts the result. + - A column uses its element target's `ContextInput`. A column of a + `RequiredContext` target must be given a context, and a column of a + `NoCallerContext` target cannot be given one. `Default` does not change + either rule. + - Each element is encrypted under its own data key and authenticated on + its own. + - A column makes one logical batch of key requests, in element order. That + is one `generate_keys` call to encrypt it, and one `retrieve_keys` call + for each keyset to decrypt it. An empty column makes no call. The key + source can still split a logical batch into several requests to its + provider. + - The plaintext `Vec` is wiped before the pending request is returned, even + when the context is refused. The elements do not need to implement + `Clone`. + - If an element fails after earlier elements were decrypted, the earlier + plaintexts are wiped before the error is returned. If an element's + conversion panics, they are wiped before the panic unwinds. + - A `Vec` is not a column. A `Vec` is one byte string, as the next + entry describes. + - `keyset.encrypt` still takes a single value, and `encrypt_tree` is still + the structural path. +- **Byte strings encrypt as one value: `Vec` and `[u8; N]` encrypt into + one `Ciphertext`, and a `Vec>` encrypts into a column.** So `keyset.encrypt(vec![1u8, 2, 3])` and - `Vec::::decrypt_from(ciphertext, &keyset)` now compile, as the - structural API and the Go SDK already sealed them. A type of your own - joins by implementing `Scalar` for its borrow and `Zeroize`. A `Vec` of - any other scalar, and `u8` on its own, still do not. + `Vec::::decrypt_from(ciphertext, &keyset)` now compile. The structural + API and the Go SDK already encrypted byte strings this way. + - `OpaqueEncode` is now implemented for every owned `Zeroize` type whose + borrow is a Vitamin C `plaintext::Scalar`. A `Scalar` is a type that + Vitamin C encrypts as a single value. `OpaqueDecode` is implemented for + every `Zeroize` type that implements `FromScalar`. + - Before this change, both traits were implemented for a list of types + kept by hand. That list did not include the byte strings that Vitamin C + treats as single values. + - Your own type becomes an `OpaqueEncode` type if its borrow implements + `Scalar` and the type implements `Zeroize`. + - `u8` alone, and a `Vec` of any type other than `u8`, still do not encrypt + into one `Ciphertext`. ## [0.3.0] - 2026-10-27 diff --git a/packages/stack-encrypt/src/dynamic/value_frame.rs b/packages/stack-encrypt/src/dynamic/value_frame.rs index 681ce80d9..ca0e88e90 100644 --- a/packages/stack-encrypt/src/dynamic/value_frame.rs +++ b/packages/stack-encrypt/src/dynamic/value_frame.rs @@ -90,14 +90,18 @@ impl ValueFrame { /// /// # Errors /// - /// [`DecodeError::Expected`] for a scalar outside the model (a uuid, a - /// logical type this build does not know, a date, decimal or timestamp - /// outside vitaminc's range), [`DecodeError::DuplicateKey`] for a map in a - /// passthrough with a repeated key, and [`DecodeError::Source`] for a - /// source that fails, a value deeper or longer than a frame carries, or - /// a repeated key anywhere else. That last is the frame writer's - /// [`FrameWriteError::DuplicateKey`], which names no key: the key is - /// plaintext, and the writer copies none out of the frame. + /// - [`DecodeError::Expected`] for a scalar outside the model. That is a + /// uuid, a logical type this build does not know, or a date, decimal, or + /// timestamp outside vitaminc's range. + /// - [`DecodeError::DuplicateKey`] for a map inside a passthrough that + /// repeats a key. + /// - [`DecodeError::Source`] for a source that fails, or for a value + /// deeper or longer than a frame allows. + /// - [`DecodeError::Source`] for a map outside a passthrough that repeats a + /// key. The error wraps the frame writer's + /// [`FrameWriteError::DuplicateKey`]. That error does not name the key, + /// because the key is plaintext. The writer does not copy plaintext out + /// of the frame. pub fn of(value: P) -> Result { write(|writer| value.visit(Modeled::new(writer))) } @@ -149,9 +153,9 @@ impl ValueFrame { Ok(Self::from_buffer(buffer)) } - /// A frame a writer finished writing. The bytes move out of the - /// [`Buffer`] they grew in, which wiped every allocation it outgrew, - /// into the [`Protected`] the frame holds; nothing is copied. + /// The frame in a [`Buffer`] that a writer has finished writing. The bytes + /// move into the frame's [`Protected`] without being copied. While the + /// writer wrote, the `Buffer` wiped each allocation it outgrew. fn from_buffer(buffer: Buffer) -> Self { Self(Protected::new(buffer.risky_unwrap())) } @@ -451,18 +455,8 @@ pub(super) struct FieldFault { pub(super) error: DecodeError, } -/// A frame writer's refusal as a decode error. A value the model does not -/// hold inside a passthrough subtree reaches here wrapped as the writer's -/// source error ([`ModelOnly`] can only return the writer's error type), and -/// is unwrapped, so it is refused as the same `Expected` a sealed value is. -/// -/// A repeated key stays the writer's own unnamed -/// [`FrameWriteError::DuplicateKey`], in or out of a passthrough: the writer -/// finds it when the map ends, among the keys already in the frame, and -/// names none, since naming it would copy plaintext out of the frame where -/// it would be freed unwiped. [`is_unnamed_repeated_key`] recognises it. -/// Whether `error` is the frame writer's refusal of a repeated map key, which -/// reaches a caller unnamed (see [`decode_error`]). +/// Whether `error` is the frame writer's refusal of a repeated map key. That +/// error does not name the key (see [`decode_error`]). pub(super) fn is_unnamed_repeated_key(error: &DecodeError) -> bool { matches!( error, @@ -474,6 +468,21 @@ pub(super) fn is_unnamed_repeated_key(error: &DecodeError) -> bool { ) } +/// Converts a frame writer's refusal into a decode error. +/// +/// Inside a passthrough subtree, [`ModelOnly`] can only return the writer's +/// error type. So a value outside the model, or a repeated key, arrives +/// wrapped as the writer's source error. This function unwraps it, and the +/// caller gets the same `Expected` or `DuplicateKey` error a sealed value +/// gives. +/// +/// Outside a passthrough, the writer finds a repeated key when the map ends, +/// by comparing it with the keys already in the frame. The writer then +/// returns its own [`FrameWriteError::DuplicateKey`]. That error does not +/// name the key. +/// Naming the key would copy plaintext out of the frame, into memory that is +/// freed without being wiped. [`is_unnamed_repeated_key`] recognises this +/// error. fn decode_error(error: FrameWriteError) -> DecodeError { match error { FrameWriteError::Source(error) => match error.inner().downcast_ref::() { diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index a5dd0b24a..a28cb563e 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -241,19 +241,32 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! //! # Collections //! -//! A `Vec` of owned plaintexts encrypts into a `Vec` of any owned target -//! through the same two traits, with or without a caller context, and back. -//! Each element is sealed under its own data key and authenticated on its -//! own: the elements are rows, so neither their order nor their number is -//! authenticated, and any one of them opens alone through -//! [`KeysetCipher::decrypt`]. The whole column is one logical batch: one -//! `generate_keys` call to seal it, one `retrieve_keys` call per keyset to -//! open it, in element order, and no call at all for an empty column. The -//! key source may still split a logical batch across several provider -//! requests. The column is held in one guard and wiped before the pending -//! request is returned, as a scalar is, and a column that fails after some -//! elements were recovered wipes those before reporting the error (or -//! before unwinding, when an element's conversion panics). +//! [`EncryptInto`] encrypts a `Vec` of owned plaintexts into a `Vec` of any +//! owned target, and [`DecryptFrom`] decrypts it back. A caller context is +//! optional, as it is for a single value. In these docs, a `Vec` of +//! ciphertexts is called a column. +//! +//! Each element is encrypted under its own data key and authenticated on its +//! own, so [`KeysetCipher::decrypt`] can decrypt any one element alone. The +//! order and the number of elements are not authenticated, because stored +//! values are database rows. A database can return rows in any order, or +//! only some of them. +//! +//! A column makes one logical batch of key requests, with the requests in +//! element order: +//! +//! - Encrypting a column makes one `generate_keys` call. +//! - Decrypting a column makes one `retrieve_keys` call for each keyset. +//! - An empty column makes no call. +//! +//! The key source can still split one logical batch into several requests to +//! its provider. +//! +//! The plaintext `Vec` is wiped before the pending request is returned, as a +//! single value is. Sometimes an element fails to decrypt after earlier +//! elements succeeded. The earlier plaintexts are then wiped before the error +//! is returned. If an element's conversion panics, they are wiped before the +//! panic unwinds. //! //! ``` //! # async fn example() -> Result<(), stack_encrypt::Error> { @@ -262,13 +275,13 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! .kms(stack_encrypt::kms::FakeDataKeySource::new()).init().await?; //! let keyset = cipher.default_keyset(); //! -//! // No caller context: every element seals under the empty context. +//! // Without a caller context, each element is encrypted under the empty context. //! let ages: Vec = vec![29i32, 34, 41].encrypt_into(&keyset).await?; //! assert_eq!(Vec::::decrypt_from(ages.clone(), &keyset).await?, [29, 34, 41]); -//! // An element is an ordinary ciphertext. +//! // Each element is an ordinary ciphertext, which decrypts on its own. //! assert_eq!(keyset.decrypt::(ages[1].clone()).await?, 34); //! -//! // With a caller context, bound to every element. +//! // With a caller context, the context is bound to every element. //! let names: Vec = vec!["ada".to_owned(), "grace".to_owned()] //! .encrypt_into_with_context(&keyset, ("users", "name")) //! .await?; @@ -279,13 +292,14 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); //! ``` //! -//! The column takes the element target's caller-context capability as its -//! own, so a column of a target that requires a context cannot omit it, and -//! a column of a target that accepts none cannot be given one. A `Vec` -//! is not a column of scalars: Vitamin C keeps `Vec` one byte leaf and -//! gives `u8` no source of its own, so `u8` is not an [`OpaqueEncode`] type -//! and a `Vec` encrypts as one `Ciphertext`, never byte by byte. A -//! `Vec>` is a column of byte strings. +//! A column follows its element target's rules for a caller context. If the +//! element target requires a context, the column requires one. If the +//! element target accepts no context, the column cannot be given one. +//! +//! A `Vec` is one byte string, not a column of bytes. Vitamin C encrypts +//! a `Vec` as a single value, and `u8` is not an [`OpaqueEncode`] type. +//! So a `Vec` encrypts into one `Ciphertext`, and a `Vec>` is a +//! column of byte strings. //! //! Existing structural plans and derives still use [`StackCipherText`], a tree //! of sealed leaves. Their low-level direct entry points are diff --git a/packages/stack-encrypt/src/scalar.rs b/packages/stack-encrypt/src/scalar.rs index a589883df..dfa41ccff 100644 --- a/packages/stack-encrypt/src/scalar.rs +++ b/packages/stack-encrypt/src/scalar.rs @@ -21,28 +21,40 @@ mod sealed { /// A plaintext type that encrypts as one opaque [`Ciphertext`]. /// -/// Implemented for every owned type that Vitamin C walks as exactly one -/// leaf and that can be wiped: a type `S` where `&S` is a -/// [`Scalar`](vitaminc_aead::plaintext::Scalar) and `S` is [`Zeroize`]. -/// That is `bool`, the fixed-width signed integers, the unsigned integers -/// from `u16` through `u128`, `f32`, `f64`, `String`, and the byte strings -/// `Vec` and `[u8; N]`, each one byte leaf; and any type of your own -/// whose borrow implements `Scalar` and which implements `Zeroize`. The -/// trait is sealed: implement Vitamin C's marker, and this follows. +/// `OpaqueEncode` is implemented for every owned type `S` that meets two +/// conditions: /// -/// A sequence is not a `Scalar`, so no `Vec` other than `Vec` encrypts -/// into one `Ciphertext`. A `Vec` of these encrypts element by element into -/// a `Vec` through [`EncryptInto`] instead; see the crate docs -/// on collections. `u8` is absent, as it is from Vitamin C's sources: -/// `Vec` is one byte leaf, not a column of bytes. -/// Nullable `Option` values are also deferred in this scalar slice. Use -/// [`KeysetCipher::encrypt_tree`] and [`KeysetCipher::decrypt_tree`] for them; -/// fixed-width nullable values retain Vitamin C's padded-null representation. +/// - `&S` implements Vitamin C's [`Scalar`] marker, so Vitamin C encrypts it +/// as a single value. +/// - `S` implements [`Zeroize`], so it can be wiped. +/// +/// These built-in types meet both conditions: +/// +/// - `bool`, `f32`, `f64`, and `String` +/// - the fixed-width signed integers +/// - the unsigned integers from `u16` to `u128` +/// - the byte strings `Vec` and `[u8; N]` +/// A type of your own +/// meets them if its borrow implements `Scalar` and the type implements +/// `Zeroize`. The trait is sealed, so you cannot implement it directly. +/// +/// A sequence is not a `Scalar`, so `Vec` is the only `Vec` that encrypts +/// into one `Ciphertext`. A `Vec` of any other `OpaqueEncode` type encrypts +/// element by element into a `Vec`, through [`EncryptInto`]. The +/// crate docs on collections explain how. `u8` is not an `OpaqueEncode` type, +/// because Vitamin C encrypts a `Vec` as one byte string, not as a column +/// of bytes. +/// +/// A nullable `Option` is not an `OpaqueEncode` type either. Use +/// [`KeysetCipher::encrypt_tree`] and [`KeysetCipher::decrypt_tree`] for +/// nullable values. A fixed-width nullable value keeps Vitamin C's padded +/// form for null. pub trait OpaqueEncode: sealed::Encode + Zeroize + Send + 'static {} -/// A plaintext type that one opaque [`Ciphertext`] decrypts to: every -/// [`FromScalar`] target that can be wiped, which covers the built-in -/// [`OpaqueEncode`] types. Sealed, like it. +/// A plaintext type that one opaque [`Ciphertext`] decrypts to. +/// +/// `OpaqueDecode` is implemented for every [`FromScalar`] type that also +/// implements [`Zeroize`]. Every built-in [`OpaqueEncode`] type is one. The trait is sealed, as `OpaqueEncode` is. pub trait OpaqueDecode: sealed::Decode + Decrypt + Zeroize + Send + 'static {} // The bridge from an owned `S` to its borrow's walk: `OpaqueEncode`'s diff --git a/packages/stack-encrypt/src/target/collections.rs b/packages/stack-encrypt/src/target/collections.rs index b5d94ca03..c39cbf1e3 100644 --- a/packages/stack-encrypt/src/target/collections.rs +++ b/packages/stack-encrypt/src/target/collections.rs @@ -1,26 +1,29 @@ -//! Owned collections: `Vec` encrypts into `Vec` as one logical batch, -//! and `Vec` decrypts back, through the element's own declaration. +//! Owned collections. A `Vec` encrypts into a `Vec` as one logical +//! batch, and a `Vec` decrypts back. Both use the element target's own +//! declaration. //! -//! A collection declares nothing of its own. Its `ContextInput` is the -//! element's, so whether a caller may omit or must supply a context is -//! decided once, by the element target, and holds for the column: a -//! `Vec` is reached with no caller context, a column of a -//! `RequiredContext` target is not. `Default` plays no part in that. +//! A collection declares nothing of its own. A collection's `ContextInput` is +//! the element target's, so the element target decides once whether a caller can +//! omit a context or must give one. The same rule then applies to the whole +//! column. For example, a `Vec` needs no caller context, but a +//! column of a `RequiredContext` target does. `Default` does not affect +//! this. //! -//! Each element is sealed under its own ZeroKMS data key and authenticated -//! on its own. Neither the collection's length nor the order of its -//! elements is authenticated: stored values are rows and columns, which -//! come back in any order and in part. The elements' key requests are -//! merged into one logical batch ([`Pending::collect`]), in element order, -//! and an empty collection makes no request at all. The key source may -//! still split one logical batch into several provider requests. +//! Each element is encrypted under its own ZeroKMS data key and authenticated +//! on its own. The collection's length and the order of its elements are not +//! authenticated, because stored values are database rows and columns. A +//! database can return them in any order, or only some of them. //! -//! A `Vec` is not a collection here: Vitamin C keeps `Vec` one byte -//! leaf and gives `u8` no source of its own, so `u8` is not an opaque scalar -//! and `vec![1u8].encrypt_into::, _>(..)` does not compile. -//! The byte vector is one `Ciphertext`, and a `Vec>` is a column of -//! them. A nullable element, `Option`, is not an opaque scalar in this -//! slice. +//! [`Pending::collect`] merges the elements' key requests into one logical +//! batch, in element order. An empty collection makes no request. The key +//! source can still split one logical batch into several requests to its +//! provider. +//! +//! A `Vec` is not a collection. Vitamin C encrypts a `Vec` as one +//! byte string, and `u8` is not an opaque scalar. So +//! `vec![1u8].encrypt_into::, _>(..)` does not compile. A +//! `Vec` encrypts into one `Ciphertext`, and a `Vec>` is a column +//! of them. A nullable element, `Option`, is not an opaque scalar either. use super::{DecryptSource, Decryption, EncryptTarget, Encryption, Pending}; use stack_kms::MaybeSend; use zeroize::Zeroize; @@ -29,8 +32,9 @@ impl> EncryptTarget> for Vec where T::ContextInput: Clone + MaybeSend + 'static, { - /// The element's: a column may omit or must supply exactly what one - /// element may or must. + /// The element target's context input. A column can omit a context only + /// if one element can. A column must be given a context if one element + /// must. type ContextInput = T::ContextInput; fn encryption<'s, K: 'static>() -> Encryption<'s, Vec, Self, K, Self::ContextInput> where @@ -58,7 +62,7 @@ impl> DecryptSource> for Vec where T::ContextInput: Clone, { - /// The element's, as on the encrypt side. + /// The element target's context input, as for encryption. type ContextInput = T::ContextInput; fn decryption(self, context: Self::ContextInput) -> Decryption, K> { // Every element's keys are retrieved in one batch and the results diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 430db8f36..7792d3a83 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -4,9 +4,11 @@ //! executed by [`EncryptInto`] and [`DecryptFrom`]. Their `ContextInput` //! capabilities explicitly allow omission or supply; `Default` does not //! determine either. [`scalar_ciphertext`] is the first owned operation. +//! //! A `Vec` of owned plaintexts encrypts into a `Vec` of any owned -//! target, under the element's `ContextInput`, as one logical batch of -//! independently authenticated elements; `Vec` decrypts back the same way. +//! target, and decrypts back the same way. The column uses the element +//! target's `ContextInput`. The column makes one logical batch of key +//! requests. Each element is authenticated on its own. //! //! # Structural API during migration //! @@ -82,9 +84,9 @@ //! //! # Collections and authentication //! -//! `Vec` describes independently encrypted rows under the same context, -//! on the owned path (`Vec` into a `Vec` of [`EncryptTarget`]s) as on the -//! structural one. Encrypting a plaintext `Vec` into `StackCipherText` instead +//! `Vec` describes rows that are each encrypted on their own, under +//! the same context. That rule applies on the owned path, where a `Vec` encrypts +//! into a `Vec` of [`EncryptTarget`]s, and on the structural path. Encrypting a plaintext `Vec` into `StackCipherText` instead //! follows Vitamin C's native sequence model, including its authenticated empty //! marker. The same distinction applies to `Option` versus a native //! encrypted option. diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 3bfce0e98..5c416e382 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -165,10 +165,11 @@ impl fmt::Debug for Decryption { impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's, M: SourceMode<'s, S>> Encryption<'s, S, T, K, Ctx, M> { - /// A description from its execution closure. Crate-internal: this is - /// the constructor that hands a closure the source and the cipher - /// together, which the public constructors never do. The collection - /// declarations use it to run an element's description once per element. + /// A description built from its execution closure. This constructor is + /// crate-internal, because it gives a closure the source and the cipher + /// together, and the public constructors never do that. The collection + /// declarations use it to run an element's description once for each + /// element. pub(crate) fn from_build(build: F) -> Self where F: for<'a, 'k> FnOnce(M::Source, &'a KeysetCipher<'k, K>, Ctx) -> Pending<'a, T, K> @@ -830,10 +831,10 @@ impl Decryption { })), } } - /// [`all`](Self::all) for recovered plaintext: when a later item fails - /// after its key arrived, the items recovered before it are wiped - /// rather than dropped as they are. The owned `Vec` conversions - /// open through here. + /// The same as [`all`](Self::all), but for recovered plaintext. When an + /// item fails after its key arrived, `all` drops the items recovered + /// before it. This function wipes them. The owned `Vec` conversions + /// decrypt through this function. pub(crate) fn all_wiping(items: I) -> Decryption, K> where I: IntoIterator, @@ -846,8 +847,9 @@ impl Decryption { })), } } - /// The openings of `items`, up to the first failed description, which - /// is returned instead: the descriptions after it are never built. + /// The openings of `items`, up to the first description that fails. If a + /// description fails, this returns its error, and the descriptions after + /// it are never built. fn openings(items: I) -> Result>, Error> where I: IntoIterator, diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index df6da3735..11089a99f 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -343,12 +343,16 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { Self::collect_releasing(scope, items, drop) } - /// [`collect`](Self::collect) for a collection of recovered plaintext. - /// When a later item fails at fulfilment (an authentication or type - /// failure after its key arrived, or a panic in its conversion), the - /// items recovered before it are wiped before the error is returned or - /// the panic unwinds, not dropped as they are. A failure before - /// fulfilment recovers nothing, so there is nothing to wipe. + /// The same as [`collect`](Self::collect), but for a collection of + /// recovered plaintext. `collect` drops the items recovered before a + /// failure. This function wipes them. + /// + /// The wipe applies when a later item fails at fulfilment, after its key + /// arrived. That failure can be an authentication failure, a type + /// failure, or a panic in the item's conversion. The items recovered + /// before it are wiped before the error is returned, or before the panic + /// unwinds. A failure before fulfilment recovers nothing, so there is + /// nothing to wipe. pub(crate) fn collect_wiping( scope: impl CipherScope<'a, K>, items: impl IntoIterator>, @@ -361,9 +365,9 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { }) } - /// The one collection builder. `release` is handed the items fulfilled - /// before a later item failed or panicked; it is not called when every - /// item succeeds. + /// The collection builder that `collect` and `collect_wiping` both use. + /// When an item fails or panics, `release` receives the items fulfilled + /// before it. When every item succeeds, `release` is not called. fn collect_releasing( scope: impl CipherScope<'a, K>, items: impl IntoIterator>, @@ -409,16 +413,17 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { } } -/// The items a collection has recovered so far. Dropped before -/// [`keep`](Self::keep), by an early return or an unwind, it hands them to -/// `release`. +/// The items a collection has recovered so far. If this guard is dropped +/// before [`keep`](Self::keep) is called, by an early return or an unwind, it +/// passes the items to `release`. struct Recovered)> { items: Vec, release: Option, } impl)> Recovered { - /// Every item succeeded: the collection is the output, not released. + /// Returns the items as the output, without releasing them. Call this + /// when every item has succeeded. fn keep(mut self) -> Vec { self.release = None; std::mem::take(&mut self.items) From 14de0dad5b29b472065087392ea08ff98fd32ca6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 6 Nov 2026 11:12:30 +1100 Subject: [PATCH 11/17] docs(stack-encrypt): hide the fake key source in the collections example The crate docs' collections example showed .kms(FakeDataKeySource::new()) in the code a reader copies, which goes against the crate's misuse resistance guidelines. The line is now hidden, so the doctest still runs without credentials. The visible code calls init() with no .kms(..), which builds a ZeroKMS cipher from the environment or the CLI profile, and a comment says so. --- packages/stack-encrypt/src/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index a28cb563e..07a4dde59 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -271,8 +271,12 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! ``` //! # async fn example() -> Result<(), stack_encrypt::Error> { //! use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto, StackCipher}; +//! // With no `.kms(..)`, the cipher uses ZeroKMS. It reads credentials from +//! // the environment, or from the profile that `npx stash auth login` writes. //! let cipher = StackCipher::builder() -//! .kms(stack_encrypt::kms::FakeDataKeySource::new()).init().await?; +//! # .kms(stack_encrypt::kms::FakeDataKeySource::new()) +//! .init() +//! .await?; //! let keyset = cipher.default_keyset(); //! //! // Without a caller context, each element is encrypted under the empty context. From 72ffaf6acb0a2f2d6592a7adfeae4965fef8ac4f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 6 Nov 2026 11:16:33 +1100 Subject: [PATCH 12/17] docs(stack-encrypt): say collection, not column, and hide the fake key source in the EncryptInto example The docs called a Vec of ciphertexts a column. They now say collection, the word the module and the crate docs' section already use. The docs also explained that an element's position and the number of elements are not authenticated as if every collection were stored as database rows. They now present that as a very common case, not the only one, and say plainly that this API does not authenticate a Vec's order or length. The crate docs' EncryptInto example hides its FakeDataKeySource line, as the collections example does, so the code a reader copies builds a ZeroKMS cipher. --- conformance/stack-encrypt/coverage.md | 8 ++-- .../stack-encrypt/lifecycle-contract.md | 8 ++-- packages/stack-encrypt/CHANGELOG.md | 23 +++++---- packages/stack-encrypt/src/lib.rs | 47 +++++++++++-------- packages/stack-encrypt/src/scalar.rs | 4 +- .../stack-encrypt/src/target/collections.rs | 21 +++++---- packages/stack-encrypt/src/target/mod.rs | 4 +- 7 files changed, 64 insertions(+), 51 deletions(-) diff --git a/conformance/stack-encrypt/coverage.md b/conformance/stack-encrypt/coverage.md index 000b3e21c..ce3f4c4f4 100644 --- a/conformance/stack-encrypt/coverage.md +++ b/conformance/stack-encrypt/coverage.md @@ -132,10 +132,10 @@ cleanup after a late authenticated type failure, and chunk concurrency/drop. The owned `Vec` to `Vec` conversion is enrolled under the same requirements. The conversion's tests cover: -- dropping a column before it is polled -- dropping a column while its key request is running -- one logical batch of key requests for each column, and none for an empty - column +- dropping a collection before it is polled +- dropping a collection while its key request is running +- one logical batch of key requests for each collection, and none for an + empty collection - wiping the source `Vec` through its guard - wiping the elements already decrypted, when a later element fails diff --git a/conformance/stack-encrypt/lifecycle-contract.md b/conformance/stack-encrypt/lifecycle-contract.md index 62945fa32..7186846f7 100644 --- a/conformance/stack-encrypt/lifecycle-contract.md +++ b/conformance/stack-encrypt/lifecycle-contract.md @@ -11,12 +11,12 @@ process. Python and TypeScript are explicitly not enrolled. | Requirement | Native evidence | Go evidence and limits | |---|---|---| | `lifecycle-ownership-v1` | Actual record operations and owned `Vec` to `Vec` conversions release fulfilment captures exactly once on success, provider failure and unpolled drop; an unpolled encrypt or decrypt makes no key request. Zipped fulfilments do not share key material. | Client keys are consumed/redacted; explicit close and unreachable-client cleanup release guest memory; calls on a closed client fail. GC cleanup is eventual, so callers should close explicitly. | -| `lifecycle-cancellation-v1` | Dropping an encrypt future paused in key generation, or a decrypt future paused in key retrieval, drops the provider future and operation captures, for a record operation or an owned column; subsequent work on the cipher succeeds. Guest ABI abort spends the handle and drops the operation. | Cancellation before a call sends no request; cancellation between steps or during token fetch leaves unrelated operations usable. A guest step that overruns cancellation closes the client. Discarded late output is wiped. | -| `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue. Tests check this for the dynamic record path and for the owned `Vec` to `Vec` conversion. For owned columns, drop witnesses are test values that record whether they were wiped. They show that the prefix is wiped, not leaked, after a late failure or an element's panic. Existing context cases reject late authentication failures without returning partial output. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | -| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths and has a negative control. One of those paths encrypts an owned `Vec` column through its guard. Drop witnesses show that an owned column's source is wiped before its pending request is returned. That includes a refused context, and a context conversion that panics. Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | +| `lifecycle-cancellation-v1` | Dropping an encrypt future paused in key generation, or a decrypt future paused in key retrieval, drops the provider future and operation captures, for a record operation or an owned collection; subsequent work on the cipher succeeds. Guest ABI abort spends the handle and drops the operation. | Cancellation before a call sends no request; cancellation between steps or during token fetch leaves unrelated operations usable. A guest step that overruns cancellation closes the client. Discarded late output is wiped. | +| `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue. Tests check this for the dynamic record path and for the owned `Vec` to `Vec` conversion. For owned collections, drop witnesses are test values that record whether they were wiped. They show that the prefix is wiped, not leaked, after a late failure or an element's panic. Existing context cases reject late authentication failures without returning partial output. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | +| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths and has a negative control. One of those paths encrypts an owned `Vec` collection through its guard. Drop witnesses show that an owned collection's source is wiped before its pending request is returned. That includes a refused context, and a context conversion that panics. Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | | `lifecycle-diagnostics-v1` | Guest errors discard untrusted foreign cause messages; failed/successful exports do not hand over stale diagnostics. | Structured errors remain available after failures; SDK errors and redacted display/logging expose types or fields rather than plaintext values. | | `lifecycle-validation-before-io-v1` | Existing runtime/read/context cases count provider calls. Wrong shape, independently detectable context mismatch and oversized descriptors fail before key I/O; lazy assembly stops after local failure. | Host refusals happen before entering the guest, and shared runtime/read cases exercise the uninitialised guest's preflight boundary. Go does not yet expose native provider counters. | -| `lifecycle-logical-batching-v1` | Instrumented `DataKeySource` calls establish one logical generation batch, retrieval grouping by keyset, descriptor ordering and no work for empty batches, for record operations and for an owned `Vec` column of any length, including an empty one. | Go uses the same engine, but this profile does not claim independently instrumented Go key-request counts. | +| `lifecycle-logical-batching-v1` | Instrumented `DataKeySource` calls establish one logical generation batch, retrieval grouping by keyset, descriptor ordering and no work for empty batches, for record operations and for an owned `Vec` collection of any length, including an empty one. | Go uses the same engine, but this profile does not claim independently instrumented Go key-request counts. | | `lifecycle-provider-chunks-v1` | The actual KMS chunking helper preserves order, handles a final short chunk, fails the whole call on a chunk error, bounds in-flight chunks and releases their futures on cancellation. | This is a native algorithm observation, not a measurement of live HTTP request counts. | The drop witnesses belong to fulfilment closures and provider futures in real diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 4e30ac674..deba4ddb6 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -42,17 +42,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and `DecryptFrom` decrypts it back** (#1482). `Vec` implements `EncryptTarget>` and `DecryptSource>` for any owned element target. For example, `vec![29i32, 34].encrypt_into::, _>(&keyset)` - needs no caller context, and `Vec::::decrypt_from(column, &keyset)` + needs no caller context, and `Vec::::decrypt_from(ciphertexts, &keyset)` decrypts the result. - - A column uses its element target's `ContextInput`. A column of a - `RequiredContext` target must be given a context, and a column of a + - A collection uses its element target's `ContextInput`. A collection of a + `RequiredContext` target must be given a context, and a collection of a `NoCallerContext` target cannot be given one. `Default` does not change either rule. - Each element is encrypted under its own data key and authenticated on - its own. - - A column makes one logical batch of key requests, in element order. That - is one `generate_keys` call to encrypt it, and one `retrieve_keys` call - for each keyset to decrypt it. An empty column makes no call. The key + its own. An element's position and the number of elements are not + authenticated. That suits the common case of storing each element in its + own database row. A query can return rows in any order, or only some of + them. + - A collection makes one logical batch of key requests, in element order. + That is one `generate_keys` call to encrypt it, and one `retrieve_keys` + call for each keyset to decrypt it. An empty collection makes no call. The key source can still split a logical batch into several requests to its provider. - The plaintext `Vec` is wiped before the pending request is returned, even @@ -61,12 +64,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - If an element fails after earlier elements were decrypted, the earlier plaintexts are wiped before the error is returned. If an element's conversion panics, they are wiped before the panic unwinds. - - A `Vec` is not a column. A `Vec` is one byte string, as the next - entry describes. + - A `Vec` is not a collection of bytes. A `Vec` is one byte + string, as the next entry describes. - `keyset.encrypt` still takes a single value, and `encrypt_tree` is still the structural path. - **Byte strings encrypt as one value: `Vec` and `[u8; N]` encrypt into - one `Ciphertext`, and a `Vec>` encrypts into a column.** So + one `Ciphertext`, and a `Vec>` encrypts into a `Vec`.** So `keyset.encrypt(vec![1u8, 2, 3])` and `Vec::::decrypt_from(ciphertext, &keyset)` now compile. The structural API and the Go SDK already encrypted byte strings this way. diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 07a4dde59..8bb1143e8 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -228,8 +228,12 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! ``` //! # async fn example() -> Result<(), stack_encrypt::Error> { //! use stack_encrypt::{Ciphertext, EncryptInto, DecryptFrom, StackCipher}; +//! // With no `.kms(..)`, the cipher uses ZeroKMS. It reads credentials from +//! // the environment, or from the profile that `npx stash auth login` writes. //! let cipher = StackCipher::builder() -//! .kms(stack_encrypt::kms::FakeDataKeySource::new()).init().await?; +//! # .kms(stack_encrypt::kms::FakeDataKeySource::new()) +//! .init() +//! .await?; //! let keyset = cipher.default_keyset(); //! let encrypted: Ciphertext = 42i32.encrypt_into(&keyset).await?; //! let plain = i32::decrypt_from(encrypted, &keyset).await?; @@ -243,21 +247,25 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! //! [`EncryptInto`] encrypts a `Vec` of owned plaintexts into a `Vec` of any //! owned target, and [`DecryptFrom`] decrypts it back. A caller context is -//! optional, as it is for a single value. In these docs, a `Vec` of -//! ciphertexts is called a column. +//! optional, as it is for a single value. //! //! Each element is encrypted under its own data key and authenticated on its -//! own, so [`KeysetCipher::decrypt`] can decrypt any one element alone. The -//! order and the number of elements are not authenticated, because stored -//! values are database rows. A database can return rows in any order, or -//! only some of them. +//! own, so [`KeysetCipher::decrypt`] can decrypt any one element alone. An +//! element's position in the `Vec`, and the number of elements, are not part +//! of what is authenticated. So you can decrypt the elements in a different +//! order, or decrypt only some of them. +//! +//! This suits a very common case: storing each element in its own database +//! row. A database can return rows in any order. A query can also return only +//! some of them. If your application needs the order or the length of a +//! `Vec` to be authenticated, this API does not provide that. //! -//! A column makes one logical batch of key requests, with the requests in -//! element order: +//! A collection makes one logical batch of key requests, with the requests +//! in element order: //! -//! - Encrypting a column makes one `generate_keys` call. -//! - Decrypting a column makes one `retrieve_keys` call for each keyset. -//! - An empty column makes no call. +//! - Encrypting a collection makes one `generate_keys` call. +//! - Decrypting a collection makes one `retrieve_keys` call for each keyset. +//! - An empty collection makes no call. //! //! The key source can still split one logical batch into several requests to //! its provider. @@ -296,14 +304,15 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); //! ``` //! -//! A column follows its element target's rules for a caller context. If the -//! element target requires a context, the column requires one. If the -//! element target accepts no context, the column cannot be given one. +//! A collection follows its element target's rules for a caller context. If +//! the element target requires a context, the collection requires one. If +//! the element target accepts no context, the collection cannot be given +//! one. //! -//! A `Vec` is one byte string, not a column of bytes. Vitamin C encrypts -//! a `Vec` as a single value, and `u8` is not an [`OpaqueEncode`] type. -//! So a `Vec` encrypts into one `Ciphertext`, and a `Vec>` is a -//! column of byte strings. +//! A `Vec` is one byte string, not a collection of bytes. Vitamin C +//! encrypts a `Vec` as a single value, and `u8` is not an +//! [`OpaqueEncode`] type. So a `Vec` encrypts into one `Ciphertext`, and +//! a `Vec>` is a collection of byte strings. //! //! Existing structural plans and derives still use [`StackCipherText`], a tree //! of sealed leaves. Their low-level direct entry points are diff --git a/packages/stack-encrypt/src/scalar.rs b/packages/stack-encrypt/src/scalar.rs index dfa41ccff..4683d502e 100644 --- a/packages/stack-encrypt/src/scalar.rs +++ b/packages/stack-encrypt/src/scalar.rs @@ -42,8 +42,8 @@ mod sealed { /// into one `Ciphertext`. A `Vec` of any other `OpaqueEncode` type encrypts /// element by element into a `Vec`, through [`EncryptInto`]. The /// crate docs on collections explain how. `u8` is not an `OpaqueEncode` type, -/// because Vitamin C encrypts a `Vec` as one byte string, not as a column -/// of bytes. +/// because Vitamin C encrypts a `Vec` as one byte string, not as a +/// collection of bytes. /// /// A nullable `Option` is not an `OpaqueEncode` type either. Use /// [`KeysetCipher::encrypt_tree`] and [`KeysetCipher::decrypt_tree`] for diff --git a/packages/stack-encrypt/src/target/collections.rs b/packages/stack-encrypt/src/target/collections.rs index c39cbf1e3..08be9547e 100644 --- a/packages/stack-encrypt/src/target/collections.rs +++ b/packages/stack-encrypt/src/target/collections.rs @@ -5,14 +5,15 @@ //! A collection declares nothing of its own. A collection's `ContextInput` is //! the element target's, so the element target decides once whether a caller can //! omit a context or must give one. The same rule then applies to the whole -//! column. For example, a `Vec` needs no caller context, but a -//! column of a `RequiredContext` target does. `Default` does not affect +//! collection. For example, a `Vec` needs no caller context, but +//! a collection of a `RequiredContext` target does. `Default` does not affect //! this. //! //! Each element is encrypted under its own ZeroKMS data key and authenticated //! on its own. The collection's length and the order of its elements are not -//! authenticated, because stored values are database rows and columns. A -//! database can return them in any order, or only some of them. +//! part of what is authenticated. That suits a very common case, where each +//! element is stored in its own database row. A database can return rows in +//! any order. A query can also return only some of them. //! //! [`Pending::collect`] merges the elements' key requests into one logical //! batch, in element order. An empty collection makes no request. The key @@ -22,8 +23,8 @@ //! A `Vec` is not a collection. Vitamin C encrypts a `Vec` as one //! byte string, and `u8` is not an opaque scalar. So //! `vec![1u8].encrypt_into::, _>(..)` does not compile. A -//! `Vec` encrypts into one `Ciphertext`, and a `Vec>` is a column -//! of them. A nullable element, `Option`, is not an opaque scalar either. +//! `Vec` encrypts into one `Ciphertext`, and a `Vec>` is a +//! collection of them. A nullable element, `Option`, is not an opaque scalar either. use super::{DecryptSource, Decryption, EncryptTarget, Encryption, Pending}; use stack_kms::MaybeSend; use zeroize::Zeroize; @@ -32,9 +33,9 @@ impl> EncryptTarget> for Vec where T::ContextInput: Clone + MaybeSend + 'static, { - /// The element target's context input. A column can omit a context only - /// if one element can. A column must be given a context if one element - /// must. + /// The element target's context input. A collection can omit a context + /// only if one element can. A collection must be given a context if one + /// element must. type ContextInput = T::ContextInput; fn encryption<'s, K: 'static>() -> Encryption<'s, Vec, Self, K, Self::ContextInput> where @@ -67,7 +68,7 @@ where fn decryption(self, context: Self::ContextInput) -> Decryption, K> { // Every element's keys are retrieved in one batch and the results // keep element order. An element that fails after its key arrived - // fails the column, and the elements recovered before it are wiped. + // fails the collection, and the elements recovered before it are wiped. Decryption::all_wiping( self.into_iter() .map(|element| element.decryption(context.clone())), diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 7792d3a83..b89e6f579 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -6,8 +6,8 @@ //! determine either. [`scalar_ciphertext`] is the first owned operation. //! //! A `Vec` of owned plaintexts encrypts into a `Vec` of any owned -//! target, and decrypts back the same way. The column uses the element -//! target's `ContextInput`. The column makes one logical batch of key +//! target, and decrypts back the same way. The collection uses the element +//! target's `ContextInput`. The collection makes one logical batch of key //! requests. Each element is authenticated on its own. //! //! # Structural API during migration From 4f6a1de1c663b0c9e58701be617e7f925c2cf982 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 6 Nov 2026 20:51:12 +1100 Subject: [PATCH 13/17] feat(stack-encrypt)!: frame readers take a ProtectedRef<[u8]> The frame readers took &[u8], so a caller holding a Buffer or a ValueFrame read it through risky_ref to pass it in, doc examples included. vitaminc-protected 0.9.1 lets a Buffer lend its bytes, and narrows a protected Vec or byte array to a ProtectedRef<[u8]>. dynamic::context, record::plan, record::plan_with, ValueFrame::from_bytes, ValueFrame::check, IndexSpec::from_frame and op::Stats::from_frame now take a ProtectedRef<'_, [u8]>, and each reads the bytes once, before the frame parser. ValueFrame lends its bytes, and AsProtectedRef and ProtectedRef are re-exported. A passthrough frame, stored in the clear by contract, is read through a crate-private from_clear rather than copied into a Protected. The doc examples and tests this branch had moved to risky_ref and risky_unwrap no longer use them. Plain bytes go into a Protected first. In the Go guest, every frame input is a Protected>. stack-guest-abi's take_plaintext returns one, so plaintext moves in with no new copy, and the plan, context and statistics copies the exports already made are now Protected too. The guest reads an input in one place, parse_frame, before vitaminc's Frame::parse. The vitaminc-protected pin moves to 0.9.1, with the root, Go guest, EQL and fuzz lockfiles. Two trybuild snapshots list &ProtectedRef among the Scalar types. --- Cargo.lock | 46 +-- Cargo.toml | 2 +- languages/golang/encrypt/guest/Cargo.lock | 44 +-- languages/golang/encrypt/guest/src/abi.rs | 38 +- languages/golang/encrypt/guest/src/io.rs | 21 +- languages/golang/encrypt/guest/src/ops.rs | 186 +++++---- languages/golang/encrypt/guest/src/targets.rs | 5 +- languages/golang/encrypt/guest/tests/leak.rs | 110 +++--- .../golang/encrypt/guest/tests/native_ops.rs | 356 +++++++++++------- .../golang/encrypt/guest/tests/support/mod.rs | 7 +- packages/eql/Cargo.lock | 42 +-- .../eql-bindings/src/encryption/targets.rs | 10 +- .../encryption/tests/json_tree/elements.rs | 17 +- .../encryption/tests/json_tree/predicate.rs | 6 +- .../encryption/tests/json_tree_data_plan.rs | 26 +- .../encryption/tests/v4_date_time_numeric.rs | 7 +- packages/stack-auth/fuzz/Cargo.lock | 24 +- packages/stack-encrypt/CHANGELOG.md | 18 +- packages/stack-encrypt/fuzz/Cargo.lock | 44 +-- packages/stack-encrypt/fuzz/Cargo.toml | 2 +- .../fuzz/fuzz_targets/check_record.rs | 4 +- packages/stack-encrypt/src/dynamic/context.rs | 22 +- packages/stack-encrypt/src/dynamic/query.rs | 142 ++++--- packages/stack-encrypt/src/dynamic/record.rs | 57 ++- packages/stack-encrypt/src/dynamic/term.rs | 30 +- .../stack-encrypt/src/dynamic/test_frame.rs | 19 +- .../stack-encrypt/src/dynamic/value_frame.rs | 91 +++-- packages/stack-encrypt/src/lib.rs | 6 +- packages/stack-encrypt/tests/conformance.rs | 6 +- .../tests/context_conformance.rs | 9 +- packages/stack-encrypt/tests/declarations.rs | 17 +- packages/stack-encrypt/tests/heap_hygiene.rs | 18 +- packages/stack-encrypt/tests/json_index.rs | 32 +- .../stack-encrypt/tests/json_operators.rs | 31 +- .../tests/lifecycle_conformance.rs | 8 +- .../stack-encrypt/tests/query_operators.rs | 14 +- .../stack-encrypt/tests/read_conformance.rs | 8 +- .../stack-encrypt/tests/record_lowering.rs | 29 +- .../tests/runtime_conformance.rs | 6 +- packages/stack-encrypt/tests/stored_rows.rs | 28 +- .../tests/ui/owned_collection_elements.stderr | 2 +- .../tests/ui/scalar_nullable.stderr | 4 +- packages/stack-encrypt/tests/value_kinds.rs | 9 +- packages/stack-guest-abi/src/abi.rs | 9 +- packages/stack-kms/fuzz/Cargo.lock | 26 +- 45 files changed, 992 insertions(+), 646 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f24c7111a..e69dd133d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -565,7 +565,7 @@ dependencies = [ "subtle", "thiserror 1.0.69", "vitaminc-ore", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "zeroize", ] @@ -2315,7 +2315,7 @@ dependencies = [ "vitaminc-hmac", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "vitaminc-random 0.9.0", "zeroize", ] @@ -3362,7 +3362,7 @@ dependencies = [ "url", "uuid", "vitaminc 0.9.0", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "web-time", "zeroize", "zerokms-protocol", @@ -3384,7 +3384,7 @@ dependencies = [ "tempfile", "tokio", "url", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "zeroize", ] @@ -3441,7 +3441,7 @@ dependencies = [ "vitaminc-normalize", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "zeroize", "zerokms-protocol", ] @@ -3469,7 +3469,7 @@ dependencies = [ "thiserror 1.0.69", "vitaminc-aead 0.9.0", "vitaminc-aead-frame", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "zeroize", ] @@ -3503,7 +3503,7 @@ dependencies = [ "url", "uuid", "vitaminc 0.9.0", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "zeroize", "zerokms-protocol", ] @@ -4272,7 +4272,7 @@ dependencies = [ "vitaminc-aead 0.9.0", "vitaminc-context 0.9.0", "vitaminc-encrypt 0.9.0", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "vitaminc-random 0.9.0", "vitaminc-traits 0.9.0", ] @@ -4306,7 +4306,7 @@ dependencies = [ "thiserror 2.0.18", "vitaminc-aead-derive 0.9.0", "vitaminc-context 0.9.0", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "vitaminc-random 0.9.0", "zeroize", ] @@ -4341,7 +4341,7 @@ checksum = "ba4a3e8a2057ce32724b0d42f71e87ee348d13c818fc54a16c443b86c4fe8d9a" dependencies = [ "serde", "vitaminc-aead 0.9.0", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", ] [[package]] @@ -4361,7 +4361,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b407b3127be198a8da7d4d914c11215e1eae1b467cdf89eca8a7a296055e863" dependencies = [ "mutants", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", ] [[package]] @@ -4387,7 +4387,7 @@ dependencies = [ "aes-gcm", "aws-lc-rs", "vitaminc-aead 0.9.0", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "vitaminc-random 0.9.0", "zeroize", ] @@ -4401,7 +4401,7 @@ dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "zeroize", ] @@ -4415,7 +4415,7 @@ dependencies = [ "unicode-general-category", "unicode-normalization", "vitaminc-aead 0.9.0", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", ] [[package]] @@ -4430,7 +4430,7 @@ dependencies = [ "vitaminc-aead 0.9.0", "vitaminc-context 0.9.0", "vitaminc-normalize", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "zeroize", ] @@ -4445,7 +4445,7 @@ dependencies = [ "vitaminc-aead 0.9.0", "vitaminc-context 0.9.0", "vitaminc-normalize", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", ] [[package]] @@ -4467,9 +4467,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44e61d6e58132793aace6898403ffa949bc06e388c1fc89c08d48ad0303133c3" +checksum = "67a44502e43de42fbd95c12a35efbaea903eedcc7a3922fceacea93054f44485" dependencies = [ "bitvec", "digest 0.11.3", @@ -4478,7 +4478,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.18", - "vitaminc-protected-derive 0.9.0", + "vitaminc-protected-derive 0.9.1", "zeroize", ] @@ -4495,9 +4495,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a60e915eb16c115882ea4754cecc99d7bcd3d8a1bfb9b8e512af4b6f7dd14801" +checksum = "f4315341c559af17bd773e8b4e32125a2025d7fbe3e31cbae49120ee1dcab02e" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -4530,7 +4530,7 @@ dependencies = [ "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "vitaminc-random-derives 0.9.0", "zeroize", ] @@ -4584,7 +4584,7 @@ dependencies = [ "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "vitaminc-random 0.9.0", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index 19339fc61..7dec61963 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -153,4 +153,4 @@ vitaminc-normalize = "0.9.0" # which text ORE and OPE terms are derived over (ADR-0009). vitaminc-ore = "0.9.0" vitaminc-prf = { version = "0.9.0", features = ["normalize"] } -vitaminc-protected = "0.9.0" +vitaminc-protected = "0.9.1" diff --git a/languages/golang/encrypt/guest/Cargo.lock b/languages/golang/encrypt/guest/Cargo.lock index df1645103..9c2cc2d59 100644 --- a/languages/golang/encrypt/guest/Cargo.lock +++ b/languages/golang/encrypt/guest/Cargo.lock @@ -366,7 +366,7 @@ dependencies = [ "subtle", "thiserror 1.0.69", "vitaminc-ore", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "zeroize", ] @@ -1474,7 +1474,7 @@ dependencies = [ "vitaminc-hmac", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "vitaminc-random 0.9.0", "zeroize", ] @@ -2111,7 +2111,7 @@ dependencies = [ "url", "uuid", "vitaminc 0.9.0", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "web-time", "zeroize", "zerokms-protocol", @@ -2144,7 +2144,7 @@ dependencies = [ "vitaminc-normalize", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "zeroize", ] @@ -2186,7 +2186,7 @@ dependencies = [ "thiserror 1.0.69", "vitaminc-aead 0.9.0", "vitaminc-aead-frame", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "zeroize", ] @@ -2213,7 +2213,7 @@ dependencies = [ "url", "uuid", "vitaminc 0.9.0", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "zeroize", "zerokms-protocol", ] @@ -2695,7 +2695,7 @@ dependencies = [ "vitaminc-aead 0.9.0", "vitaminc-context 0.9.0", "vitaminc-encrypt 0.9.0", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "vitaminc-random 0.9.0", "vitaminc-traits 0.9.0", ] @@ -2727,7 +2727,7 @@ dependencies = [ "thiserror 2.0.20", "vitaminc-aead-derive 0.9.0", "vitaminc-context 0.9.0", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "vitaminc-random 0.9.0", "zeroize", ] @@ -2762,7 +2762,7 @@ checksum = "ba4a3e8a2057ce32724b0d42f71e87ee348d13c818fc54a16c443b86c4fe8d9a" dependencies = [ "serde", "vitaminc-aead 0.9.0", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", ] [[package]] @@ -2782,7 +2782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b407b3127be198a8da7d4d914c11215e1eae1b467cdf89eca8a7a296055e863" dependencies = [ "mutants", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", ] [[package]] @@ -2808,7 +2808,7 @@ dependencies = [ "aes-gcm", "aws-lc-rs", "vitaminc-aead 0.9.0", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "vitaminc-random 0.9.0", "zeroize", ] @@ -2822,7 +2822,7 @@ dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "zeroize", ] @@ -2836,7 +2836,7 @@ dependencies = [ "unicode-general-category", "unicode-normalization", "vitaminc-aead 0.9.0", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", ] [[package]] @@ -2851,7 +2851,7 @@ dependencies = [ "vitaminc-aead 0.9.0", "vitaminc-context 0.9.0", "vitaminc-normalize", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "zeroize", ] @@ -2866,7 +2866,7 @@ dependencies = [ "vitaminc-aead 0.9.0", "vitaminc-context 0.9.0", "vitaminc-normalize", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", ] [[package]] @@ -2888,9 +2888,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44e61d6e58132793aace6898403ffa949bc06e388c1fc89c08d48ad0303133c3" +checksum = "67a44502e43de42fbd95c12a35efbaea903eedcc7a3922fceacea93054f44485" dependencies = [ "bitvec", "digest 0.11.3", @@ -2899,7 +2899,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.20", - "vitaminc-protected-derive 0.9.0", + "vitaminc-protected-derive 0.9.1", "zeroize", ] @@ -2916,9 +2916,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a60e915eb16c115882ea4754cecc99d7bcd3d8a1bfb9b8e512af4b6f7dd14801" +checksum = "f4315341c559af17bd773e8b4e32125a2025d7fbe3e31cbae49120ee1dcab02e" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2951,7 +2951,7 @@ dependencies = [ "getrandom 0.4.3", "rand 0.10.2", "thiserror 2.0.20", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "vitaminc-random-derives 0.9.0", "zeroize", ] @@ -3005,7 +3005,7 @@ dependencies = [ "rmp-serde", "serde", "thiserror 2.0.20", - "vitaminc-protected 0.9.0", + "vitaminc-protected 0.9.1", "vitaminc-random 0.9.0", "zeroize", ] diff --git a/languages/golang/encrypt/guest/src/abi.rs b/languages/golang/encrypt/guest/src/abi.rs index fd539231b..8fad3f7af 100644 --- a/languages/golang/encrypt/guest/src/abi.rs +++ b/languages/golang/encrypt/guest/src/abi.rs @@ -90,7 +90,7 @@ use std::cell::{Cell, RefCell}; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::rc::Rc; -use stack_encrypt::StackCipher; +use stack_encrypt::{Controlled as _, StackCipher}; use stack_guest_abi::abi::{export, input, take_plaintext, wipe_input}; use stack_guest_abi::buffers; use stack_guest_abi::call; @@ -99,7 +99,7 @@ use stack_guest_abi::status::STATUS_NEEDS_IO; use stack_guest_abi::step::{self, Operation, Step}; use crate::io; -use crate::ops; +use crate::ops::{self, Bytes}; use crate::options::{parse_options, parse_selector, scope_for, KeysetSelector, Side}; #[cfg(not(feature = "deterministic-kms"))] use crate::status::STATUS_INTERNAL; @@ -418,7 +418,7 @@ pub unsafe extern "C" fn se_term( let value = unsafe { take_plaintext(val_ptr, val_len)? }; // SAFETY: host-owned ranges the export was handed; the borrows end // before it returns and before any wipe of an overlapping range. - let context = unsafe { input(ctx_ptr, ctx_len)? }.to_vec(); + let context = Bytes::new(unsafe { input(ctx_ptr, ctx_len)? }.to_vec()); let opts = unsafe { input(opt_ptr, opt_len)? }; ops::validate::term(&value, &context, kind)?; let options = parse_options(opts, Side::Mint)?; @@ -453,7 +453,7 @@ pub unsafe extern "C" fn se_encrypt_record( let source = unsafe { take_plaintext(src_ptr, src_len)? }; // SAFETY: host-owned ranges the export was handed; the borrows end // before it returns and before any wipe of an overlapping range. - let plan = unsafe { input(plan_ptr, plan_len)? }.to_vec(); + let plan = Bytes::new(unsafe { input(plan_ptr, plan_len)? }.to_vec()); let opts = unsafe { input(opt_ptr, opt_len)? }; ops::validate::record(&source, &plan)?; let options = parse_options(opts, Side::Mint)?; @@ -494,7 +494,7 @@ pub unsafe extern "C" fn se_decrypt_record( // SAFETY: host-owned ranges the export was handed; the borrows end // before it returns and before any wipe of an overlapping range. let record = unsafe { input(rec_ptr, rec_len)? }.to_vec(); - let plan = unsafe { input(plan_ptr, plan_len)? }.to_vec(); + let plan = Bytes::new(unsafe { input(plan_ptr, plan_len)? }.to_vec()); let opts = unsafe { input(opt_ptr, opt_len)? }; let options = parse_options(opts, Side::Open)?; ops::validate::record_tree(&record, &plan, options.context.as_ref())?; @@ -520,8 +520,8 @@ pub unsafe extern "C" fn se_plan_check(plan_ptr: *const u8, plan_len: u32) -> u6 export(|| { // SAFETY: a host-owned range the export was handed; the borrow ends // before it returns. - let plan = unsafe { input(plan_ptr, plan_len)? }; - ops::plan_check(plan).map(|()| Vec::new()) + let plan = Bytes::new(unsafe { input(plan_ptr, plan_len)? }.to_vec()); + ops::plan_check(&plan).map(|()| Vec::new()) }) } @@ -557,7 +557,7 @@ pub unsafe extern "C" fn se_query_term( let value = unsafe { take_plaintext(val_ptr, val_len)? }; // SAFETY: host-owned ranges the export was handed; the borrows end // before it returns and before any wipe of an overlapping range. - let plan = unsafe { input(plan_ptr, plan_len)? }.to_vec(); + let plan = Bytes::new(unsafe { input(plan_ptr, plan_len)? }.to_vec()); let field = unsafe { input(field_ptr, field_len)? }.to_vec(); let operator = unsafe { input(op_ptr, op_len)? }.to_vec(); let opts = unsafe { input(opt_ptr, opt_len)? }; @@ -599,10 +599,10 @@ pub unsafe extern "C" fn se_json_open( export(|| { // SAFETY: host-owned ranges the export was handed; the borrows end // before it returns. - let rows = unsafe { input(rows_ptr, rows_len)? }.to_vec(); - let plan = unsafe { input(plan_ptr, plan_len)? }.to_vec(); + let rows = Bytes::new(unsafe { input(rows_ptr, rows_len)? }.to_vec()); + let plan = Bytes::new(unsafe { input(plan_ptr, plan_len)? }.to_vec()); let field = unsafe { input(field_ptr, field_len)? }.to_vec(); - let request = unsafe { input(req_ptr, req_len)? }.to_vec(); + let request = Bytes::new(unsafe { input(req_ptr, req_len)? }.to_vec()); let opts = unsafe { input(opt_ptr, opt_len)? }; ops::validate::json_open(&rows, &plan, &field, &request)?; let options = parse_options(opts, Side::Mint)?; @@ -647,10 +647,10 @@ pub unsafe extern "C" fn se_verify( let (query, values) = (query?, values?); // SAFETY: host-owned ranges the export was handed; the borrows end // before it returns. - let plan = unsafe { input(plan_ptr, plan_len)? }; + let plan = Bytes::new(unsafe { input(plan_ptr, plan_len)? }.to_vec()); let field = unsafe { input(field_ptr, field_len)? }; let operator = unsafe { input(op_ptr, op_len)? }; - ops::verify(query.as_slice(), values.as_slice(), plan, field, operator) + ops::verify(&query, &values, &plan, field, operator) }) } @@ -682,11 +682,11 @@ pub unsafe extern "C" fn se_target_limit( // SAFETY: host-owned ranges the export was handed; the borrows end // before it returns. let term = unsafe { input(term_ptr, term_len)? }; - let stats = unsafe { input(stats_ptr, stats_len)? }; - let plan = unsafe { input(plan_ptr, plan_len)? }; + let stats = Bytes::new(unsafe { input(stats_ptr, stats_len)? }.to_vec()); + let plan = Bytes::new(unsafe { input(plan_ptr, plan_len)? }.to_vec()); let field = unsafe { input(field_ptr, field_len)? }; let operator = unsafe { input(op_ptr, op_len)? }; - ops::target_limit(term, stats, plan, field, operator, want) + ops::target_limit(term, &stats, &plan, field, operator, want) }) } @@ -717,9 +717,9 @@ pub unsafe extern "C" fn se_operators( export(|| { // SAFETY: host-owned ranges the export was handed; the borrows end // before they return. - let plan = unsafe { input(plan_ptr, plan_len)? }; + let plan = Bytes::new(unsafe { input(plan_ptr, plan_len)? }.to_vec()); let field = unsafe { input(field_ptr, field_len)? }; - ops::operators(plan, field) + ops::operators(&plan, field) }) } @@ -753,7 +753,7 @@ pub extern "C" fn se_io() -> u64 { pub unsafe extern "C" fn se_resume(handle: u64, resp_ptr: *mut u8, resp_len: u32) -> u64 { export(|| { let bytes = unsafe { take_plaintext(resp_ptr, resp_len) }; - let responses = bytes.and_then(|bytes| io::decode_responses(&bytes)); + let responses = bytes.and_then(|bytes| io::decode_responses(bytes.risky_ref())); let responses = responses.inspect_err(|_| { let _ = step::abort(handle); })?; diff --git a/languages/golang/encrypt/guest/src/io.rs b/languages/golang/encrypt/guest/src/io.rs index d71fcd50f..dad3a6e63 100644 --- a/languages/golang/encrypt/guest/src/io.rs +++ b/languages/golang/encrypt/guest/src/io.rs @@ -29,7 +29,7 @@ use serde::{Deserialize, Serialize, Serializer}; use stack_encrypt::dynamic::frame::serde::{from_slice, to_vec}; -use stack_encrypt::Controlled as _; +use stack_encrypt::{Buffer, Controlled as _}; use stack_guest_abi::last_error::malformed; use stack_guest_abi::step::{Request, Response}; use zeroize::Zeroizing; @@ -66,13 +66,22 @@ pub fn encode_step(handle: u64, requests: &[Request]) -> Result, u32> { }; // A `Buffer`, which wipes on drop and as it grows. let frame = to_vec(&wire).map_err(|_| STATUS_ENCODING)?; - // Sized exactly, as every output is: the registry would otherwise - // reallocate a buffer with spare capacity and leave the old one unwiped. + to_host(&frame) + .ok_or_else(|| stack_guest_abi::last_error::internal("no memory for a step's requests")) +} + +/// A finished frame, copied into the output buffer the host reads. +/// +/// This is where a frame's bytes leave the guest, so this is the one place +/// the guest reads a `Buffer` through `risky_ref`. The `Buffer` is still +/// wiped when it drops. The copy is sized exactly, as every output is: the +/// registry would otherwise reallocate a buffer with spare capacity, and +/// leave the old one unwiped. `None` means the copy could not be allocated. +pub(crate) fn to_host(frame: &Buffer) -> Option> { let mut out = Vec::new(); - out.try_reserve_exact(frame.len()) - .map_err(|_| stack_guest_abi::last_error::internal("no memory for a step's requests"))?; + out.try_reserve_exact(frame.len()).ok()?; out.extend_from_slice(frame.risky_ref()); - Ok(out) + Some(out) } /// The responses the host handed `se_resume`, copied out of its buffer into diff --git a/languages/golang/encrypt/guest/src/ops.rs b/languages/golang/encrypt/guest/src/ops.rs index d69536b0f..4389d901c 100644 --- a/languages/golang/encrypt/guest/src/ops.rs +++ b/languages/golang/encrypt/guest/src/ops.rs @@ -49,9 +49,11 @@ use stack_encrypt::dynamic::frame::{decode_ciphertext, encode_ciphertext, Frame} use stack_encrypt::dynamic::{self, Scope, ValueFrame}; use stack_encrypt::sem::MatchOptions; use stack_encrypt::target::IndexSpec; -use stack_encrypt::Controlled as _; use stack_encrypt::ErrorPayload; -use stack_encrypt::{CipherText, KeysetCipher, Label, Node, SealedValue, StackCipherText}; +use stack_encrypt::{ + AsProtectedRef, CipherText, Controlled, KeysetCipher, Label, Node, Protected, ProtectedRef, + SealedValue, StackCipherText, +}; use stack_kms::{DataKeySource, IndexKeySource}; use stack_guest_abi::last_error; @@ -73,6 +75,12 @@ pub const TERM_OPE: u32 = 4; /// encoding — the shape that crosses the FFI codec. type BytesTree = Node>; +/// An input frame the host handed over: a plan, a context, statistics, or +/// plaintext. The export takes it into a guest buffer, and this wipes it +/// when it drops. Plaintext moves in from the host's buffer, which +/// [`take_plaintext`](stack_guest_abi::abi::take_plaintext) wipes at once. +pub type Bytes = Protected>; + // ============================================================================= // Terms // ============================================================================= @@ -96,8 +104,8 @@ type BytesTree = Node>; /// extended context. pub async fn term( cipher: &KeysetCipher<'_, K>, - value: &[u8], - context: &[u8], + value: &Bytes, + context: &Bytes, kind: u32, ) -> Result, u32> where @@ -117,10 +125,10 @@ where /// are not a frame are refused as every other input's are, before the /// context's own grammar. fn parse_context( - context: &[u8], + context: &Bytes, ) -> Result>, u32> { let _ = parse_frame(context)?; - dynamic::context(context).map_err(|e| fail_dynamic(&e)) + dynamic::context(lend(context)).map_err(|e| fail_dynamic(&e)) } /// The static half of a term: the kind is one of the ABI's table, and the @@ -174,8 +182,8 @@ fn parse_term(value: Frame<'_>, kind: u32) -> Result { /// [`dynamic::record`]'s to state. pub async fn encrypt_record( cipher: &KeysetCipher<'_, K>, - source: &[u8], - plan: &[u8], + source: &Bytes, + plan: &Bytes, ) -> Result, u32> where K: DataKeySource + IndexKeySource + Sync + 'static, @@ -209,7 +217,7 @@ where pub async fn decrypt_record( scope: Scope<'_, K>, record: &[u8], - plan: &[u8], + plan: &Bytes, expected: Option