diff --git a/conformance/stack-encrypt/README.md b/conformance/stack-encrypt/README.md index 11d4cd96b..4889deebc 100644 --- a/conformance/stack-encrypt/README.md +++ b/conformance/stack-encrypt/README.md @@ -21,7 +21,7 @@ native Rust against the ciphertexts Go has just produced. It never regenerates expectations. CI also runs the Go cases on its other supported host platforms. The same task runs the shared declaration acceptance/rejection corpus in both languages, with native observations of normalization. It also runs the shared -runtime and read cases, and enrolls the existing value-kind and query-operator +runtime, read and context cases, and enrolls the existing value-kind, context-field and query-operator fixtures described below. Set no fixture-update variables when checking conformance. The Rust runner is `packages/stack-encrypt/tests/conformance.rs`; the Go runner @@ -29,6 +29,15 @@ is `languages/golang/encrypt/conformance_test.go`. Go calls the record interface used by generated code. Testing public SDK declarations is a separate layer. Python and TypeScript runners are pending; their absence is not a passing result. +## Context composition and expectations + +The [context contract](context-contract.md) covers ordered extensions, canonical +context encodings, typed/generated API calls, stored-context expectations, +tampering and preflight boundaries. The required command runs the new +`context_conformance` Rust binary and `TestContextConformance` / +`TestContextBoundaries` Go runners, together with native `plan_grammar` and +existing context-field fixture tests. + ## Existing value-kind and query-operator fixtures The [fixture contract](fixture-contract.md) defines `record-value-kinds-v1` and diff --git a/conformance/stack-encrypt/context-contract.md b/conformance/stack-encrypt/context-contract.md new file mode 100644 index 000000000..77793ac66 --- /dev/null +++ b/conformance/stack-encrypt/context-contract.md @@ -0,0 +1,126 @@ +# Context composition + +`record-contexts-v1` (`v1/contexts.json`) specifies composition for the current +record API. `context-boundaries-v1` (`v1/context-boundaries.json`) specifies +stored-context expectations and refusal boundaries. Both are required in Rust +and Go. Python and TypeScript must implement these profiles when their runners +are added; their absence is not conformance evidence. + +## Current semantics + +A field's base is a plain label: the record's declared label followed by the +field's identity. A context-field record obtains the record label from each +row's stored field. A Rust plan built without a context can instead take that +label from the call. Go generated types declare either a literal context or a +context field; they do not expose Rust's contextless saved-plan builder. + +Caller input has two distinct purposes: + +- A **base label** supplies a missing base for a query or a contextless Rust plan. + For a record storing its own context, an expected label checks that stored + value. It does not replace or extend it. A caller label on a plan with a + literal base is refused. +- An **extension** appends to the resolved field context for encryption, + decryption and search. Each call appends in order. Rust + `.extend(7u64).extend(nonempty!("eu"))` and Go + `.Extend(uint64(7)).Extend("eu")` produce `((label)/7u64)/eu`. + Rust `.extend(())` and Go `.Extend()` append nothing. A Rust + `DeclaredContext` containing several parts appends all of those parts. + +Grouping is significant. Rust `.extend(NonEmpty::from(7u64).with("eu"))` adds +one composite part, producing `(label)/(7u64/eu)`. This has different AAD and +terms from separate extensions. Nested `ContextPiece` values are supported by +Rust's lower-level context APIs; the dynamic record grammar and Go `Extend` +accept scalar extension parts only. Their refusal of a grouped extension is +part of the supported API boundary, not permission to flatten it. + +Previously, repeated Rust chain extensions replaced earlier parts. Records +written by those chains still require the +**last extension alone** when opening them; callers must explicitly retain that +old context when reading and querying old records. A query using repeated +extensions now succeeds with different terms, silently missing old rows. +Re-encrypt and re-index old rows to adopt the appended context. This change +does not rewrite ciphertext or migrate stored terms. + +The native base and each appended scalar retain their type and structure. +`7i32`, `7i64`, `7u64`, text `eu`, and bytes `6575` do not collapse to one AAD +or PRF context. Descriptor rendering is deliberately coarser in some cases: +text and bytes can render alike, as can positive signed and unsigned integers. +A matching descriptor alone therefore does not prove matching AAD. Empty text +is accepted by dynamic record/Go extension APIs when the base remains nonempty; +Rust's `NonEmpty` constructor rejects an empty standalone extension. The +composition runner records this API difference instead of bypassing it. + +## Composition evidence + +Each case has a unique `id`, `base` (`literal` or `field`), nonempty `label`, and +ordered `parts`. Each part is `{ "type": ..., "text": ... }`: string, int32, +int64, uint64, or bytes (hex). Integers are decimal strings, never JSON numbers. +Every case encrypts and queries the string `hello` in the `text` field, using +the public all-zero deterministic-provider test seed. + +`v1/contexts-native.json` is keyed by exactly those case IDs. It pins the +resolved context frame, the field context frame sent in a plan, descriptor, +AAD, PRF bytes, one Rust ciphertext and equality term. Rust checks actual descriptors sent to the key provider, fresh typed and +dynamic writes, queries, opens, and frozen ciphertexts. The typed chain calls +`extend` repeatedly, including a final no-op, so replacement behavior cannot +regress unnoticed. + +Go observes the actual plan buffer at the `se_encrypt_record` WASM boundary +through a test-only function wrapper. It compares the field context in those +bytes with the frozen native plan-context frame, then opens the Rust ciphertext. +It compares fresh stored and query terms, both variadic and repeated extensions, +and generated `Note` (field base) and `LiteralNote` (literal base) methods. +Go does not expose a separate descriptor/AAD encoder: matching the actual plan +and successful authenticated cross-opening provide indirect evidence for those +observations. Native Rust checks the canonical descriptor/AAD/PRF bytes directly. +Ciphertext bytes are randomized and are not compared for equality. + +Regenerate deliberately with `STACK_ENCRYPT_UPDATE_FIXTURES=1` and the native +`context_conformance` binary. Required conformance rejects fixture updates. +Changes to frozen context encodings require compatibility review. + +## Expectations and boundaries + +Boundary cases specify rows' `tenants`, the caller's `expected` label (the empty +string explicitly means no expectation in this corpus), a mutation of the last +stored row, an `outcome`, and `native_retrievals`. All properties are required; +null is not an alternative spelling for omission or the empty string. + +Cases cover unrestricted reads, matching expectations, late mismatches, empty +batches, changed stored labels with and without matching expectations, and +missing/empty stored contexts late in a batch. A two-row ciphertext-tampering +case reaches AEAD after retrieval in both languages and returns no partial +plaintext. Typed Rust opening and generated Go opening exercise the boundary +cases too, except that an absent member cannot be represented by a generated Go +struct and is tested at the raw-tree boundary. + +The separate `queries` array specifies a caller `context`, an `outcome` and +whether the query `matches` the stored term for `tenants/acme`. Missing and +invalid contexts refuse; another tenant produces a valid nonmatching query. +Rust typed query chains and generated Go query methods execute these cases. +Key-independent refusals must make no retrieval. An empty valid batch also makes none. Changing the stored +label does not confer permission to open the ciphertext even if the caller's +expectation matches the replacement label. + +Native `CountingSource` checks exact data-key call counts. Its fake provider +does not authenticate descriptors, so a tampered context fails AEAD after one +retrieval. Go's deterministic provider authenticates the descriptor and refuses +the same tampering with `ErrForbidden`. An uninitialised Go checker separately +proves the key-free preflight boundary. This is not a claim that Go exposes +provider call counters. + +The earlier read profile continues to cover context storage shape and exact +structured diagnostics. The enrolled `record-context-fields-v1` fixture +retains generated Go expectation, query, tampering and single-field update +checks and Rust typed/dynamic cross-opening. The required native `plan_grammar` +binary additionally covers missing caller bases, conflicting sources, invalid +labels, caller-derived queries and empty-batch validation before keyset loading. + +## Future roles + +This profile describes the current shared context: it feeds the data-key +descriptor, ciphertext AAD and index PRF context. It does not introduce separate +ciphertext-binding and search-scope contexts. That API design remains separate work; +any future split needs explicit compatibility rules and new profile coverage. +It also does not assert plaintext erasure in Go, Python or TypeScript heaps. diff --git a/conformance/stack-encrypt/coverage.md b/conformance/stack-encrypt/coverage.md index bad3e2a5d..47f204db3 100644 --- a/conformance/stack-encrypt/coverage.md +++ b/conformance/stack-encrypt/coverage.md @@ -91,3 +91,18 @@ For each removal, link the old assertion to stable case IDs, compare coverage and failure behavior, and run both runners in CI. Preserve unrelated assertions from mixed tests. Enroll Python and TypeScript against the existing corpus so adding a language does not create another copy of the expected behavior. + +## Context checkpoint + +The [context contract](context-contract.md) adds 18 shared composition cases and +eleven shared read boundary cases and four query cases, plus enrollment of the +existing record-lowering +context-field fixture. Rust typed write/query/open chains and Go generated +methods execute the composition cases. Required native plan-grammar tests cover +caller-provided bases and empty-batch source validation. Existing context tests +remain: no mixed-purpose Rust or Go test is removed. + +Go proves canonical AAD indirectly through its actual guest plan frame and native +ciphertext opening; provider-call instrumentation remains a separate gap. +Nested composite extensions remain a native API capability rather than an +unsupported feature silently advertised by the Go/dynamic record profile. diff --git a/conformance/stack-encrypt/fixture-contract.md b/conformance/stack-encrypt/fixture-contract.md index efb589960..587c6762d 100644 --- a/conformance/stack-encrypt/fixture-contract.md +++ b/conformance/stack-encrypt/fixture-contract.md @@ -1,6 +1,7 @@ -# Value-kind and query-operator profiles +# Enrolled fixture profiles -`record-value-kinds-v1` and `record-query-operators-v1` enroll the existing +`record-value-kinds-v1`, `record-query-operators-v1` and +`record-context-fields-v1` enroll the existing Rust/Go fixtures in the conformance contract. They are experimental profiles of the cases those fixtures contain, not exhaustive type/operator certification. They use the plain record resolver (`NoTargets`), with public deterministic @@ -108,3 +109,15 @@ the listed observations through their own host values and public APIs. Their integer/decimal/time mappings and actual backends must be specified by those implementations. EQL domains, JSON operations, all-type boundary matrices, shared query refusals and supported-release matrices remain separate work. + +## Context-field requirements + +`record-context-fields-v1` enrolls the context-field section of the existing +`record_lowering.json` fixture. Its format and seed remain owned by that fixture. +The [context contract](context-contract.md) adds composition and boundary cases +without copying these existing vectors. + +| Requirement ID | Required observation | Fixture evidence | +|---|---|---| +| `contexts.frozen-open` | Typed and lowered Rust records open through the opposite path and generated Go methods, under their stored context. | `context_field.records` | +| `contexts.expectations` | An expectation checks the stored context; a mismatch refuses it. Generated Go queries name the base context, and tampering does not open. | Fresh records and `context_field` plan | diff --git a/conformance/stack-encrypt/v1/context-boundaries.json b/conformance/stack-encrypt/v1/context-boundaries.json new file mode 100644 index 000000000..bda82bbf3 --- /dev/null +++ b/conformance/stack-encrypt/v1/context-boundaries.json @@ -0,0 +1,120 @@ +{ + "version": 1, + "profile": "context-boundaries-v1", + "cases": [ + { + "id": "unrestricted", + "tenants": ["tenants/acme", "tenants/globex"], + "expected": "", + "mutation": "none", + "outcome": "success", + "native_retrievals": 1 + }, + { + "id": "expected", + "tenants": ["tenants/acme"], + "expected": "tenants/acme", + "mutation": "none", + "outcome": "success", + "native_retrievals": 1 + }, + { + "id": "late-mismatch", + "tenants": ["tenants/acme", "tenants/globex"], + "expected": "tenants/acme", + "mutation": "none", + "outcome": "mismatch", + "native_retrievals": 0 + }, + { + "id": "empty", + "tenants": [], + "expected": "tenants/acme", + "mutation": "none", + "outcome": "success", + "native_retrievals": 0 + }, + { + "id": "tampered", + "tenants": ["tenants/acme"], + "expected": "", + "mutation": "tamper", + "outcome": "authentication", + "native_retrievals": 1 + }, + { + "id": "tampered-matching-expectation", + "tenants": ["tenants/acme"], + "expected": "tenants/globex", + "mutation": "tamper", + "outcome": "authentication", + "native_retrievals": 1 + }, + { + "id": "tampered-mismatching-expectation", + "tenants": ["tenants/acme"], + "expected": "tenants/acme", + "mutation": "tamper", + "outcome": "mismatch", + "native_retrievals": 0 + }, + { + "id": "late-missing", + "tenants": ["tenants/acme", "tenants/globex"], + "expected": "", + "mutation": "missing", + "outcome": "shape", + "native_retrievals": 0 + }, + { + "id": "late-empty", + "tenants": ["tenants/acme", "tenants/globex"], + "expected": "", + "mutation": "empty", + "outcome": "shape", + "native_retrievals": 0 + }, + { + "id": "late-tampered-context", + "tenants": ["tenants/acme", "tenants/acme"], + "expected": "", + "mutation": "tamper", + "outcome": "authentication", + "native_retrievals": 1 + }, + { + "id": "late-tampered-ciphertext", + "tenants": ["tenants/acme", "tenants/acme"], + "expected": "", + "mutation": "ciphertext", + "outcome": "aead", + "native_retrievals": 1 + } + ], + "queries": [ + { + "id": "query-missing", + "context": "", + "outcome": "refused", + "matches": false + }, + { + "id": "query-invalid", + "context": "tenants/(acme)", + "outcome": "refused", + "matches": false + }, + { + "id": "query-matching", + "context": "tenants/acme", + "outcome": "success", + "matches": true + }, + { + "id": "query-other-tenant", + "context": "tenants/globex", + "outcome": "success", + "matches": false + } + ] +} diff --git a/conformance/stack-encrypt/v1/contexts-native.json b/conformance/stack-encrypt/v1/contexts-native.json new file mode 100644 index 000000000..44089fc94 --- /dev/null +++ b/conformance/stack-encrypt/v1/contexts-native.json @@ -0,0 +1,164 @@ +{ + "field-base": { + "context_frame": "f0030000000a0700000074656e616e74730a0400000061636d650a0400000074657874", + "plan_context_frame": "f0010000000a0400000074657874", + "descriptor": "tenants/acme/text", + "aad": "0300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874", + "prf": "0300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874", + "ciphertext": "0100000000000000000000000000000000e42a8b64607989ab5b6197de58ff5f322000175d4e4987626d968634ab7dda022fdbc41dce31bf9183e729c1f562185b175202698f817c8ac5153febe0a12c6d017bedc729dcd3c0e6a34704bda1de65ba86edabb3", + "equality": "d07ccccb55205dbb5c69c80bd88c907496a18bbed9317f39d96a83f6fd308a85" + }, + "field-bytes": { + "context_frame": "f002000000f0030000000a0700000074656e616e74730a0400000061636d650a04000000746578740b020000006575", + "plan_context_frame": "f002000000f0010000000a04000000746578740b020000006575", + "descriptor": "(tenants/acme/text)/eu", + "aad": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874540000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311900000000000000766974616d696e632f636f6e746578742f62797465732f763102000000000000006575", + "prf": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874540000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311900000000000000766974616d696e632f636f6e746578742f62797465732f763102000000000000006575", + "ciphertext": "0100000000000000000000000000000000ab137bf75fe4fa4f42a20f099ff3a77f20001a726f50cba00da93aab760c6e038be772c928d0cf8a5877dcff2e9ecbafadfe02a07f63015a3bb45c950f1a6669327d39ea2dd813655ea0f990f66f83bca2a4d98c29", + "equality": "df58c27184a7707a4735ac1afd925fa3be93689eb1fa26e615cc96e78107524e" + }, + "field-empty-part": { + "context_frame": "f002000000f0030000000a0700000074656e616e74730a0400000061636d650a04000000746578740a00000000", + "plan_context_frame": "f002000000f0010000000a04000000746578740a00000000", + "descriptor": "(tenants/acme/text)/b64:", + "aad": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874510000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f76310000000000000000", + "prf": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874510000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f76310000000000000000", + "ciphertext": "01000000000000000000000000000000001c03b0987bb51494b2f64841124a60c12000deede8eb83519fef962190d1e9c7104f75ed87d19c0f221d7c85136429b3f4b702523faf4b8207654708e4699416aaa800f0917353fb5ad19c9ba9c9cdfe63697c11a9", + "equality": "6562228f385c28b4762ff6bcb92057ecf739847f85fc18c4c9b917e96b240ad6" + }, + "field-narrow": { + "context_frame": "f002000000f0030000000a0700000074656e616e74730a0400000061636d650a04000000746578740407000000", + "plan_context_frame": "f002000000f0010000000a04000000746578740407000000", + "descriptor": "(tenants/acme/text)/7u32", + "aad": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874570000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f6933322d6c652f7631040000000000000007000000", + "prf": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874570000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f6933322d6c652f7631040000000000000007000000", + "ciphertext": "0100000000000000000000000000000000d8767b62020fa04a135b492d4bed93e62000023c54cc52c80ba067235a64a801d702f3f558640fa9f80508c999afe98f7cbf0201d9c0bdd57345ca59e8758b8cbd823f2087c300740fc1f878fa15c2686f415164cf", + "equality": "a600febf5d31afa2e8fa9dfd0d47bbed76e2a6b69b3a26f3d9ba490d953c5c43" + }, + "field-one": { + "context_frame": "f002000000f0030000000a0700000074656e616e74730a0400000061636d650a0400000074657874070700000000000000", + "plan_context_frame": "f002000000f0010000000a0400000074657874070700000000000000", + "descriptor": "(tenants/acme/text)/7u64", + "aad": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f76310400000000000000746578745b0000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f7536342d6c652f763108000000000000000700000000000000", + "prf": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f76310400000000000000746578745b0000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f7536342d6c652f763108000000000000000700000000000000", + "ciphertext": "01000000000000000000000000000000004216f7c1c404ef65c06bf83fab7d67b720009c0baa84274beb7ccb55fb28f343ab4ad12e6d00803b3955cb5bcf9e6ed2b51202dc72629ac375399fc444afa1bb11918c9a62f1341d353ac50009aef27fdd5a5bcfaf", + "equality": "ef4fd47818a5ec5b472f768bed2e28e9f3e0ef1bf86b95fb85b7d23208fb215a" + }, + "field-ordered": { + "context_frame": "f002000000f002000000f0030000000a0700000074656e616e74730a0400000061636d650a04000000746578740707000000000000000a020000006575", + "plan_context_frame": "f002000000f002000000f0010000000a04000000746578740707000000000000000a020000006575", + "descriptor": "((tenants/acme/text)/7u64)/eu", + "aad": "02000000000000009501000000000000020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f76310400000000000000746578745b0000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f7536342d6c652f763108000000000000000700000000000000530000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f763102000000000000006575", + "prf": "02000000000000009501000000000000020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f76310400000000000000746578745b0000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f7536342d6c652f763108000000000000000700000000000000530000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f763102000000000000006575", + "ciphertext": "0100000000000000000000000000000000d25279cf7ce2862c0e1ead193d5a7698200054b497feda700c887e38cd921614aac2016a88eb2c367e4c0b72b5c6483f5b0c02df5aa44610fba1a90888e44cd3c8cb61d60ec171d4e6f1c026925ff97b0e8d090eb3", + "equality": "ad34715d40cf4bb40e35830f19c01180c08ef0492d40febc9d3267cab1464562" + }, + "field-reversed": { + "context_frame": "f002000000f002000000f0030000000a0700000074656e616e74730a0400000061636d650a04000000746578740a020000006575070700000000000000", + "plan_context_frame": "f002000000f002000000f0010000000a04000000746578740a020000006575070700000000000000", + "descriptor": "((tenants/acme/text)/eu)/7u64", + "aad": "02000000000000008d01000000000000020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874530000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631020000000000000065755b0000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f7536342d6c652f763108000000000000000700000000000000", + "prf": "02000000000000008d01000000000000020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874530000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631020000000000000065755b0000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f7536342d6c652f763108000000000000000700000000000000", + "ciphertext": "01000000000000000000000000000000000682e6a8c346478d0bed4f633f9f591720001d0ee4f5786d90124b1bb4a56452173e72b1dbdb1837bb1761a3dc3ba04574600237a1b985afbdc095b044282f1c8828dc9dd8ba5caa9defa6a3548ff6321e52608629", + "equality": "ac1ae266a8574b0a164b771046e0028bc290e4018c2e48ba351515dcf95a7801" + }, + "field-signed": { + "context_frame": "f002000000f0030000000a0700000074656e616e74730a0400000061636d650a0400000074657874050700000000000000", + "plan_context_frame": "f002000000f0010000000a0400000074657874050700000000000000", + "descriptor": "(tenants/acme/text)/7u64", + "aad": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f76310400000000000000746578745b0000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f6936342d6c652f763108000000000000000700000000000000", + "prf": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f76310400000000000000746578745b0000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f6936342d6c652f763108000000000000000700000000000000", + "ciphertext": "0100000000000000000000000000000000e0fa052c95f14a1b6e95adbfc952cf6e2000ee4082d049338a812d8b10673453127da1fe6dcb665dea4c96da426f52df55bf0260e25b0d83fd0287132f2071ca0aa61de3dee9f05a344fda170211c9c763d4c3240f", + "equality": "1bcd9b5f12c4218bbd584dce0bad0003138408cc312c1cc8bb69bc8861a8227e" + }, + "field-text": { + "context_frame": "f002000000f0030000000a0700000074656e616e74730a0400000061636d650a04000000746578740a020000006575", + "plan_context_frame": "f002000000f0010000000a04000000746578740a020000006575", + "descriptor": "(tenants/acme/text)/eu", + "aad": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874530000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f763102000000000000006575", + "prf": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874530000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f763102000000000000006575", + "ciphertext": "0100000000000000000000000000000000d21a6d4e940e4329994b30afc2a81cb12000203fb57f381e7ef64dc8ebe63bd33f546a772bedc94a624e4d47b6a6c5eb45f3029ae4e5e1d82d1a315fffbe7865cfdae1c2666e69d534b66a652dd42fa4396199f73c", + "equality": "76f509a979f486b783f2d4f872a1913abff9f3ef66d7007b01e3cab3a734e270" + }, + "literal-base": { + "context_frame": "f0030000000a0700000074656e616e74730a0400000061636d650a0400000074657874", + "plan_context_frame": "f0030000000a0700000074656e616e74730a0400000061636d650a0400000074657874", + "descriptor": "tenants/acme/text", + "aad": "0300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874", + "prf": "0300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874", + "ciphertext": "0100000000000000000000000000000000233bbec00515bcc147012024a94dda1d2000421022b5ace076acb43d72b4aa3de668968552535247283db06bed86af0def73029d811dd365ef1445b2a7cf7dff745881871bdcd379986f1b42e5f0b83176e321d0c9", + "equality": "d07ccccb55205dbb5c69c80bd88c907496a18bbed9317f39d96a83f6fd308a85" + }, + "literal-bytes": { + "context_frame": "f002000000f0030000000a0700000074656e616e74730a0400000061636d650a04000000746578740b020000006575", + "plan_context_frame": "f002000000f0030000000a0700000074656e616e74730a0400000061636d650a04000000746578740b020000006575", + "descriptor": "(tenants/acme/text)/eu", + "aad": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874540000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311900000000000000766974616d696e632f636f6e746578742f62797465732f763102000000000000006575", + "prf": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874540000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311900000000000000766974616d696e632f636f6e746578742f62797465732f763102000000000000006575", + "ciphertext": "0100000000000000000000000000000000f992ac91510557386b3bcfcc26e9d45d2000f9ba6397d1be9552b633ffeef89249a1a3b28a1b2a58d4c2808de639001020ae02301ac6e31ef94bd6f03880588e4aa15a0c70cf5b3d498e74eab105e29c688a0d2406", + "equality": "df58c27184a7707a4735ac1afd925fa3be93689eb1fa26e615cc96e78107524e" + }, + "literal-empty-part": { + "context_frame": "f002000000f0030000000a0700000074656e616e74730a0400000061636d650a04000000746578740a00000000", + "plan_context_frame": "f002000000f0030000000a0700000074656e616e74730a0400000061636d650a04000000746578740a00000000", + "descriptor": "(tenants/acme/text)/b64:", + "aad": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874510000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f76310000000000000000", + "prf": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874510000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f76310000000000000000", + "ciphertext": "010000000000000000000000000000000014850b0d9658afa82000b1c214375fd42000ea552a62dd1fdd018c2279f78de9f25c5283b33cd3d51fee029fb98da7e0c35a02c46ea737a9175b1f64ce252c907814ec151ab8265f860e2192a18a455f1fcbe488af", + "equality": "6562228f385c28b4762ff6bcb92057ecf739847f85fc18c4c9b917e96b240ad6" + }, + "literal-narrow": { + "context_frame": "f002000000f0030000000a0700000074656e616e74730a0400000061636d650a04000000746578740407000000", + "plan_context_frame": "f002000000f0030000000a0700000074656e616e74730a0400000061636d650a04000000746578740407000000", + "descriptor": "(tenants/acme/text)/7u32", + "aad": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874570000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f6933322d6c652f7631040000000000000007000000", + "prf": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874570000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f6933322d6c652f7631040000000000000007000000", + "ciphertext": "010000000000000000000000000000000006c936cf0e7c4804593649d96a530f3e20008295873720d38723e2a83007f0cc80867629e97736b65d8da267e09028679ba102304eb13d354282baadc0f719255db1b9dff7bf443aa167959fde52a210c540def110", + "equality": "a600febf5d31afa2e8fa9dfd0d47bbed76e2a6b69b3a26f3d9ba490d953c5c43" + }, + "literal-one": { + "context_frame": "f002000000f0030000000a0700000074656e616e74730a0400000061636d650a0400000074657874070700000000000000", + "plan_context_frame": "f002000000f0030000000a0700000074656e616e74730a0400000061636d650a0400000074657874070700000000000000", + "descriptor": "(tenants/acme/text)/7u64", + "aad": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f76310400000000000000746578745b0000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f7536342d6c652f763108000000000000000700000000000000", + "prf": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f76310400000000000000746578745b0000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f7536342d6c652f763108000000000000000700000000000000", + "ciphertext": "0100000000000000000000000000000000d4e2b614715887c3b0b8b7ec13c8854820008e13eaedd78736f2531e16737e2346916e314064927acfb970d613fb11c81ba10271d40c1b9b7a0df25f48606585fc1e7337e33ab6f0816b959654cf48a22370352e03", + "equality": "ef4fd47818a5ec5b472f768bed2e28e9f3e0ef1bf86b95fb85b7d23208fb215a" + }, + "literal-ordered": { + "context_frame": "f002000000f002000000f0030000000a0700000074656e616e74730a0400000061636d650a04000000746578740707000000000000000a020000006575", + "plan_context_frame": "f002000000f002000000f0030000000a0700000074656e616e74730a0400000061636d650a04000000746578740707000000000000000a020000006575", + "descriptor": "((tenants/acme/text)/7u64)/eu", + "aad": "02000000000000009501000000000000020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f76310400000000000000746578745b0000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f7536342d6c652f763108000000000000000700000000000000530000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f763102000000000000006575", + "prf": "02000000000000009501000000000000020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f76310400000000000000746578745b0000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f7536342d6c652f763108000000000000000700000000000000530000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f763102000000000000006575", + "ciphertext": "0100000000000000000000000000000000127e42644c439f781b72f98a90a1d0282000d1df0e854edc5616954c06efdfc883277cb48bc41b10f03e96a58d6e43e9660d022ca2c0feab076d352efd3acbf89362ce17e2433f7efc4765f2e898a074883a9e1558", + "equality": "ad34715d40cf4bb40e35830f19c01180c08ef0492d40febc9d3267cab1464562" + }, + "literal-reversed": { + "context_frame": "f002000000f002000000f0030000000a0700000074656e616e74730a0400000061636d650a04000000746578740a020000006575070700000000000000", + "plan_context_frame": "f002000000f002000000f0030000000a0700000074656e616e74730a0400000061636d650a04000000746578740a020000006575070700000000000000", + "descriptor": "((tenants/acme/text)/eu)/7u64", + "aad": "02000000000000008d01000000000000020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874530000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631020000000000000065755b0000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f7536342d6c652f763108000000000000000700000000000000", + "prf": "02000000000000008d01000000000000020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874530000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631020000000000000065755b0000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f7536342d6c652f763108000000000000000700000000000000", + "ciphertext": "01000000000000000000000000000000008b7f54275b6c8f7b0acf5eed3c74894420006442ff82abfe7e68dffd900ec2826f1bc9ed4ae95e55118ccb6914165f4bd0760219cdf75189337f6170a4841f4cf1047c99b38e519538cb731a2c06dd706aece87288", + "equality": "ac1ae266a8574b0a164b771046e0028bc290e4018c2e48ba351515dcf95a7801" + }, + "literal-signed": { + "context_frame": "f002000000f0030000000a0700000074656e616e74730a0400000061636d650a0400000074657874050700000000000000", + "plan_context_frame": "f002000000f0030000000a0700000074656e616e74730a0400000061636d650a0400000074657874050700000000000000", + "descriptor": "(tenants/acme/text)/7u64", + "aad": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f76310400000000000000746578745b0000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f6936342d6c652f763108000000000000000700000000000000", + "prf": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f76310400000000000000746578745b0000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311a00000000000000766974616d696e632f636f6e746578742f6936342d6c652f763108000000000000000700000000000000", + "ciphertext": "010000000000000000000000000000000096958fa4c5718b558f2a196c4c02b901200011ff26eaa183171680c1943ed6124393bd39aa8b86e3eaadeb381dce51cb4f3902d5dad6d829cd36e38316f37972ac0daa8686959c51ab8ea85306708edbc6f6bf84e8", + "equality": "1bcd9b5f12c4218bbd584dce0bad0003138408cc312c1cc8bb69bc8861a8227e" + }, + "literal-text": { + "context_frame": "f002000000f0030000000a0700000074656e616e74730a0400000061636d650a04000000746578740a020000006575", + "plan_context_frame": "f002000000f0030000000a0700000074656e616e74730a0400000061636d650a04000000746578740a020000006575", + "descriptor": "(tenants/acme/text)/eu", + "aad": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874530000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f763102000000000000006575", + "prf": "020000000000000022010000000000000300000000000000580000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631070000000000000074656e616e7473550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000061636d65550000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f7631040000000000000074657874530000000000000003000000000000001900000000000000766974616d696e632f636f6e746578742f76616c75652f76311800000000000000766974616d696e632f636f6e746578742f757466382f763102000000000000006575", + "ciphertext": "01000000000000000000000000000000006379fc33f72fe6d1aa66fcdd2f394c342000e66e4d1f85d1e763c0244e39e813e1efc0af0620b0c18d2224e5183f49fc161402173f5892942f89e3a41297f4a9813e17da78930df22bb1f973085848b8fbaa74704a", + "equality": "76f509a979f486b783f2d4f872a1913abff9f3ef66d7007b01e3cab3a734e270" + } +} diff --git a/conformance/stack-encrypt/v1/contexts.json b/conformance/stack-encrypt/v1/contexts.json new file mode 100644 index 000000000..b7935d7a1 --- /dev/null +++ b/conformance/stack-encrypt/v1/contexts.json @@ -0,0 +1,216 @@ +{ + "version": 1, + "profile": "record-contexts-v1", + "required_capabilities": [ + "composition", + "typed-extensions", + "context-fields", + "frozen-open" + ], + "cases": [ + { + "id": "literal-base", + "base": "literal", + "label": "tenants/acme", + "parts": [] + }, + { + "id": "literal-one", + "base": "literal", + "label": "tenants/acme", + "parts": [ + { + "type": "uint64", + "text": "7" + } + ] + }, + { + "id": "literal-ordered", + "base": "literal", + "label": "tenants/acme", + "parts": [ + { + "type": "uint64", + "text": "7" + }, + { + "type": "string", + "text": "eu" + } + ] + }, + { + "id": "literal-reversed", + "base": "literal", + "label": "tenants/acme", + "parts": [ + { + "type": "string", + "text": "eu" + }, + { + "type": "uint64", + "text": "7" + } + ] + }, + { + "id": "literal-signed", + "base": "literal", + "label": "tenants/acme", + "parts": [ + { + "type": "int64", + "text": "7" + } + ] + }, + { + "id": "literal-narrow", + "base": "literal", + "label": "tenants/acme", + "parts": [ + { + "type": "int32", + "text": "7" + } + ] + }, + { + "id": "literal-text", + "base": "literal", + "label": "tenants/acme", + "parts": [ + { + "type": "string", + "text": "eu" + } + ] + }, + { + "id": "literal-bytes", + "base": "literal", + "label": "tenants/acme", + "parts": [ + { + "type": "bytes", + "text": "6575" + } + ] + }, + { + "id": "literal-empty-part", + "base": "literal", + "label": "tenants/acme", + "parts": [ + { + "type": "string", + "text": "" + } + ] + }, + { + "id": "field-base", + "base": "field", + "label": "tenants/acme", + "parts": [] + }, + { + "id": "field-one", + "base": "field", + "label": "tenants/acme", + "parts": [ + { + "type": "uint64", + "text": "7" + } + ] + }, + { + "id": "field-ordered", + "base": "field", + "label": "tenants/acme", + "parts": [ + { + "type": "uint64", + "text": "7" + }, + { + "type": "string", + "text": "eu" + } + ] + }, + { + "id": "field-reversed", + "base": "field", + "label": "tenants/acme", + "parts": [ + { + "type": "string", + "text": "eu" + }, + { + "type": "uint64", + "text": "7" + } + ] + }, + { + "id": "field-signed", + "base": "field", + "label": "tenants/acme", + "parts": [ + { + "type": "int64", + "text": "7" + } + ] + }, + { + "id": "field-narrow", + "base": "field", + "label": "tenants/acme", + "parts": [ + { + "type": "int32", + "text": "7" + } + ] + }, + { + "id": "field-text", + "base": "field", + "label": "tenants/acme", + "parts": [ + { + "type": "string", + "text": "eu" + } + ] + }, + { + "id": "field-bytes", + "base": "field", + "label": "tenants/acme", + "parts": [ + { + "type": "bytes", + "text": "6575" + } + ] + }, + { + "id": "field-empty-part", + "base": "field", + "label": "tenants/acme", + "parts": [ + { + "type": "string", + "text": "" + } + ] + } + ] +} diff --git a/conformance/stack-encrypt/v1/fixture-profiles.json b/conformance/stack-encrypt/v1/fixture-profiles.json index 050c76be8..6b50bd450 100644 --- a/conformance/stack-encrypt/v1/fixture-profiles.json +++ b/conformance/stack-encrypt/v1/fixture-profiles.json @@ -78,6 +78,29 @@ ] } ] + }, + { + "id": "record-context-fields-v1", + "fixture": "packages/stack-encrypt/tests/fixtures/record_lowering.json", + "rust_binary": "record_lowering", + "go_source": "languages/golang/encrypt/context_field_test.go", + "requirements": [ + { + "id": "contexts.frozen-open", + "rust_tests": ["the_committed_context_field_case_opens_both_ways"], + "go_tests": ["TestGeneratedCodeOpensTheRustContextFieldFixture"] + }, + { + "id": "contexts.expectations", + "rust_tests": [ + "the_chain_and_the_lowering_agree_on_a_context_field_today" + ], + "go_tests": [ + "TestContextFieldSealsEachRowUnderItsOwnContext", + "TestContextFieldQueriesNameTheContext" + ] + } + ] } ] } diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index 5037c67ad..8476eca52 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -1039,7 +1039,7 @@ The existing Go package has never been released, so these are removed, not depre - `EncryptRecord`, `EncryptRecords`, `DecryptRecord` and `DecryptRecords`, on `Cipher` and on `Client`. - `EncryptedRecord` and `EncryptedField`: a generated type replaces them. - `Cipher.Term`: the query methods of a field entry replace it. -- `RecordOption`, `WithPlan` and `ExtendContext`: `Cipher.Extend` replaces the last. +- `RecordOption`, `WithPlan` and `ExtendContext`: `Cipher.Extend` replaces the `ExtendContext` function. - `WithGuest`: the import of `encrypt/eql` chooses the guest build. - `TermKind`: `Index` replaces it, for generated code. - `Plan`, `FieldPlan`, `NewPlan` and `Plan.Validate`: the generator replaces them. diff --git a/docs/plans/2026-10-31-stack-encrypt-conformance.md b/docs/plans/2026-10-31-stack-encrypt-conformance.md index 5cde8cea2..4f55a7031 100644 --- a/docs/plans/2026-10-31-stack-encrypt-conformance.md +++ b/docs/plans/2026-10-31-stack-encrypt-conformance.md @@ -1,6 +1,6 @@ # Stack Encrypt cross-language conformance -Date: 2026-10-31. Status: scalar, declaration, initial runtime and read slices implemented; existing value-kind and query-operator fixtures enrolled. The complete contract is not frozen. +Date: 2026-10-31. Status: scalar, declaration, initial runtime, read and context-composition slices implemented; existing value-kind, query-operator and context-field fixtures enrolled. The complete contract is not frozen. This work is stacked on [PR #1404](https://github.com/cipherstash/stack/pull/1404), at `b684a217e2da629c762efa70fac958ec7e21646a`. It follows the accepted @@ -209,3 +209,18 @@ open in native Rust, fixed native values open in Go, and malformed query inputs produce the specified diagnostics. Python and TypeScript remain visibly pending until their runners execute. The complete specification, SQL profile, and all language SDK mappings remain follow-up work. + +## Context composition checkpoint + +Implemented shared composition and boundary cases in +`conformance/stack-encrypt/context-contract.md`: 18 typed extension cases, +eleven read/preflight cases and four query cases, generated Go calls and native +typed chains. Existing +context-field fixture tests and the native plan-grammar suite are required by +`test:conformance`. Repeated Rust `.extend()` now appends, aligning Rust with Go; +the changelog documents compatibility for records written under the old behavior. +The current shared binding/search context remains distinct from the future +binding/search-scope split. + +Next: the EQL/JSON operation profile. Python and TypeScript runner delivery +and the wider fresh artifact exchange remain separate planned work. diff --git a/languages/golang/encrypt/context_conformance_test.go b/languages/golang/encrypt/context_conformance_test.go new file mode 100644 index 000000000..7d921a76f --- /dev/null +++ b/languages/golang/encrypt/context_conformance_test.go @@ -0,0 +1,475 @@ +package encrypt_test + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "reflect" + "testing" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/internal/testusers" + "github.com/cipherstash/stack/languages/golang/internal/record" + "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +type contextCase struct { + ID string `json:"id"` + Base string `json:"base"` + Label string `json:"label"` + Parts []contextPart `json:"parts"` +} +type contextVector struct { + ContextFrame string `json:"context_frame"` + PlanContextFrame string `json:"plan_context_frame"` + Descriptor string `json:"descriptor"` + AAD string `json:"aad"` + PRF string `json:"prf"` + Ciphertext string `json:"ciphertext"` + Equality string `json:"equality"` +} + +func TestContextConformance(t *testing.T) { + ctx := context.Background() + client, err := encrypt.NewDeterministicClient(ctx, [32]byte{}) + skipMissingConformanceGuest(t, err) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + root := filepath.Join("..", "..", "..", "conformance", "stack-encrypt", "v1") + var corpus struct { + Version int `json:"version"` + Profile string `json:"profile"` + RequiredCapabilities []string `json:"required_capabilities"` + Cases []contextCase `json:"cases"` + } + readConformanceJSON(t, filepath.Join(root, "contexts.json"), &corpus) + if corpus.Version != 1 || corpus.Profile != "record-contexts-v1" || len(corpus.Cases) == 0 || !reflect.DeepEqual(corpus.RequiredCapabilities, []string{"composition", "typed-extensions", "context-fields", "frozen-open"}) { + t.Fatal("unsupported context profile") + } + var vectors map[string]contextVector + readConformanceJSON(t, filepath.Join(root, "contexts-native.json"), &vectors) + if len(vectors) != len(corpus.Cases) { + t.Fatal("vector case set differs") + } + t.Run("grouped-extension-is-not-a-go-record-part", func(t *testing.T) { + cipher := client.DefaultKeyset().Extend([]any{uint64(7), "eu"}) + _, err := testusers.NoteFields.Text.Eq(ctx, cipher.Context("tenants/acme"), "hello") + if !errors.Is(err, encrypt.ErrEncoding) { + t.Fatalf("grouped extension: %v", err) + } + }) + seen := map[string]bool{} + for _, c := range corpus.Cases { + if c.ID == "" || seen[c.ID] { + t.Fatal("empty or repeated case ID") + } + seen[c.ID] = true + t.Run(c.ID, func(t *testing.T) { + want, ok := vectors[c.ID] + if !ok { + t.Fatal("missing vector") + } + decode := func(s string) []byte { + t.Helper() + b, e := hex.DecodeString(s) + if e != nil { + t.Fatal(e) + } + return b + } + label, e := record.ParseContext(c.Label) + if e != nil { + t.Fatal(e) + } + p := &record.Plan{Context: label, Fields: []record.Field{{Name: "text", Kind: record.String, Outputs: []record.Output{record.Ciphertext, record.Equality}}}} + source := []record.Source{{"text": "hello"}} + switch c.Base { + case "literal": + case "field": + p.Context = nil + p.ContextField = "tenant" + source[0]["tenant"] = c.Label + default: + t.Fatal("unknown context base") + } + cipher := client.DefaultKeyset() + parts := make([]any, 0, len(c.Parts)) + for _, part := range c.Parts { + var v any + switch part.Type { + case "bytes": + v = decode(part.Text) + case "string", "int32", "int64", "uint64": + v = conformanceValue(t, conformanceInput(part)) + default: + t.Fatal("unsupported context part") + } + parts = append(parts, v) + cipher = cipher.Extend(v) + } + // Inspect the bytes actually passed to se_encrypt_record, not a + // separately reconstructed copy of the host's plan. + var sentPlans [][]byte + restore := encrypt.ObserveEncryptPlansForTest(client, func(plan []byte) { sentPlans = append(sentPlans, plan) }) + fresh, e := cipher.Seal(ctx, p, source) + restore() + if e != nil { + t.Fatal(e) + } + if len(sentPlans) != 1 { + t.Fatalf("expected one encrypt call, got %d", len(sentPlans)) + } + decoded, e := vcffi.Unmarshal(sentPlans[0]) + if e != nil { + t.Fatal(e) + } + field := contextObjectField(t, contextObjectField(t, decoded, "text"), "context") + framed, e := vcffi.Marshal(field) + if e != nil { + t.Fatal(e) + } + if hex.EncodeToString(framed) != want.PlanContextFrame { + t.Fatal("actual guest context differs from native plan") + } + if !bytes.Equal(fresh[0]["text"].Terms[record.Equality], decode(want.Equality)) { + t.Fatal("stored term differs") + } + frozen := []record.Sealed{{"text": {Ciphertext: decode(want.Ciphertext), Terms: map[record.Output][]byte{record.Equality: decode(want.Equality)}}}} + if c.Base == "field" { + frozen[0]["tenant"] = record.Outputs{Context: c.Label} + } + for _, stored := range [][]record.Sealed{fresh, frozen} { + opened, e := cipher.Open(ctx, p, stored) + if e != nil || !reflect.DeepEqual(opened, source) { + t.Fatalf("open: %v; values differ: %v", e, !reflect.DeepEqual(opened, source)) + } + } + queryCipher := cipher + if c.Base == "field" { + queryCipher = cipher.Context(c.Label) + } + query, e := queryCipher.QueryTerm(ctx, p, "text", "eq", "hello") + if e != nil { + t.Fatal(e) + } + if len(query.Terms) != 1 || !bytes.Equal(query.Terms[0], decode(want.Equality)) { + t.Fatal("query differs") + } + // Variadic and repeated calls must agree, including a no-op Extend(). + combined := client.DefaultKeyset().Extend(parts...).Extend() + if c.Base == "field" { + combined = combined.Context(c.Label) + } + q, e := combined.QueryTerm(ctx, p, "text", "eq", "hello") + if e != nil || !reflect.DeepEqual(q.Terms, query.Terms) { + t.Fatalf("combined extensions: %v", e) + } + if c.Base == "literal" { + if c.Label != "tenants/acme" { + t.Fatal("literal generated declaration has a fixed base") + } + notes := []testusers.LiteralNote{{Text: "hello"}} + sealed, err := testusers.EncryptLiteralNote(ctx, cipher, notes) + if err != nil { + t.Fatal(err) + } + if !sealed[0].Text.Equality.Equal(decode(want.Equality)) { + t.Fatal("literal generated write differs") + } + sealed[0].Text.Ciphertext = decode(want.Ciphertext) + opened, err := testusers.DecryptLiteralNote(ctx, cipher, sealed) + if err != nil || !reflect.DeepEqual(opened, notes) { + t.Fatalf("literal generated open: %v", err) + } + q, err := testusers.LiteralNoteFields.Text.Eq(ctx, cipher, "hello") + if err != nil || !q.Equal(decode(want.Equality)) { + t.Fatalf("literal generated query: %v", err) + } + } + if c.Base == "field" { + notes := []testusers.Note{{Tenant: c.Label, Text: "hello", ID: 0}} + sealed, e := testusers.EncryptNote(ctx, cipher, notes) + if e != nil { + t.Fatal(e) + } + if !bytes.Equal(sealed[0].Text.Equality, decode(want.Equality)) { + t.Fatal("generated write differs") + } + sealed[0].Text.Ciphertext = decode(want.Ciphertext) + opened, e := testusers.DecryptNote(ctx, cipher, sealed) + if e != nil || !reflect.DeepEqual(opened, notes) { + t.Fatalf("generated cross-open: %v", e) + } + q, e := testusers.NoteFields.Text.Eq(ctx, queryCipher, "hello") + if e != nil || !q.Equal(decode(want.Equality)) { + t.Fatalf("generated query: %v", e) + } + } + }) + } +} + +type contextQueryBoundary struct { + ID string `json:"id"` + Context string `json:"context"` + Outcome string `json:"outcome"` + Matches bool `json:"matches"` +} + +type contextBoundary struct { + ID string `json:"id"` + Tenants []string `json:"tenants"` + Expected string `json:"expected"` + Mutation string `json:"mutation"` + Outcome string `json:"outcome"` + NativeRetrievals int `json:"native_retrievals"` +} + +func TestContextBoundaries(t *testing.T) { + ctx := context.Background() + client, err := encrypt.NewDeterministicClient(ctx, [32]byte{}) + skipMissingConformanceGuest(t, err) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + wasm, err := encrypt.DeterministicGuest() + if err != nil { + t.Fatal(err) + } + checker, err := encrypt.NewCheckerOver(ctx, wasm) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = checker.Close() }) + var corpus struct { + Version int `json:"version"` + Profile string `json:"profile"` + Cases []contextBoundary `json:"cases"` + Queries []contextQueryBoundary `json:"queries"` + } + readConformanceJSON(t, filepath.Join("..", "..", "..", "conformance", "stack-encrypt", "v1", "context-boundaries.json"), &corpus) + if corpus.Version != 1 || corpus.Profile != "context-boundaries-v1" || len(corpus.Cases) == 0 { + t.Fatal("unsupported boundary profile") + } + p := &record.Plan{ContextField: "tenant", Fields: []record.Field{{Name: "text", Kind: record.String, Outputs: []record.Output{record.Ciphertext, record.Equality}}}} + seen := map[string]bool{} + for _, c := range corpus.Cases { + if c.ID == "" || seen[c.ID] { + t.Fatal("empty or repeated ID") + } + seen[c.ID] = true + t.Run(c.ID, func(t *testing.T) { + rows := make([]record.Source, len(c.Tenants)) + for i, tenant := range c.Tenants { + rows[i] = record.Source{"tenant": tenant, "text": "hello"} + } + cipher := client.DefaultKeyset() + sealed, e := cipher.Seal(ctx, p, rows) + if e != nil { + t.Fatal(e) + } + tree := make([]any, len(sealed)) + for i, row := range sealed { + tree[i] = map[string]any{"tenant": map[string]any{"passthrough": vcvalue.Plain{V: row["tenant"].Context}}, "text": map[string]any{"c": encrypt.Ciphertext(row["text"].Ciphertext), "eq": vcvalue.Plain{V: row["text"].Terms[record.Equality]}}} + } + switch c.Mutation { + case "none": + case "ciphertext": + row := tree[len(tree)-1].(map[string]any) + field := row["text"].(map[string]any) + ciphertext := append(encrypt.Ciphertext(nil), field["c"].(encrypt.Ciphertext)...) + ciphertext[len(ciphertext)-1] ^= 1 + field["c"] = ciphertext + case "tamper", "empty", "missing": + if len(tree) == 0 { + t.Fatal("mutation requires a row") + } + row := tree[len(tree)-1].(map[string]any) + if c.Mutation == "missing" { + delete(row, "tenant") + } else { + tenant := "" + if c.Mutation == "tamper" { + tenant = "tenants/globex" + } + row["tenant"] = map[string]any{"passthrough": vcvalue.Plain{V: tenant}} + } + default: + t.Fatal("unknown mutation") + } + _, preflight := checker.OpenTreeForTest(ctx, tree, p, c.Expected) + // An uninitialised guest cannot retrieve keys: valid shapes reach ErrState. + switch c.Outcome { + case "success", "authentication", "aead": + if !errors.Is(preflight, encrypt.ErrState) { + t.Fatalf("preflight: %v", preflight) + } + case "mismatch", "shape": + if preflight == nil || errors.Is(preflight, encrypt.ErrState) { + t.Fatalf("expected key-free refusal: %v", preflight) + } + default: + t.Fatal("unknown outcome") + } + reader := cipher + if c.Expected != "" { + reader = reader.Context(c.Expected) + } + // A generated Go struct cannot represent an absent tenant member; + // the raw-tree path above owns that shape refusal. + if c.Mutation != "missing" { + generated := make([]testusers.EncryptedNote, len(tree)) + for i, row := range tree { + fields := row.(map[string]any) + tenant := fields["tenant"].(map[string]any)["passthrough"].(vcvalue.Plain).V.(string) + ciphertext := fields["text"].(map[string]any)["c"].(encrypt.Ciphertext) + generated[i] = testusers.EncryptedNote{Tenant: tenant, Text: testusers.EncryptedNoteText{Ciphertext: ciphertext}} + } + opened, err := testusers.DecryptNote(ctx, reader, generated) + if c.Outcome == "success" { + if err != nil || len(opened) != len(c.Tenants) { + t.Fatalf("generated boundary: %v", err) + } + for i, row := range opened { + if row.Tenant != c.Tenants[i] || row.Text != "hello" { + t.Fatal("generated values differ") + } + } + } else if err == nil || len(opened) != 0 { + t.Fatalf("generated failure must return no rows: %v", err) + } + } + opened, e := reader.OpenTreeForTest(ctx, tree, p) + if e != nil && len(opened) != 0 { + t.Fatal("batch failure returned partial plaintext") + } + switch c.Outcome { + case "success": + if e != nil || !reflect.DeepEqual(opened, rows) { + t.Fatalf("open: %v; rows differ: %v", e, !reflect.DeepEqual(opened, rows)) + } + want := 1 + if len(rows) == 0 { + want = 0 + } + if c.NativeRetrievals != want { + t.Fatal("invalid retrieval expectation") + } + case "mismatch": + if !errors.Is(e, encrypt.ErrContextMismatch) || c.NativeRetrievals != 0 { + t.Fatalf("mismatch: %v", e) + } + case "shape": + if !errors.Is(e, encrypt.ErrEncoding) || c.NativeRetrievals != 0 { + t.Fatalf("shape: %v", e) + } + case "aead": + if !errors.Is(e, encrypt.ErrAuthentication) || c.NativeRetrievals != 1 { + t.Fatalf("AEAD failure: %v", e) + } + case "authentication": + if !errors.Is(e, encrypt.ErrForbidden) || c.NativeRetrievals != 1 { + t.Fatalf("tampering: %v", e) + } + } + }) + } + if len(corpus.Queries) == 0 { + t.Fatal("missing query boundaries") + } + for _, q := range corpus.Queries { + if q.ID == "" || seen[q.ID] { + t.Fatal("empty or repeated query ID") + } + seen[q.ID] = true + t.Run(q.ID, func(t *testing.T) { + cipher := client.DefaultKeyset() + stored, err := testusers.EncryptNote(ctx, cipher, []testusers.Note{{Tenant: "tenants/acme", Text: "hello"}}) + if err != nil { + t.Fatal(err) + } + if q.Context != "" { + cipher = cipher.Context(q.Context) + } + term, err := testusers.NoteFields.Text.Eq(ctx, cipher, "hello") + switch q.Outcome { + case "refused": + if !errors.Is(err, encrypt.ErrEncoding) || q.Matches { + t.Fatalf("query refusal: %v", err) + } + case "success": + if err != nil || term.Equal(stored[0].Text.Equality) != q.Matches { + t.Fatalf("query match: %v", err) + } + default: + t.Fatal("unknown query outcome") + } + }) + } + +} + +// Go's zero values must not make a missing or null field valid when Rust's +// required Deserialize fields reject it. The corpus is a strict shared format. +func decodeRequiredContextJSON(data []byte, out any, required ...string) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + for _, name := range required { + value, ok := fields[name] + if !ok || bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return fmt.Errorf("missing or null %s", name) + } + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + return decoder.Decode(out) +} + +type contextPart conformanceInput + +func (p *contextPart) UnmarshalJSON(data []byte) error { + type plain contextPart + return decodeRequiredContextJSON(data, (*plain)(p), "type", "text") +} +func (c *contextCase) UnmarshalJSON(data []byte) error { + type plain contextCase + return decodeRequiredContextJSON(data, (*plain)(c), "id", "base", "label", "parts") +} +func (v *contextVector) UnmarshalJSON(data []byte) error { + type plain contextVector + return decodeRequiredContextJSON(data, (*plain)(v), "context_frame", "plan_context_frame", "descriptor", "aad", "prf", "ciphertext", "equality") +} +func (c *contextBoundary) UnmarshalJSON(data []byte) error { + type plain contextBoundary + return decodeRequiredContextJSON(data, (*plain)(c), "id", "tenants", "expected", "mutation", "outcome", "native_retrievals") +} + +func contextObjectField(t *testing.T, value any, name string) any { + t.Helper() + object, ok := value.(vcvalue.Object) + if !ok { + t.Fatalf("expected object, got %T", value) + } + for _, field := range object { + if field.Key == name { + return field.Value + } + } + t.Fatalf("missing field %s", name) + return nil +} + +func (q *contextQueryBoundary) UnmarshalJSON(data []byte) error { + type plain contextQueryBoundary + return decodeRequiredContextJSON(data, (*plain)(q), "id", "context", "outcome", "matches") +} diff --git a/languages/golang/encrypt/context_field_test.go b/languages/golang/encrypt/context_field_test.go index 303d71ae4..e9d2ee34a 100644 --- a/languages/golang/encrypt/context_field_test.go +++ b/languages/golang/encrypt/context_field_test.go @@ -27,7 +27,7 @@ var notes = []testusers.Note{ } func TestContextFieldSealsEachRowUnderItsOwnContext(t *testing.T) { - c := deterministicClient(t) + c := contextFixtureClient(t) ctx := context.Background() cipher := c.DefaultKeyset() @@ -101,7 +101,7 @@ func TestContextFieldSealsEachRowUnderItsOwnContext(t *testing.T) { } func TestContextFieldQueriesNameTheContext(t *testing.T) { - c := deterministicClient(t) + c := contextFixtureClient(t) ctx := context.Background() cipher := c.DefaultKeyset() encrypted, err := testusers.EncryptNote(ctx, cipher, notes) @@ -185,9 +185,7 @@ func TestGeneratedCodeOpensTheRustContextFieldFixture(t *testing.T) { t.Fatalf("seed: %v (%d bytes)", err, len(seedBytes)) } c, err := encrypt.NewDeterministicClient(context.Background(), [32]byte(seedBytes)) - if errors.Is(err, encrypt.ErrDeterministicGuestNotBuilt) || errors.Is(err, encrypt.ErrGuestNotBuilt) { - t.Skip(err) - } + skipMissingConformanceGuest(t, err) if err != nil { t.Fatal(err) } @@ -234,3 +232,15 @@ func TestGeneratedCodeOpensTheRustContextFieldFixture(t *testing.T) { }) } } + +// A missing guest skips optional runs and fails required conformance. +func contextFixtureClient(t *testing.T) *encrypt.Client { + t.Helper() + c, err := encrypt.NewDeterministicClient(context.Background(), testSeed) + skipMissingConformanceGuest(t, err) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = c.Close() }) + return c +} diff --git a/languages/golang/encrypt/export_test.go b/languages/golang/encrypt/export_test.go index 2b500104e..56f8b7d13 100644 --- a/languages/golang/encrypt/export_test.go +++ b/languages/golang/encrypt/export_test.go @@ -11,6 +11,7 @@ import ( "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/cipherstash/stack/languages/golang/internal/record" "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/tetratelabs/wazero/api" ) // The test-only ways to give a client a token. The public API takes tokens @@ -219,3 +220,30 @@ func (cph *Cipher) OpenTreeForTest(ctx context.Context, tree []any, plan *record func (k *Checker) OpenTreeForTest(ctx context.Context, tree []any, plan *record.Plan, expected string) ([]record.Source, error) { return k.c.openTree(ctx, k.c.DefaultKeyset().keyset, expected, plan, tree) } + +// ObserveEncryptPlansForTest copies the actual plan buffer at the WASM call +// boundary. Install and restore only while this test's client is idle. +func ObserveEncryptPlansForTest(c *Client, observe func([]byte)) func() { + original := c.inst.encryptRecord + c.inst.encryptRecord = &observedEncryptCall{Function: original, module: c.inst.module, observe: observe} + return func() { c.inst.encryptRecord = original } +} + +type observedEncryptCall struct { + api.Function + module api.Module + observe func([]byte) +} + +func (f *observedEncryptCall) Call(ctx context.Context, params ...uint64) ([]uint64, error) { + // se_encrypt_record(source_ptr, source_len, plan_ptr, plan_len, opts_ptr, opts_len). + if len(params) != 6 { + return nil, fmt.Errorf("unexpected encrypt ABI: %d arguments", len(params)) + } + plan, ok := f.module.Memory().Read(uint32(params[2]), uint32(params[3])) + if !ok { + return nil, errors.New("plan outside guest memory") + } + f.observe(append([]byte(nil), plan...)) + return f.Function.Call(ctx, params...) +} diff --git a/languages/golang/encrypt/fixture_conformance_test.go b/languages/golang/encrypt/fixture_conformance_test.go index de62ed107..e5b7720e4 100644 --- a/languages/golang/encrypt/fixture_conformance_test.go +++ b/languages/golang/encrypt/fixture_conformance_test.go @@ -74,6 +74,8 @@ func TestFixtureConformanceRequiresGuest(t *testing.T) { } // These full-suite tests also share queryFixtureClient's required-mode policy; // they remain outside the shared fixture requirements and aggregate selection. + names["TestContextConformance"] = true + names["TestContextBoundaries"] = true names["TestAQueryTheFieldDoesNotAnswerIsRefused"] = true names["TestAMatchesQueryNeverPrintsItsText"] = true tests := make([]string, 0, len(names)) diff --git a/languages/golang/encrypt/internal/testusers/context_note.go b/languages/golang/encrypt/internal/testusers/context_note.go new file mode 100644 index 000000000..c80094740 --- /dev/null +++ b/languages/golang/encrypt/internal/testusers/context_note.go @@ -0,0 +1,9 @@ +package testusers + +//go:generate go run github.com/cipherstash/stack/languages/golang/cmd/stashgen -type LiteralNote -name LiteralNote -redact + +// LiteralNote exercises the declared base used by context conformance. +type LiteralNote struct { + _ struct{} `stash:"context=tenants/acme"` + Text string `stash:"text,encrypt,index=equality"` +} diff --git a/languages/golang/encrypt/internal/testusers/literalnote_stash.go b/languages/golang/encrypt/internal/testusers/literalnote_stash.go new file mode 100644 index 000000000..a3eb49f29 --- /dev/null +++ b/languages/golang/encrypt/internal/testusers/literalnote_stash.go @@ -0,0 +1,119 @@ +// Code generated by stashgen. DO NOT EDIT. + +package testusers + +import ( + "context" + "log/slog" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/gensupport" +) + +// Stops compiling when the library does not accept this version of generated file. +const _ = gensupport.GeneratedVersion2 + +type EncryptedLiteralNote struct { + Text EncryptedLiteralNoteText +} + +type EncryptedLiteralNoteText struct { + Ciphertext encrypt.Ciphertext + Equality encrypt.EqualityTerm +} + +func (e EncryptedLiteralNote) String() string { + return gensupport.Redacted("EncryptedLiteralNote", nil, "Text") +} + +func (e EncryptedLiteralNote) LogValue() slog.Value { + return gensupport.RedactedLog(nil, "Text") +} + +// Written because of -redact: LiteralNote no longer prints its sealed fields. +func (l LiteralNote) String() string { + return gensupport.Redacted("LiteralNote", nil, "Text") +} + +func (l LiteralNote) LogValue() slog.Value { + return gensupport.RedactedLog(nil, "Text") +} + +// GoString is what %#v prints; without it %#v prints every field. +func (l LiteralNote) GoString() string { + return gensupport.Redacted("LiteralNote", nil, "Text") +} + +// Stops compiling when LiteralNote gains, loses, reorders or retypes a field. +var _ = literalNoteShape(LiteralNote{}) + +type literalNoteShape struct { + _ struct{} + Text string +} + +var literalNoteDeclaration = gensupport.Declare("tenants/acme"). + EncryptIndex("text", gensupport.String, encrypt.Equality) + +var literalNoteCodec = gensupport.New(gensupport.Generated[LiteralNote, EncryptedLiteralNote]{ + TypeName: "LiteralNote", + Declaration: literalNoteDeclaration, + Source: func(v LiteralNote) gensupport.Values { + return gensupport.Values{"text": v.Text} + }, + Seal: func(rec gensupport.Record) (EncryptedLiteralNote, error) { + var e EncryptedLiteralNote + e.Text = EncryptedLiteralNoteText{ + Ciphertext: rec["text"].Ciphertext, + Equality: rec["text"].Equality, + } + return e, nil + }, + Open: func(e EncryptedLiteralNote) gensupport.Record { + return gensupport.Record{"text": {Ciphertext: e.Text.Ciphertext, Equality: e.Text.Equality}} + }, + Value: func(e EncryptedLiteralNote, vals gensupport.Values) (LiteralNote, error) { + var v LiteralNote + var err error + if v.Text, err = gensupport.Get[string](vals, "text"); err != nil { + return LiteralNote{}, err + } + return v, nil + }, +}) + +// EncryptLiteralNote seals each LiteralNote, with one ZeroKMS request for each +// 500 sealed values, plus one the first time a keyset is used. The result has +// one element for each input, in the same order. +func EncryptLiteralNote(ctx context.Context, cipher *encrypt.Cipher, values []LiteralNote) ([]EncryptedLiteralNote, error) { + return literalNoteCodec.Encrypt(ctx, cipher, values) +} + +// DecryptLiteralNote opens each EncryptedLiteralNote, with one ZeroKMS request +// for each 500 sealed values, plus one the first time a keyset is used. +func DecryptLiteralNote(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedLiteralNote) ([]LiteralNote, error) { + return literalNoteCodec.Decrypt(ctx, d, encrypted) +} + +var LiteralNoteFields = struct { + Text LiteralNoteTextField +}{ + Text: LiteralNoteTextField{gensupport.NewField[string](literalNoteDeclaration, "text")}, +} + +type LiteralNoteTextField struct { + field gensupport.Field[string] +} + +func (f LiteralNoteTextField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (EncryptedLiteralNoteText, error) { + out, err := f.field.Encrypt(ctx, c, v) + if err != nil { + return EncryptedLiteralNoteText{}, err + } + return EncryptedLiteralNoteText{Ciphertext: out.Ciphertext, Equality: out.Equality}, nil +} + +func (f LiteralNoteTextField) Eq(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.EqualityTerm, error) { + term, err := f.field.Term(ctx, c, "eq", v) + return encrypt.EqualityTerm(term), err +} diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index fe6766795..74b5d3df6 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Breaking + +- Repeated plan-chain `.extend(..)` calls now append parts in order, matching + Go's `Cipher.Extend`, instead of replacing earlier extensions. + `.extend(())` adds nothing, and grouped `NonEmpty` parts retain their grouping. + Records written using the previous replacement behavior must still be opened + with the final extension alone. Queries using repeated extensions now derive + different terms and silently miss those old rows: they return no error. Keep + the final extension alone for both reads and queries of old data, or re-encrypt + and re-index it under the appended context. This does not migrate stored data. + ## [0.3.0] - 2026-10-27 ### Breaking diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 0cea0891a..3baeebf32 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -4368,6 +4368,21 @@ mod tests { let mut age = map(node(&mut fields, "age")); let stored = term_bytes(&node(&mut age, "eq")); + struct Age { + age: u32, + } + let mut typed = cipher + .encrypt(&Age { age: 34 }) + .context("users") + .fields() + .encrypt_index(pick("age", |a: &Age| &a.age), Equality) + .extend(7u64) + .extend(nonempty!("eu")) + .await + .expect("typed chain"); + let typed_age: Encrypted = typed.take("age").expect("age"); + assert_eq!(stored, typed_age.terms.to_bytes()); + let nested = nonempty!("users").with("age").with(7u64).with("eu"); let probe = keyset.equality_term(34u32, nested).await.expect("probe"); assert_eq!(stored, probe.to_bytes(), "left-nested, part by part"); diff --git a/packages/stack-encrypt/src/plan/chain.rs b/packages/stack-encrypt/src/plan/chain.rs index 2b8137a72..847c71327 100644 --- a/packages/stack-encrypt/src/plan/chain.rs +++ b/packages/stack-encrypt/src/plan/chain.rs @@ -232,9 +232,10 @@ macro_rules! chain_options { /// each field's `/` becomes /// `(/)/`, as a derived record's caller /// context does. The same parts must be given to read it back. - /// A second call replaces the first. + /// Further calls append in order, nesting to the left. A grouped + /// `NonEmpty` remains one part; `.extend(())` adds nothing. pub fn extend(mut self, parts: impl Into) -> Self { - self.common.extend = parts.into(); + self.common.extend.append(parts.into()); self } } diff --git a/packages/stack-encrypt/src/target/context.rs b/packages/stack-encrypt/src/target/context.rs index aaddc1ae7..3e9ca028b 100644 --- a/packages/stack-encrypt/src/target/context.rs +++ b/packages/stack-encrypt/src/target/context.rs @@ -184,6 +184,11 @@ impl DeclaredContext { self } + /// Append the caller's parts, preserving each part's grouping. + pub(crate) fn append(&mut self, other: Self) { + self.0.extend(other.0); + } + /// The context one field is derived under: its own `own`, extended by /// the caller's context if one was given — `("users", "age")` as it is /// under `()`, `(("users", "age"), id)` under a caller's `id`, and diff --git a/packages/stack-encrypt/tests/context_conformance.rs b/packages/stack-encrypt/tests/context_conformance.rs new file mode 100644 index 000000000..1607cc9f2 --- /dev/null +++ b/packages/stack-encrypt/tests/context_conformance.rs @@ -0,0 +1,700 @@ +//! Shared context composition, typed chains and dynamic lowering. +#![cfg(feature = "dynamic")] +mod common; + +use common::conformance::{value, Input}; +use common::frames::{frame, object}; +use common::{hex, unhex}; +use miette::Diagnostic; +use serde::{Deserialize, Serialize}; +use stack_encrypt::dynamic::{self, record, Scope, ValueFrame}; +use stack_encrypt::plan::pick; +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{DeclaredContext, Encrypted}; +use stack_encrypt::{ + CipherText, Descriptor, Equality, IntoAad, IntoPrfContext, Label, NonEmpty, Plan, SealedValue, + StackCipherText, +}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Corpus { + version: u32, + profile: String, + required_capabilities: Vec, + cases: Vec, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Case { + id: String, + base: String, + label: String, + parts: Vec, +} +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Vector { + context_frame: String, + plan_context_frame: String, + descriptor: String, + aad: String, + prf: String, + ciphertext: String, + equality: String, +} + +fn part(input: &Input) -> ValueFrame { + if input.kind == "bytes" { + frame(unhex(&input.text)) + } else { + assert!(["string", "int32", "int64", "uint64"].contains(&input.kind.as_str())); + value(input) + } +} +fn extension(input: &Input) -> DeclaredContext { + // An empty scalar is a valid extension of an already nonempty label. + // CallerContext's constructors enforce nonemptiness on each part, so + // the empty-part case is exercised through lowering only. + match input.kind.as_str() { + "string" => DeclaredContext::from(NonEmpty::new(input.text.clone()).unwrap()), + "bytes" => DeclaredContext::from(NonEmpty::new(unhex(&input.text)).unwrap()), + "int32" => input.text.parse::().unwrap().into(), + "int64" => input.text.parse::().unwrap().into(), + "uint64" => input.text.parse::().unwrap().into(), + _ => panic!("unsupported context part"), + } +} +fn nest(head: ValueFrame, parts: &[Input]) -> ValueFrame { + parts + .iter() + .fold(head, |head, p| frame(vec![head, part(p)])) +} + +fn context(case: &Case, field_only: bool) -> ValueFrame { + let mut label = if field_only { + vec![] + } else { + case.label.split('/').map(str::to_owned).collect::>() + }; + label.push("text".into()); + nest(frame(label), &case.parts) +} +fn plan(case: &Case) -> record::Plan { + let mut fields = vec![]; + if case.base == "field" { + fields.push(("context_field", frame("tenant"))); + fields.push(( + "tenant", + object(vec![ + ("context", nest(frame(vec!["tenant"]), &case.parts)), + ("outputs", frame(vec!["passthrough"])), + ("type", frame("string")), + ]), + )); + } + fields.push(( + "text", + object(vec![ + ("context", context(case, case.base == "field")), + ("outputs", frame(vec!["c", "eq"])), + ("type", frame("string")), + ]), + )); + record::plan(object(fields).risky_ref()).unwrap() +} +fn source(case: &Case) -> ValueFrame { + let mut fields = vec![]; + if case.base == "field" { + fields.push(("tenant", frame(case.label.clone()))); + } + fields.push(("text", frame("hello"))); + object(fields) +} +fn ciphertext(tree: StackCipherText) -> Vec { + let CipherText::Map(fields) = tree else { + panic!("record") + }; + let (_, CipherText::Map(outputs)) = fields.into_iter().find(|(k, _)| k == "text").unwrap() + else { + panic!("outputs") + }; + let (_, CipherText::Single(leaf)) = outputs.into_iter().find(|(k, _)| k == "c").unwrap() else { + panic!("leaf") + }; + leaf.to_bytes() +} +fn stored(case: &Case, vector: &Vector) -> StackCipherText { + let mut fields = vec![]; + if case.base == "field" { + fields.push(( + "tenant".into(), + CipherText::Map(vec![( + "passthrough".into(), + CipherText::Passthrough(common::passthrough(case.label.clone())), + )]), + )); + } + fields.push(( + "text".into(), + CipherText::Map(vec![ + ( + "c".into(), + CipherText::Single(SealedValue::from_bytes(&unhex(&vector.ciphertext)).unwrap()), + ), + ( + "eq".into(), + CipherText::Passthrough(common::passthrough(unhex(&vector.equality))), + ), + ]), + )); + CipherText::Map(fields) +} +struct Note { + tenant: String, + text: String, +} + +#[tokio::test] +async fn shared_context_composition() { + let update = common::fixture_updates_requested_or_panic(); + let corpus: Corpus = serde_json::from_str(include_str!( + "../../../conformance/stack-encrypt/v1/contexts.json" + )) + .unwrap(); + assert_eq!(corpus.version, 1); + assert_eq!(corpus.profile, "record-contexts-v1"); + assert_eq!( + corpus.required_capabilities, + [ + "composition", + "typed-extensions", + "context-fields", + "frozen-open" + ] + ); + assert!(!corpus.cases.is_empty()); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../conformance/stack-encrypt/v1/contexts-native.json"); + let baseline: BTreeMap = if update { + BTreeMap::new() + } else { + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap() + }; + let mut vectors = BTreeMap::new(); + let mut ids = BTreeSet::new(); + let cipher = common::deterministic_cipher([0; 32]).await; + let keyset = cipher.default_keyset(); + for case in corpus.cases { + assert!(!case.id.is_empty() && ids.insert(case.id.clone())); + assert!(["literal", "field"].contains(&case.base.as_str())); + Label::parse(&case.label).unwrap(); + eprintln!("context conformance: {}", case.id); + let context_frame = context(&case, false); + let ctx = dynamic::context(context_frame.risky_ref()).unwrap(); + let descriptor = Descriptor::of(ctx.clone()).as_str().to_owned(); + let aad = hex(ctx.clone().into_aad().as_bytes()); + let prf = hex(ctx.clone().into_prf_context().as_bytes()); + let term = keyset.equality_term("hello", ctx.clone()).await.unwrap(); + let plan = plan(&case); + let fresh = record::encrypt(&keyset, source(&case), &plan) + .unwrap() + .await + .unwrap(); + let vector = Vector { + context_frame: hex(context_frame.risky_ref()), + plan_context_frame: hex(context(&case, case.base == "field").risky_ref()), + descriptor, + aad, + prf, + ciphertext: hex(&ciphertext(fresh)), + equality: hex(&term.to_bytes()), + }; + let (recording, sent) = common::recording_cipher().await; + record::encrypt(&recording.default_keyset(), source(&case), &plan) + .unwrap() + .await + .unwrap(); + assert_eq!( + sent.lock().unwrap().generated(), + std::slice::from_ref(&vector.descriptor) + ); + let query_plan = record::plan( + object(vec![( + "text", + object(vec![ + ("context", context_frame.clone()), + ("outputs", frame(vec!["c", "eq"])), + ("type", frame("string")), + ]), + )]) + .risky_ref(), + ) + .unwrap(); + let query = dynamic::query_term( + &keyset, + &query_plan, + "text", + dynamic::OpSpec::Eq, + "hello", + &dynamic::NoTargets, + ) + .unwrap() + .await + .unwrap(); + let dynamic::Operand::One(bytes) = query.operand() else { + panic!("one term") + }; + assert_eq!(hex(bytes), vector.equality); + if case.base == "field" { + assert!( + dynamic::query_term( + &keyset, + &plan, + "text", + dynamic::OpSpec::Eq, + "hello", + &dynamic::NoTargets + ) + .is_err(), + "field query requires a context" + ); + } + let expected = if update { &vector } else { &baseline[&case.id] }; + assert_eq!(vector.context_frame, expected.context_frame); + assert_eq!(vector.plan_context_frame, expected.plan_context_frame); + assert_eq!(vector.descriptor, expected.descriptor); + assert_eq!(vector.aad, expected.aad); + assert_eq!(vector.prf, expected.prf); + assert_eq!(vector.equality, expected.equality); + let opened = record::decrypt( + Scope::Keyset(keyset.clone()), + stored(&case, expected), + &plan, + None, + ) + .unwrap() + .await + .unwrap(); + assert_eq!(opened.risky_ref(), source(&case).risky_ref()); + let plain: String = cipher + .decrypt( + CipherText::Single(SealedValue::from_bytes(&unhex(&expected.ciphertext)).unwrap()), + ctx, + ) + .await + .unwrap(); + assert_eq!(plain, "hello"); + + // Native public chains: repeated calls, not one preassembled extension. + if case.parts.iter().any(|p| p.text.is_empty()) { + assert!( + NonEmpty::new(String::new()).is_err(), + "typed empty extension must refuse" + ); + } else { + let note = Note { + tenant: case.label.clone(), + text: "hello".into(), + }; + let fields = if case.base == "field" { + Plan::fields().context_field(pick("tenant", |n: &Note| &n.tenant)) + } else { + Plan::context(case.label.as_str()).fields() + }; + let typed = fields + .encrypt_index(pick("text", |n: &Note| &n.text), Equality) + .build() + .unwrap(); + let mut chain = cipher.encrypt(¬e).using(&typed); + for p in &case.parts { + chain = chain.extend(extension(p)); + } + // A no-op extension must not clear earlier calls. + let mut row = chain.extend(()).await.unwrap(); + let field_plan = typed.field("text").unwrap(); + let mut query = cipher.query(stack_encrypt::op::Eq, "hello"); + if case.base == "field" { + query = query.context(case.label.as_str()); + } + let mut query = query.on(&field_plan); + for p in &case.parts { + query = query.extend(extension(p)); + } + let query: EqualityTerm = query.extend(()).await.unwrap(); + assert_eq!(hex(&query.to_bytes()), expected.equality); + let field: Encrypted = row.take("text").unwrap(); + assert_eq!(hex(&field.terms.to_bytes()), expected.equality); + row.insert("text", field); + let mut open = cipher.open(row).using(&typed); + for p in &case.parts { + open = open.extend(extension(p)); + } + let mut opened = open.extend(()).await.unwrap(); + assert_eq!(opened.take::("text").unwrap(), "hello"); + } + vectors.insert(case.id, vector); + } + if update { + std::fs::write(path, serde_json::to_string_pretty(&vectors).unwrap() + "\n").unwrap(); + } else { + assert_eq!( + baseline.keys().collect::>(), + vectors.keys().collect::>() + ); + } +} + +#[test] +fn grouped_extensions_preserve_their_shape() { + let own = || NonEmpty::from(Label::parse("users/age").unwrap()); + let separate = DeclaredContext::default() + .with(7u64) + .with(NonEmpty::new("eu").unwrap()) + .under(own()); + let grouped = DeclaredContext::from(NonEmpty::from(7u64).with("eu")).under(own()); + assert_eq!( + Descriptor::of(separate.clone()).as_str(), + "((users/age)/7u64)/eu" + ); + assert_eq!( + Descriptor::of(grouped.clone()).as_str(), + "(users/age)/(7u64/eu)" + ); + assert_ne!( + separate.into_aad().as_bytes(), + grouped.into_aad().as_bytes() + ); +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Boundaries { + version: u32, + profile: String, + cases: Vec, + queries: Vec, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Boundary { + id: String, + tenants: Vec, + expected: String, + mutation: String, + outcome: String, + native_retrievals: usize, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct QueryBoundary { + id: String, + context: String, + outcome: String, + matches: bool, +} + +#[tokio::test] +async fn shared_context_boundaries() { + use std::sync::atomic::Ordering; + let corpus: Boundaries = serde_json::from_str(include_str!( + "../../../conformance/stack-encrypt/v1/context-boundaries.json" + )) + .unwrap(); + assert_eq!(corpus.version, 1); + assert_eq!(corpus.profile, "context-boundaries-v1"); + assert!(!corpus.cases.is_empty()); + let mut ids = BTreeSet::new(); + let case = Case { + id: "boundary".into(), + base: "field".into(), + label: "tenants/acme".into(), + parts: vec![], + }; + let plan = plan(&case); + for case in corpus.cases { + assert!(!case.id.is_empty() && ids.insert(case.id.clone())); + eprintln!("context boundary: {}", case.id); + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let rows = frame( + case.tenants + .iter() + .map(|tenant| { + object(vec![ + ("tenant", frame(tenant.clone())), + ("text", frame("hello")), + ]) + }) + .collect::>(), + ); + let mut stored = record::encrypt(&keyset, rows.clone(), &plan) + .unwrap() + .await + .unwrap(); + let CipherText::Sequence(batch) = &mut stored else { + panic!("batch") + }; + match case.mutation.as_str() { + "none" => {} + "ciphertext" => { + let CipherText::Map(fields) = batch.last_mut().unwrap() else { + panic!("row") + }; + let CipherText::Map(outputs) = + &mut fields.iter_mut().find(|(k, _)| k == "text").unwrap().1 + else { + panic!("outputs") + }; + let node = &mut outputs.iter_mut().find(|(k, _)| k == "c").unwrap().1; + let CipherText::Single(leaf) = node else { + panic!("leaf") + }; + let mut bytes = leaf.to_bytes(); + *bytes.last_mut().unwrap() ^= 1; + *node = CipherText::Single(SealedValue::from_bytes(&bytes).unwrap()); + } + "tamper" | "empty" | "missing" => { + let CipherText::Map(fields) = batch.last_mut().expect("mutation requires a row") + else { + panic!("row") + }; + if case.mutation == "missing" { + fields.retain(|(k, _)| k != "tenant"); + } else { + let tenant = &mut fields.iter_mut().find(|(k, _)| k == "tenant").unwrap().1; + let text = if case.mutation == "tamper" { + "tenants/globex" + } else { + "" + }; + *tenant = CipherText::Map(vec![( + "passthrough".into(), + CipherText::Passthrough(common::passthrough(text)), + )]); + } + } + _ => panic!("unknown mutation"), + } + let typed_rows = batch + .iter() + .map(|row| { + let CipherText::Map(fields) = row else { + panic!("row") + }; + let mut values = stack_encrypt::plan::FieldValues::new(); + for (name, node) in fields { + let CipherText::Map(outputs) = node else { + panic!("outputs") + }; + for (output, node) in outputs { + match (output.as_str(), node) { + ("passthrough", CipherText::Passthrough(payload)) => { + values.insert( + name, + vitaminc_aead_frame::serde::from_slice::(&payload.0) + .unwrap(), + ); + } + ("c", CipherText::Single(leaf)) => { + values.insert( + name, + StackCipherText::Single( + SealedValue::from_bytes(&leaf.to_bytes()).unwrap(), + ), + ); + } + _ => {} + } + } + } + values + }) + .collect::>(); + let typed = Plan::fields() + .context_field(pick("tenant", |n: &Note| &n.tenant)) + .encrypt_index(pick("text", |n: &Note| &n.text), Equality) + .build() + .unwrap(); + retrieves.store(0, Ordering::SeqCst); + let mut open = cipher.open(typed_rows); + if !case.expected.is_empty() { + open = open.context(case.expected.as_str()); + } + let typed_result = open.using(&typed).await; + assert_eq!( + typed_result.is_ok(), + case.outcome == "success", + "typed {}", + case.id + ); + if let Ok(opened) = typed_result { + assert_eq!(opened.len(), case.tenants.len()); + for (row, tenant) in opened.iter().zip(&case.tenants) { + assert_eq!(row.get::("text").unwrap(), "hello"); + assert_eq!(row.get::("tenant").unwrap(), tenant); + } + } + assert_eq!( + retrieves.load(Ordering::SeqCst), + case.native_retrievals, + "typed {}", + case.id + ); + generates.store(0, Ordering::SeqCst); + retrieves.store(0, Ordering::SeqCst); + let expected = if case.expected.is_empty() { + None + } else { + Some(Label::parse(&case.expected).unwrap()) + }; + let result = match record::decrypt(Scope::Keyset(keyset.clone()), stored, &plan, expected) { + Ok(pending) => pending.await.map_err(|e| e.code().unwrap().to_string()), + Err(error) => Err(error.code().unwrap().to_string()), + }; + match case.outcome.as_str() { + "success" => assert_eq!(result.unwrap().risky_ref(), rows.risky_ref()), + "mismatch" => assert_eq!(result.unwrap_err(), "stack_encrypt::context_mismatch"), + "authentication" | "aead" => assert_eq!(result.unwrap_err(), "stack_encrypt::aead"), + "shape" => { + let code = if case.mutation == "missing" { + "stack_encrypt::dynamic_record" + } else { + "stack_encrypt::plan_context_label" + }; + assert_eq!(result.unwrap_err(), code); + } + _ => panic!("unknown outcome"), + } + assert_eq!( + retrieves.load(Ordering::SeqCst), + case.native_retrievals, + "{}", + case.id + ); + assert_eq!(generates.load(Ordering::SeqCst), 0); + } + assert!(!corpus.queries.is_empty()); + for query in corpus.queries { + assert!(!query.id.is_empty() && ids.insert(query.id.clone())); + let (cipher, generates, retrieves) = common::counting_cipher().await; + let typed = Plan::fields() + .context_field(pick("tenant", |n: &Note| &n.tenant)) + .encrypt_index(pick("text", |n: &Note| &n.text), Equality) + .build() + .unwrap(); + let note = Note { + tenant: "tenants/acme".into(), + text: "hello".into(), + }; + let mut stored = cipher.encrypt(¬e).using(&typed).await.unwrap(); + let field: Encrypted = stored.take("text").unwrap(); + generates.store(0, Ordering::SeqCst); + let field_plan = typed.field("text").unwrap(); + let mut call = cipher.query(stack_encrypt::op::Eq, "hello"); + if !query.context.is_empty() { + call = call.context(query.context.as_str()); + } + let result: Result = call.on(&field_plan).await; + match query.outcome.as_str() { + "refused" => { + assert!(result.is_err()); + assert!(!query.matches); + } + "success" => assert_eq!(result.unwrap() == field.terms, query.matches), + _ => panic!("unknown query outcome"), + } + assert_eq!(generates.load(Ordering::SeqCst), 0); + assert_eq!(retrieves.load(Ordering::SeqCst), 0); + } +} + +#[tokio::test] +async fn a_grouped_extension_remains_one_part_when_appended() { + let (cipher, sent) = common::recording_cipher().await; + let plan = Plan::context("users/age") + .with::(Equality) + .build() + .unwrap(); + let grouped = || NonEmpty::from(7u64).with("eu"); + let out = cipher + .encrypt(&34u32) + .using(&plan) + .extend(grouped()) + .extend(9u64) + .await + .unwrap(); + assert_eq!( + sent.lock().unwrap().generated(), + ["((users/age)/(7u64/eu))/9u64"] + ); + let query = cipher + .query(stack_encrypt::op::Eq, &34u32) + .on(&plan) + .extend(grouped()) + .extend(9u64) + .await + .unwrap(); + assert_eq!(out.terms, query); + let back: u32 = cipher + .open(out) + .using(&plan) + .extend(grouped()) + .extend(9u64) + .await + .unwrap(); + assert_eq!(back, 34); + // The record lowering accepts scalar extension parts, not this native-only group. + let grouped_context = frame(vec![ + frame(vec!["users", "age"]), + frame(vec![frame(7u64), frame("eu")]), + ]); + let declaration = object(vec![( + "age", + object(vec![ + ("context", grouped_context), + ("outputs", frame(vec!["c"])), + ]), + )]); + assert!(record::plan(declaration.risky_ref()).is_err()); +} + +#[tokio::test] +async fn replacement_context_records_keep_their_original_read_and_query_context() { + let cipher = common::deterministic_cipher([0; 32]).await; + let plan = Plan::context("users/age") + .with::(Equality) + .build() + .unwrap(); + // The old chain discarded the first extension before sealing. Writing with + // its surviving part exactly reproduces that context without an old API. + let old = cipher + .encrypt(&34u32) + .using(&plan) + .extend(7u64) + .await + .unwrap(); + let old_term = old.terms.clone(); + let current = cipher + .query(stack_encrypt::op::Eq, &34u32) + .on(&plan) + .extend(1u64) + .extend(7u64) + .await + .unwrap(); + assert_ne!( + current, old_term, + "the changed query succeeds but silently misses old rows" + ); + let compatible = cipher + .query(stack_encrypt::op::Eq, &34u32) + .on(&plan) + .extend(7u64) + .await + .unwrap(); + assert_eq!(compatible, old_term); + let value: u32 = cipher.open(old).using(&plan).extend(7u64).await.unwrap(); + assert_eq!(value, 34); +} diff --git a/packages/stack-encrypt/tests/plan.rs b/packages/stack-encrypt/tests/plan.rs index c578619c8..ef5734fc7 100644 --- a/packages/stack-encrypt/tests/plan.rs +++ b/packages/stack-encrypt/tests/plan.rs @@ -557,10 +557,9 @@ async fn an_index_only_field_matches_its_query_and_mints_no_data_key() { assert_eq!(matching.to_bytes(), sealed.terms.1.to_bytes()); } -/// A second `.extend(..)` replaces the first: every data key is asked for -/// under the last extension only, and the record opens with it alone. +/// Repeated extensions append in order, and opening needs both parts. #[tokio::test] -async fn a_second_extend_replaces_the_first() { +async fn a_second_extend_appends_to_the_first() { let (cipher, sent) = recording_cipher().await; let users_plan: Plan = users_plan(); let row = cipher @@ -573,12 +572,17 @@ async fn a_second_extend_replaces_the_first() { assert_eq!( sent.lock().unwrap().generated(), [ - "(users/email)/7u64", - "(users/age)/7u64", - "(users/notes)/7u64" + "((users/email)/1u64)/7u64", + "((users/age)/1u64)/7u64", + "((users/notes)/1u64)/7u64" ] ); - let back = cipher.open(row).using(&users_plan).extend(7u64).await; + let back = cipher + .open(row) + .using(&users_plan) + .extend(1u64) + .extend(7u64) + .await; assert!(back.is_ok(), "{back:?}"); } diff --git a/packages/stack-encrypt/tests/record_lowering.rs b/packages/stack-encrypt/tests/record_lowering.rs index ce5e7f22a..3fa04934a 100644 --- a/packages/stack-encrypt/tests/record_lowering.rs +++ b/packages/stack-encrypt/tests/record_lowering.rs @@ -840,3 +840,8 @@ async fn the_fixture_records_are_bound_to_their_labels() { Ok(_) => panic!("a leaf under another field's label opened"), } } + +#[test] +fn enrolled_fixture_tests_are_registered() { + common::assert_fixture_tests_registered("record_lowering"); +} diff --git a/scripts/__tests__/stack-conformance-ci.test.mjs b/scripts/__tests__/stack-conformance-ci.test.mjs index 25501e67d..1bf80c4ec 100644 --- a/scripts/__tests__/stack-conformance-ci.test.mjs +++ b/scripts/__tests__/stack-conformance-ci.test.mjs @@ -2,7 +2,10 @@ import { spawnSync } from 'node:child_process' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { goSelection as selectGoTests } from '../stack-conformance-selection.mjs' +import { + coreGoTests, + goSelection as selectGoTests, +} from '../stack-conformance-selection.mjs' import { REPO_ROOT } from './lib/repo-root.mjs' import { readWorkflow } from './lib/workflows.mjs' @@ -44,15 +47,11 @@ describe('shared Stack Encrypt conformance is required in CI', () => { expect(script).toMatch(/cargo nextest run[^\n]*--test declarations/) expect(script).toMatch(/cargo nextest run[^\n]*--test runtime_conformance/) expect(script).toMatch(/cargo nextest run[^\n]*--test read_conformance/) + expect(script).toMatch(/cargo nextest run[^\n]*--test context_conformance/) + expect(script).toMatch(/cargo nextest run[^\n]*--test plan_grammar/) expect(script).toMatch(/cargo nextest run[^\n]*--test fixture_safety/) const selected = goSelection(script) - for (const test of [ - 'TestStackConformance', - 'TestDeclarationConformance', - 'TestRuntimeConformance', - 'TestReadConformance', - 'TestFixtureConformanceRequiresGuest', - ]) { + for (const test of coreGoTests) { expect(selected.test(test), test).toBe(true) } }) @@ -63,11 +62,7 @@ describe('shared Stack Encrypt conformance is required in CI', () => { ) const expected = [ ...new Set([ - 'TestStackConformance', - 'TestDeclarationConformance', - 'TestRuntimeConformance', - 'TestReadConformance', - 'TestFixtureConformanceRequiresGuest', + ...coreGoTests, ...index.profiles.flatMap((p) => p.requirements.flatMap((r) => r.go_tests), ), @@ -97,6 +92,7 @@ describe('shared Stack Encrypt conformance is required in CI', () => { expect(Object.keys(index).sort()).toEqual(['profiles', 'version']) expect(index.version).toBe(1) expect(index.profiles.map((profile) => profile.id).sort()).toEqual([ + 'record-context-fields-v1', 'record-query-operators-v1', 'record-value-kinds-v1', ]) @@ -140,7 +136,7 @@ describe('shared Stack Encrypt conformance is required in CI', () => { 'id', 'rust_tests', ]) - expect(requirement.id).toMatch(/^(values|queries)\.[a-z-]+$/) + expect(requirement.id).toMatch(/^(values|queries|contexts)\.[a-z-]+$/) expect(requirements.has(requirement.id), requirement.id).toBe(false) requirements.add(requirement.id) expect(contract).toContain(`\`${requirement.id}\``) @@ -160,7 +156,7 @@ describe('shared Stack Encrypt conformance is required in CI', () => { } } const documented = [ - ...contract.matchAll(/\| `((?:values|queries)\.[a-z-]+)` \|/g), + ...contract.matchAll(/\| `((?:values|queries|contexts)\.[a-z-]+)` \|/g), ].map((match) => match[1]) expect([...requirements].sort()).toEqual(documented.sort()) }) diff --git a/scripts/stack-conformance-selection.mjs b/scripts/stack-conformance-selection.mjs index d3ca1e787..8725f360d 100644 --- a/scripts/stack-conformance-selection.mjs +++ b/scripts/stack-conformance-selection.mjs @@ -3,14 +3,18 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' +export const coreGoTests = [ + 'TestStackConformance', + 'TestDeclarationConformance', + 'TestRuntimeConformance', + 'TestReadConformance', + 'TestContextConformance', + 'TestContextBoundaries', + 'TestFixtureConformanceRequiresGuest', +] + export function goSelection(index) { - const tests = new Set([ - 'TestStackConformance', - 'TestDeclarationConformance', - 'TestRuntimeConformance', - 'TestReadConformance', - 'TestFixtureConformanceRequiresGuest', - ]) + const tests = new Set(coreGoTests) for (const profile of index.profiles) { for (const requirement of profile.requirements) { for (const name of requirement.go_tests) { diff --git a/scripts/stack-conformance.sh b/scripts/stack-conformance.sh index 025c834a4..53cf2780a 100644 --- a/scripts/stack-conformance.sh +++ b/scripts/stack-conformance.sh @@ -19,7 +19,7 @@ go_tests=$(node scripts/stack-conformance-selection.mjs) artifacts=$(mktemp -d) trap 'rm -rf "$artifacts"' EXIT -mise x --env test -- cargo nextest run --locked -p stack-encrypt --all-features --test conformance --test declarations --test runtime_conformance --test read_conformance --test value_kinds --test query_operators --test fixture_safety +mise x --env test -- cargo nextest run --locked -p stack-encrypt --all-features --test conformance --test declarations --test runtime_conformance --test read_conformance --test context_conformance --test record_lowering --test plan_grammar --test value_kinds --test query_operators --test fixture_safety ( cd languages/golang STACK_CONFORMANCE_OUTPUT="$artifacts/go.json" CGO_ENABLED=0 \