Skip to content

stack-encrypt: a plan field with an index but no "type" trusts the binding to tag every value the same way — require "type" once the Go binding declares it #1082

Description

@coderdan

Updated 2026-10-05 (PDT). The Go SDK design is PR cipherstash/stack#1070: docs/plans/2026-10-04-plan-builder.md, "The Go SDK", and the principles in docs/sdk-design-principles.md (ADR-0008). The generator fills "type" from the field's Go type, so every value a Go program sends is tagged by the generated declaration. The blocker is the generator, CIP-4163, not the chain.

Background

stack-encrypt is our Rust library that encrypts each value under its own data key from ZeroKMS (our key service), and can also derive search terms beside the ciphertext: an equality term, a full-text match term, and order terms (ORE, order-revealing encryption, and OPE, order-preserving encryption). Bindings for other languages (the Go binding today, through a WebAssembly guest) describe what to do with each field of a record in a plan: a small JSON-like object that says, per field, which encryption context to use and which outputs to produce ("c" for the ciphertext, "eq", "match", "ore", "ope" for terms).

cipherstash/stack#1069 adds an optional "type" key to each field of a plan, for example "type": "uint64". The names are vitaminc's ValueKind names (vitaminc is the cryptography library stack-encrypt is built on). When a field declares a type, the engine refuses an index that the type is not defined for (match on an integer, equality on a float), refuses to seal a value of any other type, and refuses a stored value that opens as another type.

Problem

"type" is optional, so that the plans the Go binding sends today, which carry no "type", stay valid. A field with an index output and no "type" is still handled the old way: the engine looks at each value's own type tag, as the binding set it, and derives the term for that type.

Search terms depend on the type. The ORE term of the integer 34 and the ORE term of the float 34.0 are different bytes. So for an untyped indexed field the engine trusts that the binding tags every value the same way, every time.

A concrete failure: a JavaScript host sends 34 as a Float64 on one write. Later, after a BigInt or an integer-typed ORM column arrives, it sends 34 as an Int64 under the same "ore" field. Both writes are accepted. They store different ORE terms. A range query then finds only the rows written one way, and no error says why. Rows silently go missing from query results.

Nothing catches this today. The plan parser accepts an indexed field without "type", and each value is internally consistent, so every check passes. The record::plan rustdoc says this is transitional, but nothing enforces the end of it.

Proposal

  1. Once the Go binding fills "type" on every field from the Go struct's field types (#1046), make "type" required on every field that has a term output ("eq", "match", "ore" or "ope"). Building a plan with an indexed field that has no "type" fails with Error::Plan, so the mistake shows up when the plan is built, not as missing rows later.
  2. Keep "type" optional on a field that has only a ciphertext ("c"). Sealing and opening do not depend on the type, so there is nothing to get wrong there.
  3. Update the plan grammar docs in packages/stack-encrypt/src/dynamic/record.rs (plan) and src/dynamic/mod.rs, and remove the "transitional" note they carry today.
  4. Interim step, if useful before step 1 lands: a way for a binding author to see the gap, for example a method on Plan that lists indexed fields with no declared type.

Relationship to other work

  • cipherstash/stack#1069 adds the optional "type" key and the transitional note.
  • #1046 (Go plan builder) is what fills "type" from Go struct types. Step 1 waits on it.
  • #1057 tracks the query side: reading a query value as its field's type before deriving a term.

Activity

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

Metadata

Metadata

Assignees

Labels

SDKenhancementNew feature or requestrustPull requests that update Rust code

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions