Skip to content
Merged
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
21 changes: 18 additions & 3 deletions languages/golang/stackencrypt/cipher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
coderdan marked this conversation as resolved.
if err != nil {
return nil, err
}
encodedValue, err := vcffi.Marshal(value)
if err != nil {
return nil, err
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion languages/golang/stackencrypt/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
50 changes: 49 additions & 1 deletion languages/golang/stackencrypt/guest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions languages/golang/stackencrypt/live_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
110 changes: 98 additions & 12 deletions languages/golang/stackencrypt/record.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package stackencrypt

import (
"bytes"
"context"
"errors"
"fmt"
Expand Down Expand Up @@ -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.
//
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
Loading
Loading