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
8 changes: 5 additions & 3 deletions docs/plans/2026-10-24-stack-encrypt-api-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -1757,7 +1757,8 @@ records only the work.
and `From<&Keyset>`. The entry points take `impl Into<CipherRef>`, and
the Go guest builds one from its keyset selector. A `Keyset` reference
refuses a foreign leaf with `ForeignKeyset`. A `Cipher` reference opens leaves from any keyset the client has.
- **Tracked in.** Issue #1488 is open. It is the chain removal. Issue #1120 is open. Pull request #1504 is open and adds the record pairs.
Done, in the second pull request for #1488.
- **Tracked in.** Issue #1488 is open. It is the chain removal. Issue #1120 is closed. Pull request #1504 is merged and added the record pairs.
- **Finished when** these are all true:
- the examples in [Target state](#target-state) compile;
- the compile-fail cases in `tests/ui` cover a binding in a query, and
Expand Down Expand Up @@ -1956,7 +1957,7 @@ table.
- In Go, `Client` becomes `Cipher`, with `NewCipher`, and the
keyset-bound `Cipher` becomes `Keyset`. Generated files are
regenerated, and the generator's output changes for every input.
- `CipherScope` and `dynamic::Scope` are replaced by one `CipherRef`.
- `CipherScope` and `dynamic::Scope` are replaced by one `CipherRef`. Done.
- The old context path type `Label` is removed, with `IntoLabel` and
Go's `ContextLabel()`. A plan's context is its declared `Scope`
parts, so `Label` is free for the key tag fact.
Expand Down Expand Up @@ -2005,7 +2006,8 @@ table.
lowering themselves. That inversion, the builder, the chain, the old
traits and `Label` are still to be removed.
- **Tracked in.** Issue #1488 is open. Its first pull request is the
lowering; the removals follow it.
lowering. Its second replaces `CipherScope` and `dynamic::Scope` with
`CipherRef`. The removals follow.
- **Finished when** the old API is gone, and every consumer uses the new
API.

Expand Down
18 changes: 7 additions & 11 deletions languages/golang/encrypt/guest/src/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@
//! statistics) carries no secret and is read from its frame with serde.

use stack_encrypt::dynamic::frame::{decode_ciphertext, encode_ciphertext, Frame};
use stack_encrypt::dynamic::{self, Scope, ValueFrame};
use stack_encrypt::dynamic::{self, ValueFrame};
use stack_encrypt::sem::MatchOptions;
use stack_encrypt::target::IndexSpec;
use stack_encrypt::CipherRef;
use stack_encrypt::ErrorPayload;
use stack_encrypt::{
AsProtectedRef, CipherText, Controlled, KeysetCipher, Label, Node, Protected, ProtectedRef,
Expand Down Expand Up @@ -211,11 +212,11 @@ where
///
/// The plan's opener runs in the engine; awaiting it here is the one
/// batched `retrieve_keys` per invocation, dispatched as one ZeroKMS call
/// per 500 keyed leaves and, under [`Scope::Client`], per keyset the leaves
/// per 500 keyed leaves and, through a client reference, per keyset the leaves
/// were sealed under. The output buffer contains plaintext — the ABI layer's
/// ownership rules govern its wiping.
pub async fn decrypt_record<K>(
scope: Scope<'_, K>,
pub async fn decrypt_record<'a, K>(
scope: impl Into<CipherRef<'a, K>>,
record: &[u8],
plan: &Bytes,
expected: Option<Label>,
Expand Down Expand Up @@ -984,13 +985,8 @@ mod tests {

// Opens back through the EQL type's own decryption.
validate::record_tree(&sealed, &protect(&plan), None).expect("the tree fits");
let opened = block_on(decrypt_record(
Scope::Client(&cipher),
&sealed,
&protect(&plan),
None,
))
.expect("opens");
let opened =
block_on(decrypt_record(&cipher, &sealed, &protect(&plan), None)).expect("opens");
let Tree::Object(values) = Tree::from_frame(&opened).expect("a value") else {
panic!("an object")
};
Expand Down
19 changes: 11 additions & 8 deletions languages/golang/encrypt/guest/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
//! it is objects, strings, bytes and nothing else, and this module is its
//! one home. The Go bindings plan points here.

use stack_encrypt::dynamic::Scope;
use stack_encrypt::CipherRef;
use stack_encrypt::{KeysetCipher, Label, StackCipher};
use stack_kms::{IdentifiedBy, IndexKeySource};
use uuid::Uuid;
Expand Down Expand Up @@ -239,20 +239,23 @@ impl KeysetSelector {
}
}

/// The [`Scope`] a decrypt-side selector names: the client for `{"any"}`,
/// which opens a leaf sealed under any of its keysets, or one keyset's
/// cipher, which opens only its own and refuses the rest before any key is
/// retrieved.
/// Returns the [`CipherRef`] for the keyset selector of a decrypt call.
///
/// - For `{"any"}`, the result is the client. The client opens a leaf
/// sealed under any of its keysets.
/// - For one keyset, the result is that keyset's cipher. That cipher only
/// opens leaves from its own keyset. The cipher refuses any other leaf
/// before it retrieves any key.
pub async fn scope_for<'c, K>(
Comment thread
coderdan marked this conversation as resolved.
Resolved
cipher: &'c StackCipher<K>,
selector: &KeysetSelector,
) -> Result<Scope<'c, K>, u32>
) -> Result<CipherRef<'c, K>, u32>
where
K: IndexKeySource,
{
match selector {
KeysetSelector::Any => Ok(Scope::Client(cipher)),
other => other.resolve(cipher).await.map(Scope::Keyset),
KeysetSelector::Any => Ok(CipherRef::from(cipher)),
other => other.resolve(cipher).await.map(CipherRef::from),
}
}

Expand Down
29 changes: 9 additions & 20 deletions languages/golang/encrypt/guest/tests/leak.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ mod support;

use stack_encrypt::dynamic::frame::encode_ciphertext;
use stack_encrypt::dynamic::plaintext::Logical;
use stack_encrypt::dynamic::Scope;
use stack_encrypt::{CipherText, Descriptor, Node, PassthroughFrame, StackCipher};
use stack_encrypt_guest::options::{parse_options, Side};
use stack_encrypt_guest::status::fail_error;
Expand Down Expand Up @@ -466,7 +465,7 @@ fn scenarios() -> Vec<(&'static str, Tree)> {
&["stack_guest_abi::malformed_input"],
|| {
block_on(ops::decrypt_record(
Scope::Client(&cipher),
&cipher,
CIPHERTEXT,
&protect(&plan_under(label("email"))),
None,
Expand All @@ -479,7 +478,7 @@ fn scenarios() -> Vec<(&'static str, Tree)> {
failure("leaf", &["stack_encrypt::leaf_version"], || {
let record = tree_with_leaf(CipherText::Single(CIPHERTEXT.to_vec()));
block_on(ops::decrypt_record(
Scope::Client(&cipher),
&cipher,
&record,
&protect(&plan_under(label("email"))),
None,
Expand All @@ -494,7 +493,7 @@ fn scenarios() -> Vec<(&'static str, Tree)> {
|| {
let record = tree_with_leaf(clear(Tree::Bytes(CIPHERTEXT.to_vec())));
block_on(ops::decrypt_record(
Scope::Client(&cipher),
&cipher,
&record,
&protect(&plan_under(label("email"))),
None,
Expand All @@ -510,20 +509,15 @@ fn scenarios() -> Vec<(&'static str, Tree)> {
("nick", field(label("nick"), &["c"], None)),
]));
let record = sealed(&cipher, &plan_under(label("email")));
block_on(ops::decrypt_record(
Scope::Client(&cipher),
&record,
&protect(&plan),
None,
))
block_on(ops::decrypt_record(&cipher, &record, &protect(&plan), None))
}),
),
(
"a record opened under another context",
failure("aead", &["stack_encrypt::aead"], || {
let record = sealed(&cipher, &plan_under(label("email")));
block_on(ops::decrypt_record(
Scope::Client(&cipher),
&cipher,
&record,
&protect(&plan_under(extended("email", CONTEXT))),
None,
Expand All @@ -538,7 +532,7 @@ fn scenarios() -> Vec<(&'static str, Tree)> {
.expect("keyset");
let record = sealed(&cipher, &plan_under(label("email")));
block_on(ops::decrypt_record(
Scope::Keyset(globex),
globex,
&record,
&protect(&plan_under(label("email"))),
None,
Expand Down Expand Up @@ -568,7 +562,7 @@ fn scenarios() -> Vec<(&'static str, Tree)> {
failure("retrieve", &["stack_kms::key_not_retrieved"], || {
let record = sealed(&cipher, &plan_under(label("email")));
block_on(ops::decrypt_record(
Scope::Client(&failing),
&failing,
&record,
&protect(&plan_under(label("email"))),
None,
Expand Down Expand Up @@ -755,12 +749,7 @@ fn scenarios() -> Vec<(&'static str, Tree)> {
)]);
let mut record = Vec::new();
encode_ciphertext(&tree, &mut record).expect("encode tree");
block_on(ops::decrypt_record(
Scope::Client(&cipher),
&record,
&protect(&plan),
None,
))
block_on(ops::decrypt_record(&cipher, &record, &protect(&plan), None))
}),
));
// A cause from another library this guest does not vouch for: the PRF
Expand Down Expand Up @@ -871,7 +860,7 @@ fn a_foreign_keyset_carries_both_keyset_ids() {
let record = sealed(&cipher, &plan_under(label("email")));
let error = failure("foreign", &["stack_encrypt::foreign_keyset"], || {
block_on(ops::decrypt_record(
Scope::Keyset(globex),
globex,
&record,
&protect(&plan_under(label("email"))),
None,
Expand Down
67 changes: 37 additions & 30 deletions languages/golang/encrypt/guest/tests/native_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ use std::future::IntoFuture;
mod support;

use stack_encrypt::dynamic::frame::{decode_ciphertext, encode_ciphertext};
use stack_encrypt::dynamic::Scope;
use stack_encrypt::sem::DefaultMatch;
use stack_encrypt::{
nonempty, CipherText, Label, Node, PassthroughFrame, PendingStackCipherText, SealedValue,
StackCipher,
};
use stack_encrypt::{Controlled, Protected};
use stack_encrypt_guest::ops::{self, TERM_EQUALITY, TERM_MATCH, TERM_OPE, TERM_ORE};
use stack_encrypt_guest::options::{scope_for, KeysetSelector};
use stack_encrypt_guest::status::{
STATUS_AUTH, STATUS_CONTEXT_MISMATCH, STATUS_ENCODING, STATUS_FOREIGN_KEYSET, STATUS_TERM,
};
Expand Down Expand Up @@ -430,7 +430,7 @@ fn a_record_batch_encrypts_in_one_call_and_round_trips() {
assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 1);

let pt = block_on(ops::decrypt_record(
Scope::Client(&cipher),
&cipher,
&record,
&protect(&plan()),
None,
Expand Down Expand Up @@ -487,7 +487,7 @@ fn a_forged_passthrough_ciphertext_slot_is_rejected_not_decrypted() {

assert_eq!(
block_on(ops::decrypt_record(
Scope::Client(&cipher),
&cipher,
&forged,
&protect(&plan()),
None
Expand Down Expand Up @@ -707,7 +707,7 @@ fn a_natively_sealed_field_under_an_extended_context_opens_through_a_plan() {
let plan_with = |context: Tree| single_field_plan("age", context);

let opened = block_on(ops::decrypt_record(
Scope::Client(&cipher),
&cipher,
&record,
&protect(&plan_with(extended("age"))),
None,
Expand All @@ -720,7 +720,7 @@ fn a_natively_sealed_field_under_an_extended_context_opens_through_a_plan() {

assert_eq!(
block_on(ops::decrypt_record(
Scope::Client(&cipher),
&cipher,
&record,
&protect(&plan_with(label("age"))),
None
Expand Down Expand Up @@ -778,7 +778,7 @@ fn a_structured_plan_context_is_validated_at_parse() {
))
.expect("a label extended by an empty part is never empty");
assert!(block_on(ops::decrypt_record(
Scope::Client(&cipher),
&cipher,
&sealed,
&protect(&plan_with(Tree::Array(vec![label("f"), s("")]))),
None
Expand Down Expand Up @@ -903,7 +903,7 @@ fn an_empty_plan_context_is_refused_before_anything_is_sealed() {
);
assert_eq!(
block_on(ops::decrypt_record(
Scope::Client(&cipher),
&cipher,
&source,
&protect(&bad_plan),
None
Expand All @@ -928,7 +928,7 @@ fn an_empty_plan_context_is_refused_before_anything_is_sealed() {
))
.expect("encrypt");
let opened = block_on(ops::decrypt_record(
Scope::Client(&cipher),
&cipher,
&sealed,
&protect(&odd_plan),
None,
Expand Down Expand Up @@ -987,7 +987,7 @@ fn a_mixed_keyset_record_batch_opens_through_any_one_call_per_keyset() {

let before = cipher.kms().retrieve_calls.load(Ordering::SeqCst);
let pt = block_on(ops::decrypt_record(
Scope::Client(&cipher),
&cipher,
&batch,
&protect(&plan()),
None,
Expand All @@ -1005,17 +1005,34 @@ fn a_mixed_keyset_record_batch_opens_through_any_one_call_per_keyset() {

let before = cipher.kms().retrieve_calls.load(Ordering::SeqCst);
assert_eq!(
block_on(ops::decrypt_record(
Scope::Keyset(acme),
&batch,
&protect(&plan()),
None
)),
block_on(ops::decrypt_record(acme, &batch, &protect(&plan()), None)),
Err(STATUS_FOREIGN_KEYSET)
);
assert_eq!(cipher.kms().retrieve_calls.load(Ordering::SeqCst), before);
}

/// Each opening selector names the reference a decrypt runs through:
/// `{"any"}` the client, confined to no keyset, and every other selector
/// the keyset it resolves to.
#[test]
fn each_opening_selector_names_its_keyset() {
let cipher = cipher();
let acme = keyset_named(&cipher, "acme").keyset_id();
let keyset_of =
|selector: KeysetSelector| block_on(scope_for(&cipher, &selector)).map(|r| r.keyset_id());

assert_eq!(keyset_of(KeysetSelector::Any), Ok(None));
assert_eq!(
keyset_of(KeysetSelector::Default),
Ok(Some(cipher.default_keyset().keyset_id()))
);
let name = zerokms_protocol::Name::try_from("acme")
.ok()
.expect("a valid name");
assert_eq!(keyset_of(KeysetSelector::Name(name)), Ok(Some(acme)));
assert_eq!(keyset_of(KeysetSelector::Id(acme)), Ok(Some(acme)));
}

/// Terms derive under the selected keyset's index key: the same probe
/// under two keysets differs, and matches the native derivation for each.
#[test]
Expand Down Expand Up @@ -1283,12 +1300,7 @@ fn record_tree_validation_refuses_what_decrypt_record_refuses() {
"{label} must be refused by validation"
);
assert_eq!(
block_on(ops::decrypt_record(
Scope::Client(&cipher),
&tree,
&protect(&plan),
None
)),
block_on(ops::decrypt_record(&cipher, &tree, &protect(&plan), None)),
Err(STATUS_ENCODING),
"{label} must be refused by the op too"
);
Expand Down Expand Up @@ -2215,13 +2227,8 @@ fn a_context_field_record_opens_under_the_context_it_stores() {
};
assert_ne!(eq_of(&rows[0]), eq_of(&rows[1]));

let opened = block_on(ops::decrypt_record(
Scope::Client(&cipher),
&record,
&protect(&plan),
None,
))
.expect("each record opens under the context it stores");
let opened = block_on(ops::decrypt_record(&cipher, &record, &protect(&plan), None))
.expect("each record opens under the context it stores");
let Tree::Array(opened) = decode(&opened) else {
panic!("an array of rows");
};
Expand All @@ -2239,7 +2246,7 @@ fn a_context_field_record_opens_under_the_context_it_stores() {
let before = cipher.kms().retrieve_calls.load(Ordering::SeqCst);
assert_eq!(
block_on(ops::decrypt_record(
Scope::Client(&cipher),
&cipher,
&record,
&protect(&plan),
Some(acme.clone()),
Expand Down Expand Up @@ -2275,7 +2282,7 @@ fn a_context_field_record_opens_under_the_context_it_stores() {
Ok(())
);
let opened = block_on(ops::decrypt_record(
Scope::Client(&cipher),
&cipher,
&record,
&protect(&plan),
Some(acme),
Expand Down
Loading
Loading