Skip to content

Commit c6e3af1

Browse files
authored
Merge pull request #1328 from cipherstash/feat/nullable-sealed-fields
feat(golang, stack-encrypt): optional sealed fields, stored as a sealed null or SQL NULL
2 parents ab46dcd + e6dc8f9 commit c6e3af1

50 files changed

Lines changed: 2968 additions & 331 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cipherstash/eql": patch
3+
---
4+
5+
`eql-bindings` (the Rust crate released with this package): an EQL v4 type whose terms a null has, a storage-only type (`Date`, `Integer`, `Text`, ...) or one whose only term is equality's (`DateEq`, `IntegerEq`, `TextEq`, ...), now also implements `EncryptFrom` and `DecryptInto` for an `Option` of its plaintext, and its query twin `EncryptFrom` for one. For a type of fixed width a `None` seals as a typed null as long as a value (vitaminc 0.8), so an optional column does not show which rows are absent. For text it seals as a null that is not padded, since text's ciphertext is as long as the text, so its length may show that the value is absent. Either way it opens as `None`; its `hm` is the one term every null shares, and the query of a `None` finds the stored nulls with `=`. A value seals exactly as before. The by-name dispatch (`encryption::targets`) reads, opens and queries these types through the `Option`; a type with an order or match term still refuses a null. eql-bindings' own `Date`, `Timestamp` and `Decimal` plaintexts declare their fixed width to vitaminc, so their `None` is padded too. The SQL bundle and the TypeScript types do not change.

