Skip to content

refactor(stack-encrypt)!: replace CipherScope and dynamic::Scope with one CipherRef - #1610

Open
coderdan wants to merge 3 commits into
nextfrom
refactor/stack-encrypt-remove-transitional-api
Open

coderdan wants to merge 3 commits into
nextfrom
refactor/stack-encrypt-remove-transitional-api

Conversation

@coderdan

@coderdan coderdan commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Stack Encrypt now has one type for "which cipher does this call run through": CipherRef. It replaces two types that did the same job, one in the typed API and one in the dynamic interface.

This is one part of #1488, not all of it. #1488 removes the old API in several pull requests. #1597 was the first. This is the second. The plan chain, the plan builder, EncryptFrom, DecryptInto, encrypt_as, target::legacy and Label are still there, and #1488 stays open.

Stack Encrypt (packages/stack-encrypt) is the Rust encryption engine under every CipherStash SDK. A program holds a StackCipher, which is the connection to ZeroKMS (our key service), and a KeysetCipher for each keyset it uses. A keyset is a group of keys, usually one per tenant. A call that decrypts runs through one or the other:

  • through the StackCipher, it opens a value from any keyset the client can use;
  • through a KeysetCipher, it opens only that keyset's values, and refuses the rest before any key is fetched.

The typed API expressed that choice as a sealed trait, target::CipherScope. The dynamic interface, which the Go SDK uses, expressed it as an enum, dynamic::Scope. Both came down to a connection and an optional keyset ID.

This PR is stacked on #1609. The base branch is the branch of #1609, so the diff shows only this change.

Changes

Stack Encrypt

  • CipherRef<'c, K> holds a borrowed connection and an optional keyset ID. It is built by From<&StackCipher>, From<&KeysetCipher> and From<KeysetCipher>, and in no other way: its fields are private. So the keyset it names is one the cipher loaded from ZeroKMS.
  • target::CipherScope is removed. Every entry point that took impl CipherScope takes impl Into<CipherRef>: DecryptFrom::decrypt_from and decrypt_from_with_context, Pending::ready, failed, request and all, and the legacy decrypt_into. A call that passes &cipher or &keyset compiles unchanged.
  • dynamic::Scope is removed. dynamic::record::decrypt and decrypt_with take impl Into<CipherRef>. A caller writes &cipher in place of Scope::Client(&cipher), and &keyset in place of Scope::Keyset(keyset).
  • CipherRef::keyset_id() returns the keyset the reference is confined to. CipherRef is re-exported from the crate root.
  • Two things a caller can now do that they could not before. The typed entry points accept an owned KeysetCipher. A pending built through a temporary keyset handle, such as i32::decrypt_from(ct, &cipher.default_keyset()), can be stored and awaited later.
  • The rules do not change. No stored byte changes.

Go guest

  • scope_for in languages/golang/encrypt/guest/src/options.rs returns a CipherRef built from the keyset selector. decrypt_record takes impl Into<CipherRef>.

Tests

  • A unit test in src/target/pending.rs checks the three ways to build a CipherRef, and that a pending keeps its keyset.
  • tests/scalar.rs checks that an owned KeysetCipher refuses a foreign leaf before any key is retrieved and opens its own, and that a pending outlives a temporary keyset handle.
  • each_opening_selector_names_its_keyset in the Go guest's tests/native_ops.rs checks scope_for for each selector.
  • tests/ui/cipher_ref_by_value.rs pins the compiler's "consider borrowing here" hint for a StackCipher passed by value.
  • tests/ui/foreign_cipher_ref.rs replaces foreign_cipher_scope.rs. The old case showed that an outside crate could not implement the sealed trait. The new case shows that an outside crate cannot build a CipherRef with a keyset ID of its choice.
  • Every test and conformance runner that wrote Scope::Client(..) or Scope::Keyset(..) passes the cipher or keyset directly. That is 13 files in packages/stack-encrypt/tests, two EQL encryption tests, and two Go guest tests. No expectation changed.

Docs

  • The stack-encrypt CHANGELOG has an entry under Breaking.
  • CONTEXT.md updates the term Scope.
  • The API plan's tracking lines name this pull request.

Verification

I ran these locally, on Rust 1.94.1:

Check Result
cargo nextest run -p stack-encrypt -p stack-encrypt-derive --all-features 1386 passed, 1 skipped, with the review fixes
cargo test -p stack-encrypt --all-features --doc 74 passed
cargo clippy -p stack-encrypt --all-features --all-targets -- -D warnings clean
cargo doc -p stack-encrypt --no-deps, warnings as errors, with all features and with none clean
cargo test -p eql-bindings -p eql-encryption-tests --all-features, and clippy on both pass. The tests that need Postgres are ignored.
Go guest: cargo test with and without --features eql, and clippy for both pass
Go guest: cargo check --features eql --target wasm32-wasip1 compiles

I did not run the Go module's tests, the Postgres suites, the live tests, or mutation testing locally. CI runs them.

Related

Part of #1488. It does not close it. Stacked on #1609.

Review notes

  • Where to start. Read CipherRef in packages/stack-encrypt/src/target/pending.rs. Then read decrypt_with in src/dynamic/record.rs. Most of the rest of the diff removes a wrapper at a call site.
  • A longer lifetime from a keyset. From<&KeysetCipher<'c, K>> gives a CipherRef<'c, K>: the lifetime of the connection, not of the borrow of the keyset handle. The old trait gave the shorter one. The longer one is sound, because a CipherRef copies the keyset ID and keeps only the connection. It is what lets the Go guest return a CipherRef from a keyset it resolved inside a function.
  • TargetResolver::decrypt still takes &StackCipher. The engine confines the result to the keyset afterwards, as before. A later pull request for stack-encrypt refactor: remove transitional traits and redundant execution APIs #1488 changes that trait's signature when Label is removed, so this one leaves it.
  • Why this slice and not the removals. The removals touch about 460 call sites of the plan chain in the tests alone. They need their own pull requests, as the API plan says.

@coderabbitai

coderabbitai Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI; Enforced overrides: Organization
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 8eba81d7-874a-440f-98d8-27c24fd10550

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review

@changeset-bot

changeset-bot Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 9916276

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions

github-actions Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Mutation testing (cargo-mutants, --in-diff, stack-auth + stack-encrypt)

caught missed unviable timeout
3 0 21 0

Every mutant in the changed lines was caught by a test.

@coderdan coderdan left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of this PR's own diff (one commit, c338c6212). No blockers. Four should-fix findings (two doc accuracy, two test gaps) and six nits. Details are in the inline comments; the numbers below match them.

Soundness and security: no finding

I checked whether CipherRef keeps the guarantee the sealed trait gave: that the keyset ID in a scope is one the cipher loaded from ZeroKMS.

  • The fields are private. The new compile-fail case pins that (struct literal, E0451). Struct update syntax hits the same error.
  • There is no Default, no serde, and no public constructor other than the three From impls. Copy and Clone copy a valid value whole. They cannot change the keyset.
  • keyset_id() is read-only. cipher() is pub(crate).
  • The longer lifetime from &KeysetCipher<'c, K> is sound. KeysetCipher is &'c StackCipher plus Arc<KeysetState>. A Pending holds &'a StackCipher, an Option<Uuid>, requests and boxed closures. Nothing built through a CipherRef can borrow the handle, because a CipherRef does not carry it. scoped_to takes a Uuid. The json_*_key_now path takes a KeysetCipher directly and does not go through CipherRef.
  • From<KeysetCipher> by value drops the Arc<KeysetState>, and nothing needs it. The old decrypt_with already read only keyset.cipher() and keyset.keyset_id() from Scope::Keyset.
  • Verified by running code: an owned foreign KeysetCipher passed to i32::decrypt_from is refused with ForeignKeyset, with 0 retrieve calls.

Behaviour preservation: no finding

decrypt_with resolves to the same (cipher, Option<keyset>) pair as before. open_in, bind_pending, collect_releasing, decrypt_through and the Pending constructors read the same two values that the trait methods returned. Every function that took impl CipherScope already accepted both &StackCipher and &KeysetCipher, so no keyset-only entry point now accepts the client. The only widening is that an owned KeysetCipher is now accepted by the typed API, and that is safe (above). Each function converts its argument once, but some convert and then forward to another impl Into function (finding 7).

Removal of dynamic::Scope and CipherScope: no finding

git grep finds no remaining use in Rust, Go, the fuzz crates or the benches. The Go test TestReadTreePreservesCipherScope is about the record context, not this type. No #NNNN was added to a code comment.

Findings

  1. should fix: the rustdoc on CipherRef says every entry point that runs through a cipher takes it. That is not true.
  2. should fix: the CHANGELOG entry leaves out functions that changed, and the new things a caller can now do.
  3. should fix: no test passes an owned KeysetCipher to the typed API. No test pins the longer lifetime.
  4. should fix: the guest's scope_for has no test for each selector.
  5. nit: keyset() duplicates keyset_id().
  6. nit: Debug prints keyset, but the accessor and KeysetCipher's Debug say keyset_id.
  7. nit: some functions convert, then forward to another impl Into function.
  8. nit: a compile-fail case for a wrong argument type is worth adding. The message is good.
  9. nit: a tracking line in the plan is stale.
  10. nit: the CONTEXT.md entry has one long unwrapped line, and leaves out the owned KeysetCipher.

Verified by running code: the scratch tests for 3 and the compiler output for 8. Everything else here is from reading the code.

Comment thread packages/stack-encrypt/CHANGELOG.md Resolved
Comment thread packages/stack-encrypt/CONTEXT.md Resolved
@coderdan
coderdan force-pushed the feat/eql-bindings-shared-resolver branch from 72e8860 to 2f03c31 Compare November 10, 2026 07:46
@coderdan
coderdan force-pushed the refactor/stack-encrypt-remove-transitional-api branch from 63424a2 to 4b30382 Compare November 10, 2026 07:46
@coderdan

