Skip to content

Go SDK: stashgen refuses a pointer field such as *encrypt.Date, so an optional value cannot be encrypted #1298

Open

Description

@coderdan

Background

The Go SDK (languages/golang) encrypts fields of a Go struct. A developer tags each field with a stash:"…" struct tag, and stashgen, a code generator, writes the code that encrypts and decrypts the struct. A field tagged encrypt is stored as one ciphertext column. A field tagged encrypt_into=<Type> is stored as an EQL type. EQL (Encrypt Query Language) is the SQL library we install into the customer's PostgreSQL database. Each EQL type is a PostgreSQL domain (public.eql_v4_date, for example) that holds an encrypted payload as JSON. Some EQL types carry index terms, so the database can compare or order values without decrypting them. "Storage-only" types such as Date carry no index terms. They only store the ciphertext.

Encryption itself is done by Stack Encrypt, a Rust engine. Go runs it as a WebAssembly guest. The engine seals each value as a "leaf": a type tag plus the value's bytes, encrypted with AES-GCM.

Problem

There is no way to encrypt an optional value. A struct field of type *encrypt.Date (or any *T where T is a scalar) is refused by stashgen:

a *encrypt.Date seals only as part of an opaque struct

#1293 reproduces this with a test. The two inputs are identical except for the field type, and both are tagged stash:"dob,encrypt_into=Date". DOB encrypt.Date generates. DOB *encrypt.Date is refused.

How it happens:

  1. classify (languages/golang/stashgen/read.go) gives a pointer field KindPointer.
  2. buildFields refuses any sealed field whose kind is not a scalar (read.go:697), and KindPointer is not a scalar.
  3. Nothing further down could take a nil either. gensupport.Codec.split requires every declared field to be in every row. The record plan sent to the engine has no way to mark a field optional. eql.*.Scan returns an error when it reads SQL NULL.

The limitation is documented (encrypt/values.go:26). The workarounds it suggests are to put the field in an opaque struct (the whole struct becomes one encrypted JSON document) or to store a sentinel date the program treats as "absent". Neither fits a common case: an optional date of birth on a person record. The opaque struct gives up the per-column EQL type. A sentinel date in the plaintext is a value the application has to remember never to show or compare.

Who hits it: anyone with a nullable column they want encrypted, which covers most optional personal data. Today they have to restructure their model or invent a sentinel.

Proposal

Allow *T for a sealed scalar field, and give the developer two ways to store nil:

  • Sealed null. nil is encrypted. The column holds a normal ciphertext (or EQL payload) whose sealed plaintext is a null marker. A reader of the database cannot tell an absent value from a present one. This works only for fields with no index terms. An order term (ORE) or a match filter has no meaningful value for "absent", and an equality term (HMAC) of a fixed null marker would make every absent row equal to every other, which reveals the same thing SQL NULL does.
  • SQL NULL. nil never reaches the engine. The column is NULL, with no ciphertext and no terms. Whether the value is present can be seen. This matches the TypeScript SDK, which passes null through as null (Runtime null guards stripped from stack encryption operations cause silent ciphertext for null input #494).

Steps:

  1. Accept *T in stashgen where T is one of today's sealable scalars, for encrypt, encrypt_into and encrypt,index=… fields. Keep refusing **T, pointers to slices, maps or structs, and a pointer context field or identity field. Each refusal names the field.
  2. Add a per-field tag that chooses how absence is stored, e.g. null=sealed or null=sql (name to be settled). Fixed-width and variable-width types are handled separately.
    • Fixed-width types (bool, integers, floats, encrypt.Date, time.Time, encrypt.Decimal, encrypt.Int128, encrypt.Uint128), with no index (storage-only EQL types and plain encrypt): the default is null=sealed, and null=sql opts out.
    • Fixed-width types with an index: null=sql for now. A typed null could later get its own order and equality terms (see aead: a sealed null is shorter than the value it replaces, so ciphertext length shows which optional values are absent — add a typed null padded to its kind's width vitaminc#493, item 5), which would let indexed fields use null=sealed too.
    • Variable-width types (strings, []byte, and types defined over them): null=sql only. A present value's length already shows. Hiding absence there needs a padding length chosen to match real values, and a match index (Bloom filter) for an absent value would be empty unless it were filled with noise. That is a separate, harder problem and out of scope here.
    • Open question: where null=sql is the only option, should the tag be required, so that choosing a visible NULL is deliberate, or should it be the default?
  3. Make a sealed null the same length as a present value. This is a hard requirement, not an optimisation, and it is blocked on aead: a sealed null is shorter than the value it replaces, so ciphertext length shows which optional values are absent — add a typed null padded to its kind's width vitaminc#493:
  4. Engine and wire changes.
    • Mark a plan field as optional.
    • Carry a null through the Go record model.
    • Have the guest seal a padded null for null=sealed, and skip the field for null=sql.
    • For EQL types, accept and produce a null leaf in the storage-only domains' c.
  5. Generated code.
    • null=sql fields get pointer outputs (*eql.Date). database/sql scans NULL into a nil pointer and leaves eql.Date.Scan's refusal of NULL in place for required fields.
    • null=sealed fields keep non-pointer outputs, since the column is never NULL.
    • Decrypt gives nil for either form.
    • A null leaf found in a non-pointer field is a decrypt error that names the field, not a zero value.
  6. Tests:
    • The test from test(stashgen): reproduce nullable DOB generation failure #1293. It should skip when the guest is not built, as TestRunAsksTheEmbeddedEngine does.
    • Refusal cases for the forms that stay unsupported.
    • An Encrypt and Decrypt round trip over one batch that mixes nil and present values, in both modes.
    • A test that a sealed null and a present value of the same type have equal ciphertext lengths.
    • A PostgreSQL test that writes and reads both forms.
  7. Docs. Update encrypt/values.go and encrypt/ciphertext.go, and say plainly that null=sql shows whether a value is present.

Interim step that could ship first: steps 1 and 5 with null=sql only. While sealed nulls are unavailable, a nullable field without the tag is refused, with a message telling the developer to write null=sql. This needs no vitaminc change. It never silently changes later: when sealed nulls land, they become the default only for fields that never had a tag, and those fields were refused until then.

Evidence

Verified:

  • The refusal is at languages/golang/stashgen/read.go:697, and KindPointer is not in Kind.Scalar() (stashgen/declaration.go:268).
  • split requires every field (encrypt/gensupport/codec.go, "the generated Source gave no field").
  • eql.scan errors on nil (encrypt/eql/eql.go:97). Value() already writes NULL for an empty value.
  • Leaf widths, from vitaminc-aead 0.6.1 src/tags.rs: NULL = 0x00, and a DATE = 0x12 leaf is an i32 of days. vitaminc-encrypt 0.6.1 verify_absent requires the plaintext to be exactly the null leaf (cipher_static.rs, verify_absent_requires_the_null_leaf).
  • Stack Encrypt already treats an absent value as a sealed null leaf for Option<T> (packages/stack-encrypt/src/cipher.rs, "An absent value (None) is a null leaf like any other scalar").

Not yet verified:

  • Whether the EQL v4 storage-only domain CHECKs accept a c whose sealed plaintext is a null leaf. Likely, because c is opaque to SQL.
  • Whether the guest's dynamic seal path refuses a null for a field that declares a kind. scalar_kind returns None for a null, which suggests it does.

Relationship to other work

Activity

  1. coderdan commented on Oct 20, 2026

    @coderdan
    ContributorAuthor

    Updated the proposal. Fixed-width and variable-width types are now handled separately. The sealed-null mode is limited to fixed-width types and is blocked on cipherstash/vitaminc#493, a typed null leaf padded to its kind's width, so that its ciphertext length matches a present value. Strings and bytes get null=sql only. Hiding absence for them is out of scope. The interim null=sql step is not blocked.

  2. coderdan commented on Oct 20, 2026

    @coderdan
    ContributorAuthor

    Decision: this issue waits for cipherstash/vitaminc#493 and ships as one piece, encrypted nulls included. We are not shipping the interim null=sql-only step first. Once a typed, padded null leaf is released in vitaminc, this work picks up with a dependency bump to that release.

  3. added
    StagedDone in the next branch; closes when next merges into main
    and removed
    StagedDone in the next branch; closes when next merges into main
    on Oct 20, 2026
  4. coderdan commented on Oct 21, 2026

    @coderdan
    ContributorAuthor

    Status: this is waiting on upstream work. The order it lands in:

    1. feat(aead)!: seal a fixed-width None as a typed null as long as a Some vitaminc#497 and feat(vcffi): write a nil fixed-width pointer as a typed null, and read typed nulls vitaminc#499. The typed null: an absent fixed-width value is sealed padded to the same length as a present one. feat(stack): protect-ffi 0.26.0 + auth 0.39 OidcFederationStrategy (stacked on #496) #497 is the Rust side, chore(deps): bump the production-minor-patch group across 1 directory with 23 updates #499 the Go codec (vcffi). Together they close aead: a sealed null is shorter than the value it replaces, so ciphertext length shows which optional values are absent — add a typed null padded to its kind's width vitaminc#493.
    2. feat!: vitaminc is the front door; the schemes take sealed plaintexts ore.rs#130 and chore(clww-ore): move cllw-ore into the workspace as clww-ore, with its history ore.rs#131, tracked in 0.10: vitaminc is the front door ore.rs#129. Both vitaminc PRs are built on vitaminc 0.7, and stack still uses 0.6.1. Stack can't move to 0.7 on its own, because the order-revealing encryption crates it depends on (ore-rs 0.9.0 and cllw-ore 0.5.2-rc.1) require vitaminc 0.6. These PRs move them to 0.7, as ore-rs 0.10, and rename cllw-ore to clww-ore.
    3. A stack PR moving to vitaminc 0.7+ and ore-rs 0.10. This is a dependency upgrade with no behaviour change, kept separate from this feature.
    4. This issue: first Stack Encrypt in Rust, then the Go SDK (stashgen, gensupport, the guest), with the reproduction test from test(stashgen): reproduce nullable DOB generation failure #1293 folded in.

    We considered backporting the typed null to vitaminc 0.6 so this could ship sooner, and decided to wait for the releases instead, so the fix ships on the dependency versions it will keep.

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