Repository navigation
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
Description
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
-
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_termandse_keysetdynamic::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.rsIn cipher configuration, including the client key guest config.rsIn the deterministic test seed guest abi.rsOut query term result QueryTerm::to_value(dynamic/query.rs:344)Out verifyresults, the target limitguest 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
Protectedfield. - 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.
- 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
-
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 withValueFrame::of(&FfiValue::Int32(-7))and read opened values back withFfiValue::from_frame. That happens incrates/eql-bindings/src/encryption/targets.rs(unit tests) andtests/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. -
eql-bindings turns on
dynamicfor every user of its encryption. Itsstack-encryptfeature depends onstack-encryptwithfeatures = ["dynamic"]. Only the by-name dispatch that a guest needs (encryption/targets.rs,v4/targets.rs) usesdynamic. The typed v4 types (IntegerEq,TextEq, …) do not. A Rust program that encrypts anIntegerEqthroughencrypt_asstill compiles the whole dynamic layer. Pure Rust consumers should never need it.
Proposal
- 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 againstValueFrame::of(expected)byte for byte. - Split eql-bindings' feature.
stack-encryptgives the typed EQL v4 types only, with stack-encrypt's default features off and nodynamic. A new feature (for exampletargets) adds the by-name dispatch and turns onstack-encrypt/dynamic. The Go guest andtests/encryptionenable it. eql-bindings is published to crates.io, so note the change in its CHANGELOG. - Give each control message its own type, read straight from the frame. Follow the vitaminc ADR 0001 pattern: each message type is a
Decryptsink, or reads aFramewalk, so no tree is built. Each output is anEncryptsource written byFrameWriter. 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 theReasonit reports today. The configuration holds the client key inProtected. - Remove
FfiValuefromstack_encrypt::dynamiconce nothing uses it, includingFfiValue::to_frame/from_frameand the guest'sdecode_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
- stack-encrypt: the Go guest still copies record plaintext into an
FfiValuetree — walk frames into vitaminc sinks instead #1232: the plaintext half of this, handled by feat(stack-encrypt)!: hold a dynamic plaintext as its frame, not a value tree #1245 (records, query inputs and opened values are frames). - stack-encrypt: delete
dynamic::ValueandScalar, derive terms through vitaminc, add block ORE, dropBoolorder terms #1140: removed the olderdynamic::ValueandScalartypes. - Go guests return only a status number on failure — add se_last_error carrying the full error as an FfiValue, with a leak test #1100:
se_last_error. Check whether its error payload is one more control message to type.
Activity
coderdan commentedon Oct 18, 2026
Related: #1254, the language integration guide, documents control messages versus plaintext.
coderdan commentedon Oct 18, 2026
One more item for this issue: remove eql-bindings'
Plaintexttrait (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
Stringand 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_targetcallsS::from_frameto copy the incomingValueFrameinto a typed Rust value. It checks the frame's kind, then walks the frame into the type's decrypt sink. Then it calls the typedkeyset.encrypt_as::<S, T>.open_targetcallsdecrypt_as::<S, T>and turns the result back into a frame withS::into_frame.v4/targets.rsnames one plaintext per type ("BigintEq" => run_target::<BigintEq, i64, K>(…)).(On the #1241 branch, which comes before #1245, the same trait reads and writes
FfiValuethroughfrom_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 withValueFrame::check, and opening a value should return a frame. Then no per-type Rust plaintext is needed on this path, and the trait, itssealedmodule and theTargetError::Plaintextkind 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::Int32and so on) are replaced by the Rust primitives, and the trait stays for now, implemented on those primitives.test(eql-bindings): pass the refused-kind frames as they are
docs(eql-bindings): describe the typed leaf without naming the dynami…
feat(eql-bindings): put the by-name dispatch behind a targets feature…
ci(eql-bindings): lint and wasm-check the stack-encrypt set on its own
docs(eql-bindings): say the by-name dispatch needs the targets feature
refactor(golang): read the call options and keyset selector with serd…
refactor(stack-encrypt): read query statistics from a frame with serd…
chore: DO NOT MERGE, take vitaminc from the serde-over-frames branch
refactor(golang): read the call options and keyset selector with serd…
refactor(stack-encrypt): read query statistics from a frame with serd…
refactor(golang): read the cipher config and the test seed with serde…
23 remaining items
refactor(stack-encrypt): write the engine's answers with serde, not F…
test(stack-encrypt): spell test values as a test tree or as Rust valu…
test(golang): spell the guest's test values as a test tree, not FfiValue
test(stack-encrypt): build the check_record fuzz target's plans as fr…
refactor(stack-encrypt)!: remove FfiValue
test(stack-encrypt): spell test values as a test tree or as Rust valu…
test(golang): spell the guest's test values as a test tree, not FfiValue
test(stack-encrypt): build the check_record fuzz target's plans as fr…
refactor(stack-encrypt)!: remove FfiValue
refactor(stack-encrypt): read a record plan from its frame with serde…
refactor(stack-encrypt): write the engine's answers with serde, not F…
test(stack-encrypt): spell test values as a test tree or as Rust valu…
test(golang): spell the guest's test values as a test tree, not FfiValue
test(stack-encrypt): build the check_record fuzz target's plans as fr…
refactor(stack-encrypt)!: remove FfiValue