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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"!languages/typescript/packages/protect-ffi/lib",
"!languages/typescript/packages/protect-ffi/target",
"!languages/typescript/packages/protect-ffi/src/eql-v3-types",
"!packages/stack-encrypt/tests/fixtures/record_lowering.json",
"!packages/eql/crates/eql-bindings/bindings",
"!packages/eql/crates/eql-bindings/schema",
"!packages/eql/packages/eql/src/generated",
Expand Down
4 changes: 2 additions & 2 deletions languages/golang/stackencrypt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,13 +360,13 @@ This is the golden file for the `Individuals` policy above:
table individuals

column email
context individuals/email
context ["individuals", "email"]
target EQL
terms eq match
fact fides.data_categories user.contact.email

column medicare_number
context individuals/medicare_number
context ["individuals", "medicare_number"]
target EQL
terms eq
fact fides.data_categories user.government_id
Expand Down
51 changes: 46 additions & 5 deletions languages/golang/stackencrypt/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,24 @@ 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, what a Rust
// `#[stash(context = "..")]` literal binds. [Context.With] extends it as
// `nonempty!("..")` 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 what ParseLabel("users/age") binds — 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.
// with the chain's .extend(7u64) binds for that field.
// A one-element list is not the bare part, and this type cannot spell one.
//
// Not every Context is a planned field's. A probe ([Cipher.Term]) takes any
// Context, in whatever shape the data was sealed under. A field of a record
// plan ([FieldPlan.Context]) binds a [Label] of at least two plain segments
// and nothing else — the guest lowers a plan into one context per record
// with one identity per field, and refuses any other shape — so [NewPlan]
// and [PlanFromTags] refuse a one-part context, a one-segment label and an
// extended context at construction. A record call extends every field's
// label alike with [ExtendContext].
//
// 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.
//
Expand All @@ -35,7 +44,11 @@ type Context struct {
node any
}

// NewContext makes a one-part context. The part must not be empty: a bare
// NewContext makes a one-part context, for a probe ([Cipher.Term]) against
// data sealed under one part, or as the base [Context.With] extends. It is
// not a planned field's context: a field binds a [Label] of two or more
// segments ([ParseLabel]), and [NewPlan] refuses a one-part context with the
// field named. The part must not be empty: a bare
// empty string or empty byte slice is an empty context, and the guest
// proves every context non-empty at the boundary, so such a Context could
// only ever fail — every call, with ErrEncoding. Rust refuses the same
Expand All @@ -56,8 +69,7 @@ func NewContext(part any) (Context, error) {
}

