diff --git a/languages/golang/stackencrypt/cipher.go b/languages/golang/stackencrypt/cipher.go index 5820a6aa8..79fbc6bad 100644 --- a/languages/golang/stackencrypt/cipher.go +++ b/languages/golang/stackencrypt/cipher.go @@ -86,13 +86,28 @@ func (cph *Cipher) DecryptElement(ctx context.Context, ct any, aad []byte) (any, // encoding); a string for Match; any scalar for Ore and Ope. The result is // one of EqualityTerm, MatchTerm, OreTerm or OpeTerm. // +// opts are [Option]s, the options a probe shares with the record calls; a +// [RecordOption] that only a record call takes, such as [WithPlan], does +// not compile here. [ExtendContext] extends context exactly as it extends +// each field's own context in a record call, so a probe for a field +// written under an extension is the field's context plus the same option +// value the rows were written with, never a context spelled by hand. +// // Term takes a context and returns an error because it may be a ZeroKMS // round trip: term derivation is asynchronous in the Rust crate, and a // ZeroKMS backend that derives terms server-side settles the same way. -func (cph *Cipher) Term(ctx context.Context, value any, context Context, kind TermKind) (any, error) { +func (cph *Cipher) Term(ctx context.Context, value any, context Context, kind TermKind, opts ...Option) (any, error) { if context.node == nil { return nil, fmt.Errorf("stackencrypt: term context is empty") } + var o termOptions + for _, opt := range opts { + opt.applyTerm(&o) + } + context, err := extend(context, o.extension) + if err != nil { + return nil, err + } encodedValue, err := vcffi.Marshal(value) if err != nil { return nil, err @@ -105,12 +120,12 @@ func (cph *Cipher) Term(ctx context.Context, value any, context Context, kind Te return nil, err } defer wipe(encodedContext) - opts, err := vcffi.Marshal(options(cph.keyset)) + encodedOpts, err := vcffi.Marshal(options(cph.keyset)) if err != nil { return nil, err } out, err := cph.client.call(ctx, func(inst *instance) ([]byte, error) { - return inst.call(ctx, inst.term, buf(encodedValue), buf(encodedContext), scalar(uint64(kind)), buf(opts)) + return inst.call(ctx, inst.term, buf(encodedValue), buf(encodedContext), scalar(uint64(kind)), buf(encodedOpts)) }) if err != nil { return nil, err diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index 3bc5dd0af..fa22f8ea1 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -51,7 +51,13 @@ // for any ordinary value or batch, one more per 500 sealed leaves beyond // that. Terms ([EqualityTerm], [MatchTerm], [OreTerm], [OpeTerm]) are // byte-equal to the ones the Rust crate derives, so a probe from -// [Cipher.Term] compares against a stored term from any language. +// [Cipher.Term] compares against a stored term from any language. An +// [Option] is the one value that serves encrypt, decrypt and probe alike; +// a [RecordOption], such as [WithPlan], is what only a record call takes. +// [ExtendContext] is an Option: given to a record call and to the probe it +// extends the field's context and the probe's identically, so a +// tenant-scoped probe is the field's own context plus the option value the +// rows were written with, never a context spelled by hand. // [Cipher.Term] takes a context and returns an error from day one: term // derivation may be a ZeroKMS round trip. // diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index c876b17e9..9c54e03de 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -677,7 +677,11 @@ func TestGuestAcceptsEveryEncodingThisPackageBuilds(t *testing.T) { _, err := byID.Term(ctx, 1.5, c, Ore) return err }, - "Term ope bytes": func() error { _, err := def.Term(ctx, []byte{1}, MustContext("k"), Ope); return err }, + "Term ope bytes": func() error { _, err := def.Term(ctx, []byte{1}, MustContext("k"), Ope); return err }, + "Term ext option": func() error { + _, err := byID.Term(ctx, 1.5, MustContext("users/age"), Ore, ExtendContext(uint64(7), "eu")) + return err + }, "EncryptRecords": func() error { _, err := def.EncryptRecords(ctx, rows); return err }, "EncryptRecords ext": func() error { _, err := named.EncryptRecords(ctx, &rows, ExtendContext(uint64(7), "eu")); return err }, "EncryptRecord": func() error { _, err := byID.EncryptRecord(ctx, rows[0]); return err }, @@ -731,6 +735,22 @@ func TestGuestRefusesMalformedInputsBeforeState(t *testing.T) { "record without c": func() error { return c.DecryptRecord(ctx, EncryptedRecord{"Age": {Equality: EqualityTerm{1}}, "Email": {Ciphertext: Sealed(fixtureLeaf)}}, new(recordRow)) }, + // ExtendContext checks nothing when it is built; Context.With + // refuses the part when a call applies it. A call that dropped + // that error would run under a context missing the extension: + // a probe that matches no rows, or rows no probe matches. + "bad ext part in a term": func() error { + _, err := def.Term(ctx, 1, MustContext("k"), Equality, ExtendContext(1.5)) + return err + }, + "bad ext part in a record write": func() error { + _, err := def.EncryptRecords(ctx, []recordRow{{Age: 1, Email: "a@b.c"}}, ExtendContext(1.5)) + return err + }, + "bad ext part in a record read": func() error { + record := EncryptedRecord{"Age": {Ciphertext: Sealed(fixtureLeaf)}, "Email": {Ciphertext: Sealed(fixtureLeaf)}} + return c.DecryptRecord(ctx, record, new(recordRow), ExtendContext(1.5)) + }, } for name, call := range calls { err := call() @@ -742,6 +762,34 @@ func TestGuestRefusesMalformedInputsBeforeState(t *testing.T) { } } +// A bad ExtendContext part fails the call for that reason, on the probe and +// on both record directions. TestGuestRefusesMalformedInputsBeforeState +// shows the call never reaches the cipher, but a call that ignored the +// error and went on with an empty context would be refused too, by the +// guest, for another reason; only the error's own words tell the two apart. +func TestBadExtensionPartFailsTheCall(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + def := c.DefaultKeyset() + bad := ExtendContext(uint64(7), 1.5) + record := EncryptedRecord{"Age": {Ciphertext: Sealed(fixtureLeaf)}, "Email": {Ciphertext: Sealed(fixtureLeaf)}} + for name, call := range map[string]func() error{ + "Term": func() error { + _, err := def.Term(ctx, 1, MustContext("k"), Equality, bad) + return err + }, + "EncryptRecords": func() error { + _, err := def.EncryptRecords(ctx, []recordRow{{Age: 1, Email: "a@b.c"}}, bad) + return err + }, + "DecryptRecord": func() error { return c.DecryptRecord(ctx, record, new(recordRow), bad) }, + } { + if err := call(); err == nil || !strings.Contains(err.Error(), "float64 is not a context part") { + t.Errorf("%s with a float64 extension part: %v, want the part refused", name, err) + } + } +} + func TestClosedClientIsState(t *testing.T) { ctx := context.Background() c := rawInstance(t) diff --git a/languages/golang/stackencrypt/live_test.go b/languages/golang/stackencrypt/live_test.go index 9a49bee12..f3e4e50d2 100644 --- a/languages/golang/stackencrypt/live_test.go +++ b/languages/golang/stackencrypt/live_test.go @@ -220,6 +220,34 @@ func TestLiveRecordsAndTerms(t *testing.T) { if err := cipher.DecryptRecords(ctx, ext, &back, ExtendContext(uint64(7))); err != nil { t.Fatalf("extended record with its extension: %v", err) } + + // A probe takes the same option, and matches only the rows written + // under it: not another tenant's, and not the unextended ones. + tenant7, tenant8 := ExtendContext(uint64(7)), ExtendContext(uint64(8)) + other, err := cipher.EncryptRecords(ctx, users, tenant8) + if err != nil { + t.Fatal(err) + } + scoped, err := cipher.Term(ctx, "bob@example.com", MustContext("users/email"), Equality, tenant7) + if err != nil { + t.Fatal(err) + } + if !scoped.(EqualityTerm).Equal(ext[1]["Email"].Equality) { + t.Error("tenant probe does not equal the term written under the same extension") + } + if scoped.(EqualityTerm).Equal(other[1]["Email"].Equality) { + t.Error("tenant probe equals another tenant's term") + } + if scoped.(EqualityTerm).Equal(records[1]["Email"].Equality) { + t.Error("tenant probe equals the unextended term") + } + unscoped, err := cipher.Term(ctx, "bob@example.com", MustContext("users/email"), Equality) + if err != nil { + t.Fatal(err) + } + if unscoped.(EqualityTerm).Equal(ext[1]["Email"].Equality) { + t.Error("an unextended probe equals a tenant's term") + } } // An explicit plan round-trips a struct that carries no tags, and a record diff --git a/languages/golang/stackencrypt/record.go b/languages/golang/stackencrypt/record.go index e29196535..18e417188 100644 --- a/languages/golang/stackencrypt/record.go +++ b/languages/golang/stackencrypt/record.go @@ -1,6 +1,7 @@ package stackencrypt import ( + "bytes" "context" "errors" "fmt" @@ -79,23 +80,110 @@ type EncryptedField struct { // EncryptedRecord is one record's planned fields, by wire name. type EncryptedRecord map[string]EncryptedField -// RecordOption adjusts how a record call binds its fields. -type RecordOption func(*recordOptions) +// RecordOption adjusts one record call: [Cipher.EncryptRecords], +// [Cipher.EncryptRecord], [Cipher.DecryptRecords], [Cipher.DecryptRecord] +// and the Client forms of the last two. An option is a value built by one +// of the functions below, and a call applies the options it is given in +// order. +// +// A RecordOption that is not also an [Option], such as [WithPlan], means +// something only on a record call, so handing it to [Cipher.Term] does not +// compile. +type RecordOption interface { + applyRecord(*recordOptions) +} + +// Option is a [RecordOption] that the probe call, [Cipher.Term], accepts +// as well: what a record and the probe that matches it must agree on. +// Every Option is a RecordOption, so one value serves the encrypt, decrypt +// and probe calls alike, and the three cannot drift apart: +// +// tenant := stackencrypt.ExtendContext(uint64(tenantID)) +// rows, err := cipher.EncryptRecords(ctx, users, tenant) +// probe, err := cipher.Term(ctx, "bob@example.com", email, stackencrypt.Equality, tenant) +// err = cipher.DecryptRecords(ctx, rows, &back, tenant) +type Option interface { + RecordOption + applyTerm(*termOptions) +} type recordOptions struct { extension []any plan Plan } +type termOptions struct { + extension []any +} + +// contextExtension is what [ExtendContext] returns. +type contextExtension struct{ parts []any } + +// appendTo adds the extension's parts after any an earlier option gave: +// the one rule for combining extensions, shared by the record calls and +// the probe so the two cannot combine them differently. +func (e contextExtension) appendTo(ext *[]any) { + *ext = append(*ext, e.parts...) +} + +func (e contextExtension) applyRecord(o *recordOptions) { e.appendTo(&o.extension) } + +func (e contextExtension) applyTerm(o *termOptions) { e.appendTo(&o.extension) } + // ExtendContext extends every field's context by parts, in order, the way // the Rust derive extends a field's context by the caller's // (encrypt_into_with_context): a field tagged context=users/age with -// ExtendContext(uint64(7)) binds ["users/age", 7]. The same extension must -// be given to decrypt the records. -func ExtendContext(parts ...any) RecordOption { - return func(o *recordOptions) { o.extension = append(o.extension, parts...) } +// ExtendContext(uint64(7)) binds ["users/age", 7]. On [Cipher.Term] it +// extends the probe's context the same way, so a probe built under the +// extension a record was written under compares against that record's +// terms, and under any other extension, or none, against nothing. +// +// The same extension must be given to decrypt the records. A part's type +// is part of the context (an int crosses as int64, so uint64(7) and 7 are +// different contexts), which is why an extension is best held in one value +// and passed to every call rather than spelled afresh at each. The option +// owns its parts: a byte-slice part is copied, so a caller's buffer reused +// after the call does not change what the option extends by. +// +// Several ExtendContext options on one call join in order: +// ExtendContext(a), ExtendContext(b) is the same context as +// ExtendContext(a, b), on a record call and on Term alike. So each call +// must receive a given extension once. A helper that always adds the +// tenant, called by code that adds the tenant as well, writes records +// under [field, tenant, tenant], and a probe built with the tenant once +// matches none of them, with no error. +// +// A part is checked when a call applies it, not here: a part that is not +// a string, a byte slice or an integer fails the call it is given to. +func ExtendContext(parts ...any) Option { + owned := make([]any, len(parts)) + for i, part := range parts { + if b, ok := part.([]byte); ok { + part = bytes.Clone(b) + } + owned[i] = part + } + return contextExtension{parts: owned} } +// extend is c extended by every part of ext, in order: the one definition +// of how an extension applies, shared by the record plan and the probe so +// the two cannot disagree. +func extend(c Context, ext []any) (Context, error) { + for _, part := range ext { + var err error + if c, err = c.With(part); err != nil { + return Context{}, err + } + } + return c, nil +} + +// planOption is what [WithPlan] returns. +type planOption struct{ plan Plan } + +func (p planOption) applyRecord(o *recordOptions) { o.plan = p.plan } + // WithPlan encrypts or decrypts records under an explicit plan instead of // the struct's `stash` tags. // @@ -108,7 +196,7 @@ func ExtendContext(parts ...any) RecordOption { // with the same Names and Contexts. Terms are one-way outputs, derived on // encryption and never sent to decrypt, so they need not match either. func WithPlan(p Plan) RecordOption { - return func(o *recordOptions) { o.plan = p } + return planOption{plan: p} } // FieldPlan is one planned field of a record. @@ -355,10 +443,8 @@ func planValue(plan []fieldPlan, opts recordOptions) (vcvalue.Object, error) { if err != nil { return nil, err } - for _, part := range opts.extension { - if ctx, err = ctx.With(part); err != nil { - return nil, err - } + if ctx, err = extend(ctx, opts.extension); err != nil { + return nil, err } outputs := make([]any, len(f.outputs)) for i, o := range f.outputs { @@ -375,7 +461,7 @@ func planValue(plan []fieldPlan, opts recordOptions) (vcvalue.Object, error) { func applyOptions(opts []RecordOption) recordOptions { var o recordOptions for _, opt := range opts { - opt(&o) + opt.applyRecord(&o) } return o } diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go index 12c5536a8..395d3c3fe 100644 --- a/languages/golang/stackencrypt/unit_test.go +++ b/languages/golang/stackencrypt/unit_test.go @@ -276,6 +276,153 @@ func TestExplicitPlanIsTheTagPlan(t *testing.T) { } } +// A probe's context under ExtendContext is, byte for byte, the context the +// record plan sends for a field with the same own context under the same +// extension — the one place the probe and the stored term could silently +// disagree. And it differs from the unextended context and from another +// extension's, which is what makes the match tenant-specific. +func TestTermExtensionMatchesRecordFieldContext(t *testing.T) { + type row struct { + Email string `stash:"context=users/email,index=eq"` + } + ext := []any{uint64(7), "eu"} + o := applyOptions([]RecordOption{ExtendContext(ext...)}) + bound, err := planFor(reflect.TypeOf(row{}), o) + if err != nil { + t.Fatal(err) + } + obj, err := planValue(bound, o) + if err != nil { + t.Fatal(err) + } + spec, ok := obj[0].Value.(vcvalue.Object) + if !ok || spec[0].Key != "context" { + t.Fatalf("plan field encodes as %+v", obj[0].Value) + } + fieldContext := spec[0].Value + + var to termOptions + ExtendContext(ext...).applyTerm(&to) + probe, err := extend(MustContext("users/email"), to.extension) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(probe.value(), fieldContext) { + t.Fatalf("probe context %#v, record field context %#v", probe.value(), fieldContext) + } + if reflect.DeepEqual(MustContext("users/email").value(), fieldContext) { + t.Fatal("the unextended probe context equals the extended field's") + } + other, err := extend(MustContext("users/email"), []any{uint64(8), "eu"}) + if err != nil { + t.Fatal(err) + } + if reflect.DeepEqual(other.value(), fieldContext) { + t.Fatal("another tenant's probe context equals the field's") + } + // The same option value, held once and passed to both calls, is how + // the two sides are kept in step; it applies identically through + // either interface. An option that means something only on a record + // call is not an Option, so Cipher.Term cannot accept it and ignore it. + var opt RecordOption = ExtendContext(ext...) + if _, ok := opt.(Option); !ok { + t.Fatal("ExtendContext is not an Option through its RecordOption interface") + } + if _, ok := WithPlan(Plan{}).(Option); ok { + t.Fatal("WithPlan is an Option; Cipher.Term must not accept it") + } +} + +// Several ExtendContext options on one call join in order, and the record +// calls and the probe join them by the same rule: two options a and b are +// the context ExtendContext(a, b) gives, on both sides. A rule that let a +// later option replace an earlier one on one side only would put records +// and probes under different contexts with no error. +func TestSeveralExtensionsJoinInOrder(t *testing.T) { + type row struct { + Email string `stash:"context=users/email,index=eq"` + } + typ := reflect.TypeOf(row{}) + fieldContext := func(opts ...RecordOption) any { + t.Helper() + o := applyOptions(opts) + bound, err := planFor(typ, o) + if err != nil { + t.Fatal(err) + } + obj, err := planValue(bound, o) + if err != nil { + t.Fatal(err) + } + return obj[0].Value.(vcvalue.Object)[0].Value + } + probeContext := func(opts ...Option) any { + t.Helper() + var to termOptions + for _, opt := range opts { + opt.applyTerm(&to) + } + c, err := extend(MustContext("users/email"), to.extension) + if err != nil { + t.Fatal(err) + } + return c.value() + } + + tenant, region := ExtendContext(uint64(7)), ExtendContext("eu") + want := fieldContext(ExtendContext(uint64(7), "eu")) + if got := fieldContext(tenant, region); !reflect.DeepEqual(got, want) { + t.Errorf("record: two options give %#v, one option with both parts %#v", got, want) + } + if got := probeContext(tenant, region); !reflect.DeepEqual(got, want) { + t.Errorf("probe: two options give %#v, the record's one-option context %#v", got, want) + } + if got := probeContext(ExtendContext(uint64(7), "eu")); !reflect.DeepEqual(got, want) { + t.Errorf("probe: one option gives %#v, the record's %#v", got, want) + } + // Order is part of the context: the same parts the other way round are + // another context, on both sides. + if got := fieldContext(region, tenant); reflect.DeepEqual(got, want) { + t.Error("record: options in the other order give the same context") + } + if got := probeContext(region, tenant); reflect.DeepEqual(got, want) { + t.Error("probe: options in the other order give the same context") + } + // Joining is not deduplication: the same extension given twice extends + // twice, which is why a call must receive it once. + if got := fieldContext(tenant, tenant); reflect.DeepEqual(got, fieldContext(tenant)) { + t.Error("record: the same extension given twice is the single-extension context") + } +} + +// An option owns its parts. A byte-slice part is copied when the option +// is built, so a caller's buffer reused between the write and the probe +// does not move the context the saved option extends by, on either side. +func TestExtendContextOwnsItsByteParts(t *testing.T) { + region := []byte("eu") + opt := ExtendContext(uint64(7), region) + first := applyOptions([]RecordOption{opt}) + var firstProbe termOptions + opt.applyTerm(&firstProbe) + + copy(region, "us") + + second := applyOptions([]RecordOption{opt}) + var secondProbe termOptions + opt.applyTerm(&secondProbe) + for name, ext := range map[string][]any{ + "record, before": first.extension, "record, after": second.extension, + "probe, before": firstProbe.extension, "probe, after": secondProbe.extension, + } { + if got := string(ext[1].([]byte)); got != "eu" { + t.Errorf("%s: byte part is %q after the caller's buffer changed, want \"eu\"", name, got) + } + } + if !reflect.DeepEqual(first.extension, second.extension) || !reflect.DeepEqual(firstProbe.extension, secondProbe.extension) { + t.Error("the same option applied twice gave different extensions") + } +} + // A plan can name only exported, direct fields of the struct it binds to, // and only fields that exist; an untagged struct binds fine under it. func TestPlanBindsByFieldName(t *testing.T) {