Help us shape the future of Go! Take the 2026 Go Developer Survey

encrypt

package
v0.1.0-rc.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 2, 2026 License: MIT Imports: 35 Imported by: 7

README

Stack Encrypt for Go

Searchable, field-level encryption for Go structs under per-value ZeroKMS data keys. You declare what to encrypt with struct tags; stashgen writes the encrypted type and the functions that encrypt, decrypt and search it; the stack-encrypt Rust engine runs unmodified inside a WASI module embedded in this package, through wazero, with no cgo.

The package reference is on pkg.go.dev; the generator's reference is cmd/stashgen/README.md.

Use the SDK

  1. Add the generator to your module. This needs Go 1.26.10 or later (or 1.27.3 or later on 1.27): those are the first releases whose crypto/x509 blocks the compromised eMudhra intermediate CAs (CVE-2026-111943). The module's go line requires 1.26.10, so under the default GOTOOLCHAIN=auto an older local Go downloads and switches to 1.26.10. A minimum cannot exclude 1.27.0 to 1.27.2, which are also vulnerable: do not build with those.

    go get -tool github.com/cipherstash/stack/languages/golang/cmd/stashgen
    

    This resolves to the latest tagged version, which carries the engine builds the module embeds; see Building.

  2. Put a stash tag on every exported field of the struct, and a go:generate comment beside it.

    //go:generate go tool stashgen -type User
    type User struct {
    	_     struct{} `stash:"context=users"`
    	ID    int64    `stash:"id,passthrough"`
    	Email string   `stash:"email,encrypt_into=TextEq"`
    	Name  string   `stash:"name,encrypt_into=TextMatch"`
    	Age   int32    `stash:"age,encrypt_into=Integer"`
    }
    

    encrypt_into= names the EQL type the field is stored as. The Go field type must match it; see EQL columns.

  3. Run the generator. It writes user_stash.go beside the struct.

    go generate ./...
    
  4. Commit the generated file.

  5. Call the generated functions where you write and read.

    client, err := encrypt.NewClient(ctx)
    if err != nil {
    	return err
    }
    defer client.Close()
    cipher := client.Keyset(encrypt.KeysetName("tenant-42"))
    
    encrypted, err := users.Encrypt(ctx, cipher, people)
    opened, err := users.Decrypt(ctx, cipher, encrypted)
    term, err := users.Fields.Email.Eq(ctx, cipher, "bob@example.com")
    

    Encrypt and Decrypt send one ZeroKMS request for each 500 sealed values in the batch, plus one request the first time a keyset is used. A query derives its term locally and sends no request of its own; only the first use of a keyset, by a query or anything else, loads it from ZeroKMS. See Queries.

  6. Store the encrypted type. Each encrypt_into field is one EQL v4 column: eql.TextEq implements driver.Valuer and sql.Scanner, so a database library binds and scans it as the JSON a public.eql_v4_text_eq column holds. Install EQL v4 into PostgreSQL first, with the eql-v4-install.sql attached to the release. A query casts its parameter to the column's query type:

    SELECT id, email, name, age FROM users WHERE email = $1::eql_v4.query_text_eq
    

    A field tagged encrypt or index= stores its ciphertext and each term as separate byte columns instead; see What is stored.

  7. Run the generator again after each change to the struct or to a tag. A change to the fields of the struct stops the build until you do.

  8. In CI, run the generator and fail when a generated file changes.

    go generate ./... && git diff --exit-code && test -z "$(git status --porcelain)"
    

    git diff sees only files git already tracks; the git status check also fails on a generated file that was never committed.

The rest of this file is the reference. example/ is a program that stores separate byte columns (index=) against real ZeroKMS.

Connect

A Client is one ZeroKMS client: its client key, its default keyset, and the keysets it has loaded since. Make one per process and share it; it is safe for concurrent use. The guest runs one call at a time, but never holds the client across a ZeroKMS round trip: a call that needs ZeroKMS hands its request to the client, which performs it with the client free for other callers, and then carries on. A call whose context ends while its request is in flight returns the context's error and leaves the client open.

client, err := encrypt.NewClient(ctx)
if err != nil {
    return err // encrypt.ErrNoCredentials: nothing configured
}
defer client.Close()

ctx is Go's context.Context: the deadline and cancellation for the one ZeroKMS round trip NewClient makes. It has nothing to do with an encryption context, which is what a field is sealed under; that is the context= tag.

Every call takes a ctx too, and a call whose ctx is cancelled or passes its deadline fails with the context's error. Only that call fails: one request giving up never closes the shared client for the others.

  • A call waiting for the client (it runs one call at a time) leaves the queue when its ctx ends, and never starts.
  • A call waiting on ZeroKMS has that request cancelled. Its error matches both context.Canceled or context.DeadlineExceeded and ErrTransport.
  • A call waiting on its auth strategy for a token returns too, with the context's error. If the strategy is refreshing a device session, the refresh finishes in the background, for up to 10 seconds. The service rotates the refresh token when it answers, and dropping that answer would leave the strategy unable to refresh.

A client closes itself, and every later call returns ErrState, only when the guest's state is unknown: the guest trapped, or a call ran on for 30 seconds after its ctx ended, which means a bug. A service that sees ErrState from a client it did not close builds a new one.

Every other setting is a functional option with a default:

client, err := encrypt.NewClient(ctx,
    encrypt.WithCredentials(encrypt.OIDCFederation(crn, provider)),
    encrypt.WithTransport(rt),
    encrypt.WithKeysetCacheSize(64),
    encrypt.WithRequireLockedMemory(),
)
Credentials

AutoCredentials, the default, reads the environment first (CS_CLIENT_ACCESS_KEY and CS_WORKSPACE_CRN for the token, CS_CLIENT_ID and CS_CLIENT_KEY for the key), then the developer profile stash auth login writes, through the auth package. NewCredentials takes a client id, a client key and an auth strategy explicitly; OIDCFederation mints the token from an identity provider's. No credentials take a raw token. The client key is consumed by NewClient and wiped, whatever the outcome.

The cipher

A Cipher is the client bound to one keyset, and to any extension of the context. Every generated function takes one.

cipher := client.Keyset(encrypt.KeysetName("tenant-42")).Extend("tenant-42")

Extend appends to the context that the tags declare, for every field, in every call through the cipher: the write, the query and the read. A row written through Extend("tenant-42") opens and matches only through a cipher with the same extension. No call takes a keyset or a context, so the three cannot use different ones.

Take care

The context binds each value to its table, its column and the cipher's extension. It does not bind the value to its row, so a value copied to another row of the same table decrypts there with no error. To bind each row to a context of its own, such as its tenant, give the struct a context_field instead of a context= tag:

type Note struct {
	Tenant string `stash:"tenant,context_field"`
	Text   string `stash:"text,encrypt,index=equality"`
}

The field's value is the context every other field is sealed under, a label such as "tenants/acme". It is stored as it is, in the clear and unauthenticated like a passthrough field, so the row names its own context. A row's sealed values open only under the context the row stores: a value copied to another tenant's row does not decrypt there. Encrypt and Decrypt take each row's context from the row. cipher.Context("tenants/acme") names the context every row through the cipher is under: Decrypt then refuses a row stored under another tenant with ErrContextMismatch, before any key is retrieved, and Encrypt refuses a value whose field says otherwise. A query on such a type derives its term under the context the cipher names, and fails without one.

Client.DefaultKeyset() is the keyset a ZeroKMS administrator set for the client; Client.Keyset(encrypt.KeysetName(..)) or Client.Keyset(id) any other, loaded on first use. Cipher.KeysetID(ctx) resolves it.

Reading

users.Decrypt takes a Decrypter: the *Cipher, which refuses a row another keyset sealed with ErrForeignKeyset before any key is retrieved, or the *Client, which opens each row under the keyset that sealed it. The *Client opens only rows that a cipher with no extension sealed; a row written through Extend opens only through a cipher with the same extension. For a type with a context_field, the *Cipher from cipher.Context(..) also refuses a row whose stored context is another, with ErrContextMismatch; the *Client opens each row under the context it stores.

What is stored

A field with encrypt and index= is a struct with one field for each output: Ciphertext (encrypt.Ciphertext, the frozen stack-encrypt leaf), and Equality, Match, Ore or Ope (the term types). A field with encrypt alone has Ciphertext only. A passthrough field keeps its Go type. An opaque struct is one Sealed field. Every stored type implements driver.Valuer and sql.Scanner.

A sealed field outside an opaque struct is one value of one of these types, or a type defined over one (type Email string):

Go field Seals as Indexes it can store Query methods
string text equality, match, ore, ope Eq, Matches, Lt, Lte, Gt, Gte, Between
[]byte bytes equality, ore, ope Eq
bool a boolean none none
int16, int32, int64, int an integer of the field's own width (int is 64-bit) equality, ore, ope Eq, Lt, Lte, Gt, Gte, Between
uint16, uint32, uint64, uint the same, unsigned equality, ore, ope the same
int8, uint8 (byte) an 8-bit integer equality Eq
encrypt.Int128, encrypt.Uint128 a 128-bit integer equality, ore, ope the same
float32, float64 a float of its width equality, ore, ope Eq, Lt, Lte, Gt, Gte, Between
encrypt.Decimal a decimal: a 96-bit coefficient and a scale up to 28 equality Eq
encrypt.Date a calendar date equality Eq
time.Time a timestamp, to the nanosecond, in UTC equality Eq

Each query method needs its index declared. An ore or ope index on bytes stores its term, but the engine answers no ordering through it, so such a field has no Lt (see Queries).

encrypt.Date, Int128, Uint128 and Decimal are vitaminc's vcvalue types; build them with encrypt.DateOf, ParseInt128, ParseUint128 and ParseDecimal (which refuses NaN and the infinities). A time.Time opens in UTC: compare it with Equal, not ==. A decimal opens at the scale it was sealed with, but a zero has no sign: -0.00 opens as 0.00. The zero encrypt.Date is not a date, and Encrypt or a query refuses it with ErrEncoding, naming the field; so is a decimal or a time.Time outside vitaminc's range. A sealed field cannot be a pointer, so an optional date has no sealed form yet: keep it in an opaque struct, or use a sentinel date. Each integer type seals at its own width, and each width is passed to the order scheme as itself, so an int16 field's terms are an int16's. An int8 or uint8 field has no order index: with 256 values, an order term would rank every row among 256, which a few known rows resolve to the values, as a bool's would say which rows are true. Equality follows vitaminc's normalisation: an integer equals itself at every width, decimals compare at any scale (1.50 = 1.5), and a timestamp compares to the microsecond, truncated, never rounded. An order index on a decimal, date or timestamp is refused for now: the engine derives no order term for those kinds yet.

A match index stores text with no token (an empty string, separator-only text, or text shorter than the n-gram length of 3 characters) as the empty filter: a valid value that no query matches, so an optional or short value can live under match. The empty filter tells anyone who can read the column that the value is one of those. A match query of such text is refused with ErrTerm, because an empty filter as a query would match every row. An EQL TextMatch field is stricter, as "EQL columns" below says.

Terms are byte-equal to the ones the Rust crate derives, so a term from users.Fields.Email.Eq compares against a stored term written from any language. EqualityTerm.Equal compares in constant time; OreTerm.Compare and OpeTerm.Compare order as the plaintexts; MatchTerm.Positions decodes the token positions.

A database can compare some terms by itself:

  • Equality: compare with =.
  • Ope: compare with <, > and ORDER BY. The byte order is the plaintext order.
  • Ore: do not compare in SQL. Plain byte order is almost always right, and sometimes wrong, with no error. Compare in Go with OreTerm.Compare, or use an EQL type (encrypt_into=), which the database compares correctly.
  • Match: a row matches when its stored filter holds every position of the query's term. Decode both into positions in SQL and compare with @>, as Queries shows, or in Go with MatchTerm.Positions.

Queries

Each sealed field's entry in Fields has one method for each operator the engine answers on the field: Eq, Lt, Lte, Gt, Gte, Between and Matches. A field has no method for an operator it does not answer, so such a query does not compile. The generator asks the engine which operators each field answers, and which index answers each one; it holds no copy of those rules.

The field declares Methods Each returns
index=equality Eq encrypt.EqualityTerm
index=ore or index=ope, on an integer of 16 bits or wider or a string Lt, Lte, Gt, Gte encrypt.OreTerm, or encrypt.OpeTerm when the field declares no ore
the same Between(ctx, cipher, lo, hi) encrypt.Range[encrypt.OreTerm] or encrypt.Range[encrypt.OpeTerm], the two bounds' terms
index=match, on a string Matches encrypt.MatchQuery[T]
encrypt_into=TextEq Eq eql.TextEqQuery
encrypt_into=TextMatch Matches encrypt.EQLMatchQuery[eql.TextMatchQuery, T]

A method takes the field's own Go type, so Fields.Age.Lt on a uint32 field takes a uint32.

Text orders by its folded form (ADR-0002): case and accents are folded and ß becomes ss, under Unicode 16. So Café, cafe and CAFÉ are one value and tie, alice orders before Zoe, and é orders with e, not after z. Between is inclusive at both ends and, as SQL's BETWEEN, does not refuse a range whose low bound is above its high bound: such a range matches nothing. So Between("b", "A") matches nothing, since A folds to a, below b. Text holding a code point Unicode 16 does not assign has no fold, and its query or write is refused with encrypt.ErrTerm.

Floats order as PostgreSQL's float8 does (ADR-0002): -0.0 and 0.0 are one value, and every NaN is one value, above +Inf, so Between(math.NaN(), math.Inf(1)) matches nothing. A float32 orders as the float64 it widens to.

An order index on bytes stores its term but answers no ordering operator, so such a field has no Lt; nor does the ope index of a field that declares ore too, since ore answers the orderings. stashgen prints a notice for each such index, on stderr and in the generated file: the terms are stored, and only your own code can compare them. A match index on an index-only field (index=match with no encrypt) is refused, because Verify checks a match query's rows by decrypting them.

Every query type implements driver.Valuer, so it binds as a parameter in any library built on database/sql or pgx. A Range binds as two parameters, Low and High. On an EQL column a range is one value instead: package encrypt/eql has a range query type per ordered family and order index (eql.IntegerRangeOreQuery, eql.TextRangeOpeQuery, ...), the one operand of eql_v4.between(column, $1::eql_v4.query_integer_range_ore). Keep the cast to the exact range domain: an uncast $1, a jsonb parameter, or a range of another term or family fails with SQLSTATE 42725 ("function ... is not unique"), unlike the single-bound operators, which resolve an untyped $1. The ordered EQL types are producible, but stashgen does not yet generate a Between method or construct these range query values. Their generated fields expose the four single-bound comparisons.

