Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/tests-golang.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,24 @@ jobs:
# caches) the whole test toolset, which `mise run` would otherwise
# install uncached.
MISE_ENV: test
# The Go module's PostgreSQL test (encrypt/postgres_test.go) stores
# generated rows and runs the query methods against it through
# database/sql; it skips where DATABASE_URL is unset, as on the macOS
# and Windows jobs.
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
# Container port only: Docker picks the host port, and the Go step
# reads it from `job.services`, so the number is written once.
ports:
- 5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
Expand Down Expand Up @@ -215,6 +233,7 @@ jobs:
- name: Go binding
env:
STACK_ENCRYPT_TESTS_REQUIRE_LOCK: "1"
DATABASE_URL: "postgres://postgres:postgres@localhost:${{ job.services.postgres.ports['5432'] }}/postgres?sslmode=disable"
run: |
ulimit -l "$(ulimit -H -l)"
echo "RLIMIT_MEMLOCK: $(ulimit -l) KiB"
Expand Down
8 changes: 6 additions & 2 deletions languages/golang/cmd/stashgen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Your program calls those functions, and it never builds or names a plan.
```go
encrypted, err := users.Encrypt(ctx, cipher, people)
opened, err := users.Decrypt(ctx, cipher, encrypted)
query, err := users.Fields.Email.Query(ctx, cipher, "bob@example.com")
query, err := users.Fields.Email.Eq(ctx, cipher, "bob@example.com")
```

6. Store the encrypted type.
Expand Down Expand Up @@ -123,7 +123,11 @@ For `-type User`, the file `user_stash.go` holds:
- `Encrypt` and `Decrypt`, which take a slice and return a slice, and send one ZeroKMS request for each 500 sealed values in it, plus one the first time a keyset is used.
- `Fields`, with one entry for each sealed field.
An opaque struct, or a struct with no sealed field outside an opaque one, gets no `Fields`: nothing in it is sealed on its own, so nothing can be queried.
An entry encrypts one value, and it has a query method only for what the field declares: `Query` for `encrypt_into`, and `Equality`, `Match`, `Ore` or `Ope` for `index=`.
An entry encrypts one value, and has one query method for each operator the engine answers on the field: `Eq`, `Lt`, `Lte`, `Gt`, `Gte`, `Between` or `Matches`.
stashgen asks the embedded engine which operators each field answers, and which index answers each one, so it holds no copy of those rules: an equality index answers `Eq`, a match index on a string `Matches`, and an ORE or OPE index on an integer the four orderings and `Between`, through ORE when the field declares both.
`encrypt_into=TextEq` answers `Eq`, which returns the type's query value, `eql.TextEqQuery`.
The query of `Matches` carries `Verify` and `TargetLimit`, because its answer can hold rows that do not match; an exact operator's query carries neither.
See the encrypt package's README for each method's result and the query with `database/sql`.
- `String` and `LogValue` on `EncryptedUser`, which print the passthrough fields and hide the sealed ones.
- A copy of the fields of `User`, so a change to them stops the build.

Expand Down
93 changes: 87 additions & 6 deletions languages/golang/encrypt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,13 @@ The package reference is on [pkg.go.dev]; the generator's reference is

