Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
14 changes: 8 additions & 6 deletions languages/golang/stackencrypt/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ import (
//
// A Context is a part or a list of parts. A part is a string, a byte slice
// or an integer (int32, int64, uint32, uint64; Go's int is sent as int64).
// [NewContext] makes a one-part context — the bare part, the shape a Rust
// `#[derive(EncryptFrom)]` field is sealed under when the caller supplies no
// context of its own. [Context.With] extends it as Rust's NonEmpty::with
// does: the result is the two-element list [previous, part], nesting to the
// 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.
// [NewContext] makes a one-part context — the bare part, what a Rust
// `#[stash(context = "..")]` literal binds. [Context.With] extends it as
// Rust's NonEmpty::with does: the result is the two-element list
// [previous, part], nesting to the left. So NewContext("users").With("age")
// is the pair a Rust `struct = .., context = "users"` derive binds its `age`
// field under — and a plan field's "users/age" — rendering the ZeroKMS
// descriptor users/age; extended With(uint64(7)) it is what 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
Expand Down
6 changes: 5 additions & 1 deletion languages/golang/stackencrypt/example/explicit/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,11 @@ func run(ctx context.Context, cfg config, secrets secrets) error {
if err != nil {
return fmt.Errorf("encrypting records: %w", err)
}
probe, err := cipher.Term(ctx, "bob@example.com", stackencrypt.MustContext("users/email"), stackencrypt.Equality)
emailCtx, err := stackencrypt.PlanContext("users/email")
if err != nil {
return fmt.Errorf("the probe's context: %w", err)
}
probe, err := cipher.Term(ctx, "bob@example.com", emailCtx, stackencrypt.Equality)
if err != nil {
return fmt.Errorf("deriving a probe: %w", err)
}
Expand Down
12 changes: 10 additions & 2 deletions languages/golang/stackencrypt/example/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,11 @@ func recordsAndTerms(ctx context.Context, cipher *stackencrypt.Cipher) ([]stacke
// being searched for. It never touches the ciphertext — matching is what
// the term is for.
fmt.Println()
probe, err := cipher.Term(ctx, "bob@example.com", stackencrypt.MustContext("users/email"), stackencrypt.Equality)
emailCtx, err := stackencrypt.PlanContext("users/email")
if err != nil {
return nil, fmt.Errorf("the probe's context: %w", err)
}
probe, err := cipher.Term(ctx, "bob@example.com", emailCtx, stackencrypt.Equality)
if err != nil {
return nil, fmt.Errorf("deriving a probe: %w", err)
}
Expand All @@ -155,7 +159,11 @@ func recordsAndTerms(ctx context.Context, cipher *stackencrypt.Cipher) ([]stacke
// A term is bound to its context. The same value under another field's
// context is a different term, which is what stops a match in one column
// from being a match in another.
wrong, err := cipher.Term(ctx, "bob@example.com", stackencrypt.MustContext("users/name"), stackencrypt.Equality)
nameCtx, err := stackencrypt.PlanContext("users/name")
if err != nil {
return nil, fmt.Errorf("the probe's context: %w", err)
}
wrong, err := cipher.Term(ctx, "bob@example.com", nameCtx, stackencrypt.Equality)
if err != nil {
return nil, fmt.Errorf("deriving a probe: %w", err)
}
Expand Down
8 changes: 5 additions & 3 deletions languages/golang/stackencrypt/plan/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@ func (t eqlTarget) Context(id Identifier) string { return id.String() }
func (t eqlTarget) String() string { return "EQL(" + termList(t.terms) + ")" }

// Custom is a non-EQL target: the field binds context, whatever its
// column, and derives the given terms. The context need not be
// table/column shaped; it is the policy's to choose and, like any context,
// must never change once data is written under it.
// column, and derives the given terms. The context is the policy's to
// choose and, like any context, must never change once data is written
// under it. It follows [stackencrypt.FieldPlan.Context]'s rule: one part,
// or two separated by a single "/" ("notes/v1" binds the pair
// ["notes", "v1"]); more than one "/" is refused when the plan is built.
func Custom(context string, terms ...stackencrypt.TermKind) Target {
return customTarget{context: context, terms: slices.Clone(terms)}
}
Expand Down
46 changes: 40 additions & 6 deletions languages/golang/stackencrypt/record.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ import (
// Notes string `stash:"context=users/notes"` // sealed only
// }
//
// Options are comma-separated: `context=<part>` (required for a planned
// field — the field's own context, a string part), `index=<kind>[;<kind>]`
// Options are comma-separated: `context=<table>/<column>` (required for a
// planned field — the field's own context; see [FieldPlan.Context]),
// `index=<kind>[;<kind>]`
// (eq, match, ore, ope), and `name=<wire name>` (the record key; the Go
// field name otherwise). A field tagged `-` or `plain`, or not tagged at
// all, is not part of the record: it never crosses the boundary, and stays
Expand Down Expand Up @@ -132,7 +133,7 @@ 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]. On [Cipher.Term] it
// 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.
Expand Down Expand Up @@ -202,8 +203,15 @@ type FieldPlan struct {
// Name is the record key the field's outputs are stored under: the
// column name, in EQL terms. Field when empty.
Name string
// Context is the field's own encryption context, a string part; the
// record call extends it by any ExtendContext parts. Required.
// Context is the field's own encryption context; the record call
// extends it by any ExtendContext parts. Required.
//
// "users/age" is a table and a column: it crosses the boundary as the
// two-part context ["users", "age"] — what a Rust `#[derive(EncryptFrom)]`
// with `struct = .., context = "users"` binds its `age` field under, and
// what renders the ZeroKMS descriptor users/age. A probe for the field is
// built with [PlanContext]. A context with no "/" is one part, as
// NewContext makes it; more than one "/", or an empty side, is refused.
Context string
// Terms lists the terms to derive beside the ciphertext, in order.
Terms []TermKind
Expand Down Expand Up @@ -275,6 +283,9 @@ func newPlan(fields []FieldPlan) (Plan, error) {
if f.Context == "" {
return Plan{}, fmt.Errorf("plan field %s: a planned field needs a context", f.Field)
}
if _, err := PlanContext(f.Context); err != nil {
return Plan{}, fmt.Errorf("plan field %s: %w", f.Field, err)
}
pf := planField{field: f.Field, name: f.Field, context: f.Context}
if f.Name != "" {
pf.name = f.Name
Expand Down Expand Up @@ -435,7 +446,7 @@ func planFor(t reflect.Type, o recordOptions) ([]fieldPlan, error) {
func planValue(plan []fieldPlan, opts recordOptions) (vcvalue.Object, error) {
out := make(vcvalue.Object, 0, len(plan))
for _, f := range plan {
ctx, err := NewContext(f.context)
ctx, err := PlanContext(f.context)
Comment thread
coderdan marked this conversation as resolved.
Outdated
if err != nil {
return nil, err
}
Expand All @@ -454,6 +465,29 @@ func planValue(plan []fieldPlan, opts recordOptions) (vcvalue.Object, error) {
return out, nil
}

// PlanContext is the context a [FieldPlan] whose Context is s binds:
// "table/column" as the two-part context [table, column], anything without
// a "/" as one part. A [Cipher.Term] probe for a planned field is built
// with it, so the probe and the field cannot spell their context apart:
//
// email, err := stackencrypt.PlanContext("users/email")
// if err != nil { ... }
// probe, err := cipher.Term(ctx, "bob@example.com", email, stackencrypt.Equality)
func PlanContext(s string) (Context, error) {
table, column, paired := strings.Cut(s, "/")
if !paired {
return NewContext(s)
}
if table == "" || column == "" || strings.Contains(column, "/") {
return Context{}, fmt.Errorf("context %q: give one part, or a table and a column as \"<table>/<column>\"", s)
}
ctx, err := NewContext(table)
if err != nil {
return Context{}, err
}
return ctx.With(column)
}

func applyOptions(opts []RecordOption) recordOptions {
var o recordOptions
for _, opt := range opts {
Expand Down
45 changes: 40 additions & 5 deletions languages/golang/stackencrypt/unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ func TestPlanFromTags(t *testing.T) {
t.Fatal(err)
}
age := obj[0].Value.(vcvalue.Object)
if got := age[0].Value; !reflect.DeepEqual(got, []any{"users/age", uint64(7)}) {
if got := age[0].Value; !reflect.DeepEqual(got, []any{[]any{"users", "age"}, uint64(7)}) {
t.Fatalf("extended context = %v", got)
}
if _, err := vcffi.Marshal(obj); err != nil {
Expand Down Expand Up @@ -329,17 +329,17 @@ func TestTermExtensionMatchesRecordFieldContext(t *testing.T) {

var to termOptions
ExtendContext(ext...).applyTerm(&to)
probe, err := extend(MustContext("users/email"), to.extension)
probe, err := extend(planContext(t, "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) {
if reflect.DeepEqual(planContext(t, "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"})
other, err := extend(planContext(t, "users/email"), []any{uint64(8), "eu"})
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -388,7 +388,7 @@ func TestSeveralExtensionsJoinInOrder(t *testing.T) {
for _, opt := range opts {
opt.applyTerm(&to)
}
c, err := extend(MustContext("users/email"), to.extension)
c, err := extend(planContext(t, "users/email"), to.extension)
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -786,3 +786,38 @@ func TestTermOrderingAgreesWithRust(t *testing.T) {
t.Error("empty ORE terms do not order by length")
}
}

// planContext is PlanContext for a context known to be valid: the probe
// context a plan field's Context binds.
func planContext(t *testing.T, s string) Context {
t.Helper()
c, err := PlanContext(s)
if err != nil {
t.Fatal(err)
}
return c
}

// A plan field's "table/column" is the pair a Rust struct derive binds,
// rendering the descriptor table/column; a context with no "/" is one part.
func TestPlanContextSpellsATableAndAColumnAsAPair(t *testing.T) {
for in, want := range map[string]any{
"users/age": []any{"users", "age"},
"users": "users",
} {
if got := planContext(t, in).value(); !reflect.DeepEqual(got, want) {
t.Errorf("PlanContext(%q) = %#v, want %#v", in, got, want)
}
}
for _, bad := range []string{"a/b/c", "/age", "users/", "/", ""} {
if _, err := PlanContext(bad); err == nil {
t.Errorf("PlanContext(%q) succeeded; want a refusal", bad)
}
if bad == "" {
continue // NewPlan's own "needs a context" refusal covers it.
}
if _, err := NewPlan(FieldPlan{Field: "Age", Context: bad}); err == nil {
t.Errorf("NewPlan accepted context %q", bad)
}
}
}
4 changes: 2 additions & 2 deletions packages/stack-encrypt-derive/src/attrs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ pub(crate) struct ContainerAttrs {
/// field says otherwise. Exclusive with `plaintext`; requires `context`.
pub(crate) by_field: Option<Type>,
/// `#[stash(context = "...")]` on the container: the first half of every
/// field's inferred context — `"<context>/<field>"`. Names the stored
/// field's inferred context, the pair `("<context>", "<field>")`. Names the stored
/// data, not the Rust type: it is part of the stored data's identity, so
/// it is given explicitly rather than inferred from a name a refactor
/// can change. Only meaningful with `struct`.
Expand Down Expand Up @@ -149,7 +149,7 @@ impl ContainerAttrs {
by_field,
"`struct = ..` needs a `context = \"..\"` beside it naming the stored data \
(e.g. `#[stash(struct = User, context = \"users\")]`): each field is derived \
under `\"<context>/<field>\"`, and the prefix is part of the stored data's \
under the pair `(\"<context>\", \"<field>\")`, and the prefix is part of the stored data's \
identity, so it is given explicitly rather than inferred from the Rust \
type's name",
));
Expand Down
4 changes: 3 additions & 1 deletion packages/stack-encrypt-derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@
//! `context_type = AeadContext` and accept a context type that implements
//! `IntoAad` alone, as the ciphertext leaf itself does.
//! `struct = User, context = "users"` selects plaintext fields
//! and binds them under `"users/<field>"`. The storage envelope itself adds no
//! and binds each under the pair `("users", "<field>")`, which renders
//! `users/<field>` as its ZeroKMS descriptor. A `context = ".."` literal on a
//! field is one text part, exactly as written. The storage envelope itself adds no
Comment thread
coderdan marked this conversation as resolved.
//! cryptographic map-entry context. Vitamin C still binds keys inside plaintext
//! maps and preserves authenticated absence and empty-container markers.
//!
Expand Down
Loading
Loading