‎languages/golang/CHANGELOG.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,28 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`.
1010

1111
## [Unreleased]
1212

13+
### Added
14+
15+
- **An optional field: a sealed field declared over a pointer (#1298).**
16+
`DOB *encrypt.Date` with `stash:"dob,encrypt_into=Date"` generates (it
17+
was refused as a composite, #1293), and `Decrypt` gives `nil` back for a
18+
`nil`. The new `null=` tag part says how `nil` is stored:
19+
`null=sealed` seals it as a null as long as a value of the field's type,
20+
so the column does not show which rows have no value; `null=sql` stores
21+
nothing, so the field's columns are SQL `NULL` and its member in the
22+
generated type is a pointer. `stashgen` picks `null=sealed` for a field
23+
whose type has a fixed width (a bool, a number, `encrypt.Date`,
24+
`time.Time`, `encrypt.Decimal`, the 128-bit integers) and whose only
25+
index, if any, is equality, and asks for `null=sql` to be written on any
26+
other: a null has an equality term and no other, and text shows its
27+
length. Beside equality a sealed null stores the term every nil of the
28+
field shares, and a generated `EqNull` method derives its `eq` query,
29+
which finds the rows whose value is absent. A generated `EncryptNull`
30+
method seals `nil` for an update of one such column, which writing SQL
31+
`NULL` would not store. A stored null read into a
32+
field that is not a pointer is an error from `Decrypt`. `-model` does not
33+
map a `null=sql` field yet.
34+
1335
### Breaking
1436

1537
- **Every order term changes: re-derive stored `ore`, `ope` and

‎languages/golang/cmd/stashgen/README.md‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ The first part of a tag is the field's name, which is the column name in a datab
7979
| `stash:"tenant,context_field"` | the field's value is the context of every other field in the struct, in place of a `context=` tag; stored as it is |
8080
| `stash:"-"` | leave the field out |
8181
| `` _ struct{} `stash:"context=documents,opaque"` `` | seal the struct as one value |
82+
| `stash:"born,encrypt_into=DateEq,null=sql"` | on a field declared over a pointer (`*encrypt.Date`): how it stores `nil`, `null=sealed` or `null=sql`; see "Optional fields" |
8283

8384
A struct has one context: either a `_ struct{}` field with `context=`, or one field with `context_field`, not both.
8485
A `context_field` is a `string` whose value is a label such as `"tenants/acme"`, segments separated by `/`.
@@ -109,6 +110,26 @@ These words are the same as the Rust API's words for the same behaviour, with tw
109110
`opaque` seals the whole struct as one `bytes` field holding a JSON document (see "What crosses the binding").
110111
`json` names the JSON index, which the Rust API does not declare yet; this build refuses it.
111112

113+
### Optional fields
114+
115+
A sealed field declared over a pointer to one scalar (`*encrypt.Date`, `*int32`, `*string`, a pointer to a type defined over a scalar) is optional: `nil` is a value it may hold, and `Decrypt` gives it back as `nil`.
116+
`null=` says how the field stores `nil`. There are two ways, and they differ in what a reader of the database can see:
117+
118+
| `null=` | Stored for `nil` | What a reader of the column sees |
119+
|---|---|---|
120+
| `sealed` | a sealed null, as long as a sealed value of the field's type, and beside an equality index the one term every null of the field shares | nothing in the ciphertext; in an equality column, that the absent rows are one group, as rows holding one repeated value are |
121+
| `sql` | nothing: the field's columns are `NULL`, and its member in the generated type is `nil` | which rows have no value |
122+
123+
A sealed null hides that the value is absent only where its length matches a value's and no index beside it shows it, so it is the default for a field whose type has a fixed width (a `bool`, a number, `encrypt.Date`, `time.Time`, `encrypt.Decimal`, `encrypt.Int128` and `encrypt.Uint128`) and whose only index, if it has one, is `equality`: sealed with `encrypt`, `encrypt,index=equality`, or into an EQL type with no index or with equality alone (`Date`, `DateEq`, `Integer`, `IntegerEq`, ...).
124+
A null has an equality term, so its rows are one equality group and a query finds them: the generated `EqNull` derives the `eq` query of `nil`, which matches every row whose value is absent.
125+
A `null=sealed` field's entry in the generated `Fields` value also has `EncryptNull`, which seals `nil` for an update of that one column: a sealed null is a value, so set the column to what it returns, not to SQL `NULL`.
126+
A null has no order or match term, so a field with any other index, and a field of text or bytes, has to write `null=sql`: an order or match column would show which values are absent, and text shows its length, and `stashgen` will not choose a visible `NULL` for you.
127+
`null=sealed` on such a field stops `stashgen` with the reason.
128+
129+
A `null=sql` field's member in the generated type is a pointer (`*eql.DateEq`, `*EncryptedUserScore`), `nil` when the value was; a `null=sealed` field's is the same type as a non-optional field's, and its column is never `NULL`.
130+
A stored null read into a field that is not a pointer is an error from `Decrypt`, naming the field, never a zero value.
131+
`-model` does not map a `null=sql` field yet, and `stashgen` stops if it is asked to.
132+
112133
An embedded struct of your own adds its tagged fields to the outer struct.
113134
An embedded struct from another package takes one tag for all of its fields: `stash:",passthrough"` stores them as they are, and `stash:"-"` leaves them out.
114135
Tags for other libraries, such as `gorm`, `db` and `json`, are copied to the same field of the generated type.
@@ -254,10 +275,10 @@ Generated code sends the engine every sealed field with its value, under the dec
254275
A passthrough field stays on the host: the engine does nothing to it 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`).
255276
An `opaque` struct crosses as one JSON document and is one column, decoded back into the exact Go types the struct declares; a field may be any type `encoding/json` carries both ways (a scalar or a type defined over one, `[]byte`, slices, maps with string or integer keys, pointers, structs whose fields are all exported, `time.Time`).
256277
A float in an opaque struct must be finite: `encoding/json` refuses NaN and the infinities, so `Encrypt` fails for the batch with `encrypt.ErrEncoding`. A sealed float field outside an opaque struct carries them.
257-
A sealed field outside an opaque struct is one scalar, or a type defined over one; a struct, slice or map field seals only inside an opaque struct.
278+
A sealed field outside an opaque struct is one scalar, a type defined over one, or a pointer to either (see "Optional fields"); a struct, slice or map field seals only inside an opaque struct.
258279
The scalars are `string`, `[]byte`, `bool`, every integer type (each sealed at its own width, `int` and `uint` as 64-bit), `encrypt.Int128` and `encrypt.Uint128`, `float32`, `float64`, `encrypt.Decimal`, `encrypt.Date` and `time.Time` (a timestamp). The wire type `stashgen` writes for each is the field's own: `int8` is `gensupport.Int8`, `time.Time` is `gensupport.Timestamp`, and so on.
259280
An order index (`ore`, `ope`) on a decimal, date or timestamp is refused: those order terms are not supported yet (#1236).
260-
The zero `encrypt.Date` is not a date: a date field must be set, and `Encrypt` refuses a zero one with `encrypt.ErrEncoding`, naming the field. A sealed field cannot be a pointer (`*encrypt.Date` is refused as a composite), so an optional date has no sealed form yet: keep it in an opaque struct, or store a sentinel date.
281+
The zero `encrypt.Date` is not a date: a date field must be set, and `Encrypt` refuses a zero one with `encrypt.ErrEncoding`, naming the field. An optional date is a `*encrypt.Date` field (see "Optional fields").
261282
A nil `[]byte` in a sealed field, or a nil slice of a type defined over `[]byte`, comes back from `Decrypt` as an empty, non-nil slice; inside an opaque struct, nil comes back as nil.
262283
A protobuf message with a `oneof` cannot be generated from a policy: protoc-gen-go puts its members in wrapper types, not in the message struct, and `Generate` refuses it.
263284

‎languages/golang/cmd/stashgen/main_test.go‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,48 @@ func TestRunFlags(t *testing.T) {
119119
}
120120
}
121121

