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
45 changes: 45 additions & 0 deletions languages/golang/stackencrypt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,51 @@ their record key, and `plan.Identity` is refused. The plan a
policy builds is a `Plan` like any other: the guest receives the same bytes
as for the equivalent hand-built plan.

### Checking contexts in with a golden test

Nothing on the write path notices a changed context: rename a proto or
struct field with no pin and new rows are simply written under a new one,
while the rows already written stop decrypting. The `plan/plantest` package
turns that into a test failure:

```go
import "github.com/cipherstash/stack/languages/golang/stackencrypt/plan/plantest"

func TestIndividualsPolicy(t *testing.T) {
plantest.Golden(t, source, Individuals)
}
```

Run it once with `go test -run '^TestIndividualsPolicy$' -update` to write
`testdata/TestIndividualsPolicy.golden`, and check the file in. It lists the
message's table and, for every field the policy decides, what it is stored
as: an encrypted field's column, context, index terms and facts, or a
plaintext field's name and facts.

```text
table individuals

column email
context individuals/email
terms eq match
fact fides.data_categories user.contact.email

column medicare_number
context individuals/medicare_number
terms eq
fact fides.data_categories user.government_id
```

From then on the test builds the plan as `MustPlanFor` does at startup and
fails when it no longer matches the file, sorting the changes by what they
cost. A changed context is data loss and is reported first, with the
`plan.Column` (or `plan.Identity`) pin that keeps it; a changed target, such
as different index terms or a plaintext field now encrypted, is a migration;
anything else, such as a new field, is reported last. Encrypted fields are
listed by column, not by field name, so a rename the policy pins leaves the
file unchanged and the test passes. When a change is intended, rerun with
`-update` and review the diff.

## Errors

Errors are sentinel values, matched with `errors.Is`. The wasm guest
Expand Down
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)
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
18 changes: 16 additions & 2 deletions languages/golang/stackencrypt/context.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package stackencrypt

import (
"bytes"
"errors"
"fmt"
)
Expand All @@ -20,6 +21,9 @@ import (
// left, so NewContext("users/age").With(uint64(7)) is the context a row
// sealed with encrypt_into_with_context(row, 7u64) binds for that field.
// A one-element list is not the bare part, and this type cannot spell one.
//
// A Context owns its parts: a byte-slice part is copied in, so a caller's
// buffer reused once the Context is built does not change it.
type Context struct {
node any
}
Expand All @@ -41,7 +45,7 @@ func NewContext(part any) (Context, error) {
if err := checkRootNonEmpty(part); err != nil {
return Context{}, err
}
return Context{node: part}, nil
return Context{node: ownPart(part)}, nil
}

// MustContext is [NewContext] for a part known to be valid; it panics
Expand All @@ -63,7 +67,17 @@ func (c Context) With(part any) (Context, error) {
if err := checkPart(part); err != nil {
return Context{}, err
}
return Context{node: []any{c.node, part}}, nil
return Context{node: []any{c.node, ownPart(part)}}, nil
}

// ownPart is part as a context stores it: a byte slice is copied, so
// neither a Context nor an option that extends one ([ExtendContext])
// aliases a caller's buffer. Every other part type is a value.
func ownPart(part any) any {
if b, ok := part.([]byte); ok {
return bytes.Clone(b)
}
return part
}

// value renders the context in the guest's grammar: a scalar or nested
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
10 changes: 10 additions & 0 deletions languages/golang/stackencrypt/plan/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@
// A [Custom] target supplies its context itself; [Column] names only its
// record key, and [Identity] is refused.
//
// Nothing on the write path notices a context that changed: a rename with
// no pin simply writes new rows under a new context. Check them in with a
// golden test ([github.com/cipherstash/stack/languages/golang/stackencrypt/plan/plantest.Golden]),
// which snapshots what the policy stores each field as and fails, naming
// the pin, when a context changes:
//
// func TestIndividualsPolicy(t *testing.T) {
// plantest.Golden(t, source, Individuals)
// }
//
// # Failing closed
//
// A field with annotations that no rule decides is an error when the plan
Expand Down
Loading
Loading