Skip to content
Merged
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"!languages/typescript/packages/protect-ffi/target",
"!languages/typescript/packages/protect-ffi/src/eql-v3-types",
"!packages/stack-encrypt/tests/fixtures/record_lowering.json",
"!packages/stack-encrypt/tests/fixtures/query_operators.json",
"!packages/eql/tests/encryption/fixtures/ste_vec_selectors.json",
"!packages/eql/tests/encryption/fixtures/v4_rows.json",
"!packages/eql/crates/eql-bindings/bindings",
Expand Down
91 changes: 90 additions & 1 deletion languages/golang/encrypt/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ func parseTarget(entry vcvalue.Object) (record.Target, error) {
t.Producible = b
case "reason":
t.Reason, err = targetOptionalString(f)
case "operators":
t.Operators, err = targetOperators(f.Value)
default:
return t, fmt.Errorf("unknown key %q", f.Key)
}
Expand All @@ -175,14 +177,101 @@ func parseTarget(entry vcvalue.Object) (record.Target, error) {
if t.Name == "" {
return t, errors.New("no name")
}
for _, key := range []string{"indexes", "producible"} {
for _, key := range []string{"indexes", "producible", "operators"} {
if !seen[key] {
return t, fmt.Errorf("no %s", key)
}
}
return t, nil
}

// targetOperators reads an entry's operators: a list of objects, each with
// exactly the keys operator, fidelity, arity and index, every value one the
// engine writes.
func targetOperators(value any) ([]record.TargetOperator, error) {
list, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("operators is %T, not a list", value)
}
operators := make([]record.TargetOperator, 0, len(list))
for i, item := range list {
entry, ok := item.(vcvalue.Object)
if !ok {
return nil, fmt.Errorf("operator %d is %T, not an object", i, item)
}
var op record.TargetOperator
seen := map[string]bool{}
for _, f := range entry {
if seen[f.Key] {
return nil, fmt.Errorf("operator %d: key %q twice", i, f.Key)
}
seen[f.Key] = true
s, err := targetString(f)
if err != nil {
return nil, fmt.Errorf("operator %d: %v", i, err)
}
switch f.Key {
case "operator":
op.Name, err = parseOperatorName(s)
case "fidelity":
op.Fidelity, err = parseFidelity(s)
case "arity":
switch s {
case "one":
case "pair":
op.Pair = true
default:
return nil, fmt.Errorf("operator %d: unknown arity %q", i, s)
}
case "index":
op.Index, err = parseOperatorIndex(s)
default:
return nil, fmt.Errorf("operator %d: unknown key %q", i, f.Key)
}
if err != nil {
return nil, fmt.Errorf("operator %d: %v", i, err)
}
}
if len(seen) != 4 || op.Name == "" {
return nil, fmt.Errorf("operator %d: not every key, or no name", i)
}
operators = append(operators, op)
}
return operators, nil
}

// parseOperatorName reads an operator's name as the engine writes it: one
// of eq, lt, lte, gt, gte, between and matches, the names the engine's
// OpSpec::parse accepts. The catalog (se_targets) and se_query_term are
// read through this, parseFidelity and parseOperatorIndex, so the two
// readers refuse the same values.
func parseOperatorName(s string) (string, error) {
switch s {
case "eq", "lt", "lte", "gt", "gte", "between", "matches":
return s, nil
}
return "", fmt.Errorf("unknown operator %q", s)
}

// parseFidelity reads an operator's fidelity: exact or probabilistic.
func parseFidelity(s string) (record.Fidelity, error) {
switch f := record.Fidelity(s); f {
case record.Exact, record.Probabilistic:
return f, nil
}
return "", fmt.Errorf("unknown fidelity %q", s)
}

// parseOperatorIndex reads the index that answers an operator: eq, match,
// ore or ope.
func parseOperatorIndex(s string) (record.Output, error) {
switch o := record.Output(s); o {
case record.Equality, record.Match, record.Ore, record.Ope:
return o, nil
}
return "", fmt.Errorf("unknown index %q", s)
}