122+
// A storage-only EQL type over a pointer (#1293): the generator takes
123+
// *encrypt.Date as it takes encrypt.Date, and stores nil as a sealed null,
124+
// as long as a date, since Date carries no index. From the reproduction in
125+
// #1293. Skips when the guest is not built, as the engine decides what an
126+
// EQL type is.
127+
func TestRunGeneratesNullableDOB(t *testing.T) {
128+
checker, err := encrypt.NewChecker(context.Background())
129+
if err != nil {
130+
t.Skip(err)
131+
}
132+
_ = checker.Close()
133+
for _, dateType := range []string{"encrypt.Date", "*encrypt.Date"} {
134+
t.Run(dateType, func(t *testing.T) {
135+
src := `package people
136+
137+
import "github.com/cipherstash/stack/languages/golang/encrypt"
138+
139+
type Person struct {
140+
_ struct{} ` + "`stash:\"context=people\"`" + `
141+
DOB ` + dateType + " `stash:\"dob,encrypt_into=Date\"`" + `
142+
}
143+
`
144+
dir := writeModule(t, map[string]string{"model.go": src})
145+
var stdout, stderr bytes.Buffer
146+
if code := run([]string{"-type", "Person"}, dir, &stdout, &stderr, stashgen.GuestEngine); code != 0 {
147+
t.Fatalf("exit %d, want 0\n%s", code, stderr.String())
148+
}
149+
written, err := os.ReadFile(filepath.Join(dir, "person_stash.go"))
150+
if err != nil {
151+
t.Fatal(err)
152+
}
153+
sealedNull := strings.Contains(string(written), `Null("dob", gensupport.NullSealed)`)
154+
if pointer := strings.HasPrefix(dateType, "*"); sealedNull != pointer {
155+
t.Fatalf("a %s field stores nil as a sealed null: %v, want %v\n%s", dateType, sealedNull, pointer, written)
156+
}
157+
if !strings.Contains(string(written), "DOB eql.Date") {
158+
t.Fatalf("a sealed null keeps the column's type, eql.Date:\n%s", written)
159+
}
160+
})
161+
}
162+
}
163+
122164
// The real engine: the embedded guest, which the command links with the EQL
123165
// types, so a struct with encrypt_into=TextEq generates, and a type the
124166
// engine cannot produce yet is refused with nothing written. Skips when the

