Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions conformance/stack-encrypt/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<S>` to `Vec<T>` 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
Expand Down
16 changes: 10 additions & 6 deletions conformance/stack-encrypt/lifecycle-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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; 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 a future paused in generation 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<S>` to `Vec<T>` conversions release fulfilment captures exactly once on success, provider failure and unpolled drop; 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 a future paused in generation, for a record operation or an owned column, 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<Ciphertext>` to `Vec<String>` 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<String>` 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. | 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<S>` 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
Expand Down Expand Up @@ -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<S>` 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
Expand Down
36 changes: 33 additions & 3 deletions conformance/stack-encrypt/v1/lifecycle-v1.enrollment.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -89,7 +97,9 @@
"evidence": {
"lifecycle": [
"dropping_unpolled_work_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"
Expand All @@ -105,7 +115,10 @@
{
"id": "lifecycle-cancellation-v1",
"evidence": {
"lifecycle": ["cancelling_in_flight_work_drops_provider_and_captures"],
"lifecycle": [
"cancelling_in_flight_work_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",
"step::tests::the_wrong_number_of_responses_is_refused_and_drops_the_operation"
Expand All @@ -123,7 +136,14 @@
{
"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"
],
"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",
Expand All @@ -138,10 +158,16 @@
"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"
],
"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",
Expand Down Expand Up @@ -201,6 +227,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",
Expand Down
24 changes: 24 additions & 0 deletions packages/stack-encrypt/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,30 @@ 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<S>` encrypts into `Vec<T>` and back through
`EncryptInto` and `DecryptFrom`** (#1482). `Vec<T>` implements
`EncryptTarget<Vec<S>>` and `DecryptSource<Vec<P>>` for any owned element
target, so `vec![29i32, 34].encrypt_into::<Vec<Ciphertext>, _>(&keyset)`
needs no caller context and `Vec::<i32>::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<u8>` is not a column of
scalars: `u8` is not `OpaqueEncode`, because Vitamin C keeps `Vec<u8>` 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
Expand Down
13 changes: 13 additions & 0 deletions packages/stack-encrypt/examples/scalar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.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<Ciphertext> = vec![29i32, 34, 41].encrypt_into(&keyset).await?;
let one: i32 = keyset.decrypt(ages[1].clone()).await?;
assert_eq!(one, 34);
let values = Vec::<i32>::decrypt_from(ages, &keyset).await?;
assert_eq!(values, [29, 34, 41]);

let names: Vec<Ciphertext> = vec!["ada".to_owned(), "grace".to_owned()]
.encrypt_into_with_context(&keyset, ("users", "name"))
.await?;
let names = Vec::<String>::decrypt_from_with_context(names, &keyset, ("users", "name")).await?;
assert_eq!(names, ["ada", "grace"]);
Ok(())
}
48 changes: 48 additions & 0 deletions packages/stack-encrypt/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,54 @@ 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 (or
//! before unwinding, when an element's conversion panics).
//!
//! ```
//! # 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<Ciphertext> = vec![29i32, 34, 41].encrypt_into(&keyset).await?;
//! assert_eq!(Vec::<i32>::decrypt_from(ages.clone(), &keyset).await?, [29, 34, 41]);
//! // An element is an ordinary ciphertext.
//! assert_eq!(keyset.decrypt::<i32>(ages[1].clone()).await?, 34);
//!
//! // With a caller context, bound to every element.
//! let names: Vec<Ciphertext> = vec!["ada".to_owned(), "grace".to_owned()]
//! .encrypt_into_with_context(&keyset, ("users", "name"))
//! .await?;
//! let names = Vec::<String>::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<u8>`
//! is not a column of scalars: `u8` is not an [`OpaqueEncode`] type, since
//! Vitamin C keeps `Vec<u8>` a byte leaf and gives `u8` no source of its
//! own, so `Vec<u8>` 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
Expand Down
8 changes: 5 additions & 3 deletions packages/stack-encrypt/src/scalar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>` an implicit single
/// ciphertext.
/// A `Vec` of these encrypts element by element into a `Vec<Ciphertext>`
/// through [`EncryptInto`] instead; see the crate docs on collections.
/// `u8` is deliberately absent: Vitamin C keeps `Vec<u8>` 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<T>` 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.
Expand Down
Loading
Loading