// MustContext is [NewContext] for a part known to be valid; it panics
// otherwise, an empty part included. For string literals in plans and
// probes.
// otherwise, an empty part included. For string literals in probes.
func MustContext(part any) Context {
c, err := NewContext(part)
if err != nil {
Expand Down Expand Up @@ -91,6 +103,35 @@ func ownPart(part any) any {
// lists of scalars, ready for the transport codec.
func (c Context) value() any { return c.node }

// fieldLabel is the context a planned field may bind, as the guest's
// lowering reads it: a label of at least two plain segments and nothing
// else, returned as that Label. A one-part context, an extended context and
// a part that is not text are refused with a reason that says what is
// accepted; a flat list of plain text parts is the label it spells,
// whichever constructor built it.
func (c Context) fieldLabel() (Label, error) {
parts, ok := c.node.([]any)
if !ok {
return Label{}, errors.New(`is one part, not a label; a planned field binds a label of at least two plain segments, ParseLabel("table/column").Context()`)
}
segments := make([]string, 0, len(parts))
for _, part := range parts {
s, ok := part.(string)
if !ok {
return Label{}, errors.New("is extended, or holds a part that is not text; a planned field binds a plain label, and a record call extends every field's label alike with ExtendContext")
}
segments = append(segments, s)
}
if len(segments) < 2 {
return Label{}, errors.New("has one segment; a planned field binds a label of at least two")
}
l, err := NewLabel(segments...)
if err != nil {
return Label{}, fmt.Errorf("is not a plain label: %w", err)
}
return l, nil
}

// checkRootNonEmpty refuses the bare parts that are themselves an empty
// context. Integers never are, whatever their value.
func checkRootNonEmpty(part any) error {
Expand Down
29 changes: 18 additions & 11 deletions languages/golang/stackencrypt/guest/src/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ pub async fn term<K>(
kind: u32,
) -> Result<Vec<u8>, u32>
where
K: DataKeySource + Sync,
K: DataKeySource + Sync + 'static,
{
// The same proof every stack-encrypt leaf demands: an empty context is
// `STATUS_ENCODING` here, before any derivation.
Expand Down Expand Up @@ -212,11 +212,14 @@ fn parse_term(value: FfiValue, kind: u32) -> Result<(Scalar, IndexSpec), u32> {
///
/// Both arguments are codec-encoded: the plan is the object
/// [`dynamic::record::plan`] parses, the source an object of
/// `{ field: scalar }` (one record) or an array of them (a batch). The
/// `{ field: value }` (one record) or an array of them (a batch). The
/// result is a codec-encoded ciphertext tree — per record a map of
/// `field → { output-key → node }`.
///
/// All rows and fields seal in one batched `generate_keys`; that batch
/// The plan is lowered into the engine's plan builder and run there
/// (ADR-0007): [`dynamic::record::encrypt`] hands back the plan's `Pending`
/// with every key request queued and nothing sent, and awaiting it here is
/// the one batched `generate_keys` for all rows and fields. That batch
/// reaches ZeroKMS as one request per
/// [`ClientOpts::max_keys_per_req`](stack_kms::ClientOpts::with_max_keys_per_req)
/// keyed leaves (500 by default, sent sequentially: the guest pins
Expand All @@ -230,22 +233,25 @@ pub async fn encrypt_record<K>(
plan: &[u8],
) -> Result<Vec<u8>, u32>
where
K: DataKeySource + Sync,
K: DataKeySource + Sync + 'static,
{
let plan = dynamic::record::plan(decode_value(plan)?).map_err(|e| status_for_dynamic(&e))?;
let tree = dynamic::record::encrypt(cipher, decode_value(source)?, &plan)
.map_err(|e| status_for_dynamic(&e))?
.await
.map_err(|e| status_for_dynamic(&e))?;
.map_err(|e| status_for_error(&e))?;
encode_tree(tree)
}

/// Decrypt a record — or a batch — produced by [`encrypt_record`] under the
/// same plan. Only the `"c"` outputs participate (terms are one-way).
/// same plan. Only the `"c"` and `"passthrough"` outputs participate (terms
/// are one-way).
///
/// One batched `retrieve_keys` per invocation, dispatched as one ZeroKMS
/// call per 500 keyed leaves and, under [`Scope::Client`], per keyset the
/// leaves were sealed under. The output buffer contains plaintext — the ABI
/// layer's ownership rules govern its wiping.
/// The plan's opener runs in the engine; awaiting it here is the one
/// batched `retrieve_keys` per invocation, dispatched as one ZeroKMS call
/// per 500 keyed leaves and, under [`Scope::Client`], per keyset the leaves
/// were sealed under. The output buffer contains plaintext — the ABI layer's
/// ownership rules govern its wiping.
pub async fn decrypt_record<K>(
scope: Scope<'_, K>,
record: &[u8],
Expand All @@ -256,8 +262,9 @@ where
{
let plan = dynamic::record::plan(decode_value(plan)?).map_err(|e| status_for_dynamic(&e))?;
let value = dynamic::record::decrypt(scope, decode_tree(record)?, &plan)
.map_err(|e| status_for_dynamic(&e))?
.await
.map_err(|e| status_for_dynamic(&e))?;
.map_err(|e| status_for_error(&e))?;
encode_value(value)
}

Expand Down
15 changes: 15 additions & 0 deletions languages/golang/stackencrypt/guest/src/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ pub fn status_for_error(error: &stack_encrypt::Error) -> u32 {
// A context that renders past ZeroKMS's descriptor limit is the
// caller's input, refused before any request is sent.
stack_encrypt::Error::DescriptorTooLong { .. } => STATUS_ENCODING,
// A plan refusal — a value or stored record that does not fit the
// plan it is run with, a typed field opened to another kind — is a
// statement about the caller's input, raised by the engine the
// record exports run their plans through.
stack_encrypt::Error::Plan(_) => STATUS_ENCODING,
_ => STATUS_INTERNAL,
}
}
Expand Down Expand Up @@ -296,6 +301,16 @@ mod tests {
);
}

#[test]
fn a_plan_refusal_is_the_callers_input() {
assert_eq!(
status_for_error(&stack_encrypt::Error::Plan(
stack_encrypt::PlanError::NoContext
)),
STATUS_ENCODING
);
}

#[test]
fn a_foreign_keyset_is_its_own_status_and_scope_bugs_are_internal() {
let (a, b) = (uuid::Uuid::from_u128(1), uuid::Uuid::from_u128(2));
Expand Down
Loading
Loading