‎languages/golang/encrypt/ciphertext.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ import (
1515
// vitaminc-encrypt and must never scan or marshal where one belongs.
1616
//
1717
// A sealed field is one scalar — a string, a number, a bool or a []byte, or
18-
// a type defined over one — and seals as vitaminc's tagged leaf, which a
18+
// a type defined over one, or a pointer to either, whose nil is sealed as a
19+
// null as long as a value or stored as SQL NULL — and seals as vitaminc's
20+
// tagged leaf, which a
1921
// 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,
2022
// which crosses as one JSON document and is one leaf; stashgen refuses it
2123
// anywhere else, because the engine would seal it as a tree of leaves and a

‎languages/golang/encrypt/gensupport/codec.go‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,10 @@ func (c *Codec[P, E]) Decrypt(ctx context.Context, d encrypt.Decrypter, encrypte
154154
switch {
155155
case f.verb == verbOmit:
156156
continue
157+
case !ok && f.null == NullSQL:
158+
// The field was nil, so nothing was stored for it; it opens
159+
// as nil.
160+
continue
157161
case !ok:
158162
return nil, fmt.Errorf("gensupport: %s: value %d: Open gave no field %q", c.g.TypeName, i, f.name)
159163
case f.verb == verbContextField:
@@ -190,10 +194,13 @@ func (c *Codec[P, E]) Decrypt(ctx context.Context, d encrypt.Decrypter, encrypte
190194
}
191195

192196
// split sorts a value's fields into what crosses the binding and what stays:
193-
// every declared field must be present and nothing else may be.
197+
// every declared field must be present and nothing else may be. A nil field
198+
// declared with NullSQL is left out of the row, since nothing is stored for
199+
// it; one declared with NullSealed crosses as nil, which the engine seals.
194200
func (c *Codec[P, E]) split(vals Values) (record.Source, map[string]any, error) {
195201
row := make(record.Source, len(c.plan.Fields))
196202
keep := map[string]any{}
203+
left := 0
197204
for _, f := range c.g.Declaration.fields {
198205
if f.verb == verbOmit {
199206
continue
@@ -202,6 +209,10 @@ func (c *Codec[P, E]) split(vals Values) (record.Source, map[string]any, error)
202209
if !ok {
203210
return nil, nil, fmt.Errorf("the generated Source gave no field %q", f.name)
204211
}
212+
if v == nil && f.null == NullSQL {
213+
left++
214+
continue
215+
}
205216
if f.crosses() {
206217
converted, err := asKind(f.kind, v)
207218
if err != nil {
@@ -212,7 +223,7 @@ func (c *Codec[P, E]) split(vals Values) (record.Source, map[string]any, error)
212223
keep[f.name] = v
213224
}
214225
}
215-
if len(vals) != len(row)+len(keep) {
226+
if len(vals) != len(row)+len(keep)+left {
216227
for name := range vals {
217228
if _, ok := row[name]; ok {
218229
continue
@@ -297,6 +308,33 @@ func Get[T any](vals Values, name string) (T, error) {
297308
return out, nil
298309
}
299310

311+
// Deref is a field declared over a pointer as Source gives it: nil for a nil
312+
// pointer, else the value it points to.
313+
func Deref[T any](p *T) any {
314+
if p == nil {
315+
return nil
316+
}
317+
return *p
318+
}
319+
320+
// GetPtr reads one opened field declared over a pointer: nil for a field
321+
// that opened as nil, which it stored as a null or as nothing, else a
322+
// pointer to the value as [Get] reads it.
323+
func GetPtr[T any](vals Values, name string) (*T, error) {
324+
v, ok := vals[name]
325+
if !ok {
326+
return nil, fmt.Errorf("gensupport: the opened value has no field %q", name)
327+
}
328+
if v == nil {
329+
return nil, nil
330+
}
331+
out, err := Get[T](vals, name)
332+
if err != nil {
333+
return nil, err
334+
}
335+
return &out, nil
336+
}
337+
300338
// Records wraps a codec with a model's conversions, for separate columns:
301339
// EncryptRows and DecryptRows return and take the model.
302340
func Records[P, E, R any](codec *Codec[P, E], to func(E) R, from func(R) E) *RecordsCodec[P, R] {

‎languages/golang/encrypt/gensupport/declaration.go‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,25 @@ type field struct {
7676
verb verb
7777
indexes []encrypt.Index
7878
eqlType string
79+
null NullMode
7980
}
8081

82+
// NullMode is how a sealed field declared over a pointer (*T) stores nil:
83+
// stashgen's `null=` tag. A field with none is never nil.
84+
type NullMode string
85+
86+
const (
87+
// NullSealed seals nil like a value, as a null as long as a value of
88+
// the field's kind, so the stored record does not show which rows are
89+
// absent. Decrypt gives nil. An equality index stores the term every nil
90+
// shares, which [Field.NullTerm] queries; the field has no other index,
91+
// and its kind has a fixed width.
92+
NullSealed NullMode = "sealed"
93+
// NullSQL stores nothing for nil: the field's columns hold SQL NULL,
94+
// which shows that the value is absent. Decrypt gives nil.
95+
NullSQL NullMode = "sql"
96+
)
97+
8198
// Declaration is a generated struct's declaration as data: its context and,
8299
// for each field, what happens to it. Only generated code builds one, from
83100
// the struct's stash tags, and only generated code reads it. A mistake in a
@@ -152,6 +169,35 @@ func (d Declaration) EncryptInto(name string, kind Kind, eqlType string) Declara
152169
return d.add(field{name: name, kind: kind, verb: verbEncryptInto, eqlType: eqlType})
153170
}
154171

172+
// Null says how the named field, declared over a pointer, stores nil. The
173+
// engine decides whether the field can, when the declaration is first used:
174+
// a sealed null needs a field whose only index is equality, of a kind of
175+
// fixed width.
176+
func (d Declaration) Null(name string, mode NullMode) Declaration {
177+
if d.err != nil {
178+
return d
179+
}
180+
if mode != NullSealed && mode != NullSQL {
181+
d.err = fmt.Errorf("gensupport: Null(%q): unknown mode %q", name, mode)
182+
return d
183+
}
184+
for i := range d.fields {
185+
if d.fields[i].name != name {
186+
continue
187+
}
188+
if !d.fields[i].sealed() {
189+
d.err = fmt.Errorf("gensupport: Null(%q): only a sealed field stores nil", name)
190+
return d
191+
}
192+
// A copy, so the receiver's fields are not changed under it.
193+
d.fields = slices.Clone(d.fields)
194+
d.fields[i].null = mode
195+
return d
196+
}
197+
d.err = fmt.Errorf("gensupport: Null(%q): no such field", name)
198+
return d
199+
}
200+
155201
// Omit leaves the field out: it does not cross the binding and is not
156202
// stored. The declaration lists it so a reader sees the choice.
157203
func (d Declaration) Omit(name string) Declaration {
@@ -238,7 +284,7 @@ func (d Declaration) plan() (*record.Plan, error) {
238284
if !f.sealed() {
239285
continue
240286
}
241-
rf := record.Field{Name: f.name, Identity: f.identity, Kind: record.Kind(f.kind)}
287+
rf := record.Field{Name: f.name, Identity: f.identity, Kind: record.Kind(f.kind), Null: record.Null(f.null)}
242288
switch f.verb {
243289
case verbEncryptInto:
244290
// The EQL type's own plan decides the outputs; the engine

‎languages/golang/encrypt/gensupport/field.go‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,33 @@ func (f Field[T]) Encrypt(ctx context.Context, c *encrypt.Cipher, v T) (Output,
5050
if f.err != nil {
5151
return Output{}, f.err
5252
}
53-
if c == nil {
54-
return Output{}, fmt.Errorf("gensupport: %s: Encrypt needs a cipher", f.name)
55-
}
5653
value, err := asKind(f.kind(), v)
5754
if err != nil {
5855
return Output{}, fmt.Errorf("gensupport: %s: %w", f.name, err)
5956
}
57+
return f.seal(ctx, c, "Encrypt", value)
58+
}
59+
60+
// EncryptNull seals nil for a field that stores nil as a sealed null: a
61+
// ciphertext as long as a value's, and the term every nil of the field
62+
// shares. It is the update that sets one such column to nil, which writing
63+
// SQL NULL is not. A field that stores nil any other way is refused: under
64+
// null=sql nil is SQL NULL, and a field of no null mode has no nil.
65+
func (f Field[T]) EncryptNull(ctx context.Context, c *encrypt.Cipher) (Output, error) {
66+
if f.err != nil {
67+
return Output{}, f.err
68+
}
69+
if pf := f.plan.Field(f.name); pf == nil || pf.Null != record.SealedNull {
70+
return Output{}, fmt.Errorf("%w: gensupport: %s: EncryptNull seals nil only for a field that stores it as a sealed null (null=sealed)", encrypt.ErrEncoding, f.name)
71+
}
72+
return f.seal(ctx, c, "EncryptNull", nil)
73+
}
74+
75+
// seal seals one value of the field, or nil, through the field's own plan.
76+
func (f Field[T]) seal(ctx context.Context, c *encrypt.Cipher, method string, value any) (Output, error) {
77+
if c == nil {
78+
return Output{}, fmt.Errorf("gensupport: %s: %s needs a cipher", f.name, method)
79+
}
6080
row := record.Source{f.name: value}
6181
if f.plan.ContextField != "" {
6282
// One value of one field has no record to take its context from:
@@ -100,6 +120,18 @@ func (f Field[T]) Range(ctx context.Context, c *encrypt.Cipher, operator string,
100120
return terms[0], terms[1], nil
101121
}
102122

123+
// NullTerm derives an exact operator's term for nil: for eq on a field that
124+
// stores nil as a sealed null, the term every such nil shares, so it finds
125+
// the rows whose value is absent. The engine refuses it for any other field
126+
// or operator: nothing else stores a null's term.
127+
func (f Field[T]) NullTerm(ctx context.Context, c *encrypt.Cipher, operator string) ([]byte, error) {
128+
terms, err := f.query(ctx, c, operator, nil, 1)
129+
if err != nil {
130+
return nil, err
131+
}
132+
return terms[0], nil
133+
}
134+
103135
// query derives an exact operator's terms, and refuses an answer of another
104136
// shape than the generated method's: a probabilistic term has a query type
105137
// of its own (Matches). Each operand is converted to the field's kind as a

0 commit comments

Comments
 (0)