Eq, the orderings and Between are exact: the server's answer is the answer. Matches is probabilistic: the server answers with every row whose stored filter holds the query's bits, which can include rows that do not hold the query's text. So MatchQuery carries two more methods, and an exact query's type carries neither (on an encrypt_into=TextMatch field, Matches returns an encrypt.EQLMatchQuery, the eql.TextMatchQuery operand with the same two):

  • TargetLimit(ctx, want, stats) is how many rows to fetch for want of them to verify. A field declared with tags states no expected number of tokens, so with no measured histogram of the stored filters in encrypt.Stats the limit is unbounded, and Limit.FetchOr(page) fetches a page.
  • Verify(ctx, values) says, for each decrypted value of a page, whether it really holds the query's text: one call into the engine for the page, with no key.

The example below uses separate byte columns, as example/ declares its fields (Email with stash:"email,encrypt,index=equality;match", Age with stash:"age,encrypt,index=equality;ore"), not the encrypt_into User of the quick start:

// Positions of a stored filter or a match term, for @>.
const positions = `CREATE FUNCTION match_positions(term bytea) RETURNS int[]
LANGUAGE sql IMMUTABLE STRICT AS $$
  SELECT coalesce(array_agg(get_byte(term, 2 * i) | (get_byte(term, 2 * i + 1) << 8)), '{}')
  FROM generate_series(0, length(term) / 2 - 1) AS i
$$`

q, err := users.Fields.Email.Matches(ctx, cipher, "alice")
limit, err := q.TargetLimit(ctx, 10, encrypt.Stats{Rows: rowCount})

rows, err := db.QueryContext(ctx, `
	SELECT id, email, email_eq, email_match, age, age_eq, age_ore FROM users
	WHERE match_positions(email_match) @> match_positions($1)
	ORDER BY id LIMIT $2`, q, limit.FetchOr(50))
// scan each row into a users.Encrypted, then
people, err := users.Decrypt(ctx, cipher, encrypted)
emails := make([]string, len(people))
for i, p := range people {
	emails[i] = p.Email
}
verified, err := q.Verify(ctx, emails)
hits := people[:0]
for i, p := range people {
	if verified[i] {
		hits = append(hits, p)
	}
}

Verify cuts the query's text into tokens again in the engine, with the field's own tokenizer, so a MatchQuery keeps that text for as long as the program holds the query. Go cannot wipe a string: the text stays in memory until the garbage collector reclaims it, as the string you passed in does. A MatchQuery never prints it; fmt, slog and %#v show the field and the size of the term only.

Errors

The generator and the compiler find a mistake in a declaration, so no call returns an error for one. A call returns an error for a key, for the network, or for stored data. Check an error two ways.

errors.Is tells you the kind, which is what a program branches on:

  • ErrForeignKeyset: a *Cipher got a row another keyset sealed.
  • ErrContextMismatch: a *Cipher with a Context got a row whose context_field names another context, or a value whose field does.
  • ErrForbidden, ErrAuthentication: a ciphertext that does not open under its field's context (ZeroKMS refuses the key, or the AEAD fails).
  • ErrEncoding: a stored value or a call that does not fit the declaration.
  • ErrUnauthorized, ErrNotFound, ErrTransport, ErrKMS: ZeroKMS.
  • ErrState: a call on a closed client: you closed it, or the guest trapped or ran on long after a call's context ended (see Connect). ErrMemoryLock: see below.
  • context.Canceled, context.DeadlineExceeded: the call's ctx ended. The client is still open.

errors.As with a *Diagnostic gives you the detail behind a failure the engine reports: a stable Code (stack_encrypt::foreign_keyset, stack_kms::keyset_not_found, ...), the one-line Message that Error() returns after the kind's text, Help saying what to do about it, Fields (structured values, by name) and Causes (the errors behind it). Accessors read the values a program is likely to branch on:

var d *encrypt.Diagnostic
if errors.As(err, &d) {
    log.Printf("%s: %s (%s)", d.Code, d.Message, d.Help)
    if expected, ok := d.ExpectedKeyset(); ok {
        found, _ := d.FoundKeyset()
        log.Printf("cipher is bound to %s; the row was sealed under %s",
            encrypt.KeysetID(expected), encrypt.KeysetID(found))
    }
}

Field and Reason name the field and the problem (field_missing, unknown_key, ...) on a refused plan, record or value. An error the client raises itself carries no Diagnostic: a closed client, a guest that did not return, ErrMemoryLock, and an argument refused before it reached the engine.

What an error may contain is fixed. It may carry keyset ids and names, field names, counts, index kinds, ZeroKMS request kinds and HTTP statuses. It never carries a plaintext value, key material, a token, ciphertext or term bytes, or the values of an encryption context. No warning or log line holds a plaintext value either. A generated type hides its sealed fields when a program prints or logs it; the struct you wrote does not, and stashgen -redact writes String and LogValue for it.

Key material

Every key the guest holds lives in the guest's linear memory, which this package supplies: reserved once, locked in RAM and excluded from core dumps where the platform allows, and wiped before it is released. The lock is best effort (RLIMIT_MEMLOCK is 64 KiB on many Linux hosts); Client.MemoryLocked reports it, Client.MemoryLockError says why not, and WithRequireLockedMemory makes NewClient refuse to start unlocked. See the package documentation for the full account.

EQL columns

A field tagged encrypt_into=TextEq is stored as one EQL value, the JSON PostgreSQL holds in a public.eql_v4_text_eq column, as an eql.TextEq from package encrypt/eql. Stack Encrypt writes EQL v4, the eql_v4 install of EQL's SQL; the eql_v3 columns the TypeScript SDK writes hold cipherstash-client's terms, which these never match. Install EQL v4 into PostgreSQL first, with the eql-v4-install.sql attached to each Go release (psql -f eql-v4-install.sql); stash eql install installs EQL v3 only, and an eql_v4_* column needs the v4 domains.

The EQL types the engine produces, and the Go field types each takes:

EQL type Go field Queries
TextEq string =, <>
TextMatch string @@ (3-grams and whole two-letter words, case-insensitive; see below)
TextOrd, TextOrdOpe, TextOrdOre string =, <>, <, <=, >, >=, ORDER BY
TextSearch, TextSearchOre string =, <>, <, <=, >, >=, ORDER BY, @@
Text string none: stored and read only
Smallint int16, or any Go integer type (see below) none
SmallintEq the same =, <>
SmallintOrd, SmallintOrdOpe, SmallintOrdOre the same =, <>, <, <=, >, >=, ORDER BY
Integer int32, or any Go integer type (see below) none
IntegerEq the same =, <>
IntegerOrd, IntegerOrdOpe, IntegerOrdOre the same =, <>, <, <=, >, >=, ORDER BY
Bigint int64, or any Go integer type none
BigintEq the same =, <>
BigintOrd, BigintOrdOpe, BigintOrdOre the same =, <>, <, <=, >, >=, ORDER BY
Boolean bool none
Real float32 none
RealEq float32 =, <>
RealOrd, RealOrdOpe, RealOrdOre float32 =, <>, <, <=, >, >=, ORDER BY
Double float64 none
DoubleEq float64 =, <>
DoubleOrd, DoubleOrdOpe, DoubleOrdOre float64 =, <>, <, <=, >, >=, ORDER BY
Date encrypt.Date none
DateEq encrypt.Date =, <>
DateOrd, DateOrdOpe, DateOrdOre encrypt.Date <, <=, >, >=, ORDER BY
Timestamp time.Time none
TimestampEq time.Time =, <> (to the microsecond, truncated)
TimestampOrd, TimestampOrdOpe, TimestampOrdOre time.Time <, <=, >, >=, ORDER BY
Numeric encrypt.Decimal none
NumericEq encrypt.Decimal =, <> (1.5 = 1.50)
NumericOrd, NumericOrdOpe, NumericOrdOre encrypt.Decimal <, <=, >, >=, ORDER BY
JsonSearch (eql.JSONSearch) struct, map, slice, or json.RawMessage containment, equality at a path, ordering at declared paths, several members of one array element, selection, subtree reads; see Encrypted JSON documents

Any built-in Go integer field takes an integer EQL type (ADR-0002): the field is sealed at the type's width, and opens back into the field's type. A type every value of which fits (an int16 or a uint16 field for Integer, an int8 for Smallint) always seals; a wider one (an int64 or a uint32 field for Integer) is range checked value by value, and Encrypt refuses a value that does not fit with ErrEncoding, naming the field's kind and not the value. encrypt.Int128 and encrypt.Uint128 are not converted, and floats take exactly their own width.

A TextMatch value is indexed by its 3-grams and by each two-letter word whole, so a two-letter surname such as "Ng" is stored, and Matches with "ng" finds the rows holding the word "Ng" ("Ng", "Mike Ng"), not "Nguyen". A query's two-letter words must be whole words of the row, so "Mike Ng" does not find "Mike Nguyen". The word token reveals one more token per two-letter word, in the same filter: someone who clusters tokens across rows can tell "Ng" from "Nguyen", where before only the trigram they share was visible. Text that cuts no token, such as an empty string or one letter, is refused. A TextSearch value of such text is stored with an empty match filter: Eq, Lt, Lte, Gt and Gte find it through its other terms, and no Matches query does. A TextSearch query carries only the term its operator reads, so Eq and Lt on one-letter text work and Matches on it is refused; EQL v4 raises if a query lacks the term its operator compares. The number equality types compare as PostgreSQL compares the plain types: an integer equals itself whatever its width, and for floats -0.0 = 0.0 and NaN = NaN; the stored value still decrypts to exactly what was written. The same holds for the date, timestamp and numeric types: NumericEq normalises the scale, so 1.5 and 1.50 are equal, and TimestampEq compares to the microsecond, truncating (never rounding) the nanoseconds below it, so two instants in one microsecond are equal; each still opens as it was written, a decimal with its scale and a time.Time with its nanoseconds, in UTC. A time.Time is sealed as its instant, whatever its location. A zero encrypt.Date, a date or instant outside years -262143 to 262142 and a decimal with more than 28 digits after the point are refused with ErrEncoding. Ordered dates compare by day, timestamps by the truncated microsecond, and decimals by value (1.5 ties 1.50).

The Ord and OrdOpe types store an OPE term, which EQL compares as bytes, and order by ADR-0002's order key: integers by value, floats as PostgreSQL's float8 sorts them (-0.0 ties 0.0, every NaN is one value after +Infinity), text by its fold (case and accents folded, under Unicode 16). A field gets Lt, Lte, Gt and Gte, and a text field Eq too; sort with ORDER BY eql_v4.ord_term(col). ord_term returns a bytea domain, so a plain functional btree index, CREATE INDEX ON t (eql_v4.ord_term(col)), serves <, > and ORDER BY with no operator class. That is why these types work on an install without superuser rights, where the ORE operator class is skipped. On a number column SQL's = compares the order term; stashgen writes no Eq method for one yet, since the engine answers Eq through an equality term only. A text order type also stores TextEq's equality term, and = reads that, so Café and cafe are neither < nor > each other and not = either. Text holding a code point Unicode 16 does not assign has no order term and is refused. There is no generated Between method on these EQL fields yet, although EQL v4 has range query domains and eql_v4.between for them.