encrypted, err := users.Encrypt(ctx, cipher, people)
opened, err := users.Decrypt(ctx, cipher, encrypted)
term, err := users.Fields.Email.Equality(ctx, cipher, "bob@example.com")
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; see
[Queries](#queries).

6. Store the encrypted type. Each sealed field is one or more byte columns:
`e.Email.Ciphertext`, `e.Email.Equality`, `e.Email.Match`. `Ciphertext`
Expand Down Expand Up @@ -198,7 +200,7 @@ field and the index. So an optional or short value does not belong under
`match`: give such a field `equality` alone, or make the value required.

Terms are byte-equal to the ones the Rust crate derives, so a term from
`users.Fields.Email.Equality` compares against a stored term written from any
`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.
Expand All @@ -211,8 +213,86 @@ A database can compare some terms by itself:
- `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`: do not compare in SQL. Decode the positions in Go with
`MatchTerm.Positions`, or use an EQL type.
- `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](#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 | `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` |

A method takes the field's own Go type, so `Fields.Age.Lt` on a `uint32`
field takes a `uint32`. An order index on text, bytes or a float stores its
term but answers no ordering operator yet, so such a field has no `Lt`.

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`.

`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:

- `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.

```go
// 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

Expand Down Expand Up @@ -280,8 +360,9 @@ PostgreSQL holds in a `public.eql_v3_text_eq` column, as an `eql.TextEq`
from package `encrypt/eql`. The guest builds the 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.Query` returns the `eql.TextEqQuery`
the column's `eql_v3.query_text_eq` operand takes. `mise run
stores what it returns. `Fields.Email.Eq` returns the `eql.TextEqQuery`
the column's `eql_v3.query_text_eq` operand takes; `TextEq` answers `eq`
alone. `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
Expand Down
45 changes: 38 additions & 7 deletions languages/golang/encrypt/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,37 @@ func (k *Checker) Targets(ctx context.Context) ([]record.Target, error) {
return parseTargets(decoded)
}

// 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 (k *Checker) Operators(ctx context.Context, plan *record.Plan, field string) ([]record.Operator, error) {
if err := plan.Validate(); err != nil {
return nil, fmt.Errorf("%w: %v", ErrEncoding, err)
}
encoded, err := vcffi.Marshal(plan.Wire())
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrEncoding, err)
}
out, err := k.c.call(ctx, func(inst *instance) ([]byte, error) {
return inst.call(ctx, inst.operators, buf(encoded), buf([]byte(field)))
})
if err != nil {
return nil, err
}
decoded, err := vcffi.Unmarshal(out)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInternal, err)
}
operators, err := parseOperators(decoded)
if err != nil {
return nil, fmt.Errorf("%w: se_operators: %v", ErrInternal, err)
}
return operators, nil
}

// parseTargets reads se_targets's decoded value: {"targets": [entry, ...]},
// each entry an object with exactly the keys record.Target documents. The
// shape is a contract between eql-bindings' serialiser and this reader, so
Expand Down Expand Up @@ -166,7 +197,7 @@ func parseTarget(entry vcvalue.Object) (record.Target, error) {
case "reason":
t.Reason, err = targetOptionalString(f)
case "operators":
t.Operators, err = targetOperators(f.Value)
t.Operators, err = parseOperators(f.Value)
default:
return t, fmt.Errorf("unknown key %q", f.Key)
}
Expand All @@ -185,21 +216,21 @@ func parseTarget(entry vcvalue.Object) (record.Target, error) {
return t, nil
}

// targetOperators reads an entry's operators: a list of objects, each with
// exactly the keys operator, fidelity, arity and index, every value one the
// engine writes.
func targetOperators(value any) ([]record.TargetOperator, error) {
// parseOperators reads a list of operators, an se_targets entry's or
// se_operators's: a list of objects, each with exactly the keys operator,
// fidelity, arity and index, every value one the engine writes.
func parseOperators(value any) ([]record.Operator, error) {
list, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("operators is %T, not a list", value)
}
operators := make([]record.TargetOperator, 0, len(list))
operators := make([]record.Operator, 0, len(list))
for i, item := range list {
entry, ok := item.(vcvalue.Object)
if !ok {
return nil, fmt.Errorf("operator %d is %T, not an object", i, item)
}
var op record.TargetOperator
var op record.Operator
seen := map[string]bool{}
for _, f := range entry {
if seen[f.Key] {
Expand Down
8 changes: 4 additions & 4 deletions languages/golang/encrypt/context_field_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,19 +109,19 @@ func TestContextFieldQueriesNameTheContext(t *testing.T) {
t.Fatal(err)
}
// A query through a cipher with no context has nothing to derive under.
if _, err := testusers.NoteFields.Text.Equality(ctx, cipher, "hello"); !errors.Is(err, encrypt.ErrEncoding) || !strings.Contains(err.Error(), "Cipher.Context") {
if _, err := testusers.NoteFields.Text.Eq(ctx, cipher, "hello"); !errors.Is(err, encrypt.ErrEncoding) || !strings.Contains(err.Error(), "Cipher.Context") {
t.Fatalf("a query with no context: %v, want ErrEncoding naming Cipher.Context", err)
}
// Under the tenant it singles out that tenant's row.
acme := cipher.Context("tenants/acme")
probe, err := testusers.NoteFields.Text.Equality(ctx, acme, "hello")
probe, err := testusers.NoteFields.Text.Eq(ctx, acme, "hello")
if err != nil {
t.Fatal(err)
}
if !probe.Equal(encrypted[0].Text.Equality) || probe.Equal(encrypted[1].Text.Equality) || probe.Equal(encrypted[2].Text.Equality) {
t.Error("the probe under tenants/acme does not single out acme's hello")
}
globex, err := testusers.NoteFields.Text.Equality(ctx, cipher.Context("tenants/globex"), "hello")
globex, err := testusers.NoteFields.Text.Eq(ctx, cipher.Context("tenants/globex"), "hello")
if err != nil || !globex.Equal(encrypted[1].Text.Equality) {
t.Fatalf("the probe under tenants/globex: %v", err)
}
Expand Down Expand Up @@ -223,7 +223,7 @@ func TestGeneratedCodeOpensTheRustContextFieldFixture(t *testing.T) {
t.Fatalf("through the globex cipher: %v, want ErrContextMismatch", err)
}
// The term Go derives under the tenant is the bytes Rust stored.
eq, err := testusers.NoteFields.Text.Equality(ctx, cipher.Context(want.Tenant), want.Text)
eq, err := testusers.NoteFields.Text.Eq(ctx, cipher.Context(want.Tenant), want.Text)
if err != nil || !eq.Equal(stored.Text.Equality) {
t.Errorf("text equality: %v, equal=%v", err, eq.Equal(stored.Text.Equality))
}
Expand Down
43 changes: 41 additions & 2 deletions languages/golang/encrypt/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@
// 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.Equality(ctx, cipher, "bob@example.com")
// 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.
// the batch, plus one the first time a keyset is used. A query derives its
// term with no request; see Queries below.
//
// 6. Store the encrypted type. Each sealed field is one or more columns of
// bytes ([Ciphertext] and the term types), each implementing driver.Valuer
Expand Down Expand Up @@ -110,6 +111,44 @@
// can live under match. 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. 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.
// Every query type implements driver.Valuer, so it binds as a parameter
// through database/sql or pgx:
//
// 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.
//
// # Transport and auth
//
// The guest imports exactly two host functions: an HTTP send, served by any
Expand Down
2 changes: 1 addition & 1 deletion languages/golang/encrypt/eql/eql.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// the type's domain, and the query types that match them.
//
// A field with `stash:"email,encrypt_into=TextEq"` is an [TextEq] in the
// generated encrypted type, and its Fields entry's Query returns a
// generated encrypted type, and its Fields entry's Eq returns a
// [TextEqQuery]. The guest builds every value: generated code stores what
// the guest returns and never assembles an EQL value itself (ADR-0007,
// amended 2026-10-06). Each type implements driver.Valuer and sql.Scanner
Expand Down
4 changes: 2 additions & 2 deletions languages/golang/encrypt/eql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ func TestTextEqQueryMatchesTheStoredValueAndTheRustFixture(t *testing.T) {
t.Fatalf("the fixture's column changed: %+v", fixture)
}

query, err := testusers.ContactFields.Email.Query(ctx, cipher, fixture.Plaintext)
query, err := testusers.ContactFields.Email.Eq(ctx, cipher, fixture.Plaintext)
if err != nil {
t.Fatal(err)
}
Expand All @@ -184,7 +184,7 @@ func TestTextEqQueryMatchesTheStoredValueAndTheRustFixture(t *testing.T) {
if env := decodeEnvelope(t, stored); env.HM != probe.HM {
t.Fatalf("the query's hm %s does not match the stored %s", probe.HM, env.HM)
}
if other, _ := testusers.ContactFields.Email.Query(ctx, cipher, "Bob@example.com"); string(other) == fixture.Query {
if other, _ := testusers.ContactFields.Email.Eq(ctx, cipher, "Bob@example.com"); string(other) == fixture.Query {
t.Fatal("no case folding: a different plaintext has a different term")
}
}
Expand Down
2 changes: 1 addition & 1 deletion languages/golang/encrypt/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func TestEachKindCarriesItsDiagnostic(t *testing.T) {
return err
}, encrypt.ErrAuthentication, "stack_encrypt::aead"},
{"match text with no token", func() error {
_, err := testusers.Fields.Email.Match(ctx, cipher, "")
_, err := testusers.Fields.Email.Matches(ctx, cipher, "")
return err
}, encrypt.ErrTerm, "stack_encrypt::empty_term_text"},
{"a ciphertext that is not a sealed value", func() error {
Expand Down
Loading
Loading