func targetString(f vcvalue.Field) (string, error) {
s, ok := f.Value.(string)
if !ok {
Expand Down
4 changes: 4 additions & 0 deletions languages/golang/encrypt/guest.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ type instance struct {
exports guest.Exports
cipherInit, shutdown, keyset api.Function
term, query api.Function
queryTerm, verify, limit api.Function
encryptRecord, decryptRecord api.Function
planCheck, targets api.Function
}
Expand Down Expand Up @@ -172,6 +173,9 @@ func newInstance(ctx context.Context, wasm []byte, t *transport, policy guest.Lo
"se_keyset": &inst.keyset,
"se_term": &inst.term,
"se_query": &inst.query,
"se_query_term": &inst.queryTerm,
"se_verify": &inst.verify,
"se_target_limit": &inst.limit,
"se_encrypt_record": &inst.encryptRecord,
"se_decrypt_record": &inst.decryptRecord,
"se_plan_check": &inst.planCheck,
Expand Down
128 changes: 125 additions & 3 deletions languages/golang/encrypt/guest/src/abi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
//!
//! - **Every export handed plaintext wipes that buffer in place before it
//! returns**, rather than leaving it for `se_dealloc`: [`se_cipher_init`]
//! (the config carries the client key), and [`se_term`], [`se_query`] and
//! [`se_encrypt_record`] (their value/source buffers). The host's plaintext therefore lives no longer
//! (the config carries the client key), and [`se_term`], [`se_query`],
//! [`se_query_term`], [`se_verify`] and [`se_encrypt_record`] (their
//! value, source and input buffers, and [`se_verify`]'s page of decrypted
//! values). The host's plaintext therefore lives no longer
//! than the call, instead of until the host gets round to releasing it.
//! **A host must not read a plaintext input buffer back after the call, or
//! pass the same buffer to two calls** — it will be zeros. Option, context,
Expand Down Expand Up @@ -53,7 +55,8 @@
//! otherwise): each is a [`stack_encrypt::NonEmpty`] from the moment it is
//! parsed, and the sealing and opening sides bind that one value.
//! [`se_plan_check`] and [`se_targets`] answer the Go generator's questions
//! and need no cipher.
//! and need no cipher; nor do [`se_verify`] and [`se_target_limit`], a
//! probabilistic query's two questions, which use no key.
//!
//! Every export decodes and validates *all* of its inputs — the operation
//! payload, the plan or context, the term kind, the value against the
Expand Down Expand Up @@ -500,6 +503,125 @@ pub unsafe extern "C" fn se_query(
})
}

/// Derive the query term for one plan field, an operator and its input
/// under the keyset `opts` selects ([`ops::query_term`]): a codec-encoded
/// input (one value, or for `between` a list of two), the codec-encoded
/// plan, and the field's and the operator's names (UTF-8 bytes) in; the
/// term's codec-encoded object out. An operator the field does not answer
/// is `STATUS_ENCODING` naming the field, as a mistyped value is, before
/// the cipher is consulted; the refusals the typed path makes before a term
/// exists (a range the wrong way round, match text that cuts no token or
/// does not normalize) are `STATUS_TERM`, also before the cipher. The input
/// buffer is wiped in place.
///
/// # Safety
///
/// As for [`se_term`].
#[no_mangle]
pub unsafe extern "C" fn se_query_term(
val_ptr: *mut u8,
val_len: u32,
plan_ptr: *const u8,
plan_len: u32,
field_ptr: *const u8,
field_len: u32,
op_ptr: *const u8,
op_len: u32,
opt_ptr: *const u8,
opt_len: u32,
) -> u64 {
export(|| {
let value = unsafe { take_plaintext(val_ptr, val_len)? };
let value = value.as_slice();
// SAFETY: host-owned ranges the export was handed; the borrows end
// before it returns and before any wipe of an overlapping range.
let plan = unsafe { input(plan_ptr, plan_len)? };
let field = unsafe { input(field_ptr, field_len)? };
let operator = unsafe { input(op_ptr, op_len)? };
let opts = unsafe { input(opt_ptr, opt_len)? };
ops::validate::query_term(value, plan, field, operator)?;
with_keyset(opts, |keyset| {
block_on(ops::query_term(keyset, value, plan, field, operator))
})
})
}

/// Verify a page of decrypted values against a probabilistic query
/// ([`ops::verify`]): the query's codec-encoded input and a codec-encoded
/// list of values, the codec-encoded plan, and the field's and the
/// operator's names in; a codec-encoded list of booleans out, one per value.
/// Both plaintext buffers, the input and the values, are wiped in place. An
/// exact operator has nothing to verify and is `STATUS_ENCODING`. Needs no
/// cipher: it works in every instance state, as [`se_plan_check`] does.
///
/// # Safety
///
/// As for [`se_term`].
#[no_mangle]
pub unsafe extern "C" fn se_verify(
in_ptr: *mut u8,
in_len: u32,
vals_ptr: *mut u8,
vals_len: u32,
plan_ptr: *const u8,
plan_len: u32,
field_ptr: *const u8,
field_len: u32,
op_ptr: *const u8,
op_len: u32,
) -> u64 {
export(|| {
// Both buffers are taken, and so wiped, before either result is
// acted on: a bad query pointer must not leave the page of
// decrypted values unwiped behind an early return.
let query = unsafe { take_plaintext(in_ptr, in_len) };
let values = unsafe { take_plaintext(vals_ptr, vals_len) };
let (query, values) = (query?, values?);
// SAFETY: host-owned ranges the export was handed; the borrows end
// before it returns.
let plan = unsafe { input(plan_ptr, plan_len)? };
let field = unsafe { input(field_ptr, field_len)? };
let operator = unsafe { input(op_ptr, op_len)? };
ops::verify(query.as_slice(), values.as_slice(), plan, field, operator)
})
}

/// How many rows a probabilistic query should fetch for `want` of them to
/// verify ([`ops::target_limit`]): the term's bytes as [`se_query_term`]
/// returned them, the codec-encoded statistics, the codec-encoded plan,
/// and the field's and the operator's names in, with `want`; the
/// codec-encoded limit out, an unsigned integer or null. An exact operator
/// has no fetch to size and is `STATUS_ENCODING`. Needs no cipher.
///
/// # Safety
///
/// As for [`se_term`].
#[no_mangle]
pub unsafe extern "C" fn se_target_limit(
term_ptr: *const u8,
term_len: u32,
stats_ptr: *const u8,
stats_len: u32,
plan_ptr: *const u8,
plan_len: u32,
field_ptr: *const u8,
field_len: u32,
op_ptr: *const u8,
op_len: u32,
want: u64,
) -> u64 {
export(|| {
// SAFETY: host-owned ranges the export was handed; the borrows end
// before it returns.
let term = unsafe { input(term_ptr, term_len)? };
let stats = unsafe { input(stats_ptr, stats_len)? };
let plan = unsafe { input(plan_ptr, plan_len)? };
let field = unsafe { input(field_ptr, field_len)? };
let operator = unsafe { input(op_ptr, op_len)? };
ops::target_limit(term, stats, plan, field, operator, want)
})
}

/// The EQL types this build knows, as a codec-encoded
/// `{"targets": [...]}` ([`ops::targets`]): the catalog in the `eql` build,
/// nothing in the other. Needs no cipher.
Expand Down
4 changes: 3 additions & 1 deletion languages/golang/encrypt/guest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@
//!
//! The exports are the record path (`se_encrypt_record`, `se_decrypt_record`),
//! per-field term derivation (`se_term`), a target field's EQL query value
//! (`se_query`), keyset resolution (`se_keyset`), the generator's two
//! (`se_query`), a query named by operator and a probabilistic query's two
//! questions (`se_query_term`, `se_verify`, `se_target_limit`; ADR-0007,
//! amended 2026-10-16), keyset resolution (`se_keyset`), the generator's two
//! questions (`se_plan_check`, `se_targets`) and the lifetime pair
//! (`se_cipher_init`, `se_shutdown`). There is no whole-value export: the
//! Go SDK seals every value under a declaration (ADR-0007).
Expand Down
Loading
Loading