The OrdOre types and TextSearchOre order by the same key, through a block ORE term (ore-rs's 6-bit schemes) in place of the OPE term. Each term carries a random nonce, so EQL v4's own comparator orders them, not their bytes: sort with ORDER BY eql_v4.ord_term_ore(col), and index with CREATE INDEX ON t USING btree (eql_v4.ord_term_ore(col)). That index needs the ORE operator class, which only a superuser install creates; elsewhere the installer disables these domains, and the Ord types are the ones to use. Text whose fold is longer than 1023 bytes, or ends in NUL, has no block ORE term and is refused, stored or queried.

The catalog in eql.Types also lists EQL types the engine does not produce, with the reason for each; stashgen refuses those types with that reason. The guest builds a producible value: the generated code imports encrypt/eql, which embeds the build of the engine that holds the EQL types and registers it on import, so the program runs that build and stores what it returns. Fields.Email.Eq returns the eql.TextEqQuery the column's eql_v4.query_text_eq operand takes; TextEq answers eq alone, and a TextMatch field's Matches returns an encrypt.EQLMatchQuery: the eql.TextMatchQuery operand with Verify and TargetLimit. mise run wasm:guest:build:eql builds that guest beside the other. A cipher extended with a tenant part refuses a struct with an encrypt_into field: an EQL value is stored under a table and a column, and the extended label has no column.

Encrypted JSON documents

JsonSearch stores a whole JSON document in one public.eql_v4_json_search column, from a struct, map or slice field, or a json.RawMessage (stash:"profile,encrypt_into=JsonSearch(ordered=$.age)"). See the stashgen reference and the runnable typed-document example. A struct is marshalled with encoding/json when it is sealed and read back into its type when it is opened. Its numbers come back exactly; the stashgen README lists what encoding/json does not keep (invalid UTF-8, a nil map or slice, a time's zone). Unlike the types above, a type defined over json.RawMessage (type Doc json.RawMessage) is refused; an alias of it (type Doc = json.RawMessage) is accepted. One entry per node is encrypted, so the database can test containment, equality and ordering at a path, return one node with ->, and walk a subtree, without decrypting:

q, err := users.Fields.Profile.Contains(ctx, cipher, map[string]any{"plan": "pro"})
rows, err := db.QueryContext(ctx, `SELECT id FROM users WHERE profile @> $1::eql_v4.query_json`, q)

read, err := users.Fields.Profile.Subtree(ctx, cipher, "$.visits[*].at", encrypt.JSONReadOptions{})
// SELECT e FROM users, eql_v4.json_tree_subtree(profile, $1::text[], 64) AS e WHERE id = $2
// with read.Tokens() as $1; collect each row's entries, then:
values, err := read.Open(ctx, entriesPerRow)
at, err := encrypt.DecodeJSON[int64](values[0][0])

A needle or a value is a json.RawMessage of JSON text, or any Go value encoding/json writes. A document contains a needle when it holds every member the needle writes, and encoding/json writes a zero struct field that has no omitempty: that field narrows the query to documents holding the zero. Give a needle struct pointer fields with omitempty, or use a map. Contains refuses a needle of the field's own struct type for this reason. Each value a read opens is JSON text, and encrypt.DecodeJSON reads it into a Go type.

A selection or read opens rows only as answers to the request that prepared it: the engine derives the selector or the read again from the field, the path and the cipher's keyset, and refuses a row that is not one it asked for. It does not prove the database returned every row, or rows of the database row you meant. encrypt.JSONTokens binds as a text[] literal, and a selector or token as text; cast each parameter, as above, so PostgreSQL picks EQL's operator and not jsonb's; a zero one is refused rather than bound. A token is a capability over its node's subtree in every row of the column, and a path's keys can be your customer's data, so JSONToken, JSONTokens, JSONSelection and JSONRead print and log with the tokens, the selector and the path redacted. A JSONValue holds the plaintext a read opened, and prints it. Numbers order as float64, so integers past 2^53 can tie at an ordered path; containment and EqAt compare them exactly. At a wildcard path, -> returns an unspecified element of each row, so an ordering over every element is written over eql_v4.json_tree_path_query(col, $sel), its matches (the stashgen README shows the SQL).

Choosing a JSON query
To find rows where… Use Engages an index
the document contains a value, has a value at a path, or orders at one ordered path Contains, EqAt, At.<Path>.Lt and the other orderings yes: the GIN index for @>, a btree on the path's ordering term
one element of an array passes several tests (a visit that is at home and after 15) Elements.<Array>.Match no
a whole filter holds, written as a jsonpath predicate Match no

The last two are evaluated inside a function, which tests every row a scan gives it. On a large table, put an indexed condition beside them to narrow the rows first, such as WHERE profile @> $1::eql_v4.query_json AND profile @@ $2::eql_v4.query_json_predicate.

Test several members of one array element

At a wildcard path, an EqAt and an ordering are each answered by any element, so a row can pass both through different elements: a visit at home and another visit after 15. To require one element to pass every test, use Match on the array's accessor under Elements. A field has one for each array whose elements have an ordered member (ordered=$.visits[*].at;$.visits[*].place). The generated Paths names those members, nested as the JSON nests them, and encrypt.AtPath makes a predicate from one:

q, err := users.Fields.Profile.Elements.Visits.Match(ctx, cipher, encrypt.JSONReadOptions{},
	encrypt.AtPath(users.Paths.Profile.Visits.Place).Eq("home"),
	encrypt.AtPath(users.Paths.Profile.Visits.At).Gt(15),
)
rows, err := db.QueryContext(ctx, `SELECT id FROM users WHERE eql_v4.json_tree_element_match(profile, $1::eql_v4.query_json_elements)`, q)

A path carries its array and the member's Go type from the document's struct, so a value of another type, or a predicate on another array's member, does not compile. For a filter built at run time, name the member with the array's Member: users.Fields.Profile.Elements.Visits.Member("place").Eq("home"). A member the field does not order is refused with ErrTerm before the engine is asked, and the error names it.

Each comparison is of the member's ordering term, so Eq compares text case- and accent-folded ("home" equals "Home"), and integers past 2^53 can tie; EqAt is exact. A row whose array is longer than JSONReadOptions.WildcardBound (64 by default) fails the query with SQLSTATE 22023 rather than answer over part of the array: set the bound past your longest array.

Filter with a jsonpath predicate (jsonb_path_match)

Match on the field takes a whole filter as one parameter: a jsonpath predicate and its variables, the two arguments plaintext jsonb_path_match(col, $1, $2) takes. The engine parses the predicate and compiles it into a tree of the operands the methods above derive; the Go side reads none of it.

q, err := users.Fields.Profile.Match(ctx, cipher,
	`$."plan" == $plan && $."age" < $max`,
	map[string]any{"plan": "pro", "max": 40}, encrypt.JSONReadOptions{})
rows, err := db.QueryContext(ctx, `SELECT id FROM users WHERE profile @@ $1::eql_v4.query_json_predicate`, q)

eql_v4.json_tree_match(profile, $1::eql_v4.query_json_predicate) returns the same rows, for a client that calls functions rather than operators.

The variables are a map, a struct, or a json.RawMessage that writes a JSON object. Pass an empty map, map[string]any{}, when the predicate has none. A nil vars does not mean "no variables": it binds as SQL NULL, as it does over plaintext, and the query matches no row.

The predicate may use:

  • paths of $ followed by .key, ."quoted key" and [*] steps;
  • ==, !=, <, <=, > and >= between a path and a variable or a JSON literal;
  • exists(path), and exists(path ? (@.a == $x && @.b > $y)) to test several members of one array element;
  • &&, || and !(…).

An ordering needs its path in the field's ordered=, and so does each member of an exists filter with more than one condition; one that is not ordered is refused with ErrTerm. Anything outside the grammar is refused as a *Diagnostic wrapping ErrEncoding. Its Reason(), Construct() and At() give the reason, the construct in the grammar's words and the byte offset in the predicate where it starts. None of them includes the predicate's text or the variables' values.

Each row answers as jsonb_path_match does over its plaintext, except in five cases. The stashgen reference gives an example and a workaround for each:

  • Lax mode does not unwrap an array whose path has no [*]: write the [*].
  • Nor does it wrap a value that is not an array under [*]: keep arrays as arrays.
  • A string compared with a number is false under == and true under !=.
  • Orderings, and filters with more than one condition, compare folded terms: text case- and accent-folded, numbers as float64.
  • An ordering of a JSON null is NULL where jsonpath says false. A WHERE returns the same rows either way; only selecting the boolean itself shows the difference.

A filter with more than one condition reads each array to JSONReadOptions.WildcardBound elements, as the element match does.

Building

The package embeds wasm/stack_encrypt_guest.wasm, a build artefact of the Rust crate in guest/, and package encrypt/eql embeds the EQL build beside it. A tagged version of the module (such as v0.1.0-alpha.1, as go get resolves it) carries both builds and the credential guest: the release workflow commits them to the commit the tag names, so a module that requires a tagged version needs no Rust toolchain and no build step. A pseudo-version does not. go get ...@main, or any commit rather than a tag, fetches a tree without the builds; it compiles, and NewClient returns ErrGuestNotBuilt.

In a checkout of this repository the builds are not committed: run mise run wasm:guest:build (and mise run wasm:auth-guest:build for the credential guest) before go test, and mise run wasm:guest:build:deterministic for the test build the hermetic round-trip and fixture tests use. Without the builds NewClient returns ErrGuestNotBuilt and the tests skip.

Licence

The Go code in this module is MIT-licensed (LICENSE). The WASI guests it embeds are compiled from Rust crates that are not: the stack-encrypt guest and its EQL build are compiled from stack-encrypt, stack-kms, stack-auth, stack-profile (a dependency of stack-auth) and stack-guest-abi, which are licensed under the PolyForm Internal Use License 1.0.0. Those terms allow use for your internal business operations only. Each crate's LICENSE in the CipherStash Stack repository has the full text.

Documentation

Overview

Package encrypt is the Stack Encrypt Go SDK: searchable, field-level encryption under per-value ZeroKMS data keys, running the stack-encrypt Rust engine unmodified inside a WASI guest under wazero (CGO_ENABLED=0).

Use the SDK

1. Add the generator to your module (Go 1.26.10 or later, or 1.27.3 or later on 1.27: the first releases whose crypto/x509 blocks the compromised eMudhra intermediate CAs, CVE-2026-111943):

go get -tool github.com/cipherstash/stack/languages/golang/cmd/stashgen

2. Put a stash tag on every exported field of the struct, and a go:generate comment beside it:

//go:generate go tool stashgen -type User
type User struct {
	_     struct{} `stash:"context=users"`
	ID    int64    `stash:"id,passthrough"`
	Email string   `stash:"email,encrypt_into=TextEq"`
	Name  string   `stash:"name,encrypt_into=TextMatch"`
	Age   int32    `stash:"age,encrypt_into=Integer"`
}

encrypt_into names the EQL type the field is stored as. The Go field type must match it: TextEq, TextMatch and Text take a string, Smallint and SmallintEq an int16, Integer and IntegerEq an int32, Bigint and BigintEq an int64, Boolean a bool, Real and RealEq a float32, Double and DoubleEq a float64, Date and DateEq a Date, Timestamp and TimestampEq a time.Time, and Numeric and NumericEq a Decimal. An integer EQL type also takes any other built-in Go integer type, sealed at the type's width: one every value of which fits it (an int16 into Integer) always seals, and a wider one (an int64 into Integer) is range checked, a value that does not fit refused with ErrEncoding. The *Eq types answer Eq; the *Ord and *OrdOpe types (IntegerOrd, SmallintOrd, BigintOrd, RealOrd, DoubleOrd, TextOrd and their OrdOpe twins) answer Lt, Lte, Gt and Gte, and Eq too on text; Smallint, Integer, Bigint, Boolean, Real, Double, Text, Date, Timestamp and Numeric are stored and read only. NumericEq compares decimals with their scale normalised (1.5 = 1.50), and TimestampEq compares instants to the microsecond, truncated, never rounded.

3. Run the generator. It writes user_stash.go beside the struct:

go generate ./...

4. Commit the generated file.

5. Call the generated functions where you write and read:

client, err := encrypt.NewClient(ctx)
if err != nil {
	return err
}
defer client.Close()
cipher := client.Keyset(encrypt.KeysetName("tenant-42"))
encrypted, err := users.Encrypt(ctx, cipher, people) // []users.EncryptedUser
opened, err := users.Decrypt(ctx, cipher, encrypted) // []users.User
term, err := users.Fields.Email.Eq(ctx, cipher, "bob@example.com")

Encrypt and Decrypt send one ZeroKMS request for each 500 sealed values in the batch, plus one the first time a keyset is used. A query derives its term locally, with no request of its own; only the first use of a keyset, by a query or anything else, loads it from ZeroKMS. See Queries below.

6. Store the encrypted type. Each encrypt_into field is one EQL v4 column (an eql.TextEq goes in a public.eql_v4_text_eq column), implementing driver.Valuer and sql.Scanner, so a database library binds and scans it like any other value. Install EQL v4 into PostgreSQL first, with the eql-v4-install.sql attached to the release. A query casts its parameter to the column's query type:

SELECT id, email, name, age FROM users WHERE email = $1::eql_v4.query_text_eq

A field tagged encrypt or index= stores its ciphertext (Ciphertext) and each term as separate byte columns instead; see Terms below.

7. Run the generator again after each change to the struct or to a tag. A change to the fields of the struct stops the build until you do.

8. In CI, run the generator and fail when a generated file changes:

go generate ./... && git diff --exit-code && test -z "$(git status --porcelain)"

git diff sees only tracked files; the git status check also fails on a generated file that was never committed.

The rest is the reference. The generator's own reference — the tag grammar, the flags, what it writes and what it refuses — is in cmd/stashgen/README.md.

Shape

A Client is one wasm instance and one ZeroKMS client: NewClient resolves the client's Credentials, instantiates the embedded guest, hands it the client key once — the ClientKey the credentials resolved to is consumed and wiped, whatever the outcome — and loads the client's default keyset. It takes functional options (ClientOption), every one with a default, so NewClient(ctx) alone is a working client. Client.Close runs the guest's shutdown so the client key and every loaded index key are wiped before the instance is freed.

A Cipher is the client bound to one keyset (Client.Keyset and Client.DefaultKeyset) and to any extension of the context (Cipher.Extend): what changes from one caller to the next — a tenant, a region — attaches here and not to each call, so the write, the query and the read cannot use different ones. A Cipher opens only its own keyset's records; the Client opens records from any of its keysets. Both are a Decrypter, which the generated Decrypt functions take.

Declarations

A struct's stash tags declare how each field is encrypted: the context of the struct, and for each field whether it is sealed, which indexes are derived beside it, or whether it passes through as it is. stashgen reads the tags and writes the encrypted type, Encrypt, Decrypt and Fields into the struct's package; the generated code hands the declaration to the engine as data through package gensupport, and the engine runs the same plan the Rust chain and the derive run. A program never builds or names a plan, and no call takes a context or an index choice of its own.

A sealed field outside an opaque struct is one value: a string, []byte, bool, an integer of any width (int8 to int64, uint8 to uint64, int, uint, or Int128 and Uint128), a float32 or float64, a Decimal, a Date or a time.Time (a timestamp), or a type defined over one of the built-in types. Each integer seals at its own width. Equality applies to every one but a bool. ORE and OPE apply to integers of 16 bits or more, floats, text, bytes, decimals, dates and timestamps. A bool or an 8-bit integer takes no order index, since over so few values its order terms would show which rows hold which value.

Passthrough fields never cross the binding: the engine does nothing to a passthrough value that a program could observe, and the FFI codec cannot carry every Go type a program stores beside a ciphertext (a driver.Valuer, a sql.NullTime). An opaque struct crosses as one storage blob (vitaminc's storage profile, vcffi.MarshalStorage) and is one column; a row an earlier release sealed as a JSON document still opens.

Terms

A sealed field with an index gets a term beside its ciphertext: EqualityTerm, MatchTerm, OreTerm or OpeTerm, byte-equal to the ones the Rust crate derives, so a term from a generated Fields entry compares against a stored term from any language. The indexes are Equality, Match, Ore and Ope; JSON is declared and refused until the engine derives it. Every stored type implements driver.Valuer and sql.Scanner.

A match index stores text with no token (an empty string, separator-only text, or text shorter than the n-gram length of 3 characters) as the empty filter: a valid value that no query matches, so an optional or short value can live under match. The empty filter tells anyone who can read the column that the value is one of those. A match *query* of such text is refused with ErrTerm, because an empty filter as a query would match every row.

Queries

A generated Fields entry has one method for each operator the engine answers on the field, named for it: Eq, Lt, Lte, Gt, Gte, Between and Matches. A field has no method for an operator it does not answer, so such a query does not compile; the generator asks the engine which operators each field answers, and holds no copy of the rules. Each method takes the field's own Go type.

Eq returns an EqualityTerm (or, for a field with encrypt_into, the EQL type's query value), an ordering an OreTerm or OpeTerm, and Between a Range of two. Their answers are exact. The orderings are answered on integers, on floats, which order as PostgreSQL's float8 does (-0.0 ties 0.0, NaN sorts last), on text, which orders by its folded form (case and accents folded, under Unicode 16), so Café and cafe tie and alice orders before Zoe, and on a Decimal, which orders by value (1.5 ties 1.50), a Date, and a time.Time, which orders by the microsecond. Matches returns a MatchQuery, whose answer can hold rows that do not match: size the fetch with MatchQuery.TargetLimit, then decrypt the rows and keep those MatchQuery.Verify reports true, one call into the engine for the page. On a field with encrypt_into=TextMatch, Matches returns an EQLMatchQuery, the type's query value with the same two methods. Every query type implements driver.Valuer, so it binds as a parameter through database/sql or pgx. With separate byte columns, as the example/ program declares its fields (Email `stash:"email,encrypt,index=equality;match"`, Age `stash:"age,encrypt,index=equality;ore"`), not the encrypt_into User above:

q, err := users.Fields.Email.Matches(ctx, cipher, "alice")
limit, err := q.TargetLimit(ctx, 10, encrypt.Stats{Rows: rowCount})
rows, err := db.QueryContext(ctx, `SELECT id, email, email_eq, email_match, age, age_eq, age_ore
	FROM users WHERE match_positions(email_match) @> match_positions($1)
	ORDER BY id LIMIT $2`, q, limit.FetchOr(50))
// scan into []users.EncryptedUser, then
people, err := users.Decrypt(ctx, cipher, encrypted)
emails := make([]string, len(people))
for i, p := range people {
	emails[i] = p.Email
}
verified, err := q.Verify(ctx, emails) // one bool per row

match_positions is a SQL function of your own that reads a match term's little-endian uint16 positions into an int[]; the package README has one. A MatchQuery keeps the text it was derived from, because Verify cuts its tokens again in the engine. Go cannot wipe a string, so the text stays in memory until the garbage collector reclaims it; a MatchQuery never prints it.

Encrypted JSON documents

A field tagged encrypt_into=JsonSearch is stored as one encrypted JSON document. Declare it as a json.RawMessage, which holds the document's JSON text, or as a struct, map or slice type. The generated code marshals such a type with encoding/json when it seals the field. It unmarshals the opened text into the declared type, so integers to 64 bits and finite float64s come back exactly. The rest comes back as encoding/json reads what it wrote, which is not always the value sealed: invalid UTF-8 in a string becomes U+FFFD, with no error (a property of the value, which the generated code does not walk, so check it with utf8.ValidString first); a nil map or slice is stored as JSON null, which a needle of an empty one does not match; and a time.Time inside a document is stored as RFC 3339 text in its own offset, so EqAt with a time in another zone misses and an ordering on that path orders the text, not the instants. Store a time you query as an integer, such as t.UnixNano().

The field's operands are Go values. Contains and ContainedBy take a needle, and EqAt and the orderings take a scalar. Each is a json.RawMessage of JSON text or any value encoding/json writes:

q, err := customers.Fields.Profile.Contains(ctx, cipher, map[string]any{"tier": "gold"})
older, err := customers.Fields.Profile.At.Age.Gt(ctx, cipher, 40)

A Go string is the string value itself, never parsed. So Contains and ContainedBy refuse a string needle, or a pointer to one; put JSON text in a json.RawMessage. They refuse a needle that writes JSON null (nil, or a nil pointer, map or slice) too: only a null document contains it.

A document contains a needle when it holds every member the needle writes. encoding/json writes a struct field that has no omitempty even when the field is zero. So a zero field narrows the query to documents that hold that zero. Give a needle struct pointer fields with omitempty, or use a map. Contains refuses a needle of the field's own type when that type holds a struct, the likeliest way to make this mistake, a slice of structs included: for a []Visit document, pass a slice of a needle type with pointer fields. ContainedBy takes one: there a zero field widens the query.

A selection or a subtree read opens values as JSON text (JSONValue, JSONRead.Open). DecodeJSON reads one into a Go type.

Two queries at an array's paths can each be answered by a different element. To test several members of one element together, use Match on the array's accessor under Elements, which a field has for each array whose elements have an ordered member. The generated Paths names those members, nested as the JSON nests them, and AtPath makes a predicate from one:

q, err := customers.Fields.Profile.Elements.Visits.Match(ctx, cipher, encrypt.JSONReadOptions{},
	encrypt.AtPath(customers.Paths.Profile.Visits.Place).Eq("home"),
	encrypt.AtPath(customers.Paths.Profile.Visits.At).Gt(15),
)
// WHERE eql_v4.json_tree_element_match(profile, $1::eql_v4.query_json_elements)

A Path carries its array and the member's Go type, so a value of another type, or a predicate on another array's member, does not compile. For a filter built at run time, the array accessor's Member names the member as text: Visits.Member("place").Eq("home").

Each comparison is of the member's ordering term, so MemberTest.Eq compares text case- and accent-folded, and integers past 2^53 can tie. A row whose array is longer than JSONReadOptions.WildcardBound (64 by default) fails the query with SQLSTATE 22023.

A whole filter binds as one parameter with the field's Match: a jsonpath predicate and its variables, as plaintext jsonb_path_match takes them. The engine parses the predicate and compiles it into a tree of the operands above; nothing in Go reads it.

q, err := customers.Fields.Profile.Match(ctx, cipher,
	`$."name" == $name && $."age" < $max`,
	map[string]any{"name": "Alice", "max": 40}, encrypt.JSONReadOptions{})
// WHERE profile @@ $1::eql_v4.query_json_predicate

Pass an empty map when the predicate has no variables. A nil vars binds as SQL NULL, as it does over plaintext, and matches no row.

A predicate the engine cannot answer is refused as a Diagnostic wrapping ErrEncoding, whose Reason, Construct and At name the refusal and the byte offset in the predicate where it starts. The tree is evaluated inside eql_v4.json_tree_match, so no index engages: a query that needs one writes its leaves with EqAt, Contains and At.<Path>.

Cancellation

Every call takes a context.Context, and a call whose context is cancelled or passes its deadline fails with that context's error. Only that call fails: one Client is shared by every request in a service, and one request giving up never closes it for the others. A call waiting for the client, which runs one call at a time, leaves the queue when its context ends and never starts. A call waiting on ZeroKMS has that request cancelled; its error matches both the context's error and ErrTransport. A call waiting on its auth strategy for a token returns too, with the context's error. If the strategy is refreshing a device session, the refresh finishes in the background, for up to 10 seconds: the service rotates the refresh token when it answers, and dropping that answer would leave the strategy unable to refresh.

A Client closes itself, and every later call returns ErrState, only when the guest's state is unknown: the guest trapped, or a call ran on for 30 seconds after its context ended, which means a bug, not a caller that gave up. A service that sees ErrState from a client it did not close builds a new one.

Transport and auth

The guest imports exactly one host function, a bearer-token fetch, served by the credentials' auth strategy. It never performs a request itself: an operation that needs ZeroKMS pauses and hands the request to the client, which sends it with any net/http.RoundTripper and resumes the operation with the response. What crosses per ZeroKMS call is what would cross TLS anyway; derived key material never leaves the guest. Under AutoCredentials and OIDCFederation the same RoundTripper also carries the authentication requests to CTS, so one scoped to the ZeroKMS host alone is not enough. Under NewCredentials those requests go through the store the caller opened the strategy from.

Credentials

A Credentials supplies the client id, the client key and the auth strategy the token comes from, and NewClient resolves it host-side: the crypto guest is never given the environment or a filesystem to find them in. The default, AutoCredentials, mirrors the Rust client — the environment first (CS_CLIENT_ACCESS_KEY with CS_WORKSPACE_CRN for the token, CS_CLIENT_ID with CS_CLIENT_KEY for the key), then the developer profile, which it reads through the auth package's credential guest. CS_ZEROKMS_HOST (or CS_VITUR_HOST) pins the endpoint whatever the credentials. NewCredentials takes a client id, a client key and a strategy explicitly, and OIDCFederation mints the token from an identity provider's. None takes a raw token: a token is always a strategy's, since a raw one cannot be refreshed when it expires. Credentials that cannot be resolved fail NewClient with ErrNoCredentials; credentials that resolve but do not work fail it too, at the one ZeroKMS round trip it makes.

Errors

The generator and the compiler find a mistake in a declaration, so no call returns an error for one. A call returns an error for a key, for the network, or for stored data, or because its context ended (see Cancellation): ErrForeignKeyset when a *Cipher is given a record another keyset sealed, ErrAuthentication or ErrForbidden for a ciphertext that does not open under its field's context, ErrEncoding for a stored value that does not fit its declaration. Read the kind with errors.Is, and the detail behind a failure the engine reports (its code, help, structured fields and causes) with errors.As into a *Diagnostic. No error, warning or log line holds a plaintext value; a generated type hides its sealed fields when a program prints it.

Host runtime

The guest also imports WASI random_get and clock_time_get, and the cipher's security rests on the first: ZeroKMS IVs and AEAD nonces are drawn from it. wazero's defaults for both are deterministic, so every instance is configured with the process CSPRNG (crypto/rand.Reader) and the system clocks.

Memory

Every key the guest holds — the client key, each loaded index key, each data key for the length of a call — lives in the guest's linear memory, and the package supplies that memory itself: reserved once so growth never copies it, locked in RAM (mlock, VirtualLock) so it is never written to swap, excluded from core dumps on Linux (MADV_DONTDUMP), and wiped before it is released, on every release path. That is done at allocation, where the caller cannot get it wrong, and not at exit, where they cannot be relied on.

The lock is best effort: RLIMIT_MEMLOCK defaults to 64 KiB on many Linux hosts and the guest is larger, so it is commonly refused, and a client then works on with memory that may be swapped. Client.MemoryLocked reports the outcome and Client.MemoryLockError the reason, naming the limit to raise. WithRequireLockedMemory turns a refusal into a NewClient failure with ErrMemoryLock, and refuses any later growth of the guest's memory that cannot be locked. A Client prints its memory state (Client.String) and logs it (Client.LogValue).

Between calls the guest holds the client key and its keyset cache. Data keys are per-call values, wiped when the export returns, and every buffer staged for a call is wiped before the call's result is returned. One exception remains: after Decrypt, one unwiped copy of each opened string stays in the guest's freed heap until that memory is reused. The test TestPlaintextDoesNotRemainInGuestMemoryAfterDecrypt records the gap and is skipped until vitaminc wipes that copy.

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrAuthentication is an AEAD open failure: a tampered ciphertext, a
	// wrong element derivation, or a wrong AAD that reached the AEAD. Against
	// ZeroKMS a wrong AAD is usually refused earlier as ErrForbidden, because
	// every data key is bound to its context.
	ErrAuthentication = guest.ErrAuthentication
	// ErrEncoding is a malformed input: a value, ciphertext, plan, context,
	// selector or config the guest refused before any cryptography.
	ErrEncoding = guest.ErrEncoding
	// ErrState is a call on a client that has been closed.
	ErrState = guest.ErrState
	// ErrInternal is a guest panic or any other unexpected guest failure.
	ErrInternal = guest.ErrInternal
	// ErrUnauthorized is ZeroKMS refusing the bearer token (HTTP 401): the
	// token is invalid, expired, or for another workspace.
	ErrUnauthorized = guest.ErrUnauthorized
	// ErrForbidden is ZeroKMS refusing the request (HTTP 403): the token is
	// valid but not permitted, or a data key's bound context did not match
	// the one presented — the production form of a wrong-AAD open.
	ErrForbidden = guest.ErrForbidden
	// ErrNotFound is ZeroKMS reporting a missing resource (HTTP 404): an
	// unknown keyset name or id, or a data key that does not exist.
	ErrNotFound = guest.ErrNotFound
	// ErrConflict is ZeroKMS reporting a resource conflict (HTTP 409).
	ErrConflict = guest.ErrConflict
	// ErrTransport is a failure to reach ZeroKMS or to read its response:
	// the transport returned an error, or the endpoint could not be resolved.
	ErrTransport = guest.ErrTransport
	// ErrKMS is any other ZeroKMS failure: an unparseable response, invalid
	// key material, or an unclassified server error.
	ErrKMS = guest.ErrKMS
	// ErrTerm is a term derivation the scheme could not perform for the
	// given input, such as match text that yields no tokens.
	ErrTerm = guest.ErrTerm
	// ErrForeignKeyset is a keyset-bound Cipher refusing a ciphertext sealed
	// under another keyset, before any key is retrieved. Open it through the
	// Client, which is not bound to one keyset.
	ErrForeignKeyset = guest.ErrForeignKeyset
	// ErrContextMismatch is a row whose context field, stored in the clear
	// beside its sealed fields, is not the context named with
	// [Cipher.Context]. Refused before any key is retrieved; a row stored
	// under another tenant is a mismatch, never a decrypted value.
	ErrContextMismatch = guest.ErrContextMismatch
	// ErrMemoryLock is guest memory that could not be locked in RAM (or,
	// on Linux, excluded from core dumps). NewClient returns it when
	// WithRequireLockedMemory is given, and so does any later call under
	// that setting whose growth of the guest's memory could not be locked;
	// otherwise Client.MemoryLockError reports it and the client works on
	// with unlocked memory. The wrapped error names the limit that refused
	// the lock and the size the guest holds: on Linux, RLIMIT_MEMLOCK
	// (ulimit -l, a systemd LimitMEMLOCK=, or a pod's securityContext).
	ErrMemoryLock = guest.ErrMemoryLock
)

Failure kinds surfaced across the boundary: the guest reports a status code, and these are what the codes decode to. They separate a tampered ciphertext from a bad token from a malformed input, and reveal nothing about plaintext or key material. Check one with errors.Is; the detail behind it is a Diagnostic.

They are the sentinels every guest package shares (one status table for every guest, decoded once), exposed here under this package's names: an error from auth is the same value, so errors.Is holds across the two.

View Source
var ErrCredentialsConsumed = errors.New("encrypt: the credentials' client key was already consumed by an earlier NewClient")

ErrCredentialsConsumed is NewCredentials given to a second NewClient: the first consumed its key — whether it made a client or refused its config — so there is nothing left to give. Build new credentials, with a new key, for another client.

View Source
var ErrEQLGuestNotBuilt = errors.New("encrypt: guest module with the EQL types not built — run `mise run wasm:guest:build:eql`")

ErrEQLGuestNotBuilt is returned by NewClient when the program links package eql (its generated code names an EQL type) and the guest build with the EQL types is not embedded. There is no fallback to the build without them: every encrypt_into call would fail there, so the program fails at startup instead.

View Source
var ErrGuestNotBuilt = errors.New("encrypt: guest module not built — run `mise run wasm:guest:build`")

ErrGuestNotBuilt is returned by NewClient when no guest module is embedded.

View Source
var ErrNoCredentials = errors.New("encrypt: no credentials")

ErrNoCredentials is AutoCredentials finding no token strategy or no client key in either place it looks. The wrapped error says which, and what to set.

View Source
var ErrNonFiniteDecimal = vcvalue.ErrNonFiniteDecimal

ErrNonFiniteDecimal is the error ParseDecimal returns for NaN or an infinity.

View Source
var ErrStorageVersion = vcffi.ErrUnsupportedVersion

ErrStorageVersion is an opaque struct sealed as a storage blob of a version this release does not read: a later release wrote it. Its error wraps ErrEncoding too. It is vcffi.ErrUnsupportedVersion, named here so a program can check for it without importing vitaminc's bindings.

Functions

func DecodeJSON

func DecodeJSON[T any](text json.RawMessage) (T, error)

DecodeJSON reads JSON text a read opened, a JSONValue's JSON or one value of JSONRead.Open, into a value of the caller's type T, with encoding/json's rules: a number reads into a declared integer field exactly, int64 and uint64 included, and into an interface (any) as a json.Number, never a float64 that rounds it. A member T does not declare is skipped, as encoding/json skips it. Text that is not one JSON value (empty, as an unfound JSONValue's is, or followed by more) is refused.

An error wraps ErrEncoding and names T and the kind of JSON value that did not fit, never the value: what a read opened is plaintext.

Types

type Bucket

type Bucket struct {
	Bits uint32
	Rows uint64
}

Bucket is one bar of a histogram of the stored match filters' set-bit counts: Rows rows whose filter sets Bits bits.

type Cause

type Cause = guest.Cause

Cause is one error in a Diagnostic's cause chain: a Code and a Message. A cause from a library outside the stack crates has no Code, and its Message is a description the guest vouches for, never that library's own text.

type Checker

type Checker struct {
	// contains filtered or unexported fields
}

Checker asks the embedded engine about declarations, with no credentials, no keyset and no network: what stashgen uses to refuse a declaration the engine would refuse, and to learn which EQL types the engine produces. It holds a guest instance that was never given a client key, so every other operation on it is ErrState.

func NewChecker

func NewChecker(ctx context.Context) (*Checker, error)

NewChecker instantiates the embedded guest for the generator's questions.

func (*Checker) Check

func (k *Checker) Check(ctx context.Context, plan *record.Plan) error

Check refuses a plan the engine would refuse: an index its field's type does not admit, a context that is not a label, two fields under one identity. The error is ErrEncoding. A refusal from the engine is a *Diagnostic that names the field with [Diagnostic.Field] and the rule with [Diagnostic.Reason].

func (*Checker) Close

func (k *Checker) Close() error

Close releases the guest.

func (*Checker) Operators

func (k *Checker) Operators(ctx context.Context, plan *record.Plan, field string) ([]record.Operator, error)

Operators lists the operators one field of a plan answers, in the engine's order, each with its fidelity, its arity and the index that answers it: exactly the operators Cipher.QueryTerm derives a term for on that field, so stashgen writes one query method for each and holds no copy of which index answers which operator. A field the plan does not have is ErrEncoding.

func (*Checker) Targets

func (k *Checker) Targets(ctx context.Context) ([]record.Target, error)

Targets lists the EQL types this build of the engine produces: none until the EQL target dispatch lands.

type Cipher

type Cipher struct {
	// contains filtered or unexported fields
}

Cipher is a Client bound to one keyset, and to any extension of the context: the Go form of the Rust crate's KeysetCipher. Generated code seals values, derives terms and opens records through it; a program never calls the engine directly. A Cipher opens only its own keyset's ciphertexts — a leaf sealed under another keyset is refused as ErrForeignKeyset before any key is retrieved. To open ciphertexts from any keyset, pass the Client where a Decrypter is taken. A Client opens only rows that a cipher with no extension encrypted; rows sealed through Cipher.Extend open only through a cipher with the same extension.

A Cipher holds no guest state: the keyset is selected on every call, and loaded by the guest on first use, so one is cheap to make per request or per tenant.

func (*Cipher) Client

func (cph *Cipher) Client() *Client

Client is the client this cipher belongs to.

func (*Cipher) Context

func (cph *Cipher) Context(label string) *Cipher

Context returns a cipher that names the context every row through it is under, for a type whose context is one of its own fields (a field tagged `context_field`): the Rust chain's `.context(..)`. Through such a cipher, Decrypt checks each row's stored context field against the label before any key is retrieved and refuses a row that names another with ErrContextMismatch; Encrypt refuses a value whose context field says otherwise the same way, before anything is sent; a query derives its term under the label, which such a type cannot derive otherwise; and a field's own Encrypt seals the one value under it. Without a Context, Encrypt and Decrypt take each row's context from the row, and a query has no context to derive under. A type with a `context=` tag has its context already, so every call through a cipher with a Context refuses it as ErrEncoding. The label is segments separated by '/', such as "tenants/acme"; one that is not a plain label is reported by the first call, as ErrEncoding.

func (*Cipher) ContextLabel

func (cph *Cipher) ContextLabel() string

ContextLabel is the label Cipher.Context named, or "" for a cipher that names none.

func (*Cipher) Extend

func (cph *Cipher) Extend(parts ...any) *Cipher

Extend returns a cipher that extends the context of every field, in every call through it: the write, the query and the read. It appends to the context the tags declare and never replaces it, so a field sealed through cipher.Extend("tenant-42") opens and matches only through a cipher with the same extension. A part is a string, a byte slice or an integer (int32, int64, uint32, uint64; Go's int is sent as int64); a byte slice is copied. Several parts nest in order: Extend(a, b) is Extend(a).Extend(b). An unsupported part type is reported by the first call through the cipher, as ErrEncoding.

func (*Cipher) Extension

func (cph *Cipher) Extension() []any

Extension is the parts the cipher extends every field's context by, in order. Empty for a cipher straight from Client.Keyset.

func (*Cipher) JSONOpen

func (cph *Cipher) JSONOpen(ctx context.Context, plan *record.Plan, field string, request vcvalue.Object, rows [][][]byte) ([][][]byte, error)

JSONOpen opens the rows a read of a field that stores a JSON document returned (the guest's se_json_open), against the request that prepared the read: {"operation": "selector" | "subtree", "path", "wildcard_bound"}. rows holds one group per database row: for selector, the entry col -> selector returned, or none for SQL NULL; for subtree, the rows eql_v4.json_tree_subtree returned. Each entry is the public.eql_v4_json_entry JSON the database sent.

The engine derives the selector or the read's key again from the request, the field's declaration and this cipher's keyset, and checks every row against it; the rows never say which path they are for. A row that is not one the read asked for is ErrEncoding; a wildcard read of an array longer than its bound is too, and so is a selector entry that does not open alone: a container with members, whose entry commits to children col -> does not return, or an altered entry, which the client cannot tell apart (code stack_encrypt::json_not_a_leaf). Read a container with a subtree read. What comes back is, per group, the JSON text of each value the read reached, in document order: plaintext, which the caller owns. One ZeroKMS request for every group. For generated code.

Opening does not prove the database returned every row the read asked for, or rows of the database row the caller meant: see the EQL json_tree leakage reference.

func (*Cipher) Keyset

func (cph *Cipher) Keyset() KeysetSelector

Keyset is the selector this cipher is bound to.

func (*Cipher) KeysetID

func (cph *Cipher) KeysetID(ctx context.Context) (KeysetID, error)

KeysetID resolves the cipher's keyset to its id: Rust's KeysetCipher::keyset_id, and in Go the one explicit resolution point, since a Cipher is made without a request. The first use of a name or id on the client is one ZeroKMS round trip, later uses come from the guest's cache; the default keyset never makes a request. Use it at boot to validate a tenant's keyset and learn its id.

func (*Cipher) Open

func (cph *Cipher) Open(ctx context.Context, plan *record.Plan, records []record.Sealed) ([]record.Source, error)

Open decrypts records sealed under the plan: every row, in order, with one ZeroKMS request for each 500 sealed values. A record from another keyset is ErrForeignKeyset; under Cipher.Context, a record whose stored context field names another context is ErrContextMismatch. Both are refused before any key is retrieved. For generated code.

func (*Cipher) QueryTerm

func (cph *Cipher) QueryTerm(ctx context.Context, plan *record.Plan, field, operator string, input any) (QueryTerm, error)

QueryTerm derives the query term for one field of the plan, an operator and its input under this cipher's keyset: one value for every operator but between, whose input is a two-element slice, low then high, and a document's operators (contains, eq_at, selector, ...), whose input is a vcvalue.Object of named inputs with JSON values as JSON text. The engine reads the input as the field's declared type, finds the index that answers the operator, and derives that index's term under the field's context; for a field that names an EQL type it runs the type's query. An operator the field does not answer, or one the engine does not know, is ErrEncoding naming the field, as a mistyped value is; match text that cuts no token is ErrTerm. A range the wrong way round is not refused: it matches no row, as SQL's BETWEEN does. No data key is minted. For generated code.

func (*Cipher) Seal

func (cph *Cipher) Seal(ctx context.Context, plan *record.Plan, rows []record.Source) ([]record.Sealed, error)

Seal encrypts rows under the plan, through this cipher's keyset and with its extension: the sealed fields of every row, in order, with one ZeroKMS request for each 500 sealed values. For generated code.

func (*Cipher) TargetLimit

func (cph *Cipher) TargetLimit(ctx context.Context, plan *record.Plan, field, operator string, term []byte, want uint64, stats Stats) (Limit, error)

TargetLimit is how many rows to fetch so that, at the confidence stats names, want of them verify: term is the probabilistic term's bytes as Cipher.QueryTerm returned them. An exact operator has no fetch to size and is ErrEncoding, as are bytes the field's index would not derive. Uses no key. For generated code.

func (*Cipher) Verify

func (cph *Cipher) Verify(ctx context.Context, plan *record.Plan, field, operator string, input any, values []any) ([]bool, error)

Verify reports, for each of a page of decrypted values, whether it really matches a probabilistic query: one call into the guest for the page. The query is named as it was derived, by the field, the operator and the input; the engine cuts the input's tokens again with the field's own tokenizer, so nothing here holds a copy of it. A nil value is false. An exact operator has nothing to verify and is ErrEncoding. Uses no key. For generated code.

type Ciphertext

type Ciphertext []byte

Ciphertext is one sealed field as a database column holds it: the frozen stack-encrypt leaf encoding (version, keyset id, IV, ZeroKMS tag, ciphertext). A generated type holds one for each field sealed in separate columns, beside that field's terms. It is a distinct type from vitaminc's own leaf on purpose: a stack-encrypt leaf is not decryptable by vitaminc-encrypt and must never scan or marshal where one belongs.

A sealed field is one scalar — a string, a number, a bool or a []byte, or a type defined over one, or a pointer to either, whose nil is sealed as a null as long as a value or stored as SQL NULL — and seals as vitaminc's tagged leaf, which a Rust record opens when its field is a Value (not a bare u32 or String). A struct, slice or map seals only as part of an opaque struct, which crosses as one storage blob and is one leaf; stashgen refuses it anywhere else, because the engine would seal it as a tree of leaves and a column holds one.

func (*Ciphertext) Scan

func (c *Ciphertext) Scan(src any) error

Scan implements sql.Scanner, loading a leaf from a byte column.

func (Ciphertext) Value

func (c Ciphertext) Value() (driver.Value, error)

Value implements driver.Valuer, binding the leaf as a byte column.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is one wasm instance holding one ZeroKMS client: its key, its default keyset, and the keysets it has loaded since. It is safe for concurrent use; the guest runs one call's step at a time, because a wasm instance is single-threaded, but never waits on the network inside one: a ZeroKMS request is performed between steps, with the client free for other callers (see step.go). Close it when done, as with any resource.

A call whose context ends fails with the context's error, and only that call: one request's cancellation or deadline never closes the client for the others sharing it. A call waiting its turn leaves the queue when its context ends; a call waiting on ZeroKMS has its request cancelled, and the error matches both the context's error and ErrTransport. The client closes itself, and every later call is ErrState, only when the guest's state is unknown: it trapped, or it went on running long after its caller gave up, which is a bug.

Its key material lives in the guest's linear memory, which this package supplies: reserved once so it never moves, locked in RAM and excluded from core dumps where the platform allows, and wiped before it is released. None of that depends on Close running — no exit path a process can take (a signal with no handler, SIGKILL, the OOM killer, a panic on another goroutine, os.Exit) runs deferred calls, and none of them is where the protection lives. Client.MemoryLocked reports whether the lock was granted.

func NewClient

func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error)

NewClient resolves the credentials, instantiates the guest, loads the client key into it, and loads the default keyset — one ZeroKMS round trip, which is where credentials that resolve but do not work fail: a token that cannot be minted or is refused fails here, not at first use. The returned client is ready to seal.

With no options it is a working client: the credentials are AutoCredentials, and every other setting has a default. Each ClientOption changes one.

Example
// With no options, NewClient uses AutoCredentials. To supply the
// credentials yourself, the token comes from an auth strategy —
// here an access key; live_test.go has a real round trip. The caller
// opened the store and the strategy, and closes them after the client.
ctx := context.Background()
err := func() error {
	store, err := auth.OpenWithoutProfile(ctx)
	if err != nil {
		return err
	}
	defer store.Close()
	strategy, err := store.AccessKey(ctx, "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY", "CSAK...")
	if err != nil {
		return err
	}
	defer strategy.Close()
	client, err := NewClient(ctx, WithCredentials(NewCredentials(
		"6a70bd18-99ac-4650-b104-37eec3a15b09",
		NewClientKey([]byte("...")), // not a real key: NewClient refuses it
		strategy,
	)))
	if err != nil {
		return err
	}
	return client.Close()
}()
fmt.Println(err != nil)
Output:
true

func (*Client) Close

func (c *Client) Close() error

Close shuts the guest down — the client key and every loaded index key are wiped inside the instance — and releases the runtime and the guest's memory, which is wiped on the way out. Idempotent. Every call after it fails with ErrState.

It takes no context because it does no I/O and must not be skippable: a deferred Close is ordinary resource hygiene, and the memory's protection (see Client) does not wait on it.

func (*Client) DefaultKeyset

func (c *Client) DefaultKeyset() *Cipher

DefaultKeyset binds the client to its default keyset — the one a ZeroKMS administrator set for this client, which is what naming no keyset resolves to: Rust's StackCipher::default_keyset. Loaded at NewClient, so using it never touches ZeroKMS. There is no way to redefine it from here; which keyset is the default is the server's to say. Any other keyset is Client.Keyset.

func (*Client) Keyset

func (c *Client) Keyset(sel KeysetSelector) *Cipher

Keyset binds the client to one keyset, by name or by id: Rust's StackCipher::keyset. No request is made here — Go has no await, so the keyset is resolved by the guest on the cipher's first use (and cached), which makes a Cipher cheap to make per call, per tenant or per request. Cipher.KeysetID is the explicit resolution point. A nil selector gives a cipher whose every call fails with ErrEncoding; the default keyset is Client.DefaultKeyset.

func (*Client) LogValue

func (c *Client) LogValue() slog.Value

LogValue implements slog.LogValuer: a group with memory_locked and, when false, memory_lock_error.

func (*Client) MemoryLockError

func (c *Client) MemoryLockError() error

MemoryLockError is why MemoryLocked is false: an error wrapping ErrMemoryLock that names what was refused and the limit that refused it, for this guest, the credentials' memory, or both. Nil while every one is locked.

func (*Client) MemoryLocked

func (c *Client) MemoryLocked() bool

MemoryLocked reports whether the memory the client's key material lives in — this guest's, where the client key and every loaded index key are, and whatever the credentials held it in on the way (auth's credential guest, for AutoCredentials) — is locked in RAM and, on Linux, excluded from core dumps. False means a lock was refused (on Linux, most often RLIMIT_MEMLOCK, which defaults to 64 KiB on many hosts) or is not available on this platform, and the client is working on with memory the kernel may swap out. Nothing else changes. A production checklist should assert this, or set WithRequireLockedMemory and let NewClient refuse. Client.MemoryLockError says why.

func (*Client) Open

func (c *Client) Open(ctx context.Context, plan *record.Plan, records []record.Sealed) ([]record.Source, error)

Open decrypts records sealed under the plan by any keyset of this client: each record is opened under the keyset that sealed it, with one ZeroKMS request for each 500 sealed values from each keyset. For generated code.

func (*Client) String

func (c *Client) String() string

String implements fmt.Stringer so that a Client printed with %v or %s shows its memory state: "locked", or the refusal. Nothing secret is printed. The state is what an operator reading a startup log needs to see, and Client.LogValue gives it structured form.

type ClientKey

type ClientKey = guest.ClientKey

ClientKey is the ZeroKMS client key: long-lived key material that lives for the process. It is opaque — it prints a redaction under every verb and hands its bytes to no caller — and it is wiped once consumed.

It is the one type both guest packages share: auth reads one out of the developer profile, and this package consumes it. The alias is what makes a key read there the type taken here without auth importing this package, so a binary that only wants the profile does not carry the crypto guest.

func NewClientKey

func NewClientKey(b []byte) *ClientKey

NewClientKey wraps key material — the CS_CLIENT_KEY hex form, or the base64 of secretkey.json — as a ClientKey. It takes ownership of b: the caller must not keep or reuse the slice, which is wiped along with the key.

type ClientOption

type ClientOption func(*clientOptions)

ClientOption configures NewClient. Each one sets one thing; a later option setting the same thing wins. NewClient with none is a working client, with its credentials from AutoCredentials.

func WithCredentials

func WithCredentials(c Credentials) ClientOption

WithCredentials supplies the client id, the client key and the auth strategy the token comes from. The default, and what nil means, is AutoCredentials: the environment, then the developer profile. NewCredentials takes the three explicitly, and OIDCFederation mints tokens from an identity provider's.

The client key is consumed: NewClient marshals it into the config buffer, wipes the key, and wipes the buffer once the guest has the key, so after NewClient returns — whatever the outcome, a configuration it refused included — the key is empty and the bytes it was built from are zero. A key is for one client. That holds for credentials a later WithCredentials replaces as well: they are consumed, not left holding a live key.

func WithKeysetCacheSize

func WithKeysetCacheSize(n int) ClientOption

WithKeysetCacheSize sets how many keysets beyond the default the guest keeps loaded. Zero means the crate default (1024); negative is refused.

func WithRequireLockedMemory

func WithRequireLockedMemory() ClientOption

WithRequireLockedMemory makes NewClient fail with ErrMemoryLock when the guest's memory cannot be locked in RAM or, on Linux, excluded from core dumps, instead of continuing with memory that may be swapped or dumped and reporting so through Client.MemoryLocked. It holds for the life of the client: a later growth of the guest's memory that cannot be locked is refused too, and what the guest already holds stays locked. When the growth was for a buffer the host is staging, the call fails with ErrMemoryLock and the client goes on. When it was for the guest's own allocation, the guest cannot report it: it aborts, and the client is closed with its keys wiped, the call still failing with ErrMemoryLock. Set it where swap is a real exposure and the deployment grants a lock limit with room for the guest to grow (RLIMIT_MEMLOCK on Linux; the error names the size held so far); see Client.MemoryLocked.

It covers the credential guest too, where the token strategy lives and the client key may have passed through. AutoCredentials and OIDCFederation open that guest under the same policy, so it is refused at NewClient and on every later growth alike. NewCredentials' guest is the auth store the caller opened: NewClient fails with ErrMemoryLock if that store's memory is unlocked when it is asked, but only the store's own policy governs its later growth, so open it with auth.RequireLockedMemory to hold it locked for the life of the client. Client.MemoryLocked reports the store's state live either way.

func WithTransport

func WithTransport(rt http.RoundTripper) ClientOption

WithTransport performs the client's HTTP requests: to ZeroKMS, and, for AutoCredentials and OIDCFederation, the authentication requests auth's credential guest makes to CTS: an access-key exchange, a device-session refresh, a federation exchange. A RoundTripper scoped to the ZeroKMS host alone (a pinned client certificate, an egress allowlist) refuses those; the failure then surfaces as the token strategy's. Under NewCredentials the token exchange runs in the store the caller opened, not through this RoundTripper: pass auth.WithRoundTripper to that store instead. Nil means http.DefaultTransport, the default.

type Credentials

type Credentials interface {
	// contains filtered or unexported methods
}

Credentials is where a Client's ZeroKMS credentials come from: the client id, the client key, and the auth strategy that supplies the bearer token. NewClient resolves them once, host-side — the crypto guest is never given the environment or a filesystem to look them up itself — and hands the key to the guest.

AutoCredentials is the default: the environment, then the developer profile, in the Rust client's order. NewCredentials takes a client id, a client key and a strategy explicitly; OIDCFederation mints the token from an identity provider's. Those three are the only implementations: the interface is sealed, so a bearer token always comes from an auth strategy. A raw token cannot be refreshed when it expires, and a source outside the strategies would bypass the cross-process refresh lock the device session shares with the CLI (a refresh token used twice gets the whole chain revoked).

func AutoCredentials

func AutoCredentials() Credentials

AutoCredentials is Credentials from the environment first, then the developer profile `stash auth login` writes — the order the Rust client (stack-encrypt's StackCipher::builder().init()) resolves them in, so one set of variables configures a service in either language:

  • The token, by stack-auth's AutoStrategy order: CS_CLIENT_ACCESS_KEY (with CS_WORKSPACE_CRN, which is then required) exchanged for a token; else the current workspace's stored device session, refreshed under the same cross-process lock as the CLI. CS_CTS_HOST overrides the authentication endpoint.
  • The client key: CS_CLIENT_ID and CS_CLIENT_KEY when both are set (the hex form, or the base64 of secretkey.json); else the current workspace's secretkey.json. Only one of the two set is the same as neither. Set but empty is an error, not a fall-through.

The profile is CS_CONFIG_PATH, else ~/.cipherstash; a profile that cannot be opened is not an error — an environment-only deployment has none — it just leaves the environment as the only source, as the Rust client does. Nothing found in either place is ErrNoCredentials, and when the profile would have been consulted, that error says why it could not be: a directory that does not exist, one that cannot be read, a path that is not a directory.

The profile and the token strategies run in auth's credential guest, not in the crypto guest, which still sees no environment and no filesystem. The credential guest lives as long as the client, and Client.Close releases it.

func NewCredentials

func NewCredentials(clientID string, key *ClientKey, strategy *auth.Strategy) Credentials

NewCredentials is Credentials from explicit values: a client id, a client key (from NewClientKey, or auth's typed read), and the auth strategy that supplies the bearer token (ProfileStore's AccessKey, DeviceSession, OIDC or Auto). The key is consumed by the first NewClient given these credentials; a second is refused with ErrCredentialsConsumed, as a key is for one client. A nil strategy is refused by NewClient.

The caller opened the strategy's ProfileStore and the strategy, and keeps them: the client asks the strategy for a token on every ZeroKMS request but never closes it or the store. Both must stay open until Client.Close has returned, and are the caller's to close after it — the strategy, then the store (closing the store closes its strategies too). A token asked of a closed strategy is an error from the operation that needed it.

func OIDCFederation

func OIDCFederation(crn string, provider auth.OIDCProvider, opts ...auth.StrategyOption) Credentials

OIDCFederation is Credentials whose token is minted by federation: CTS exchanges a token from the application's own identity provider (Clerk, Auth0, Okta, a cloud workload identity) for a CipherStash one in the workspace crn names. provider is asked on every token fetch for the IdP token of the user the call is for, and each distinct IdP token is exchanged once while its CipherStash token lasts, so one client serves many users without one user ever riding another's token; auth.OAuth2TokenSource adapts a golang.org/x/oauth2 source. The client key is resolved as AutoCredentials resolves it: CS_CLIENT_ID and CS_CLIENT_KEY, else the developer profile.

opts configure the federation strategy as they would auth.ProfileStore.OIDC: auth.WithBaseURL pins the CTS endpoint for these credentials alone (without it, CS_CTS_HOST overrides the endpoint, else it is discovered), and auth.WithCacheCapacity sets how many users' tokens are kept (1024 unless set).

type Date

type Date = vcvalue.Date

Date is a calendar date, with no time of day and no time zone: Year, Month and Day, in the proleptic Gregorian calendar, years -262143 to 262142. Validate reports a date that does not exist (February 30).

The zero Date (year 0, month 0, day 0) is not a date: a Date field must be set before it is encrypted or queried, and a zero one is refused with ErrEncoding, naming the field. An optional date is a *Date field: nil is stored as a sealed null as long as a date, or, with the tag's null=sql, as SQL NULL (see stashgen's README, "Optional fields").

func DateOf

func DateOf(t time.Time) Date

DateOf is the calendar day t falls on in its own location: what t.Date() returns. A local midnight east of UTC is that day, not the UTC day before it.

type Decimal

type Decimal = vcvalue.Decimal

Decimal is a finite decimal number with its scale: "1.50" is not "1.5", and opens as it was sealed, except that a zero has no sign: -0.00 opens as 0.00, at the same scale. Its mantissa fits 96 bits and its scale is at most 28, rust_decimal's range. There is no NaN and no infinity: the type cannot hold one, and ParseDecimal refuses one. An equality term normalises the scale, so 1.5, 1.50 and 1.500 match each other.

func ParseDecimal

func ParseDecimal(s string) (Decimal, error)

ParseDecimal reads a decimal from its fixed-point text ("-1234.5600"), keeping its scale, without rounding. NaN and the infinities are ErrNonFiniteDecimal; a value with more than 28 digits after the point or a mantissa past 96 bits is refused too.

type Decrypter

type Decrypter interface {
	Open(ctx context.Context, plan *record.Plan, records []record.Sealed) ([]record.Source, error)
}

Decrypter opens records: a *Cipher, which refuses a record sealed under another keyset before any key is retrieved, or a *Client, which opens each record under the keyset that sealed it. A *Client opens only records that a cipher with no extension sealed, and checks no stored context field against a label: that is Cipher.Context. Generated Decrypt functions take one. Its method is for generated code; a program does not call it.

type Diagnostic

type Diagnostic = guest.Diagnostic

Diagnostic is the full error behind a failure the guest reports, beside the kind: every such failure is a *Diagnostic wrapping one of the sentinels above, so errors.Is matches the kind and errors.As reads the rest:

var d *encrypt.Diagnostic
if errors.As(err, &d) {
	log.Printf("%s: %s (%s)", d.Code, d.Message, d.Help)
}

Its fields are Code ("stack_encrypt::foreign_keyset", "stack_kms::keyset_not_found", ...; stable), Message (what Error returns after the sentinel's text), Help, URL, Severity, Fields (the structured fields, by name) and Causes (the errors behind it, outermost first). Accessors read the fields a caller branches on: ExpectedKeyset and FoundKeyset on an ErrForeignKeyset (each a KeysetID's bytes); Field and Reason on a refused plan, record or value; and Construct and At on a refused Match predicate, the construct in the grammar's words and the byte offset in the predicate where it starts.

What one may carry is fixed: keyset ids and names, field names, counts, index kinds, ZeroKMS request kinds and HTTP statuses, and a refused predicate's construct and offset. Never plaintext, key material, tokens, ciphertext or term bytes, or the values of an encryption context.

Errors the client raises itself carry none: a closed client (ErrState), a guest that did not return, ErrMemoryLock, and an argument refused before it reached the guest. A guest built before the detail existed returns the bare sentinel too.

It is the same type as auth.Diagnostic, by identity.

type EQLMatchQuery

type EQLMatchQuery[Q ~[]byte, T ~string] struct {
	// contains filtered or unexported fields
}

EQLMatchQuery is a matches query on a field that names an EQL type with a match index, such as TextMatch: the type's query value to bind in SQL (EQLMatchQuery.Query, cast to the type's query domain), and the same two questions a MatchQuery leaves, EQLMatchQuery.Verify and EQLMatchQuery.TargetLimit.

Like a MatchQuery it keeps the text it was derived from, for Verify, and never prints it. Q is the EQL query type (eql.TextMatchQuery), T the field's Go type. The zero EQLMatchQuery holds no query, and its methods return ErrEncoding.

func NewEQLMatchQuery

func NewEQLMatchQuery[Q ~[]byte, T ~string](ctx context.Context, cph *Cipher, plan *record.Plan, field string, text T) (EQLMatchQuery[Q, T], error)

NewEQLMatchQuery derives the matches query for text on one field of the plan that names an EQL type, under this cipher's keyset, through Cipher.QueryTerm. For generated code: a program calls the field's Matches method.

func (EQLMatchQuery[Q, T]) Format

func (q EQLMatchQuery[Q, T]) Format(f fmt.State, _ rune)

Format prints EQLMatchQuery.String for every verb fmt hands it.

func (EQLMatchQuery[Q, T]) GoString

func (q EQLMatchQuery[Q, T]) GoString() string

GoString is what %#v prints: EQLMatchQuery.String.

func (EQLMatchQuery[Q, T]) LogValue

func (q EQLMatchQuery[Q, T]) LogValue() slog.Value

LogValue is what slog records: the field, the domain and the value's size.

func (EQLMatchQuery[Q, T]) Query

func (q EQLMatchQuery[Q, T]) Query() Q

Query is the EQL query value to bind in SQL, cast to its query domain. Nil for the zero EQLMatchQuery.

func (EQLMatchQuery[Q, T]) String

func (q EQLMatchQuery[Q, T]) String() string

String names the field, the query domain and the value's size, never the text.

func (EQLMatchQuery[Q, T]) TargetLimit

func (q EQLMatchQuery[Q, T]) TargetLimit(ctx context.Context, want uint64, stats Stats) (Limit, error)

TargetLimit is how many rows to fetch so that, at the confidence stats names, want of them verify: the LIMIT to send with the query. With no measured Stats.Histogram the limit is unbounded, and a caller pages with Limit.FetchOr. Uses no key.

func (EQLMatchQuery[Q, T]) Value

func (q EQLMatchQuery[Q, T]) Value() (driver.Value, error)

Value implements driver.Valuer: the query value's JSON text. The zero EQLMatchQuery returns ErrEncoding, as the zero MatchQuery does.

func (EQLMatchQuery[Q, T]) Verify

func (q EQLMatchQuery[Q, T]) Verify(ctx context.Context, values []T) ([]bool, error)

Verify reports, for each of a page of decrypted values of the field, whether it really holds the query's text, in order: one call into the engine for the page, with no key, under the EQL type's match options. Keep the rows it reports true.

type ElementPredicate

type ElementPredicate[E any] struct {
	// contains filtered or unexported fields
}

ElementPredicate is one test on a member of the elements of the array E, for that array's generated Match. Make one with AtPath, or with the generated array accessor's Member.

Its member names one field of each element, relative to the element: relationship, or address.city, written as the field's ordered path is written below the array. Its full path must be one the field orders.

func (ElementPredicate[E]) Format

func (p ElementPredicate[E]) Format(f fmt.State, _ rune)

Format prints ElementPredicate.String for every verb fmt hands it.

func (ElementPredicate[E]) GoString

func (p ElementPredicate[E]) GoString() string

GoString is what %#v prints: ElementPredicate.String.

func (ElementPredicate[E]) LogValue

func (p ElementPredicate[E]) LogValue() slog.Value

LogValue is what slog records: the member and the comparison, and [redacted] for the values.

func (ElementPredicate[E]) Member

func (p ElementPredicate[E]) Member() string

Member is the member the predicate tests, relative to an element.

func (ElementPredicate[E]) Op

func (p ElementPredicate[E]) Op() string

Op is the comparison, as the engine names it: eq, lt, lte, gt, gte or between; "" for a zero ElementPredicate.

func (ElementPredicate[E]) String

func (p ElementPredicate[E]) String() string

String names the member and the comparison, and hides the values.

func (ElementPredicate[E]) Values

func (p ElementPredicate[E]) Values() []any

Values are a copy of the predicate's values: one, or for between the low bound then the high. They are the plaintext the caller gave. The slice and the bytes of a json.RawMessage or []byte in it are copies, so changing them does not change the predicate; a value of another reference type is the caller's own. For generated code.

type EqualityTerm

type EqualityTerm []byte

EqualityTerm is a PRF equality term. Two terms derived under the same keyset and context from equal values are equal bytes; nothing else about the value is revealed.

func (EqualityTerm) Equal

func (t EqualityTerm) Equal(other EqualityTerm) bool

Equal compares two equality terms in constant time.

func (*EqualityTerm) Scan

func (t *EqualityTerm) Scan(src any) error

Scan implements sql.Scanner.

func (EqualityTerm) Value

func (t EqualityTerm) Value() (driver.Value, error)

Value implements driver.Valuer.

type Index

type Index interface {
	// Output is the index's wire key.
	Output() record.Output
	// String is the index's tag word.
	String() string
}

Index is one index on a field, as a generated declaration names it: Equality, Ore, Ope, Match or JSON. The words are the Rust API's words for the same behaviour, and a declaration in any language spells them the same way.

var (
	// Equality is a PRF equality term: 32 bytes, compared with
	// [EqualityTerm.Equal]. Defined for integers, strings and bytes.
	Equality Index = index(record.Equality)
	// Ore is an order-revealing (CLLW ORE) term over any scalar.
	Ore Index = index(record.Ore)
	// Ope is an order-preserving (CLLW OPE) term over any scalar.
	Ope Index = index(record.Ope)
)

The indexes that take no options.

func JSON deprecated

func JSON(options ...JSONOption) Index

JSON is the index over a JSON document as an output beside a ciphertext, which the engine does not derive: a declaration that names it is refused by stashgen, and at run time by the engine.

Deprecated: store a JSON document as a struct, map, slice or json.RawMessage field with `encrypt_into=JsonSearch`, whose EQL type carries the JSON index.

func Match

func Match(options ...MatchOption) Index

Match is a full-text match index: the tokenized positions of a string. Strings only.

type Int128

type Int128 = vcvalue.Int128

Int128 is a signed 128-bit integer, two's complement, little-endian. ParseInt128 reads one from its decimal text, and String writes it.

func ParseInt128

func ParseInt128(s string) (Int128, error)

ParseInt128 reads a signed 128-bit integer from decimal text, refusing a value outside its range.

type JSONOption

type JSONOption interface {
	// contains filtered or unexported methods
}

JSONOption is an option of the json index.

type JSONRange

type JSONRange[Q ~[]byte] struct {
	Low  Q
	High Q
}

JSONRange is both bounds of a between at a path, each an ordering operand Q, low then high, inclusive.

type JSONRead

type JSONRead[E ~[]byte] struct {
	// contains filtered or unexported fields
}

JSONRead is a prepared subtree read of a JSONSearch field: the tokens to bind, and what opens the rows eql_v4.json_tree_subtree returns for them. It keeps the request it was prepared from (the field's declaration, the path, the bound and the cipher's keyset); the engine derives the read again from it, and checks every row against it. E is the column's entry type, eql.JSONEntry.

func NewJSONRead

func NewJSONRead[E ~[]byte](c *Cipher, declaration *record.Plan, field string, input vcvalue.Object, tokens []string) JSONRead[E]

NewJSONRead is a read prepared by Cipher.QueryTerm's subtree operator on field of the record type that declaration declares, from input, its {"path", "wildcard_bound"?} object. For generated code.

func (JSONRead[E]) Format

func (r JSONRead[E]) Format(f fmt.State, _ rune)

Format prints JSONRead.String for every verb fmt hands it.

func (JSONRead[E]) GoString

func (r JSONRead[E]) GoString() string

GoString is what %#v prints: JSONRead.String.

func (JSONRead[E]) LogValue

func (r JSONRead[E]) LogValue() slog.Value

LogValue is what slog records: the field, [redacted] for the path, and how many tokens.

func (JSONRead[E]) Open

func (r JSONRead[E]) Open(ctx context.Context, rows [][]E) ([][]json.RawMessage, error)

Open opens the rows the read returned, one group per database row, with one ZeroKMS request: per row, the value at each concrete path the read named that the row has, in document order. A row with no node there opens to no value. Past its walk budget the database returns the sealed root instead, and the read's paths are evaluated over the whole document to the same values; only those values come back. A row that is not one the read asked for, or a wildcard read of an array longer than the bound, is ErrEncoding.

Opening checks each row against the read, not that the database returned every row it should have, nor that the rows came from the row the caller meant.

func (JSONRead[E]) OpenOne

func (r JSONRead[E]) OpenOne(ctx context.Context, entries []E) (JSONValue, error)

OpenOne opens one database row's entries to the one value at the read's path: Found with its JSON, or not Found where the row has no node there. A read that reaches more than one value (a wildcard) is ErrEncoding; open it with JSONRead.Open.

func (JSONRead[E]) String

func (r JSONRead[E]) String() string

String names the field and counts the tokens, and hides the path and the tokens: a path's keys can be a customer's data, and a token is a capability.

func (JSONRead[E]) Tokens

func (r JSONRead[E]) Tokens() JSONTokens

Tokens are the tokens to bind: `eql_v4.json_tree_subtree(col, $1::text[], 64)`.

type JSONReadOptions

type JSONReadOptions struct {
	// WildcardBound is how many elements each [*] in the path is expanded
	// to; 0 is the engine's default, 64. A read of an array longer than the
	// bound fails rather than open to part of it ([ErrEncoding], with the
	// diagnostic code stack_encrypt::json_wildcard_truncated). Two wildcard
	// levels send bound² + bound + 1 tokens, and the engine refuses a read
	// past 8,192.
	WildcardBound uint32
}

JSONReadOptions are a subtree read's options.

type JSONSelection

type JSONSelection[E ~[]byte] struct {
	// contains filtered or unexported fields
}

JSONSelection is a prepared `->` on a JSONSearch field: the selector to bind, and what opens the entries the database returns for it. It keeps the request it was prepared from (the field's declaration, the path and the cipher's keyset), so the entries open only as answers to it: the engine derives the selector again and refuses an entry that does not carry it. E is the column's entry type, eql.JSONEntry.

func NewJSONSelection

func NewJSONSelection[E ~[]byte](c *Cipher, declaration *record.Plan, field, path, selector string) JSONSelection[E]

NewJSONSelection is a selection prepared by Cipher.QueryTerm's selector operator on field of the record type that declaration declares, at path. For generated code.

func (JSONSelection[E]) Format

func (s JSONSelection[E]) Format(f fmt.State, _ rune)

Format prints JSONSelection.String for every verb fmt hands it.

func (JSONSelection[E]) GoString

func (s JSONSelection[E]) GoString() string

GoString is what %#v prints: JSONSelection.String.

func (JSONSelection[E]) LogValue

func (s JSONSelection[E]) LogValue() slog.Value

LogValue is what slog records: the field, and [redacted] for the path and the selector.

func (JSONSelection[E]) Open

func (s JSONSelection[E]) Open(ctx context.Context, rows []*E) ([]JSONValue, error)

Open opens the entries `col -> selector` returned, one per database row, nil for a row where it returned SQL NULL, with one ZeroKMS request. Each opens to the scalar it holds (Found), or for the sealed root at "$" the whole document; a nil opens to no value. A container's entry is refused: read a container with a subtree read. An entry that is not the selector's is ErrEncoding.

func (JSONSelection[E]) Selector

func (s JSONSelection[E]) Selector() JSONSelector

Selector is the selector to bind: `col -> $1::text`.

func (JSONSelection[E]) String

func (s JSONSelection[E]) String() string

String names the field and hides the path and the selector: a path's keys can be a customer's data.

type JSONSelector

type JSONSelector string

JSONSelector is a JSON path's selector: what `->`, `->>` and the path functions of a JSONSearch column take. It binds as SQL text; cast it, `col -> $1::text`, so PostgreSQL picks EQL's operator and not jsonb's. It is column-wide: the same path has the same selector in every row.

func (JSONSelector) Value

func (s JSONSelector) Value() (driver.Value, error)

Value binds the selector as text. A zero JSONSelector (one never prepared) is refused rather than bound as ”, which matches no row.

type JSONTerm deprecated

type JSONTerm []byte

JSONTerm is the term of the json index as an output. The engine does not derive it; the type exists so a generated declaration that names JSON has a Go type to fail into.

Deprecated: a JsonSearch field's queries are eql.JSONSearchQuery and eql.JSONSearchLeafQuery.

func (*JSONTerm) Scan

func (t *JSONTerm) Scan(src any) error

Scan implements sql.Scanner.

func (JSONTerm) Value

func (t JSONTerm) Value() (driver.Value, error)

Value implements driver.Valuer.

type JSONToken

type JSONToken string

JSONToken is one node's token: what eql_v4.json_tree_children and eql_v4.json_tree_child_count take. It binds as SQL text. A token is a capability over the node's subtree in every row of the column: send it to the database for a query, and nowhere else. It prints and logs redacted.

func (JSONToken) Format

func (t JSONToken) Format(f fmt.State, _ rune)

Format prints JSONToken.String for every verb fmt hands it.

func (JSONToken) GoString

func (t JSONToken) GoString() string

GoString is what %#v prints: JSONToken.String.

func (JSONToken) LogValue

func (t JSONToken) LogValue() slog.Value

LogValue is what slog records: [redacted], never the token.

func (JSONToken) String

func (t JSONToken) String() string

String hides the token: it is a capability, so it does not print.

func (JSONToken) Value

func (t JSONToken) Value() (driver.Value, error)

Value binds the token as text. A zero JSONToken is refused, as a zero JSONSelector is.

type JSONTokens

type JSONTokens []string

JSONTokens are a subtree read's tokens: what eql_v4.json_tree_subtree takes. They bind as a PostgreSQL text[] literal; cast the parameter, `$2::text[]`. Like a JSONToken, each is a capability, and they print and log redacted.

func (JSONTokens) Format

func (t JSONTokens) Format(f fmt.State, _ rune)

Format prints JSONTokens.String for every verb fmt hands it.

func (JSONTokens) GoString

func (t JSONTokens) GoString() string

GoString is what %#v prints: JSONTokens.String.

func (JSONTokens) LogValue

func (t JSONTokens) LogValue() slog.Value

LogValue is what slog records: how many tokens, never the tokens.

func (JSONTokens) String

func (t JSONTokens) String() string

String counts the tokens and hides them: each is a capability.

func (JSONTokens) Value

func (t JSONTokens) Value() (driver.Value, error)

Value binds the tokens as a text[] literal. A token is base64, so it needs no escape inside the quotes. Empty JSONTokens (a zero JSONRead's) are refused, as a zero JSONToken is: bound as {}, they would read nothing from every row.

type JSONValue

type JSONValue struct {
	Found bool
	JSON  json.RawMessage
}

JSONValue is one value a read reached, or none. Found is false for no value at the path (the row has no node there, or SQL NULL); a JSON null that is there is Found, with JSON "null". JSON is the value's JSON text: plaintext, which the caller owns. JSONValue prints its JSON in the clear: it has no String or LogValue method, so do not log one.

type KeysetID

type KeysetID [16]byte

KeysetID is a keyset's UUID, the identity a sealed leaf carries. It selects a keyset by id; ids are never re-resolved.

func ParseKeysetID

func ParseKeysetID(s string) (KeysetID, error)

ParseKeysetID parses a canonical hyphenated UUID.

func (KeysetID) String

func (id KeysetID) String() string

String renders the id in canonical hyphenated form.

type KeysetName

type KeysetName string

KeysetName selects a keyset by name. Its first use on a client is one ZeroKMS round trip; the binding is cached by the guest for a bounded window, after which the name is resolved again.

type KeysetSelector

type KeysetSelector interface {
	// contains filtered or unexported methods
}

KeysetSelector names the keyset a call binds to, as Rust's IdentifiedBy does: KeysetName or KeysetID. Every variant is spelled, there is no empty-string or nil sentinel; the default keyset is not a selector but Client.DefaultKeyset, exactly as it is a method and not an IdentifiedBy variant in Rust. Names follow ZeroKMS's rules (non-empty, at most 64 bytes, of A-Z a-z 0-9 _ - /), checked by the guest before any request is made; ZeroKMS itself never issues a UUID-shaped name, so a name and an id cannot be confused.

type Limit

type Limit struct {
	// contains filtered or unexported fields
}

Limit is how many rows a probabilistic query should fetch: a number, or unbounded when the statistics say nothing of the stored filters or the confidence is not below 1, in which case a caller pages with a cursor.

func (Limit) FetchOr

func (l Limit) FetchOr(page uint64) uint64

FetchOr is the rows to fetch, or page when no finite fetch can be promised, for a caller that pages either way. Never more than math.MaxInt64, the largest LIMIT PostgreSQL and database/sql take: a larger page is cut to it.

func (Limit) Rows

func (l Limit) Rows() (uint64, bool)

Rows is the rows to fetch, and false when no finite fetch can be promised.

type MatchOption

type MatchOption interface {
	// contains filtered or unexported methods
}

MatchOption is an option of the match index. None is defined yet: the record plan can carry match options, but the guest derives a query term under the default options only, so a stored term with other options would match no query.

type MatchQuery

type MatchQuery[T ~string] struct {
	// contains filtered or unexported fields
}

MatchQuery is a matches query on a field declared with a match index: the term to bind in SQL, and the two questions its probabilistic answer leaves, MatchQuery.Verify and MatchQuery.TargetLimit. The server answers a matches query with every row whose stored filter holds the query's bits, which can include rows that do not hold the query's text, so a caller decrypts the rows it fetched and verifies them.

A MatchQuery keeps the text it was derived from, because Verify cuts that text's tokens again in the engine, with the field's own tokenizer: no term carries them, as they are plaintext. Go cannot wipe a string, so the text stays in memory until the garbage collector reclaims it, as the string the caller passed in does. A MatchQuery never prints it: fmt, slog and %#v show the field and the term's size only, and the text sits behind a pointer, so a verb fmt reports as wrong (%p) or a MatchQuery in an unexported struct field, which fmt prints by reflection, shows an address.

T is the field's Go type. The zero MatchQuery holds no query, and its methods return ErrEncoding.

func NewMatchQuery

func NewMatchQuery[T ~string](ctx context.Context, cph *Cipher, plan *record.Plan, field string, text T) (MatchQuery[T], error)

NewMatchQuery derives the matches query for text on one field of the plan under this cipher's keyset, through Cipher.QueryTerm. For generated code: a program calls the field's Matches method.

func (MatchQuery[T]) Format

func (q MatchQuery[T]) Format(f fmt.State, _ rune)

Format prints MatchQuery.String for every verb fmt hands it.

func (MatchQuery[T]) GoString

func (q MatchQuery[T]) GoString() string

GoString is what %#v prints: MatchQuery.String.

func (MatchQuery[T]) LogValue

func (q MatchQuery[T]) LogValue() slog.Value

LogValue is what slog records: the field and the term's size.

func (MatchQuery[T]) String

func (q MatchQuery[T]) String() string

String names the field and the term's size, never the text.

func (MatchQuery[T]) TargetLimit

func (q MatchQuery[T]) TargetLimit(ctx context.Context, want uint64, stats Stats) (Limit, error)

TargetLimit is how many rows to fetch so that, at the confidence stats names, want of them verify: the LIMIT to send with the query. A field declared with tags states no expected token count, so with no measured Stats.Histogram the limit is unbounded, and a caller pages with Limit.FetchOr. Uses no key.

func (MatchQuery[T]) Term

func (q MatchQuery[T]) Term() MatchTerm

Term is the match term to bind in SQL: the bits the query sets in the field's filter, which a stored filter must hold for the row to match. Nil for the zero MatchQuery.

func (MatchQuery[T]) Value

func (q MatchQuery[T]) Value() (driver.Value, error)

Value implements driver.Valuer: the term's bytes. The zero MatchQuery returns ErrEncoding rather than binding NULL, which would match no row without an error.

func (MatchQuery[T]) Verify

func (q MatchQuery[T]) Verify(ctx context.Context, values []T) ([]bool, error)

Verify reports, for each of a page of decrypted values of the field, whether it really holds the query's text, in order: one call into the engine for the page, with no key. Keep the rows it reports true; the others are the false positives of the server's answer.

type MatchTerm

type MatchTerm []byte

MatchTerm is a full-text match term: the positions of the value's tokens in the keyset's token space, as little-endian uint16s.

func (MatchTerm) Positions

func (t MatchTerm) Positions() ([]uint16, error)

Positions decodes the term into its token positions.

func (*MatchTerm) Scan

func (t *MatchTerm) Scan(src any) error

Scan implements sql.Scanner.

func (MatchTerm) Value

func (t MatchTerm) Value() (driver.Value, error)

Value implements driver.Valuer.

type MemberTest

type MemberTest[E, T any] struct {
	// contains filtered or unexported fields
}

MemberTest makes the predicates on one member of an array's elements. Make one with AtPath, or with the generated array accessor's Member for a member named at run time, whose T is any.

Each comparison is of the member's ordering term, as the orderings at a path compare: numbers order as float64, so integers past 2^53 can tie, and text orders case- and accent-folded. Equality is both inclusive bounds at one value, so MemberTest.Eq is an equality of those folded values.

A value is a Go number or string, or a json.RawMessage of JSON text, written to JSON as the orderings at a path write theirs. It is plaintext: an ElementPredicate prints and logs its member and comparison, and never its values. A predicate copies the bytes of a json.RawMessage or a []byte value, so changing the caller's bytes does not change it. A value of any other reference type, such as a map, a slice or a pointer, is not copied: changing what it refers to changes the predicate.

func AtPath

func AtPath[E, T any](p Path[E, T]) MemberTest[E, T]

AtPath is the comparisons of the member at p, for the element match. Each returns an ElementPredicate of p's array, so only that array's Match takes it, and each takes a value of the member's Go type:

encrypt.AtPath(Paths.Profile.Visits.At).Gt(15)

AtPath is a function, not a method of Path, because a Go method cannot take type parameters of its own.

func (MemberTest[E, T]) Between

func (m MemberTest[E, T]) Between(lo, hi T) ElementPredicate[E]

Between is the test that the member is from lo to hi, inclusive.

func (MemberTest[E, T]) Eq

func (m MemberTest[E, T]) Eq(v T) ElementPredicate[E]

Eq is the test that the member equals v, compared by its ordering term: text is case- and accent-folded, so "sibling" equals "Sibling", and integers past 2^53 can tie. For an exact equality of one member, use the field's EqAt at the member's full path, which any element can answer.

func (MemberTest[E, T]) Gt

func (m MemberTest[E, T]) Gt(v T) ElementPredicate[E]

Gt is the test that the member is greater than v.

func (MemberTest[E, T]) Gte

func (m MemberTest[E, T]) Gte(v T) ElementPredicate[E]

Gte is the test that the member is at least v.

func (MemberTest[E, T]) Lt

func (m MemberTest[E, T]) Lt(v T) ElementPredicate[E]

Lt is the test that the member is less than v.

func (MemberTest[E, T]) Lte

func (m MemberTest[E, T]) Lte(v T) ElementPredicate[E]

Lte is the test that the member is at most v.

type OpeTerm

type OpeTerm []byte

OpeTerm is an order-preserving term (CLLW OPE): the raw term bytes, ordered as the values they encode under plain byte order, so a database compares them with no custom operator.

func (OpeTerm) Compare

func (t OpeTerm) Compare(other OpeTerm) int

Compare orders two OPE terms as their plaintexts: bytes.Compare.

func (OpeTerm) Less

func (t OpeTerm) Less(other OpeTerm) bool

Less reports whether t's plaintext orders before other's.

func (*OpeTerm) Scan

func (t *OpeTerm) Scan(src any) error

Scan implements sql.Scanner.

func (OpeTerm) Value

func (t OpeTerm) Value() (driver.Value, error)

Value implements driver.Valuer.

type OreTerm

type OreTerm []byte

OreTerm is an order-revealing term (CLLW ORE): the raw term bytes. Two terms derived under the same keyset and context order as their plaintexts through Compare and Less. Plain byte order is almost always the same order, and sometimes not, with no error: do not compare ORE terms in SQL (ORDER BY, <, >) on a byte column. Compare them in Go, or store the field as an EQL type, which the database compares correctly.

func (OreTerm) Compare

func (t OreTerm) Compare(other OreTerm) int

Compare orders two ORE terms as their plaintexts: -1, 0 or +1. Ports the CLLW comparison of the Rust crate (constant time over the term bytes): at the first differing byte the two sides share the PRF block, so they differ by exactly the plaintext bit, and the side one greater is the greater plaintext. Terms of different lengths (strings, byte slices) compare on their common prefix, then the shorter is less.

func (OreTerm) Less

func (t OreTerm) Less(other OreTerm) bool

Less reports whether t's plaintext orders before other's.

func (*OreTerm) Scan

func (t *OreTerm) Scan(src any) error

Scan implements sql.Scanner.

func (OreTerm) Value

func (t OreTerm) Value() (driver.Value, error)

Value implements driver.Valuer.

type Path

type Path[E, T any] struct {
	// contains filtered or unexported fields
}

Path is one ordered member of the elements of an array in a JSONSearch field, for the element match: the generated Fields.<Field>.Elements.<Array>.Match. Read one from the generated Paths, which names each ordered member as the JSON nests it: Paths.Profile.Visits.Place is the member place of each element of $.visits[*].

E is the array: a type the generator writes for each array, which the array's Match takes predicates of. T is the member's Go type in the document's struct, or any when the generator cannot name one (a json.RawMessage document, say). Pass a Path to AtPath for a predicate.

func NewPath

func NewPath[E, T any](array, member string) Path[E, T]

NewPath is the Path of member below array: for generated code. A program reads its paths from the generated Paths, or names a member at run time with the generated array accessor's Member.

func (Path[E, T]) String

func (p Path[E, T]) String() string

String is the member's full path: $.visits[*].place.

type QueryTerm

type QueryTerm struct {
	// Operator is the operator the term answers: eq, lt, lte, gt, gte,
	// between or matches.
	Operator string
	// Fidelity is how far the server's answer can be trusted. A
	// [record.Probabilistic] term's rows are checked with [Cipher.Verify]
	// after decrypt, and its fetch sized with [Cipher.TargetLimit].
	Fidelity record.Fidelity
	// Index is the field's index that answers the operator.
	Index record.Output
	// Domain is the PostgreSQL domain the term is cast to, for a field
	// that names an EQL type; "" for a field with indexes, whose terms are
	// bytes.
	Domain string
	// Terms is the term: one, or for between two, the low bound's then the
	// high's. For a field that names an EQL type it is the query value's
	// JSON. Empty when the term is Text.
	Terms [][]byte
	// Encoding says what the term holds: bytes (an index term), eql (an EQL
	// query value's JSON), text (a JSON path's selector or one node's
	// token, in Text[0]) or text_array (a read's tokens, in Text).
	Encoding string
	// Text is the term as text, for the text and text_array encodings: what
	// a document's selector, children, child_count and subtree operators
	// derive. It binds as SQL text, or text[].
	Text []string
}

QueryTerm is the query term the engine derives for one field, an operator and its input.

type Range

type Range[T OreTerm | OpeTerm] struct {
	// Low is the low bound's term.
	Low T
	// High is the high bound's term.
	High T
}

Range is a between query on a field declared with an order index: the term of the low bound and the term of the high bound, both inclusive, derived under one context. Its answer is exact, so it has nothing to verify. A range binds as two parameters, one for each bound, and each bound implements driver.Valuer.

An OreTerm does not order as its plaintext under plain byte order: a Range of them is compared in Go, with OreTerm.Compare, not in SQL on a byte column. A Range of OpeTerm values orders under byte order, so a database compares it with no custom operator.

type Stats

type Stats struct {
	// Rows is the rows the query scans, such as reltuples from pg_class
	// read with a reltuples >= 0 filter. reltuples is -1 for a table never
	// vacuumed or analysed: that is an unknown count, not an empty table,
	// and a count of 0 would promise a fetch of exactly want. While the
	// count is unknown, call no TargetLimit and page with a cursor, or run
	// ANALYZE first.
	Rows uint64
	// Histogram is a measured histogram of the stored filters' set-bit
	// counts. With none, the engine uses the index's declared prior, and
	// a field declared as data has none, so the limit is unbounded.
	Histogram []Bucket
	// Confidence is the probability that the fetch holds the rows wanted
	// when the table has that many; 0 is the engine's default, 99%.
	Confidence float64
}

Stats is what Cipher.TargetLimit knows of the table: how many rows the query scans, how full their stored filters are, and the confidence to size the fetch at. None of it is secret: the server holds the filters and the catalog holds the row count.

type Uint128

type Uint128 = vcvalue.Uint128

Uint128 is an unsigned 128-bit integer, little-endian. ParseUint128 reads one from its decimal text, and String writes it.

func ParseUint128

func ParseUint128(s string) (Uint128, error)

ParseUint128 reads an unsigned 128-bit integer from decimal text, refusing a negative value or one outside its range.

Directories

Path Synopsis
Package eql holds the EQL types a program stores: one Go type per EQL type, each holding the EQL value as the JSON bytes PostgreSQL stores in the type's domain, and the query types that match them.
Package eql holds the EQL types a program stores: one Go type per EQL type, each holding the EQL value as the JSON bytes PostgreSQL stores in the type's domain, and the query types that match them.
Command example encrypts a batch of users against real ZeroKMS, using the credentials `stash auth login` leaves in the developer profile (or the CS_* environment variables, which win), and reads them back.
Command example encrypts a batch of users against real ZeroKMS, using the credentials `stash auth login` leaves in the developer profile (or the CS_* environment variables, which win), and reads them back.
json command
Command json demonstrates a typed encrypted JSON document in PostgreSQL.
Command json demonstrates a typed encrypted JSON document in PostgreSQL.
Package gensupport holds what only code written by stashgen calls.
Package gensupport holds what only code written by stashgen calls.
internal
eqlguest
Package eqlguest is where package eql hands the guest build with the EQL types to package encrypt.
Package eqlguest is where package eql hands the guest build with the EQL types to package encrypt.
genmember command
Command genmember writes testpolicy's generated file from a policy: the stashgen.Generate path, over testmember.Member, with the declaration testusers.User carries as tags.
Command genmember writes testpolicy's generated file from a policy: the stashgen.Generate path, over testmember.Member, with the declaration testusers.User carries as tags.
testmember
Package testmember holds a struct the encrypt tests encrypt through the two generator paths that take a type the program does not own: the `-for` flag (testusers declares the tags for Member in a struct of its own) and stashgen.Generate from a policy (genmember writes testpolicy).
Package testmember holds a struct the encrypt tests encrypt through the two generator paths that take a type the program does not own: the `-for` flag (testusers declares the tags for Member in a struct of its own) and stashgen.Generate from a policy (genmember writes testpolicy).
testpolicy
Package testpolicy holds the file stashgen.Generate writes for testmember.Member from a policy (see internal/genmember): the generated Encrypt, Decrypt and Fields over *testmember.Member, with the declaration testusers.User carries as tags.
Package testpolicy holds the file stashgen.Generate writes for testmember.Member from a policy (see internal/genmember): the generated Encrypt, Decrypt and Fields over *testmember.Member, with the declaration testusers.User carries as tags.
testusers
Package testusers holds the tagged structs the encrypt tests encrypt through the generated API: a record in separate columns, an opaque struct, and a probe struct with one field of each scalar type for the term ordering tests.
Package testusers holds the tagged structs the encrypt tests encrypt through the generated API: a record in separate columns, an opaque struct, and a probe struct with one field of each scalar type for the term ordering tests.
Package policy decides how a type that cannot carry stash tags is encrypted, from what its schema says about each field.
Package policy decides how a type that cannot carry stash tags is encrypted, from what its schema says about each field.
protosource
Package protosource reads the facts about a protobuf message's fields: each field's name, its Go name, its kind, and its options as annotations.
Package protosource reads the facts about a protobuf message's fields: each field's name, its Go name, its kind, and its options as annotations.
protosource/internal/testpb
Package testpb is protoc-gen-go output for the protosource tests: the two messages of docs/plans/2026-10-04-plan-builder/proto, where classification.data_categories is a repeated string field option.
Package testpb is protoc-gen-go output for the protosource tests: the two messages of docs/plans/2026-10-04-plan-builder/proto, where classification.data_categories is a repeated string field option.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL