Skip to content

stack-encrypt: dynamic control messages (plan, options, query results) are parsed from an untyped FfiValue tree — give each its own type, and stop eql-bindings forcing dynamic #1252

Open

Description

@coderdan

Background

Stack Encrypt (packages/stack-encrypt) is CipherStash's Rust encryption engine. Rust programs use it through typed structs and derives. Other languages use it through stack_encrypt::dynamic, a module behind the off-by-default dynamic feature. That module describes values whose types are known only at run time. Today its one production user is the Go SDK's WebAssembly guest (languages/golang/encrypt/guest).

Everything crosses between the Go host and the guest as a vitaminc frame: a self-describing byte encoding of one value. Since #1245, plaintext crosses as a frame and stays one. The engine walks it in place (Frame, ValueFrame) and never decodes it into a tree.

Problem

  1. The control messages still use a general-purpose value tree. Everything that is not plaintext is decoded into FfiValue, an untyped tree of nulls, numbers, strings, bytes, arrays and objects, and then picked apart by hand. Each message has a fixed shape, so a general tree buys nothing. Verified on the feat(stack-encrypt,golang): give every Go integer width, dates, timestamps, decimals and 128-bit integers their own kinds #1246 branch:

    Direction Message Where
    In record plan dynamic::record::plan / plan_with (dynamic/record.rs:926)
    In a field's encryption context, for se_term and se_keyset dynamic::context (dynamic/context.rs:97)
    In an index in a plan's outputs, and an output entry IndexSpec::from_value (dynamic/term.rs:99), Output::from_value (dynamic/record.rs:201)
    In query statistics, for the target limit Stats::from_value (dynamic/query.rs:964)
    In call options (keyset, expected context) guest options.rs
    In cipher configuration, including the client key guest config.rs
    In the deterministic test seed guest abi.rs
    Out query term result QueryTerm::to_value (dynamic/query.rs:344)
    Out verify results, the target limit guest ops.rs, limit_to_value (dynamic/query.rs:1027)
    Out EQL type and operator catalogues TargetDescriptor::to_value (dynamic/target.rs:150), operator_to_value (dynamic/query.rs:518)

    The cost:

    • Every message is checked by hand. Each parser walks the tree, matches variants and refuses unknown keys, repeated keys and wrong types on its own. That is most of record.rs's plan parser.
    • The tree can hold secrets. The cipher configuration carries the client key, so FfiValue has to wipe its text and byte leaves. A typed message would hold the key in one Protected field.
    • It reads like a plaintext type. FfiValue looks like a general value, so it gets used as one (see the next item). Since feat(stack-encrypt)!: hold a dynamic plaintext as its frame, not a value tree #1245, plaintext must never be one.
  2. eql-bindings' tests use FfiValue as a plaintext constructor. eql-bindings' own API takes ValueFrame, and nothing outside its tests uses FfiValue. Its tests build frames with ValueFrame::of(&FfiValue::Int32(-7)) and read opened values back with FfiValue::from_frame. That happens in crates/eql-bindings/src/encryption/targets.rs (unit tests) and tests/encryption/tests/{targets,v4_types,heap_hygiene}.rs (around 80 uses). This is the role feat(stack-encrypt)!: hold a dynamic plaintext as its frame, not a value tree #1245 says FfiValue no longer has, and it leads readers to think eql-bindings depends on it.

  3. eql-bindings turns on dynamic for every user of its encryption. Its stack-encrypt feature depends on stack-encrypt with features = ["dynamic"]. Only the by-name dispatch that a guest needs (encryption/targets.rs, v4/targets.rs) uses dynamic. The typed v4 types (IntegerEq, TextEq, …) do not. A Rust program that encrypts an IntegerEq through encrypt_as still compiles the whole dynamic layer. Pure Rust consumers should never need it.

Proposal

  1. Cheap first step: stop using FfiValue in eql-bindings' tests. Build frames from Rust values directly (ValueFrame::of(-7i32), ValueFrame::of("café")). Compare an opened frame against ValueFrame::of(expected) byte for byte.
  2. Split eql-bindings' feature. stack-encrypt gives the typed EQL v4 types only, with stack-encrypt's default features off and no dynamic. A new feature (for example targets) adds the by-name dispatch and turns on stack-encrypt/dynamic. The Go guest and tests/encryption enable it. eql-bindings is published to crates.io, so note the change in its CHANGELOG.
  3. Give each control message its own type, read straight from the frame. Follow the vitaminc ADR 0001 pattern: each message type is a Decrypt sink, or reads a Frame walk, so no tree is built. Each output is an Encrypt source written by FrameWriter. Start with the small messages (options, stats, query result, catalogues, seed). Do the plan last, since it has the largest grammar. Each type refuses an unknown key, a repeated key or a wrong type with the Reason it reports today. The configuration holds the client key in Protected.
  4. Remove FfiValue from stack_encrypt::dynamic once nothing uses it, including FfiValue::to_frame/from_frame and the guest's decode_value/encode_value/value_encoded_len. Pin each message's bytes with a fixture that the Go host's tests read too.

Relationship to other work

Activity

  1. coderdan commented on Oct 18, 2026

    @coderdan
    ContributorAuthor

    Related: #1254, the language integration guide, documents control messages versus plaintext.

  2. coderdan commented on Oct 18, 2026

    @coderdan
    ContributorAuthor

    One more item for this issue: remove eql-bindings' Plaintext trait (crates/eql-bindings/src/encryption/targets.rs). This came up in review on #1241.

    What it is. A sealed trait that eql-bindings defines for itself. It is implemented for String and the scalar plaintexts. It exists only for the by-name dispatch that the Go guest uses, where the caller passes a type name such as "BigintEq" rather than a Rust type:

    • run_target calls S::from_frame to copy the incoming ValueFrame into a typed Rust value. It checks the frame's kind, then walks the frame into the type's decrypt sink. Then it calls the typed keyset.encrypt_as::<S, T>.
    • open_target calls decrypt_as::<S, T> and turns the result back into a frame with S::into_frame.
    • The generated dispatch in v4/targets.rs names one plaintext per type ("BigintEq" => run_target::<BigintEq, i64, K>(…)).

    (On the #1241 branch, which comes before #1245, the same trait reads and writes FfiValue through from_value/into_value.)

    Why remove it. It turns a frame into a Rust value only so that the typed API can turn it straight back into a frame-shaped plaintext. Along the way it makes an extra copy of the plaintext, which then has to be wiped (Zeroizing). The by-name path should give the frame to the type's plan directly. The plan checks the leaf's kind, as a data plan field does with ValueFrame::check, and opening a value should return a frame. Then no per-type Rust plaintext is needed on this path, and the trait, its sealed module and the TargetError::Plaintext kind check go away.

    Scope. This goes with proposal items 2 and 3: it changes the same by-name dispatch that would move behind the new feature. #1241 does not do it. There, the plaintext newtypes (encryption::plaintext::Int32 and so on) are replaced by the Rust primitives, and the trait stays for now, implemented on those primitives.

  3. 23 remaining items

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions