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
3 changes: 3 additions & 0 deletions .cargo/mutants.toml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ exclude_re = [
'stack-encrypt/src/sem/mod\.rs:\d+:\d+: replace <impl MatchConfig for DefaultMatch>::options -> MatchOptions with Default::default\(\)$',
# Both unit-context conversions explicitly return Self::default().
'stack-encrypt/src/target/context\.rs:\d+:\d+: replace <impl From<\(\)> for (DeclaredContext|ExpectedContext<T>)>::from -> Self with Default::default\(\)$',
# A caller-free declaration adds nothing to the declared context: the
# conversion returns Self::default().
'stack-encrypt/src/target/input\.rs:\d+:\d+: replace <impl From<NoCallerContext> for super::DeclaredContext>::from -> Self with Default::default\(\)$',
# The resolver of a build without EQL types holds none: its `targets` IS
# the empty list (`dynamic::target::NoTargets`), which a test asserts on
# and `resolve` turns into `TargetError::NoTargets`; `vec![]` is the body.
Expand Down
13 changes: 13 additions & 0 deletions .changeset/stack-encrypt-owned-scalars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
---

Stack Encrypt's Rust scalar API now consumes plaintext and returns an opaque
`Ciphertext`, with separate methods for omitted and supplied context. Owned
`EncryptInto` / `DecryptFrom` conversions use explicit context capabilities.
The stored sealed-leaf format is unchanged.

Low-level structural encryption and decryption use `encrypt_tree` and
`decrypt_tree`, returning and opening a tree of individually sealed leaves.

This is a Rust-only release, versioned by release-plz from the conventional
commit. No npm package needs a version bump; EQL changes only adapt tests.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/eql/tests/encryption/tests/text_eq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ async fn text_round_trips_through_json_and_both_canonical_directions() {
let opened: String = cipher.decrypt_as(stored, Default::default()).await.unwrap();
assert_eq!(opened, value);

let native = keyset.encrypt(value.clone(), column()).await.unwrap();
let native = keyset.encrypt_tree(value.clone(), column()).await.unwrap();
let stored = TextEq {
v: SchemaVersion::CURRENT,
i: column().into_inner(),
Expand Down
2 changes: 1 addition & 1 deletion packages/eql/tests/encryption/tests/v4_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ async fn sealed_from_frame(
keyset: &stack_encrypt::KeysetCipher<'_, common::Observed>,
value: ValueFrame,
) -> Ciphertext {
match keyset.encrypt(value, column()).await.unwrap() {
match keyset.encrypt_tree(value, column()).await.unwrap() {
CipherText::Single(leaf) => CiphertextVisitor.sealed(leaf).unwrap(),
_ => panic!("a scalar seals as one leaf"),
}
Expand Down
14 changes: 14 additions & 0 deletions packages/stack-encrypt/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Breaking

- **`KeysetCipher::encrypt` and `decrypt` are the owned scalar API.**
`keyset.encrypt(value)` consumes an `OpaqueEncode` value (`bool`, the fixed-width
integers except `u8`, `f32`, `f64`, `String`), wipes it, and returns one
opaque `Ciphertext`; `keyset.decrypt::<T>(ciphertext)` opens it to an `OpaqueDecode` type.
`encrypt_with_context` and `decrypt_with_context` bind a context. The
previous structural forms, which take any `Encrypt` value and a context and
return a `StackCipherText`, are renamed `encrypt_tree` and `decrypt_tree`.
`Ciphertext`'s bytes are the unchanged `SealedValue` v1 envelope, so
stored values open through either API.
- **The root `EncryptInto` and `DecryptFrom` are new owned conversion
traits** over `EncryptTarget` / `DecryptSource`. The borrowed traits they
replace, and that `#[derive(EncryptFrom, DecryptInto)]` types use, are
`stack_encrypt::target::legacy::{EncryptInto, DecryptFrom}`; import one
pair or the other, not both.
- **`OpSpec`, `Operand` and the query term's wire form grow for documents**
(#1350). `OpSpec` gains twelve operators and `Operand` gains `Text` and
`TextList`; a binding that matches on either adds the arms. The query
Expand Down
7 changes: 7 additions & 0 deletions packages/stack-encrypt/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ trybuild = "1"
# stack-guest-abi takes it: `std` carries the failure persistence and the
# ChaCha RNG the runs are seeded with, and nothing else is needed.
proptest = { version = "1.12", default-features = false, features = ["std"] }
# A binary Serde format for `Ciphertext`'s bytes form (tests/scalar.rs).
# Already in the graph through vitaminc-traits.
rmp-serde = "1.3"

# The derive diagnostics are recorded with every feature on, as `test:unit`
# and CI run them. They cannot be relied on to hold under both feature sets
Expand Down Expand Up @@ -171,3 +174,7 @@ required-features = ["http"]

[package.metadata.docs.rs]
all-features = true

[[example]]
name = "scalar"
required-features = ["test-support"]
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ not close it. A concrete associated context type does not implicitly accept
arbitrary context extensions; any extension facility must preserve the declared
base context and be specified explicitly.

Amended 2026-10-31: the opaque scalar `Ciphertext` is the one target-directed
output that accepts empty context, because it is the cipher-directed
contract in owned form: `keyset.encrypt(value)` seals what
`keyset.encrypt_tree(value, ())` seals. It reaches the shared core through a
separate entry. Every other target goes through the entry that takes a
`NonEmpty`, so the nonempty requirement stays in the type.

Transcoding consumes native encryption output through a custom encrypted-data
protocol. It distinguishes sealed leaves, sequences, maps, authenticated absence
and empty-container markers, passthrough metadata, and typed terms. Readers expose
Expand Down
2 changes: 1 addition & 1 deletion packages/stack-encrypt/examples/encrypted_record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
//! `zerokms_auth` example for the lookup order).

use stack_encrypt::sem::{EqualityTerm, OreTerm};
use stack_encrypt::target::{DecryptFrom, EncryptInto};
use stack_encrypt::target::legacy::{DecryptFrom, EncryptInto};
use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, Error, StackCipher, StackCipherText};

// --- The shapes ----------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion packages/stack-encrypt/examples/mixed_user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// in the whole Vec (here: 3 rows x 2 encrypted fields = 6 data keys).
let ciphertext = cipher
.default_keyset()
.encrypt(users, record.clone())
.encrypt_tree(users, record.clone())
.await?;

println!("what the stored ciphertext reveals:");
Expand Down
35 changes: 35 additions & 0 deletions packages/stack-encrypt/examples/scalar.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//! Run without credentials: cargo run -p stack-encrypt --example scalar --features test-support
use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto, StackCipher};

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// In production, use StackCipher::new().await? with ZeroKMS credentials.
let cipher = StackCipher::builder()
.kms(stack_encrypt::kms::FakeDataKeySource::new())
.init()
.await?;
let keyset = cipher.default_keyset();

let encrypted = keyset.encrypt(42i32).await?;
let stored = encrypted.to_bytes();
let restored = Ciphertext::from_bytes(&stored)?;
let value: i32 = keyset.decrypt(restored).await?;
assert_eq!(value, 42);

let encrypted = keyset.encrypt_with_context(42i32, ("users", "age")).await?;
let value: i32 = keyset
.decrypt_with_context(encrypted, ("users", "age"))
.await?;
assert_eq!(value, 42);

let encrypted: Ciphertext = 42i32.encrypt_into(&keyset).await?;
let value = i32::decrypt_from(encrypted, &keyset).await?;
assert_eq!(value, 42);

let encrypted: Ciphertext = 42i32
.encrypt_into_with_context(&keyset, ("users", "age"))
.await?;
let value = i32::decrypt_from_with_context(encrypted, &keyset, ("users", "age")).await?;
assert_eq!(value, 42);
Ok(())
}
3 changes: 2 additions & 1 deletion packages/stack-encrypt/examples/search_terms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
//! `zerokms_auth` example for the lookup order).

use stack_encrypt::sem::{EqualityTerm, MatchTerms, OreTerm};
use stack_encrypt::target::EncryptInto;

use stack_encrypt::target::legacy::EncryptInto;
use stack_encrypt::{nonempty, EncryptFrom, StackCipher, StackCipherText};

#[tokio::main(flavor = "current_thread")]
Expand Down
4 changes: 2 additions & 2 deletions packages/stack-encrypt/examples/zerokms_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let keyset = cipher.default_keyset();
println!("connected; keyset {}", keyset.keyset_id());

let ciphertext = keyset.encrypt("hello", "demo/greeting").await?;
let ciphertext = keyset.encrypt_tree("hello", "demo/greeting").await?;
let plaintext: String = cipher.decrypt(ciphertext, "demo/greeting").await?;
assert_eq!(plaintext, "hello");
println!("round-tripped a value under the default keyset");
Expand All @@ -72,7 +72,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// let customers = cipher
// .keyset(IdentifiedBy::Name("customers".to_string().into()))
// .await?;
// let ciphertext = customers.encrypt("hello", "demo/greeting").await?;
// let ciphertext = customers.encrypt_tree("hello", "demo/greeting").await?;
//
// `default_keyset()` above is not one of these: it is the client's own
// default, the keyset a ZeroKMS administrator set for this client, and
Expand Down
28 changes: 21 additions & 7 deletions packages/stack-encrypt/src/cipher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
//!
//! * **Encrypt** — [`PendingStackCipherText::plan`] walks the value into
//! vitaminc's pending tree and does no I/O. A single
//! [`PendingStackCipherText::seal`] (or the [`KeysetCipher::encrypt`]
//! [`PendingStackCipherText::seal`] (or the [`KeysetCipher::encrypt_tree`]
//! convenience) then batches **one** `generate_keys` call for the whole
//! tree, under the handle's keyset, and seals every leaf through
//! `MintedKeys`, the sealing half of this crate's [`Cipher`], one key per
Expand Down Expand Up @@ -667,7 +667,7 @@ impl<K: IndexKeySource> StackCipher<K> {
/// .init()
/// .await?;
/// let tenant = cipher.keyset(IdentifiedBy::Name("acme".to_string().into())).await?;
/// let sealed = tenant.encrypt("hello".to_string(), nonempty!("greeting")).await?;
/// let sealed = tenant.encrypt_tree("hello".to_string(), nonempty!("greeting")).await?;
/// # Ok(())
/// # }
/// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap();
Expand Down Expand Up @@ -1010,10 +1010,15 @@ fn hmac_prf_from_index_key(index_key: &stack_kms::IndexKey) -> vitaminc_hmac::Hm
}

impl<K: DataKeySource + IndexKeySource> KeysetCipher<'_, K> {
/// Encrypt a value into a tree of individually sealed leaves.
///
/// For one opaque scalar use
/// [`Self::encrypt`] or [`Self::encrypt_with_context`].
///
/// Encrypt a value, binding `aad`, and seal it against fresh ZeroKMS data
/// keys in a single batched `generate_keys` call under this keyset.
/// Every key is requested under the [`Descriptor`] of `aad`.
pub async fn encrypt<'a, T, A>(&self, value: T, aad: A) -> Result<StackCipherText, Error>
pub async fn encrypt_tree<'a, T, A>(&self, value: T, aad: A) -> Result<StackCipherText, Error>
where
T: Encrypt,
A: IntoContext<'a>,
Expand All @@ -1023,10 +1028,16 @@ impl<K: DataKeySource + IndexKeySource> KeysetCipher<'_, K> {
pending.seal(self, context).await
}

/// [`StackCipher::decrypt`], constrained to this keyset: a leaf sealed
/// Decrypt a tree of individually sealed leaves.
///
/// Like [`StackCipher::decrypt`], constrained to this keyset: a leaf sealed
/// under any other is [`Error::ForeignKeyset`], refused before any key
/// is retrieved.
pub async fn decrypt<'a, T, A>(&self, ciphertext: StackCipherText, aad: A) -> Result<T, Error>
pub async fn decrypt_tree<'a, T, A>(
&self,
ciphertext: StackCipherText,
aad: A,
) -> Result<T, Error>
where
T: Decrypt,
A: IntoContext<'a>,
Expand Down Expand Up @@ -1784,13 +1795,16 @@ mod tests {
.await
.unwrap();
let keyset = cipher.default_keyset();
let leaf = keyset.encrypt(7u32, "users/age").await.unwrap();
let leaf = keyset.encrypt_tree(7u32, "users/age").await.unwrap();
let opened: Result<String, _> = cipher.decrypt(leaf, "users/age").await;
assert!(
matches!(opened, Err(Error::OpenedType { .. })),
"{opened:?}"
);
let seq = keyset.encrypt(vec![7u32, 8], "users/age").await.unwrap();
let seq = keyset
.encrypt_tree(vec![7u32, 8], "users/age")
.await
.unwrap();
let opened: Result<u32, _> = cipher.decrypt(seq, "users/age").await;
assert!(
matches!(opened, Err(Error::OpenedType { .. })),
Expand Down
2 changes: 1 addition & 1 deletion packages/stack-encrypt/src/dynamic/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
//! # What is not here
//!
//! Encrypting a whole value is not: a [`ValueFrame`], like any source,
//! implements `Encrypt` already, so `keyset.encrypt(value, aad)` is the whole
//! implements `Encrypt` already, so `keyset.encrypt_tree(value, aad)` is the whole
//! of it and needs nothing from this module. Nor is a second executor: nothing here calls
//! the term functions or the seal path to produce a record. A capability a
//! data plan needs and the builder lacks is added to the builder, once.
Expand Down
Loading
Loading