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: 10 additions & 4 deletions packages/stack-encrypt-derive/docs/attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,23 @@ building it needs a `T` that is both `IntoAad` and `IntoPrfContext`. A record
made only of ciphertexts needs only the first, and the leaf it wraps
(`StackCipherText`) asks for only that: its context is `AeadContext`. Such a
record says so with `#[stash(context_type = AeadContext)]`, and then accepts
every context the canonical `keyset.encrypt(value, context)` path accepts,
every context the canonical `keyset.encrypt_tree(value, context)` path accepts,
including a type that implements `IntoAad` alone:

```rust,ignore
```rust,no_run
use stack_encrypt::{DecryptInto, EncryptFrom, NonEmpty, StackCipherText};
use stack_encrypt::target::{AeadContext, legacy::EncryptInto};
#[derive(EncryptFrom, DecryptInto)]
#[stash(plaintext = String, context_type = AeadContext)]
struct SealedName {
c: StackCipherText,
}
# async fn example<K: stack_encrypt::kms::DataKeySource + stack_encrypt::kms::IndexKeySource + Sync + 'static>(keyset: stack_encrypt::KeysetCipher<'_, K>, name: String, tenant: &str) -> Result<(), Box<dyn std::error::Error>> {
let record: SealedName = name.encrypt_into_with_context(&keyset, NonEmpty::new(tenant)?).await?;
# Ok(())
# }
```

The derive cannot pick this for you: it sees the field types' names, not
Expand All @@ -93,8 +99,8 @@ record with a `context_field`, or a `struct` derive, settles its context
itself and refuses the attribute.

A declaration is executed by `keyset.encrypt_as(&value, context)` and
`cipher.decrypt_as(record, context)`, or through the blanket `EncryptInto` and
`DecryptFrom` traits for the source-side spelling (`value.encrypt_into(&keyset)`,
`cipher.decrypt_as(record, context)`, or through the blanket
`stack_encrypt::target::legacy::{EncryptInto, DecryptFrom}` traits for the source-side spelling (`value.encrypt_into(&keyset)`,
`record.decrypt_into(&cipher, context)`). A hand-written target implements the
same two declaration methods the derives emit, `EncryptFrom::encryption` and
`DecryptInto::decryption`; neither receives the plaintext or a cipher.
Expand Down
16 changes: 16 additions & 0 deletions packages/stack-encrypt/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ 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`.
Comment thread
coderdan marked this conversation as resolved.
Resolved
`Ciphertext`'s bytes are the unchanged `SealedValue` v1 envelope, so
stored values open through either API.
Nullable `Option<T>` values remain on `encrypt_tree` / `decrypt_tree` in this
slice, including Vitamin C's padded-null encoding for fixed-width types.
- **The root `EncryptInto` and `DecryptFrom` are new owned conversion
Comment thread
coderdan marked this conversation as resolved.
Resolved
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 = ["http"]
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ 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, clarified 2026-11-01: the opaque scalar operation producing
`Ciphertext` 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. Context policy belongs to the encryption operation, not the
Rust type wrapping its output: `scalar_ciphertext().map(Wrapper)` retains the
scalar policy and the same authenticated bytes. Mapping already-encrypted output
does not derive a term or change its AAD. EQL ciphertext and term operations
still require `NonEmpty`; wrapping a scalar does not bypass their constructors.
Custom scalar wrappers can require caller context with `RequiredContext`, or
declare a nonempty context with `under`.

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
33 changes: 33 additions & 0 deletions packages/stack-encrypt/examples/scalar.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//! Run with ZeroKMS credentials: `cargo run -p stack-encrypt --example scalar`.
//! Authenticate with `npx stash auth login`, or configure the `CS_*` credentials
//! described in the crate's quick start. Initialization fails without credentials.
use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto, StackCipher};

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cipher = StackCipher::new().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
36 changes: 26 additions & 10 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
Comment thread
coderdan marked this conversation as resolved.
Resolved
/// 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 @@ -1075,7 +1086,9 @@ impl<K: DataKeySource + IndexKeySource> StackCipher<K> {
/// Not keyset-scoped: each leaf carries the id of the keyset it was
/// sealed under, and this opens leaves from any keyset the client is
/// authorised for. To insist on one keyset, decrypt through its
/// [`KeysetCipher`] instead.
/// [`KeysetCipher::decrypt_tree`] instead. For an owned scalar
/// [`Ciphertext`](crate::Ciphertext), use [`DecryptFrom`](crate::DecryptFrom):
/// `i32::decrypt_from(ciphertext, &cipher)`.
///
/// # Fan-out
///
Expand All @@ -1087,7 +1100,7 @@ impl<K: DataKeySource + IndexKeySource> StackCipher<K> {
/// assembled from many tenants' leaves costs a round trip per tenant to
/// open here, whoever assembled it. A service opening rows it does not
/// trust — one tenant's data at a time — should hold that tenant's
/// [`KeysetCipher`], whose decrypt is one round trip at most and refuses
/// [`KeysetCipher::decrypt_tree`], which is one round trip at most and refuses
/// a foreign leaf before any.
pub async fn decrypt<'a, T, A>(&self, ciphertext: StackCipherText, aad: A) -> Result<T, Error>
where
Expand Down Expand Up @@ -1497,7 +1510,7 @@ impl PendingStackCipherText {
/// [`plan`](Self::plan), in the same shape (see the
/// [descriptor docs](crate::descriptor)). The tree itself only carries
/// the *derived* per-leaf AADs, so the root is named here; nothing can
/// check that the two agree, which is why [`KeysetCipher::encrypt`], which
/// check that the two agree, which is why [`KeysetCipher::encrypt_tree`], which
/// does both steps from one value, is the form to prefer.
///
/// Settles through the target layer's request carrier
Expand Down Expand Up @@ -1784,13 +1797,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
7 changes: 4 additions & 3 deletions packages/stack-encrypt/src/keyset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
//! it is what every operation that *mints* something binds to: sealing
//! values, sealing records, deriving index terms. Decrypting is not
//! keyset-scoped (a sealed leaf carries the id of the keyset it was sealed
//! under), so it lives on [`StackCipher`] as well, with the [`KeysetCipher`]
//! form adding a constraint rather than a capability — see
//! [`KeysetCipher::decrypt`].
//! under). For an owned scalar, import [`DecryptFrom`](crate::DecryptFrom)
//! and use `i32::decrypt_from(ciphertext, &cipher)`; [`KeysetCipher::decrypt`]
//! adds the keyset constraint. For a structural tree, [`StackCipher::decrypt`]
//! opens across keysets and [`KeysetCipher::decrypt_tree`] adds that constraint.
//!
//! Keysets load lazily. [`StackCipher::keyset`] resolves an id or a name
//! through a bounded cache and loads the keyset from ZeroKMS on a miss —
Expand Down
Loading
Loading