Copy link
Copy Markdown
Contributor Author

Rebased onto the reviewed head of the pull request below it in the stack. The commits are the same changes with new hashes. Nothing else changed.

@coderdan
coderdan marked this pull request as ready for review November 10, 2026 21:54
@coderdan
coderdan requested a review from a team as a code owner November 10, 2026 21:54
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-10T21:56:37.985689Z 4b30382 Draft marked ready
🔒 Security Review ✅ Completed 2026-11-10T21:58:36.864657Z 4b30382 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@cipherstash-agent cipherstash-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review attestation

Reviewed 4b303829 · posted from Claude Code · claude-opus-5-5 · effort medium
Sources: code-review · claude-opus-5-5
Skills: code-review, review-cross-check, review-post

Findings (1): 1 minor, 1 inline.

The review raised five other findings. All five were refuted at head, so none is posted:

  • Confinement in record::decrypt_with holds structurally, because collect_releasing runs check_scope over every merged item.
  • The three copies of the confine rule and the hand-split in json_query::open were already there at base and behave the same.
  • The Go guest instantiates decrypt_with only once.
  • The redundant conversions were fixed in this PR's second commit.

Generated by Claude Code

impl<K> Sealed for &crate::KeysetCipher<'_, K> {}
impl<'c, K> CipherRef<'c, K> {
/// The connection the pending settles through.
pub(crate) fn cipher(&self) -> &'c StackCipher<K> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor Base exposed the connection publicly, through CipherScope::cipher() and the public Scope::Client/Scope::Keyset variants. Here cipher() is pub(crate), so an outside function migrated as the CHANGELOG says (impl CipherScope<'a, K> → impl Into<CipherRef<'a, K>>) that calls scope.cipher() no longer compiles, and the CHANGELOG gives no replacement. Make cipher() pub, which leaves the keyset invariant intact because the fields stay private and the three From impls are still the only constructors, or say in the CHANGELOG that the connection is withheld.

Source: code-review · claude-opus-5-5


Generated by Claude Code

@coderdan
coderdan force-pushed the feat/eql-bindings-shared-resolver branch from 2f03c31 to 4cd9647 Compare November 11, 2026 07:04
@coderdan
coderdan force-pushed the refactor/stack-encrypt-remove-transitional-api branch 2 times, most recently from b24c0c7 to 18cff59 Compare November 11, 2026 07:28
@coderdan
coderdan force-pushed the feat/eql-bindings-shared-resolver branch from 914417d to edc4107 Compare November 11, 2026 07:48
@coderdan
coderdan force-pushed the refactor/stack-encrypt-remove-transitional-api branch from 18cff59 to 32519a5 Compare November 11, 2026 07:48
Base automatically changed from feat/eql-bindings-shared-resolver to next November 11, 2026 10:15
… one CipherRef

The typed API took a cipher through the sealed trait CipherScope. The
dynamic interface took one through the enum dynamic::Scope. Both reduce
to a connection and an optional keyset ID. The naming review replaced
both with one borrowed value.

CipherRef holds the connection and the keyset. It is built from a
reference to a StackCipher or a KeysetCipher, or from an owned
KeysetCipher, and its fields are private, so the keyset it names is one
the cipher loaded. Every entry point that took a scope takes
impl Into<CipherRef>, so a call that passes &cipher or &keyset is
unchanged. The Go guest builds one from its keyset selector.

The rules do not change: a client reference opens a leaf from any
keyset, and a keyset reference refuses a foreign leaf before any key is
retrieved.

Part of #1488
- The rustdoc on CipherRef named every entry point that runs through a
  cipher. It now names the ones that take it: the decrypt entry points
  and the Pending constructors. It says that an encrypt entry point takes
  a KeysetCipher, and that a CipherRef lives as long as the connection.
- The CHANGELOG entry lists every changed function. It says that an owned
  KeysetCipher is now accepted, that a pending through a temporary keyset
  handle now compiles, and what to write in place of a CipherScope bound.
- CipherRef has one accessor for the keyset, keyset_id(). Its Debug
  output names the field keyset_id, as KeysetCipher's does.
- Functions that only forward the argument no longer convert it first.
  The private collect_releasing and decrypt_through take a CipherRef.
- New tests: an owned KeysetCipher refuses a foreign leaf before any key
  is retrieved, and opens its own; a pending outlives a temporary keyset
  handle; the guest's scope_for gives the right keyset for each selector;
  and a compile-fail case pins the "consider borrowing" hint for a
  StackCipher passed by value.
- CONTEXT.md is rewrapped and names the owned KeysetCipher. The plan's
  tracking line says that #1120 is closed and #1504 is merged.
The CipherRef docs now have a section for each question a caller has:
how to pass one, what each kind of reference can do, and how long one
is valid. The docs say that a reference limits an operation to a
keyset, where they said confined.
@coderdan
coderdan force-pushed the refactor/stack-encrypt-remove-transitional-api branch from 32519a5 to 9916276 Compare November 11, 2026 10:29

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants