From 7f36ec045a19ddd197fb08900ec33b9d9591002e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 1 Nov 2026 21:29:00 +1100 Subject: [PATCH 01/15] test(encrypt): enroll EQL JSON conformance Require named Rust, Go and PostgreSQL evidence, share document and predicate refusal cases, and report engine and SQL bundle identities. Closes #1476 --- .github/workflows/bench-eql.yml | 1 + .github/workflows/test-eql.yml | 2 + .github/workflows/tests-golang.yml | 25 ++ conformance/stack-encrypt/README.md | 13 +- conformance/stack-encrypt/coverage.md | 16 + .../stack-encrypt/eql-json-contract.md | 129 ++++++++ .../stack-encrypt/v1/eql-json-profiles.json | 226 ++++++++++++++ conformance/stack-encrypt/v1/eql-json.json | 199 ++++++++++++ .../2026-10-31-stack-encrypt-conformance.md | 16 + languages/golang/encrypt/eql_test.go | 4 +- .../encrypt/fixture_conformance_test.go | 4 +- .../golang/encrypt/json_conformance_test.go | 41 +++ languages/golang/encrypt/json_match_test.go | 44 +-- languages/golang/encrypt/json_tree_test.go | 19 +- mise.toml | 15 + .../encryption/tests/json_tree_data_plan.rs | 36 +-- .../stack-encrypt/tests/json_operators.rs | 24 ++ .../stack-encrypt/tests/json_tree_vectors.rs | 24 ++ scripts/__tests__/eql-suite-ci.test.mjs | 13 +- .../__tests__/stack-eql-conformance.test.mjs | 190 ++++++++++++ scripts/stack-eql-conformance.mjs | 283 ++++++++++++++++++ 21 files changed, 1250 insertions(+), 74 deletions(-) create mode 100644 conformance/stack-encrypt/eql-json-contract.md create mode 100644 conformance/stack-encrypt/v1/eql-json-profiles.json create mode 100644 conformance/stack-encrypt/v1/eql-json.json create mode 100644 languages/golang/encrypt/json_conformance_test.go create mode 100644 scripts/__tests__/stack-eql-conformance.test.mjs create mode 100644 scripts/stack-eql-conformance.mjs diff --git a/.github/workflows/bench-eql.yml b/.github/workflows/bench-eql.yml index 8d46945ae..390745c0f 100644 --- a/.github/workflows/bench-eql.yml +++ b/.github/workflows/bench-eql.yml @@ -64,6 +64,7 @@ on: - "packages/eql/docker/**" - "packages/eql/README.md" - "packages/eql/SUPABASE.md" + - "conformance/stack-encrypt/**" - "packages/stack-encrypt/**" - "packages/stack-encrypt-derive/**" - "packages/stack-kms/**" diff --git a/.github/workflows/test-eql.yml b/.github/workflows/test-eql.yml index 438cfb3bf..fc88db912 100644 --- a/.github/workflows/test-eql.yml +++ b/.github/workflows/test-eql.yml @@ -109,6 +109,7 @@ on: # cannot see them (a task body names a cargo package, not a path); the # manifest walk in eql-suite-ci.test.mjs does, and fails when one of them # is missing from this list. + - "conformance/stack-encrypt/**" - "packages/stack-encrypt/**" - "packages/stack-encrypt-derive/**" - "packages/stack-kms/**" @@ -208,6 +209,7 @@ jobs: - "packages/eql/docker/**" - "packages/eql/README.md" - "packages/eql/SUPABASE.md" + - "conformance/stack-encrypt/**" - "packages/stack-encrypt/**" - "packages/stack-encrypt-derive/**" - "packages/stack-kms/**" diff --git a/.github/workflows/tests-golang.yml b/.github/workflows/tests-golang.yml index 0fa4a6967..dcd565006 100644 --- a/.github/workflows/tests-golang.yml +++ b/.github/workflows/tests-golang.yml @@ -60,6 +60,7 @@ on: - scripts/go-binding-test.sh - scripts/stack-conformance.sh - scripts/stack-conformance-selection.mjs + - scripts/stack-eql-conformance.mjs - Cargo.toml - Cargo.lock - .cargo/** @@ -99,6 +100,7 @@ on: - scripts/go-binding-test.sh - scripts/stack-conformance.sh - scripts/stack-conformance-selection.mjs + - scripts/stack-eql-conformance.mjs - Cargo.toml - Cargo.lock - .cargo/** @@ -258,6 +260,17 @@ jobs: echo "RLIMIT_MEMLOCK: $(ulimit -l) KiB" mise run go:test + - name: Required EQL and JSON conformance + env: + STACK_CONFORMANCE_REPORT_DIR: ${{ runner.temp }}/eql-conformance-fast + run: mise run test:conformance:eql + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: eql-conformance-fast + path: ${{ runner.temp }}/eql-conformance-fast/*.json + if-no-files-found: error + - name: Fresh Go scalar artifacts open in Rust env: STACK_CONFORMANCE_INPUT: ${{ runner.temp }}/stack-conformance-go.json @@ -464,6 +477,18 @@ jobs: EQL_TEST_DATABASE_URL: "host=localhost port=${{ job.services.postgres.ports['5432'] }} user=postgres password=postgres dbname=postgres sslmode=disable" run: mise run go:test:postgres + - name: Required EQL and JSON PostgreSQL conformance + env: + EQL_TEST_DATABASE_URL: "host=localhost port=${{ job.services.postgres.ports['5432'] }} user=postgres password=postgres dbname=postgres sslmode=disable" + STACK_CONFORMANCE_REPORT_DIR: ${{ runner.temp }}/eql-conformance-postgres + run: mise run test:conformance:eql:postgres + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: eql-conformance-postgres + path: ${{ runner.temp }}/eql-conformance-postgres/*.json + if-no-files-found: error + # Round trips through real ZeroKMS. The Go live tests skip without their # STACK_ENCRYPT_TEST_* variables, so the secrets pre-flight runs first and # turns a missing credential into a failure rather than a silent skip. The diff --git a/conformance/stack-encrypt/README.md b/conformance/stack-encrypt/README.md index 4889deebc..cd6f9dfb0 100644 --- a/conformance/stack-encrypt/README.md +++ b/conformance/stack-encrypt/README.md @@ -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. +## EQL and JSON operations + +The [EQL/JSON contract](eql-json-contract.md) enrolls resolver capabilities, +document writes, queries, extraction/subtree reads, element-match and predicate +matching. It reuses the native, Go and independent PostgreSQL suites and shares +write/predicate case data. Run `mise run test:conformance:eql` and the separate +required `test:conformance:eql:postgres` task; see the contract for guest builds, +disposable database setup, intentional SQL differences and version reports. + ## Context composition and expectations The [context contract](context-contract.md) covers ordered extensions, canonical @@ -52,8 +61,8 @@ checks. The task runs the existing tests directly, and a missing guest fails when conformance is required. The ordinary CI suites already execute them. This is representative fixture coverage, not an exhaustive type/operator -matrix. Wider fresh artifact exchange, shared query refusals and EQL/JSON -operations remain separate work. +matrix. Wider fresh artifact exchange and shared scalar query refusals remain +separate work. The EQL/JSON profiles are described below. ## `record-reads-v1` contract diff --git a/conformance/stack-encrypt/coverage.md b/conformance/stack-encrypt/coverage.md index 47f204db3..d24dd6f03 100644 --- a/conformance/stack-encrypt/coverage.md +++ b/conformance/stack-encrypt/coverage.md @@ -106,3 +106,19 @@ Go proves canonical AAD indirectly through its actual guest plan frame and nativ 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. + +## EQL/JSON checkpoint + +The [EQL/JSON contract](eql-json-contract.md) and its enrollment index cover +resolver/catalog capabilities, JSON writes and queries, request-bound reads, +element-match and predicate-match. Existing Rust, Go and PostgreSQL suites stay +in place. The common document and predicate-refusal tables now live in +`v1/eql-json.json`: Rust and Go execute them, retaining native key-call checks, +Go diagnostic mapping and all extra cases from the original suites. + +The database lane preserves independent plaintext oracles and generated +properties. Required test inventory/no-skip checks and engine/guest/SQL identity +reports make enrollment observable. This consolidates data and requirements; +it does not replace adapter tests or claim whole-database authentication. +Fresh JSON artifact exchange, released-version matrices and Python/TypeScript +runners remain open. diff --git a/conformance/stack-encrypt/eql-json-contract.md b/conformance/stack-encrypt/eql-json-contract.md new file mode 100644 index 000000000..df1eb89e3 --- /dev/null +++ b/conformance/stack-encrypt/eql-json-contract.md @@ -0,0 +1,129 @@ +# EQL and JSON operation profiles + +These experimental profiles enroll the existing EQL v4 JSON tree operations. +They do not describe EQL v3 SteVec, or certify an entire SDK. The +[enrollment index](v1/eql-json-profiles.json) names mandatory compiled tests; +[shared cases](v1/eql-json.json) supply document text and predicate refusals to +native Rust and Go. Python and TypeScript have no enrolled runner yet. + +## Requirements and evidence + +| Requirement | Contract | Existing evidence retained | +|---|---|---| +| `eql-resolver-catalog-v1` | The resolver names catalog domains, their producibility and operators. Unknown names, unavailable EQL builds and unsupported capabilities are refused. JSON options require a JSON target. | Native `targets` and `json_tree_data_plan`; Go catalog and plain-guest refusal tests | +| `json-write-v1` | JSON documents, including scalar roots, empty containers, JSON null and integer boundaries, round-trip as values. SQL null stores no document. Invalid text is refused without exposing it; native tests observe no key request. Typed and dynamic plans agree on search terms and can open each other's output. | Native typed/data-plan suites, Go raw and generated typed document APIs; existing seeded derivation fixture | +| `json-query-v1` | Containment, reverse containment, equality at a path, ordered bounds, selectors and child operations derive their documented operand shapes. Unsupported requests fail. | Native engine/EQL operand comparisons, Go generated queries, independent PostgreSQL `jsonb` containment oracles | +| `json-request-bound-reads-v1` | Extraction and subtree/wildcard reads open against the prepared path, column, position and bounds. Wrong requests and detectable tampering fail. | Native adversarial read suite, EQL request/open tests, Go selector reads, PostgreSQL extraction/subtree and hostile-row suites | +| `json-element-match-v1` | Every condition must hold on one element. Ordering paths and resolver capability are required; a probe beyond the declared bound raises rather than silently truncating. | Native operator/capability tests, Go generated member APIs, SQL domain and independent plaintext predicate tests | +| `json-predicate-match-v1` | Supported text compiles to existing operators; refusals preserve reason, construct and byte offset, with no caller text in diagnostics. Missing and null paths obey the documented truth rules. | Shared negative corpus, native compiler/property tests, Go generated `Match`, SQL `jsonb_path_match` and handwritten-operator oracles | + +The index is an evidence inventory, not a second copy of every existing test. +Native execution retains whole binaries, including their unindexed properties +and regressions. Go selects the exact enrolled tests and rejects missing passes, +failures and skips, including skipped subtests. Native inventory checks reject +missing names, filtered tests and a wrong `#[ignore]` state before execution. +Only the PostgreSQL lane executes the database tests marked `#[ignore]`. + +## Predicate grammar + +The current grammar in `packages/stack-encrypt/src/dynamic/json_query.rs` and +`json_predicate.rs` is the executable specification. This profile pins: + +```text +predicate := comparison | exists(path) | exists(path ? (filter)) + | (predicate) | !(predicate) + | predicate && predicate | predicate || predicate +comparison := path op value | value op path +op := == | != | <> | < | <= | > | >= +path := $ followed by .key, ."quoted key", or [*] steps +value := JSON scalar literal | $variable | $"quoted variable" +filter := comparison on @ or @.member, optionally joined with && +``` + +Negation binds before `&&`, which binds before `||`. Variables are a JSON +object; absence of `vars` means an empty object. Scalar literals are strings, +numbers, booleans and null; objects/arrays cannot be comparison values. +Orderings and multi-condition element filters require numbers or strings and +configured ordered paths. A filter requires a wildcard in its outer path; +its member cannot contain a wildcard. Predicate nesting is limited to 32. +A wildcard bound must be positive; its probe prevents an undersized bound from +silently producing a partial answer. + +Arithmetic, regex, `starts with`, `is unknown`, mode words, methods, `.*`, `.**`, +slices, `last`, path-to-path/value-to-value comparisons, array positions, +filters outside `exists`, and `||`, `!`, `!=` or nested `exists` inside filters +are unsupported. `!=` at a wildcard is refused. The corpus pins representative +syntax, unsupported-construct, variable, type, depth and path diagnostics. +Existing parser tests retain the wider grammar matrix and adapter-specific +invalid-input checks; the shared corpus is not an exhaustive grammar fuzzer. +Offsets are UTF-8 byte offsets, not character indices. + +## Nulls, oracles and intentional differences + +SQL null (no encrypted document), JSON null (a document/node), and an absent +path are distinct. The SQL predicate suites compare both selected rows and +three-valued answers. An absent path does not satisfy a comparison. Ordering a +JSON null can produce SQL unknown where plaintext jsonpath returns false; +the compiled predicate adds the null test under negation where it affects +selected rows. This exception is checked explicitly, not normalized away. + +The independent oracles use PostgreSQL `jsonb @>`, `<@`, `jsonb_path_query` and +`jsonb_path_match`, alongside handcrafted plaintext conditions. They do not +calculate expected results from encrypted operands. Their supported-domain +assumptions and exceptions remain explicit: + +- Paths are literal. There is no lax implicit array unwrapping or scalar-to-array + wrapping. Use `[*]` and keep array shapes consistent. +- Keep one scalar kind at an ordered path. Terms do not preserve the plaintext + type distinctions used by jsonpath's cross-kind comparisons. +- Order terms fold text case/accents and compare numbers as float64. Equality in + a multi-condition element filter uses those terms; standalone `==` is exact. +- Reverse containment excludes the plaintext root-scalar-in-root-array special + case; the generated oracle logs how many such comparisons it excludes. +- Storage and projection list order is randomized. Compare projections as sets + and stored selector collections as multisets. The existing deterministic + `packages/stack-encrypt/tests/fixtures/json_tree_vectors.json` pins a seeded + permutation, tags and tokens, not a production ordering promise. Fresh + ciphertext bytes are not equality fixtures. +- Read authentication detects substituted paths, wrong columns, mixed-document + subtrees, forged tags and incomplete returned chains. It does not establish + database completeness or row identity: withholding a whole answer can look + like no node, and another row's node at the same path can open. Those limits + have named native evidence in the index. + +## Required execution and identity + +From the repository root: + +```sh +mise run wasm:guest:build +mise run wasm:guest:build:deterministic +mise run wasm:guest:build:eql +mise run wasm:guest:build:eql:deterministic +mise run test:conformance:eql + +# A disposable database: this command installs/replaces EQL v4 there. +EQL_TEST_DATABASE_URL='host=localhost port=5432 user=postgres password=postgres dbname=postgres' \ + mise run test:conformance:eql:postgres +``` + +No CipherStash credentials are required. Both lanes are required steps in +`tests-golang.yml`; conformance changes trigger them. The database lane rebuilds +SQL from this tree, installs exactly the bundle whose SHA-256 it records, and +passes the same connection to both languages. It fails without a database or +required guests. A fast-lane pass alone is not SQL conformance. + +Reports include the native engine and EQL binding package versions, root/EQL +lockfile hashes, corpus/enrollment hashes, deterministic guest hashes, executed +requirement test names, and (for PostgreSQL) the installed EQL version, schema +version, PostgreSQL version and actual SQL bundle hash. Development bundles can +report `DEV`; their hash distinguishes them. CI uploads reports and fails if +none were produced. Locally set `STACK_CONFORMANCE_REPORT_DIR` to retain them; +the same report is always printed after success. These describe this checkout +and its guest artifacts, not a claim of compatibility across released versions. + +Required checks reject all fixture-update environment variables. Extending a +language binding means running the applicable shared cases and providing its +own adapter evidence for each claimed requirement. Fresh cross-language JSON +artifact exchange, a release compatibility matrix, and Python/TypeScript +runners remain separate work. diff --git a/conformance/stack-encrypt/v1/eql-json-profiles.json b/conformance/stack-encrypt/v1/eql-json-profiles.json new file mode 100644 index 000000000..38816107e --- /dev/null +++ b/conformance/stack-encrypt/v1/eql-json-profiles.json @@ -0,0 +1,226 @@ +{ + "version": 1, + "suites": { + "engine-queries": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "binary": "json_operators", + "features": ["--all-features"] + }, + "engine-reads": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "binary": "json_read_checks", + "features": ["--all-features"] + }, + "engine-vectors": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "binary": "json_tree_vectors", + "features": ["--all-features"] + }, + "eql-targets": { + "language": "rust", + "lane": "fast", + "workspace": "packages/eql", + "package": "eql-encryption-tests", + "binary": "targets", + "features": [] + }, + "eql-record": { + "language": "rust", + "lane": "fast", + "workspace": "packages/eql", + "package": "eql-encryption-tests", + "binary": "json_tree_data_plan", + "features": [] + }, + "eql-typed": { + "language": "rust", + "lane": "fast", + "workspace": "packages/eql", + "package": "eql-encryption-tests", + "binary": "json_tree", + "features": [] + }, + "postgres": { + "language": "rust", + "lane": "postgres", + "workspace": "packages/eql", + "package": "eql-encryption-tests", + "binary": "json_tree", + "features": [] + }, + "go": { + "language": "go", + "lane": "fast" + }, + "go-postgres": { + "language": "go", + "lane": "postgres" + } + }, + "requirements": [ + { + "id": "eql-resolver-catalog-v1", + "evidence": { + "eql-targets": [ + "the_table::names_every_stored_catalog_domain_in_order", + "the_table::every_row_is_a_compiled_domain_type_and_so_is_its_query", + "the_table::the_producible_types_are_the_ones_with_a_rust_plaintext", + "given_an_unknown_name::every_entry_point_says_no_such_type", + "given_a_name_the_engine_cannot_produce::every_entry_point_refuses_it_with_the_table_reason" + ], + "eql-record": [ + "the_engine_and_the_table_agree_on_which_targets_take_json_options" + ], + "go": [ + "TestTheCatalogListsEachTypesOperators", + "TestTextEqIsRefusedByTheBuildWithoutEQLTypes", + "TestTargetWithoutEQLIsRefusedBeforeTheGuest" + ] + } + }, + { + "id": "json-write-v1", + "evidence": { + "eql-record": [ + "a_batch_of_documents_round_trips_in_one_request", + "the_data_plan_and_the_typed_layout_agree", + "text_that_is_not_a_document_is_refused_before_any_key", + "a_sql_null_stores_no_document", + "a_document_does_not_open_under_another_column" + ], + "eql-typed": ["a_document_round_trips_through_the_typed_target"], + "engine-vectors": [ + "the_derivations_are_pinned_under_the_deterministic_source" + ], + "go": [ + "TestJSONDocumentsRoundTripThroughTheGuest", + "TestJSONDocumentsDoNotOpenUnderAnotherColumn", + "TestJSONTextThatIsNotADocumentIsErrEncoding", + "TestACustomersDocumentsRoundTrip", + "TestASubscribersTypedDocumentsRoundTrip" + ], + "go-postgres": ["TestJSONTypedDocumentsInPostgres"] + } + }, + { + "id": "json-query-v1", + "evidence": { + "eql-record": [ + "a_document_field_answers_the_json_operators", + "every_operand_is_the_typed_chains", + "a_json_operator_refuses_what_it_cannot_answer", + "a_document_has_no_one_plaintext_query" + ], + "engine-queries": [ + "every_operator_derives_its_tagged_operand", + "the_orderings_follow_the_resolved_options", + "malformed_inputs_are_refused_with_their_reason" + ], + "eql-typed": [ + "the_query_operands_are_the_engines_terms_in_the_sql_contract" + ], + "go": [ + "TestJSONOperatorsDeriveTaggedTerms", + "TestACustomersFieldsDeriveEachQuery", + "TestASubscribersTypedNeedlesDeriveTheRawQuery" + ], + "postgres": [ + "postgres_containment_over_the_encrypted_column_is_jsonb_containment", + "generated::given_generated_documents_in_postgres::contains_selects_the_rows_jsonb_contains_on_both_plans", + "generated::given_generated_documents_in_postgres::contained_by_selects_the_rows_jsonb_does_except_a_root_scalar_in_a_root_array" + ], + "go-postgres": ["TestJSONDocumentsQueryAndReadInPostgres"] + } + }, + { + "id": "json-request-bound-reads-v1", + "evidence": { + "eql-record": ["reads_open_against_the_request_that_prepared_them"], + "engine-queries": [ + "reads_open_against_the_request_that_prepared_them", + "rows_that_are_not_the_reads_are_refused", + "a_malformed_read_is_refused_before_any_key" + ], + "engine-reads": [ + "given_the_entry_arrow_returned::another_paths_entry_is_refused_before_any_key_is_retrieved", + "given_the_rows_of_a_read::the_honest_rows_open_to_the_nodes_value_in_any_order", + "given_the_rows_of_a_read::another_position_of_the_array_is_refused_by_its_label", + "given_the_rows_of_a_read::a_truncated_subtree_is_refused_by_the_cascade", + "given_the_rows_of_a_read::rows_of_two_documents_are_refused", + "given_answers_the_check_cannot_tell_apart::rows_withheld_whole_read_as_no_node", + "given_answers_the_check_cannot_tell_apart::another_rows_node_at_the_same_path_opens" + ], + "go": [ + "TestJSONSelectorReadsOpenAgainstTheirRequest", + "TestJSONReadTypesPrintAndLogRedacted" + ], + "postgres": [ + "postgres_every_subtree_read_answers_as_jsonb_does", + "postgres_wildcard_reads_answer_as_jsonb_path_query", + "postgres_a_position_reaches_array_elements_only", + "postgres_hostile_answers_are_refused_by_the_client", + "postgres_reads_never_answer_wrongly_under_tampering", + "generated::given_generated_documents_in_postgres::arrow_opens_a_leaf_to_the_value_jsonb_holds" + ] + } + }, + { + "id": "json-element-match-v1", + "evidence": { + "engine-queries": [ + "element_match::given_documents::one_element_must_satisfy_every_member", + "element_match::given_documents::answers_as_the_jsonpath_over_the_plaintext", + "element_match::given_the_fields_type::does_not_answer_it_when_the_type_names_no_domain", + "element_match::given_the_fields_type::does_not_list_it_without_an_ordered_path" + ], + "go": [ + "TestElementMatchIsTheReadsAndTheOrderingsOfEachMember", + "TestElementMatchRefusesBeforeTheEngine", + "TestElementMatchEngineRefusalsAreTypedErrors", + "TestAtPathMakesThePredicatesOfItsMember" + ], + "postgres": [ + "elements::postgres_an_element_predicate_holds_of_one_element", + "elements::postgres_one_element_must_satisfy_every_member", + "elements::postgres_a_probe_hit_raises", + "elements::postgres_the_elements_operand_domain_checks_each_member" + ], + "go-postgres": ["TestJSONElementMatchInPostgres"] + } + }, + { + "id": "json-predicate-match-v1", + "evidence": { + "engine-queries": [ + "compiled_match::given_an_input_it_cannot_answer::shared_predicate_refusals", + "compiled_match::given_documents::unknown_null_and_absent_answer_as_jsonpath_does", + "compiled_match::given_the_fields_type::does_not_answer_it_when_the_type_names_no_domain", + "compiled_match::given_a_predicate::each_leaf_is_its_operators_operand" + ], + "go": [ + "TestMatchCompilesToTheOperandsOfTheOtherOperators", + "TestMatchTakesItsVarsAsAnyJSONObject", + "TestMatchRefusalsNameTheConstructAndItsOffset", + "TestMatchOperandPrintsAndLogsRedacted" + ], + "postgres": [ + "predicate::postgres_a_compiled_predicate_answers_as_jsonb_path_match", + "predicate::postgres_a_compiled_predicate_answers_as_the_operators_written_out", + "predicate::postgres_a_null_leaf_is_unknown_under_and_or_and_not", + "predicate::postgres_the_predicate_domain_checks_each_node", + "predicate::postgres_the_operator_takes_a_typed_predicate_and_jsonpath_stays_blocked" + ], + "go-postgres": ["TestJSONPredicateMatchInPostgres"] + } + } + ] +} diff --git a/conformance/stack-encrypt/v1/eql-json.json b/conformance/stack-encrypt/v1/eql-json.json new file mode 100644 index 000000000..70d39501a --- /dev/null +++ b/conformance/stack-encrypt/v1/eql-json.json @@ -0,0 +1,199 @@ +{ + "version": 1, + "documents": [ + "{\"name\":\"Alice\",\"age\":34,\"visits\":[{\"at\":3},{\"at\":1}]}", + "{\"name\":\"Alice\",\"age\":34,\"visits\":[{\"at\":3,\"where\":\"x\"},{\"at\":1}],\"tags\":[\"a\",\"b\"]}", + "{\"name\":\"Bob\",\"age\":7.5,\"nested\":{\"deep\":[[1,2],{}]},\"none\":null}", + "[1,\"two\",{\"three\":3}]", + "\"a scalar root\"", + "18446744073709551615", + "-9223372036854775808", + "-926809.5051008083", + "null", + "{}", + "[]" + ], + "invalid_documents": [ + { + "text": "{\"secret\":1,\"secret\":2}", + "reason": "json_duplicate_key" + }, + { + "text": "99999999999999999999", + "reason": "json_integer_range" + }, + { + "text": "{\"secret\":", + "reason": "json_syntax" + }, + { + "text": "", + "reason": "json_syntax" + } + ], + "predicate_refusals": [ + { + "id": "text-the-grammar-does-not-read", + "predicate": "$.extra.country = $v", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_predicate_syntax", + "construct": "text the grammar does not read", + "at": 16 + }, + { + "id": "like-regex", + "predicate": "$.extra.country like_regex \"secret\"", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_predicate_unsupported", + "construct": "`like_regex`", + "at": 16 + }, + { + "id": "arithmetic", + "predicate": "$.weight + 1 == 2", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_predicate_unsupported", + "construct": "arithmetic (`+`, `-`, `*`, `/`, `%`)", + "at": 9 + }, + { + "id": "a-method", + "predicate": "$.weight.size() == 1", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_predicate_unsupported", + "construct": "a method (`.name()`)", + "at": 8 + }, + { + "id": "a-filter-outside-exists", + "predicate": "$.extra.preferred_languages[*] ? (@ == $v) == 1", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_predicate_unsupported", + "construct": "a filter outside `exists`", + "at": 31 + }, + { + "id": "in-a-filter", + "predicate": "exists($.extra.emergency_contacts[*] ? (@.relationship == $v || @.age > 1))", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_predicate_unsupported", + "construct": "`||` in a filter", + "at": 61 + }, + { + "id": "an-unknown-variable", + "predicate": "$.extra.country == $missing", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_predicate_unknown_variable", + "construct": "a variable", + "at": 19 + }, + { + "id": "a-value-an-ordering-cannot-take", + "predicate": "$.weight < $flag", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_predicate_value_type", + "construct": "a value", + "at": 11 + }, + { + "id": "an-object", + "predicate": "$.extra.country == $obj", + "vars": { + "obj": { + "secret-nz": 1 + } + }, + "reason": "json_predicate_value_type", + "construct": "a value", + "at": 19 + }, + { + "id": "at-a-wildcard", + "predicate": "$.extra.preferred_languages[*] != $v", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_predicate_not_equal_at_wildcard", + "construct": "`!=` at a path with a wildcard", + "at": 31 + }, + { + "id": "a-position", + "predicate": "$.extra.preferred_languages[2] == $v", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_path_position", + "construct": "a position (`[2]`)", + "at": 27 + }, + { + "id": "a-filter-with-no-wildcard", + "predicate": "exists($.address ? (@.postcode == $v))", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_path_no_wildcard", + "construct": "a filter on a path with no wildcard", + "at": 7 + }, + { + "id": "a-wildcard-in-a-member", + "predicate": "exists($.extra.emergency_contacts[*] ? (@.calls[*] == $v))", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_path_wildcard", + "construct": "a wildcard in a filter's member", + "at": 40 + }, + { + "id": "a-number-json-cannot-hold", + "predicate": "$.weight == 1e999", + "vars": { + "v": "secret-nz", + "flag": true + }, + "reason": "json_syntax", + "construct": "a value", + "at": 12 + }, + { + "id": "too-deep", + "predicate": "((((((((((((((((((((((((((((((((($.a == 1)))))))))))))))))))))))))))))))))", + "vars": {}, + "reason": "json_predicate_too_deep", + "construct": "nesting", + "at": 33 + } + ] +} diff --git a/docs/plans/2026-10-31-stack-encrypt-conformance.md b/docs/plans/2026-10-31-stack-encrypt-conformance.md index 4f55a7031..a633d0320 100644 --- a/docs/plans/2026-10-31-stack-encrypt-conformance.md +++ b/docs/plans/2026-10-31-stack-encrypt-conformance.md @@ -224,3 +224,19 @@ 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. + +## EQL/JSON operation checkpoint + +Implemented the six requirements in +`conformance/stack-encrypt/eql-json-contract.md`, enrolling the existing engine, +EQL binding, Go SDK and independent PostgreSQL suites. A shared corpus replaces +duplicate write/refusal tables; existing typed, generated, property and +adversarial assertions remain. Required fast and PostgreSQL commands check +compiled native inventory and exact Go pass events, refuse skipped evidence, +and report engine, lockfile, guest and installed SQL identities in CI artifacts. + +Next: backend lifecycle, cancellation, no-secret diagnostics, provider key-I/O +and batching requirements. Python/TypeScript enrollment and the broader fresh +artifact/version exchange remain separate delivery slices. The EQL/JSON profile +does not complete the refactoring track or introduce the future binding/search +scope split. diff --git a/languages/golang/encrypt/eql_test.go b/languages/golang/encrypt/eql_test.go index dc11ea565..10af03190 100644 --- a/languages/golang/encrypt/eql_test.go +++ b/languages/golang/encrypt/eql_test.go @@ -25,9 +25,7 @@ import ( func deterministicEQLClient(t *testing.T) *encrypt.Client { t.Helper() c, err := encrypt.NewDeterministicEQLClient(context.Background(), testSeed) - if errors.Is(err, encrypt.ErrDeterministicEQLGuestNotBuilt) || errors.Is(err, encrypt.ErrGuestNotBuilt) { - t.Skip(err) - } + skipMissingConformanceGuest(t, err) if err != nil { t.Fatal(err) } diff --git a/languages/golang/encrypt/fixture_conformance_test.go b/languages/golang/encrypt/fixture_conformance_test.go index e5b7720e4..2f3efd09f 100644 --- a/languages/golang/encrypt/fixture_conformance_test.go +++ b/languages/golang/encrypt/fixture_conformance_test.go @@ -18,7 +18,7 @@ import ( // remain failures in the caller, including errors wrapping neither sentinel. func skipMissingConformanceGuest(t *testing.T, err error) { t.Helper() - if (errors.Is(err, encrypt.ErrGuestNotBuilt) || errors.Is(err, encrypt.ErrDeterministicGuestNotBuilt)) && os.Getenv("STACK_CONFORMANCE_REQUIRED") != "1" { + if (errors.Is(err, encrypt.ErrGuestNotBuilt) || errors.Is(err, encrypt.ErrDeterministicGuestNotBuilt) || errors.Is(err, encrypt.ErrDeterministicEQLGuestNotBuilt)) && os.Getenv("STACK_CONFORMANCE_REQUIRED") != "1" { t.Skip(err) } } @@ -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["TestJSONDocumentsRoundTripThroughTheGuest"] = true + names["TestMatchRefusalsNameTheConstructAndItsOffset"] = true names["TestContextConformance"] = true names["TestContextBoundaries"] = true names["TestAQueryTheFieldDoesNotAnswerIsRefused"] = true diff --git a/languages/golang/encrypt/json_conformance_test.go b/languages/golang/encrypt/json_conformance_test.go new file mode 100644 index 000000000..5a5286a24 --- /dev/null +++ b/languages/golang/encrypt/json_conformance_test.go @@ -0,0 +1,41 @@ +package encrypt_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +type jsonConformanceCorpus struct { + Version int `json:"version"` + Documents []string `json:"documents"` + InvalidDocuments []struct { + Text string `json:"text"` + Reason string `json:"reason"` + } `json:"invalid_documents"` + PredicateRefusals []struct { + ID string `json:"id"` + Predicate string `json:"predicate"` + Vars json.RawMessage `json:"vars"` + Reason string `json:"reason"` + Construct string `json:"construct"` + At int `json:"at"` + } `json:"predicate_refusals"` +} + +func jsonConformanceCases(t *testing.T) jsonConformanceCorpus { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", "..", "conformance", "stack-encrypt", "v1", "eql-json.json")) + if err != nil { + t.Fatal(err) + } + var cases jsonConformanceCorpus + if err := json.Unmarshal(data, &cases); err != nil { + t.Fatal(err) + } + if cases.Version != 1 || len(cases.Documents) == 0 || len(cases.InvalidDocuments) == 0 || len(cases.PredicateRefusals) == 0 { + t.Fatal("empty or unsupported JSON conformance corpus") + } + return cases +} diff --git a/languages/golang/encrypt/json_match_test.go b/languages/golang/encrypt/json_match_test.go index 6edc85ba3..cea345c9d 100644 --- a/languages/golang/encrypt/json_match_test.go +++ b/languages/golang/encrypt/json_match_test.go @@ -274,46 +274,22 @@ func TestMatchRefusalsNameTheConstructAndItsOffset(t *testing.T) { ctx := context.Background() cipher := c.DefaultKeyset() f := testusers.EntityFields.CustomFieldValues - vars := map[string]any{"v": "secret-nz", "flag": true} - deep := strings.Repeat("(", 33) + "$.a == 1" + strings.Repeat(")", 33) - for _, tc := range []struct { - name, predicate string - vars any - reason string - construct string - at string // the text the offset points at - }{ - {"text the grammar does not read", `$.extra.country = $v`, vars, "json_predicate_syntax", "text the grammar does not read", "= $v"}, - {"like_regex", `$.extra.country like_regex "secret"`, vars, "json_predicate_unsupported", "`like_regex`", "like_regex"}, - {"arithmetic", `$.weight + 1 == 2`, vars, "json_predicate_unsupported", "arithmetic (`+`, `-`, `*`, `/`, `%`)", "+ 1"}, - {"a method", `$.weight.size() == 1`, vars, "json_predicate_unsupported", "a method (`.name()`)", ".size()"}, - {"a filter outside exists", `$.extra.preferred_languages[*] ? (@ == $v) == 1`, vars, "json_predicate_unsupported", "a filter outside `exists`", "? (@"}, - {"|| in a filter", `exists($.extra.emergency_contacts[*] ? (@.relationship == $v || @.age > 1))`, vars, "json_predicate_unsupported", "`||` in a filter", "|| @.age"}, - {"an unknown variable", `$.extra.country == $missing`, vars, "json_predicate_unknown_variable", "a variable", "$missing"}, - {"a value an ordering cannot take", `$.weight < $flag`, vars, "json_predicate_value_type", "a value", "$flag"}, - {"an object", `$.extra.country == $obj`, map[string]any{"obj": map[string]any{"secret-nz": 1}}, "json_predicate_value_type", "a value", "$obj"}, - {"!= at a wildcard", `$.extra.preferred_languages[*] != $v`, vars, "json_predicate_not_equal_at_wildcard", "`!=` at a path with a wildcard", "!= $v"}, - {"too deep", deep, nil, "json_predicate_too_deep", "nesting", "$.a"}, - {"a position", `$.extra.preferred_languages[2] == $v`, vars, "json_path_position", "a position (`[2]`)", "[2]"}, - {"a filter with no wildcard", `exists($.address ? (@.postcode == $v))`, vars, "json_path_no_wildcard", "a filter on a path with no wildcard", "$.address ?"}, - {"a wildcard in a member", `exists($.extra.emergency_contacts[*] ? (@.calls[*] == $v))`, vars, "json_path_wildcard", "a wildcard in a filter's member", "@.calls"}, - {"a number JSON cannot hold", `$.weight == 1e999`, vars, "json_syntax", "a value", "1e999"}, - } { - _, err := f.Match(ctx, cipher, tc.predicate, tc.vars, encrypt.JSONReadOptions{}) + for _, tc := range jsonConformanceCases(t).PredicateRefusals { + _, err := f.Match(ctx, cipher, tc.Predicate, tc.Vars, encrypt.JSONReadOptions{}) var d *encrypt.Diagnostic if !errors.Is(err, encrypt.ErrEncoding) || !errors.As(err, &d) { - t.Errorf("%s: %v, want ErrEncoding with a Diagnostic", tc.name, err) + t.Errorf("%s: %v, want ErrEncoding with a Diagnostic", tc.ID, err) continue } - want := strings.Index(tc.predicate, tc.at) - if at, ok := d.At(); d.Reason() != tc.reason || d.Construct() != tc.construct || !ok || at != want { - t.Errorf("%s: reason %q, construct %q, at %d (%v); want %q, %q, %d: %v", tc.name, d.Reason(), d.Construct(), at, ok, tc.reason, tc.construct, want, err) + want := tc.At + if at, ok := d.At(); d.Reason() != tc.Reason || d.Construct() != tc.Construct || !ok || at != want { + t.Errorf("%s: reason %q, construct %q, at %d (%v); want %q, %q, %d: %v", tc.ID, d.Reason(), d.Construct(), at, ok, tc.Reason, tc.Construct, want, err) } if d.Field() != "custom_field_values" || d.Code != "stack_encrypt::dynamic_predicate" { - t.Errorf("%s: field %q, code %q", tc.name, d.Field(), d.Code) + t.Errorf("%s: field %q, code %q", tc.ID, d.Field(), d.Code) } - if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), tc.predicate) { - t.Errorf("%s: the error holds the caller's text: %v", tc.name, err) + if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), tc.Predicate) { + t.Errorf("%s: the error holds the caller's text: %v", tc.ID, err) } } @@ -363,7 +339,7 @@ func TestMatchRefusalsNameTheConstructAndItsOffset(t *testing.T) { `$.score < 3`, `exists($.extra.emergency_contacts[*] ? (@.name == $v && @.age > 1))`, } { - _, err := f.Match(ctx, cipher, predicate, vars, encrypt.JSONReadOptions{}) + _, err := f.Match(ctx, cipher, predicate, map[string]any{"v": "secret-nz", "flag": true}, encrypt.JSONReadOptions{}) var d *encrypt.Diagnostic if !errors.Is(err, encrypt.ErrTerm) || !errors.As(err, &d) || d.Code != "stack_encrypt::json_path_not_ordered" { t.Errorf("%s: %v, want ErrTerm json_path_not_ordered", predicate, err) diff --git a/languages/golang/encrypt/json_tree_test.go b/languages/golang/encrypt/json_tree_test.go index 37023f35f..9fd49a503 100644 --- a/languages/golang/encrypt/json_tree_test.go +++ b/languages/golang/encrypt/json_tree_test.go @@ -26,18 +26,10 @@ func jsonPlan() *record.Plan { }}} } -var jsonDocuments = []string{ - `{"name":"Alice","age":34,"visits":[{"at":3},{"at":1}]}`, - `[1,"two",{"three":3}]`, - `"a scalar root"`, - `null`, - `18446744073709551615`, - `{}`, -} - func TestJSONDocumentsRoundTripThroughTheGuest(t *testing.T) { c := deterministicEQLClient(t) ctx := context.Background() + jsonDocuments := jsonConformanceCases(t).Documents rows := make([]record.Source, len(jsonDocuments)) for i, doc := range jsonDocuments { rows[i] = record.Source{"profile": []byte(doc)} @@ -93,7 +85,7 @@ func TestJSONDocumentsRoundTripThroughTheGuest(t *testing.T) { func TestJSONDocumentsDoNotOpenUnderAnotherColumn(t *testing.T) { c := deterministicEQLClient(t) ctx := context.Background() - sealed, err := c.DefaultKeyset().Seal(ctx, jsonPlan(), []record.Source{{"profile": []byte(jsonDocuments[0])}}) + sealed, err := c.DefaultKeyset().Seal(ctx, jsonPlan(), []record.Source{{"profile": []byte(jsonConformanceCases(t).Documents[0])}}) if err != nil { t.Fatal(err) } @@ -108,11 +100,8 @@ func TestJSONDocumentsDoNotOpenUnderAnotherColumn(t *testing.T) { // the engine's code and without the text. func TestJSONTextThatIsNotADocumentIsErrEncoding(t *testing.T) { c := deterministicEQLClient(t) - for text, code := range map[string]string{ - `{"secret":1,"secret":2}`: "json_duplicate_key", - `99999999999999999999`: "json_integer_range", - `{"secret":`: "json_syntax", - } { + for _, tc := range jsonConformanceCases(t).InvalidDocuments { + text, code := tc.Text, tc.Reason _, err := c.DefaultKeyset().Seal(context.Background(), jsonPlan(), []record.Source{{"profile": []byte(text)}}) if !errors.Is(err, encrypt.ErrEncoding) { t.Fatalf("%s: %v, want ErrEncoding", text, err) diff --git a/mise.toml b/mise.toml index 9e174844d..e7d5cef2e 100644 --- a/mise.toml +++ b/mise.toml @@ -433,3 +433,18 @@ cd languages/golang # if set, else the developer profile. See encrypt/example/README.md. CGO_ENABLED=0 go run ./encrypt/example """ + +[tasks."test:conformance:eql"] +description = "Required EQL/JSON profiles over native Rust and Go (build both deterministic guests first)" +run = "node scripts/stack-eql-conformance.mjs fast" + +[tasks."test:conformance:eql:postgres"] +description = "Build and install EQL v4 into disposable EQL_TEST_DATABASE_URL, then run required Rust/Go JSON PostgreSQL oracles" +run = """ +#!/usr/bin/env bash +set -euo pipefail +: "${EQL_TEST_DATABASE_URL:?set a disposable PostgreSQL database connection}" +mise -C packages/eql run clean +mise -C packages/eql run build +node scripts/stack-eql-conformance.mjs postgres +""" diff --git a/packages/eql/tests/encryption/tests/json_tree_data_plan.rs b/packages/eql/tests/encryption/tests/json_tree_data_plan.rs index 77fcb1265..ba7d38262 100644 --- a/packages/eql/tests/encryption/tests/json_tree_data_plan.rs +++ b/packages/eql/tests/encryption/tests/json_tree_data_plan.rs @@ -131,18 +131,12 @@ fn frame(value: T) -> ValueFrame { /// The documents a batch carries, as values. fn documents() -> Vec { - vec![ - json!({"name": "Alice", "age": 34, "visits": [{"at": 3, "where": "x"}, {"at": 1}], "tags": ["a", "b"]}), - json!({"name": "Bob", "age": 7.5, "nested": {"deep": [[1, 2], {}]}, "none": null}), - json!([1, "two", {"three": 3}]), - json!("a scalar root"), - json!(18446744073709551615u64), - json!(-9223372036854775808i64), - json!(-926_809.505_100_808_3), - Value::Null, - json!({}), - json!([]), - ] + conformance_cases()["documents"] + .as_array() + .unwrap() + .iter() + .map(|text| serde_json::from_str(text.as_str().unwrap()).unwrap()) + .collect() } /// The EQL value a sealed row stores for `profile`, parsed. @@ -443,12 +437,9 @@ async fn text_that_is_not_a_document_is_refused_before_any_key() { let (cipher, calls) = common::cipher().await; let keyset = cipher.default_keyset(); let plan = plan(); - for (text, code) in [ - (r#"{"secret": 1, "secret": 2}"#, "json_duplicate_key"), - ("99999999999999999999", "json_integer_range"), - (r#"{"secret": "#, "json_syntax"), - ("", "json_syntax"), - ] { + for case in conformance_cases()["invalid_documents"].as_array().unwrap() { + let text = case["text"].as_str().unwrap(); + let code = case["reason"].as_str().unwrap(); let source = BTreeMap::from([("profile", text)]); let error = match record::encrypt_with(&keyset, frame(&source), &plan, &Eql) { Err(error) => error, @@ -1154,3 +1145,12 @@ async fn the_engine_and_the_table_agree_on_which_targets_take_json_options() { assert_eq!(engine, !refused, "{}", target.name); } } + +fn conformance_cases() -> Value { + let cases: Value = serde_json::from_str(include_str!( + "../../../../../conformance/stack-encrypt/v1/eql-json.json" + )) + .unwrap(); + assert_eq!(cases["version"], 1); + cases +} diff --git a/packages/stack-encrypt/tests/json_operators.rs b/packages/stack-encrypt/tests/json_operators.rs index 63e913121..2e48fc407 100644 --- a/packages/stack-encrypt/tests/json_operators.rs +++ b/packages/stack-encrypt/tests/json_operators.rs @@ -2186,6 +2186,30 @@ mod compiled_match { /// A refusal names its reason, the construct and its offset in the /// caller's text, and the field; the input's own shape is refused /// as the other operators refuse theirs. + #[test] + fn shared_predicate_refusals() { + let cases: Value = serde_json::from_str(include_str!( + "../../../conformance/stack-encrypt/v1/eql-json.json" + )) + .unwrap(); + assert_eq!(cases["version"], 1); + for case in cases["predicate_refusals"].as_array().unwrap() { + let predicate = case["predicate"].as_str().unwrap(); + let error = check(&input(predicate, case["vars"].clone())).unwrap_err(); + let payload = stack_encrypt::ErrorPayload::payload(&error); + assert_eq!( + error.reason().map(Reason::as_str), + case["reason"].as_str(), + "{}", + case["id"] + ); + assert_eq!(payload["at"], case["at"], "{}", case["id"]); + assert_eq!(payload["construct"], case["construct"], "{}", case["id"]); + assert_eq!(error.field(), Some("profile")); + assert!(!format!("{error} {payload:?}").contains("secret")); + } + } + #[test] fn is_refused_with_its_reason_and_offset() { for (predicate, vars, reason, at) in [ diff --git a/packages/stack-encrypt/tests/json_tree_vectors.rs b/packages/stack-encrypt/tests/json_tree_vectors.rs index 2a3c5bfb4..467a973ec 100644 --- a/packages/stack-encrypt/tests/json_tree_vectors.rs +++ b/packages/stack-encrypt/tests/json_tree_vectors.rs @@ -171,6 +171,11 @@ async fn the_derivations_are_pinned_under_the_deterministic_source() { "sealed_root": vectors(&JsonOptions::default()).await, "open_root": vectors(&JsonOptions { sealed_root: false, ..JsonOptions::new() }).await, }); + assert!( + std::env::var("STACK_CONFORMANCE_REQUIRED").as_deref() != Ok("1") + || std::env::var_os("STACK_ENCRYPT_UPDATE_JSON_TREE_VECTORS").is_none(), + "required conformance must not regenerate JSON tree vectors" + ); if update_requested(std::env::var_os("STACK_ENCRYPT_UPDATE_JSON_TREE_VECTORS").as_deref()) { std::fs::write( fixture_path(), @@ -213,3 +218,22 @@ async fn the_deterministic_source_seals_the_same_tags_twice() { let two = vectors(&JsonOptions::default()).await; assert_eq!(one, two, "a pin needs a run that repeats"); } + +#[test] +fn required_checks_refuse_json_vector_updates() { + for value in ["", "0", "1"] { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "the_derivations_are_pinned_under_the_deterministic_source", + "--nocapture", + ]) + .env("STACK_CONFORMANCE_REQUIRED", "1") + .env("STACK_ENCRYPT_UPDATE_JSON_TREE_VECTORS", value) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr) + .contains("required conformance must not regenerate JSON tree vectors")); + } +} diff --git a/scripts/__tests__/eql-suite-ci.test.mjs b/scripts/__tests__/eql-suite-ci.test.mjs index 1c9dc60fe..491f85d76 100644 --- a/scripts/__tests__/eql-suite-ci.test.mjs +++ b/scripts/__tests__/eql-suite-ci.test.mjs @@ -400,16 +400,27 @@ describe('the relevance filter still selects the imported tree', () => { }) it('prefixes every source path with the subtree root', () => { - // Three legitimate exceptions, all outside the subtree by nature: the + // Legitimate exceptions, all outside the subtree by nature: the // workflow's own path, a composite action it `uses:`, and a crate the // workspace compiles by path (plus the root manifest it inherits from). const external = externalPathDependencies.dirs.map((dir) => `${dir}/**`) + // The EQL data-plan suite compiles the shared corpus from outside its + // subtree. Keep this exception tied to that actual dependency. + const sharedCorpus = 'conformance/stack-encrypt/v1/eql-json.json' + const usesSharedCorpus = readFileSync( + join( + REPO_ROOT, + 'packages/eql/tests/encryption/tests/json_tree_data_plan.rs', + ), + 'utf8', + ).includes(sharedCorpus) const offenders = filterPaths.filter( (path) => path !== EQL_WORKFLOW && !path.startsWith(EQL_PREFIX) && !localActionPrefixes.some((prefix) => path.startsWith(prefix)) && !external.includes(path) && + !(usesSharedCorpus && path === 'conformance/stack-encrypt/**') && !(externalPathDependencies.inherits && path === 'Cargo.toml'), ) diff --git a/scripts/__tests__/stack-eql-conformance.test.mjs b/scripts/__tests__/stack-eql-conformance.test.mjs new file mode 100644 index 000000000..0f547ea14 --- /dev/null +++ b/scripts/__tests__/stack-eql-conformance.test.mjs @@ -0,0 +1,190 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + checkEnvironment, + checkGoResults, + checkRustInventory, + selections, +} from '../stack-eql-conformance.mjs' +import { REPO_ROOT } from './lib/repo-root.mjs' +import { readWorkflow } from './lib/workflows.mjs' + +const read = (path) => readFileSync(join(REPO_ROOT, path), 'utf8') +const index = JSON.parse( + read('conformance/stack-encrypt/v1/eql-json-profiles.json'), +) + +describe('required EQL/JSON conformance', () => { + it('enrolls every operation family and both language/database lanes', () => { + expect(index.requirements.map((r) => r.id)).toEqual([ + 'eql-resolver-catalog-v1', + 'json-write-v1', + 'json-query-v1', + 'json-request-bound-reads-v1', + 'json-element-match-v1', + 'json-predicate-match-v1', + ]) + const contract = read('conformance/stack-encrypt/eql-json-contract.md') + for (const requirement of index.requirements) + expect(contract).toContain(requirement.id) + const reached = new Set() + for (const lane of ['fast', 'postgres']) { + for (const language of ['rust', 'go']) { + for (const [id, suite] of selections(index, lane, language)) { + reached.add(id) + expect(suite.tests.size).toBeGreaterThan(0) + } + } + } + expect([...reached].sort()).toEqual(Object.keys(index.suites).sort()) + const renamed = structuredClone(index) + renamed.requirements[0].evidence.missing = ['TestMissing'] + expect(() => selections(renamed, 'fast', 'all')).toThrow('Invalid suite') + expect(() => selections(index, 'unknown', 'all')).toThrow() + }) + + it('refuses renamed, filtered or wrongly ignored compiled native evidence', () => { + const suite = { + package: 'engine', + binary: 'json', + lane: 'fast', + tests: new Set(['required']), + } + const inventory = { + 'rust-suites': { + 'engine::json': { + status: 'listed', + testcases: { + required: { ignored: false, 'filter-match': { status: 'matches' } }, + }, + }, + }, + } + expect(() => checkRustInventory(inventory, suite)).not.toThrow() + for (const replacement of [ + undefined, + { ignored: true, 'filter-match': { status: 'matches' } }, + { ignored: false, 'filter-match': { status: 'mismatch' } }, + ]) { + const changed = structuredClone(inventory) + changed['rust-suites']['engine::json'].testcases.required = replacement + expect(() => checkRustInventory(changed, suite)).toThrow( + 'Missing or incorrectly ignored', + ) + } + expect(() => + checkRustInventory(inventory, { ...suite, lane: 'postgres' }), + ).toThrow() + inventory['rust-suites']['engine::json'].testcases.required.ignored = true + expect(() => + checkRustInventory(inventory, { ...suite, lane: 'postgres' }), + ).not.toThrow() + }) + + it('requires a Go pass for every name and rejects nested skips', () => { + const passed = JSON.stringify({ Action: 'pass', Test: 'TestRequired' }) + expect(() => checkGoResults(passed, ['TestRequired'])).not.toThrow() + expect(() => checkGoResults('', ['TestRequired'])).toThrow('did not pass') + for (const action of ['skip', 'fail']) { + const output = `${passed}\n${JSON.stringify({ Action: action, Test: 'TestRequired/case' })}` + expect(() => checkGoResults(output, ['TestRequired'])).toThrow(action) + } + expect(() => checkGoResults(passed, ['TestRenamed'])).toThrow( + 'did not pass', + ) + }) + + it('refuses every update-variable spelling, including an empty or zero value', () => { + for (const name of [ + 'STACK_CONFORMANCE_UPDATE', + 'STACK_ENCRYPT_UPDATE_FIXTURES', + 'STACK_ENCRYPT_UPDATE_JSON_TREE_VECTORS', + ]) { + for (const value of ['', '0', '1']) + expect(() => checkEnvironment({ [name]: value })).toThrow(name) + } + expect(() => checkEnvironment({})).not.toThrow() + }) + + it('shares the original write and predicate tables without dropping boundary cases', () => { + const corpus = JSON.parse( + read('conformance/stack-encrypt/v1/eql-json.json'), + ) + expect(corpus.version).toBe(1) + expect(new Set(corpus.documents).size).toBe(corpus.documents.length) + for (const text of [ + 'null', + '{}', + '[]', + '18446744073709551615', + '-9223372036854775808', + '-926809.5051008083', + ]) + expect(corpus.documents).toContain(text) + expect(corpus.invalid_documents.map((c) => c.reason)).toEqual([ + 'json_duplicate_key', + 'json_integer_range', + 'json_syntax', + 'json_syntax', + ]) + const ids = corpus.predicate_refusals.map((c) => c.id) + expect(new Set(ids).size).toBe(15) + for (const c of corpus.predicate_refusals) { + expect(c.at).toBeGreaterThanOrEqual(0) + expect(c.at).toBeLessThan(Buffer.byteLength(c.predicate)) + expect(c.reason).toMatch(/^json_/) + expect(c.construct).toBeTruthy() + } + for (const path of [ + 'packages/stack-encrypt/tests/json_operators.rs', + 'packages/eql/tests/encryption/tests/json_tree_data_plan.rs', + 'languages/golang/encrypt/json_conformance_test.go', + ]) + expect(read(path)).toContain('eql-json.json') + }) + + it('runs both required lanes in CI and retains reports with SQL and engine identity', () => { + const workflow = readWorkflow('.github/workflows/tests-golang.yml') + for (const task of [ + 'test:conformance:eql', + 'test:conformance:eql:postgres', + ]) { + const jobs = Object.values(workflow.jobs).filter((job) => + job.steps?.some((step) => step.run === `mise run ${task}`), + ) + expect(jobs).toHaveLength(1) + const step = jobs[0].steps.find((step) => step.run === `mise run ${task}`) + expect(step['continue-on-error']).toBeUndefined() + expect(step.env.STACK_CONFORMANCE_REPORT_DIR).toBeTruthy() + if (task.endsWith(':postgres')) { + expect(jobs[0].services.postgres).toBeDefined() + expect(step.env.EQL_TEST_DATABASE_URL).toContain( + 'job.services.postgres.ports', + ) + } + expect( + jobs[0].steps.some( + (s) => + s.uses?.startsWith('actions/upload-artifact@') && + s.with?.path?.includes('eql-conformance') && + s.with['if-no-files-found'] === 'error', + ), + ).toBe(true) + } + const script = read('scripts/stack-eql-conformance.mjs') + for (const field of [ + 'sql_bundle_sha256', + 'eql_bindings', + 'engine:', + 'guests', + 'eql_v4.version()', + 'server_version', + ]) + expect(script).toContain(field) + const filters = read('.github/workflows/tests-golang.yml') + expect(filters.match(/- scripts\/stack-eql-conformance.mjs/g)).toHaveLength( + 2, + ) + }) +}) diff --git a/scripts/stack-eql-conformance.mjs b/scripts/stack-eql-conformance.mjs new file mode 100644 index 000000000..39eff5806 --- /dev/null +++ b/scripts/stack-eql-conformance.mjs @@ -0,0 +1,283 @@ +#!/usr/bin/env node +// Named evidence over the existing suites. Missing, ignored and skipped tests +// are failures; a successful empty selection is not conformance. +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const read = (path) => readFileSync(join(root, path), 'utf8') +const hash = (path) => + createHash('sha256') + .update(readFileSync(join(root, path))) + .digest('hex') + +export function selections(index, lane, language) { + if ( + index.version !== 1 || + !['fast', 'postgres'].includes(lane) || + !['rust', 'go', 'all'].includes(language) + ) { + throw new Error('Unsupported EQL conformance version, lane or language') + } + const selected = new Map() + const ids = new Set() + for (const requirement of index.requirements) { + if (!/^[a-z][a-z0-9-]+-v1$/.test(requirement.id) || ids.has(requirement.id)) + throw new Error('Invalid or repeated requirement ID') + ids.add(requirement.id) + if (!Object.keys(requirement.evidence).length) + throw new Error(`Empty evidence: ${requirement.id}`) + for (const [id, tests] of Object.entries(requirement.evidence)) { + const suite = index.suites[id] + if ( + !suite || + !['rust', 'go'].includes(suite.language) || + !['fast', 'postgres'].includes(suite.lane) + ) + throw new Error(`Invalid suite: ${id}`) + if ( + !tests.length || + new Set(tests).size !== tests.length || + tests.some( + (name) => + !(suite.language === 'go' ? /^Test\w+$/ : /^[a-zA-Z0-9_:]+$/).test( + name, + ), + ) + ) + throw new Error(`Invalid tests: ${id}`) + if ( + suite.lane !== lane || + (language !== 'all' && language !== suite.language) + ) + continue + if (!selected.has(id)) selected.set(id, { ...suite, tests: new Set() }) + for (const name of tests) selected.get(id).tests.add(name) + } + } + if (!selected.size) throw new Error('No EQL conformance evidence selected') + return selected +} + +export function checkRustInventory(inventory, suite) { + const binary = inventory['rust-suites'][`${suite.package}::${suite.binary}`] + if (binary?.status !== 'listed') + throw new Error(`Missing compiled binary: ${suite.binary}`) + for (const name of suite.tests) { + const test = binary.testcases[name] + if ( + !test || + test.ignored !== (suite.lane === 'postgres') || + test['filter-match'].status !== 'matches' + ) { + throw new Error( + `Missing or incorrectly ignored compiled test: ${suite.binary}::${name}`, + ) + } + } +} + +export function checkGoResults(output, tests) { + const passed = new Set() + for (const line of output.trim().split('\n')) { + if (!line) continue + const event = JSON.parse(line) + if (event.Action === 'skip' || event.Action === 'fail') + throw new Error( + `Go conformance ${event.Action}: ${event.Test ?? event.Package}`, + ) + if (event.Action === 'pass' && event.Test) passed.add(event.Test) + } + for (const name of tests) + if (!passed.has(name)) throw new Error(`Go test did not pass: ${name}`) +} + +export function checkEnvironment(env) { + for (const name of [ + 'STACK_CONFORMANCE_UPDATE', + 'STACK_ENCRYPT_UPDATE_FIXTURES', + 'STACK_ENCRYPT_UPDATE_JSON_TREE_VECTORS', + ]) { + if (Object.hasOwn(env, name)) + throw new Error(`Conformance cannot update fixtures: ${name}`) + } +} + +function command(program, args, { capture = false, ...options } = {}) { + const result = spawnSync(program, args, { + cwd: root, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + stdio: capture ? ['ignore', 'pipe', 'inherit'] : 'inherit', + ...options, + }) + if (result.error) throw result.error + if (result.status !== 0) { + if (capture && result.stdout) process.stderr.write(result.stdout) + throw new Error(`${program} failed (${result.status})`) + } + return result.stdout +} + +function cargo(args, capture = false) { + return command('mise', ['x', '--env', 'test', '--', 'cargo', ...args], { + capture, + }) +} + +function metadata(manifest) { + return JSON.parse( + cargo( + [ + 'metadata', + '--locked', + '--no-deps', + '--format-version', + '1', + '--manifest-path', + manifest, + ], + true, + ), + ) +} + +export function main(args = process.argv.slice(2)) { + // Positional arguments intentionally have no implicit database fallback. + const [lane, language = 'all'] = args + if (args.length > 2) + throw new Error( + 'Usage: stack-eql-conformance.mjs [rust|go|all]', + ) + checkEnvironment(process.env) + const index = JSON.parse( + read('conformance/stack-encrypt/v1/eql-json-profiles.json'), + ) + const selected = selections(index, lane, language) + process.env.STACK_CONFORMANCE_REQUIRED = '1' + delete process.env.STACK_CONFORMANCE_INPUT + delete process.env.STACK_CONFORMANCE_OUTPUT + const report = { + profile: 'eql-json-v1', + lane, + language, + engine: metadata('Cargo.toml').packages.find( + (p) => p.name === 'stack-encrypt', + ).version, + eql_bindings: metadata('packages/eql/Cargo.toml').packages.find( + (p) => p.name === 'eql-bindings', + ).version, + corpus_sha256: hash('conformance/stack-encrypt/v1/eql-json.json'), + enrollment_sha256: hash( + 'conformance/stack-encrypt/v1/eql-json-profiles.json', + ), + root_lock_sha256: hash('Cargo.lock'), + eql_lock_sha256: hash('packages/eql/Cargo.lock'), + evidence: {}, + } + if ([...selected.values()].some((s) => s.language === 'go')) { + // Both builds are required: capability refusal is tested with the plain guest. + report.guests = Object.fromEntries( + [ + 'stack_encrypt_guest_deterministic.wasm', + 'stack_encrypt_guest_eql_deterministic.wasm', + ].map((name) => [ + name, + hash(`languages/golang/encrypt/testdata/${name}`), + ]), + ) + } + if (lane === 'postgres') { + const url = process.env.EQL_TEST_DATABASE_URL + if (!url) + throw new Error( + 'Set EQL_TEST_DATABASE_URL to a disposable PostgreSQL database', + ) + const bundle = 'packages/eql/release/cipherstash-encrypt-v4.sql' + report.sql_bundle_sha256 = hash(bundle) + // Install exactly the bytes whose identity is recorded, into the same + // database both runners use. Never report a merely adjacent bundle's hash. + command('psql', ['-X', url, '-v', 'ON_ERROR_STOP=1', '-q', '-f', bundle]) + report.database = JSON.parse( + command( + 'psql', + [ + '-X', + url, + '-At', + '-v', + 'ON_ERROR_STOP=1', + '-c', + "select json_build_object('postgres', current_setting('server_version'), 'eql', eql_v4.version(), 'schema', 4)", + ], + { capture: true }, + ), + ) + process.env.DATABASE_URL = url + } + for (const [id, suite] of selected) { + console.log(`EQL conformance: ${id} (${suite.tests.size} required tests)`) + if (suite.language === 'rust') { + const common = [ + '--locked', + '--manifest-path', + `${suite.workspace}/Cargo.toml`, + '-p', + suite.package, + '--test', + suite.binary, + ...suite.features, + '--run-ignored', + lane === 'postgres' ? 'only' : 'default', + ] + const inventory = JSON.parse( + cargo(['nextest', 'list', ...common, '--message-format', 'json'], true), + ) + checkRustInventory(inventory, suite) + // Run the whole original suite, retaining unindexed regression and property + // tests. PostgreSQL tests are intentionally #[ignore] outside this lane. + cargo([ + 'nextest', + 'run', + ...common, + '--success-output', + lane === 'postgres' ? 'immediate' : 'never', + ]) + } else { + const pattern = `^(${[...suite.tests].sort().join('|')})$` + const output = command( + 'go', + [ + 'test', + '-C', + 'languages/golang', + '-count=1', + '-json', + '-run', + pattern, + './encrypt', + ], + { capture: true, env: { ...process.env, CGO_ENABLED: '0' } }, + ) + checkGoResults(output, suite.tests) + console.log(`Passed ${suite.tests.size} required Go tests; no skips`) + } + report.evidence[id] = [...suite.tests].sort() + } + console.log(JSON.stringify(report, null, 2)) + if (process.env.STACK_CONFORMANCE_REPORT_DIR) { + mkdirSync(process.env.STACK_CONFORMANCE_REPORT_DIR, { recursive: true }) + writeFileSync( + join( + process.env.STACK_CONFORMANCE_REPORT_DIR, + `eql-json-${lane}-${language}.json`, + ), + `${JSON.stringify(report, null, 2)}\n`, + ) + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) main() From e6ce5d04757d21e336b85c208e08777c7fa8d12e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 1 Nov 2026 21:53:58 +1100 Subject: [PATCH 02/15] test(encrypt): address EQL conformance review --- .github/workflows/tests-golang.yml | 21 ++++++--- .../stack-encrypt/eql-json-contract.md | 17 ++++--- .../stack-encrypt/v1/eql-json-profiles.json | 40 ++++++++++++++++ conformance/stack-encrypt/v1/eql-json.json | 46 ++++++++++++------- .../golang/encrypt/json_conformance_test.go | 1 + languages/golang/encrypt/json_match_test.go | 14 ++++-- mise.toml | 8 +--- .../stack-encrypt/tests/json_operators.rs | 17 +++++-- scripts/__tests__/eql-suite-ci.test.mjs | 15 +++--- .../__tests__/stack-eql-conformance.test.mjs | 40 ++++++++-------- scripts/stack-eql-conformance.mjs | 44 +++++++++++------- 11 files changed, 179 insertions(+), 84 deletions(-) diff --git a/.github/workflows/tests-golang.yml b/.github/workflows/tests-golang.yml index dcd565006..c7f7893e4 100644 --- a/.github/workflows/tests-golang.yml +++ b/.github/workflows/tests-golang.yml @@ -146,6 +146,16 @@ jobs: with: persist-credentials: false + # Native EQL conformance uses the EQL workspace's toolchain. Install it + # before root setup, so ordinary guest commands retain the root pin. + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: packages/eql + cache: true + cache_key_prefix: mise-v1-ubuntu-2204 + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 with: version: 2026.4.0 @@ -405,6 +415,8 @@ jobs: # v4 bundle under packages/eql's mise config, which pins its own Rust; the # guests are the ones wasi-check built and checked. eql-postgres: + env: + MISE_ENV: test name: Go rows and queries in PostgreSQL with EQL v4 needs: wasi-check runs-on: blacksmith-16vcpu-ubuntu-2204 @@ -472,16 +484,11 @@ jobs: # Builds the bundle from the tree, installs it, and runs the tests; a # skip fails the task, so a missing guest or database cannot pass. - - name: Go rows and queries through EQL v4 - env: - EQL_TEST_DATABASE_URL: "host=localhost port=${{ job.services.postgres.ports['5432'] }} user=postgres password=postgres dbname=postgres sslmode=disable" - run: mise run go:test:postgres - - - name: Required EQL and JSON PostgreSQL conformance + - name: Go rows, queries and required JSON conformance through EQL v4 env: EQL_TEST_DATABASE_URL: "host=localhost port=${{ job.services.postgres.ports['5432'] }} user=postgres password=postgres dbname=postgres sslmode=disable" STACK_CONFORMANCE_REPORT_DIR: ${{ runner.temp }}/eql-conformance-postgres - run: mise run test:conformance:eql:postgres + run: mise run go:test:postgres - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: diff --git a/conformance/stack-encrypt/eql-json-contract.md b/conformance/stack-encrypt/eql-json-contract.md index df1eb89e3..299b7943b 100644 --- a/conformance/stack-encrypt/eql-json-contract.md +++ b/conformance/stack-encrypt/eql-json-contract.md @@ -14,6 +14,9 @@ native Rust and Go. Python and TypeScript have no enrolled runner yet. | `json-write-v1` | JSON documents, including scalar roots, empty containers, JSON null and integer boundaries, round-trip as values. SQL null stores no document. Invalid text is refused without exposing it; native tests observe no key request. Typed and dynamic plans agree on search terms and can open each other's output. | Native typed/data-plan suites, Go raw and generated typed document APIs; existing seeded derivation fixture | | `json-query-v1` | Containment, reverse containment, equality at a path, ordered bounds, selectors and child operations derive their documented operand shapes. Unsupported requests fail. | Native engine/EQL operand comparisons, Go generated queries, independent PostgreSQL `jsonb` containment oracles | | `json-request-bound-reads-v1` | Extraction and subtree/wildcard reads open against the prepared path, column, position and bounds. Wrong requests and detectable tampering fail. | Native adversarial read suite, EQL request/open tests, Go selector reads, PostgreSQL extraction/subtree and hostile-row suites | +| `json-extraction-v1` | Single-node extraction opens the requested scalar. | Native scalar extraction, Go selectors and PostgreSQL plaintext extraction oracle | +| `json-subtree-v1` | Complete bounded subtrees and wildcard reads match plaintext queries. | Native/EQL prepared reads and Go/PostgreSQL read paths | +| `json-tamper-v1` | Detectable mixed-document, truncated-chain and substituted-column answers are refused. | Native and PostgreSQL adversarial suites; Go observes wrong-column rejection | | `json-element-match-v1` | Every condition must hold on one element. Ordering paths and resolver capability are required; a probe beyond the declared bound raises rather than silently truncating. | Native operator/capability tests, Go generated member APIs, SQL domain and independent plaintext predicate tests | | `json-predicate-match-v1` | Supported text compiles to existing operators; refusals preserve reason, construct and byte offset, with no caller text in diagnostics. Missing and null paths obey the documented truth rules. | Shared negative corpus, native compiler/property tests, Go generated `Match`, SQL `jsonb_path_match` and handwritten-operator oracles | @@ -56,7 +59,9 @@ are unsupported. `!=` at a wildcard is refused. The corpus pins representative syntax, unsupported-construct, variable, type, depth and path diagnostics. Existing parser tests retain the wider grammar matrix and adapter-specific invalid-input checks; the shared corpus is not an exhaustive grammar fuzzer. -Offsets are UTF-8 byte offsets, not character indices. +Offsets are UTF-8 byte offsets, not character indices. Each refusal also names +`at_text`, the suffix beginning at that offset. Omitted `vars` exercises absent +variables rather than an explicit empty object. ## Nulls, oracles and intentional differences @@ -107,16 +112,16 @@ EQL_TEST_DATABASE_URL='host=localhost port=5432 user=postgres password=postgres mise run test:conformance:eql:postgres ``` -No CipherStash credentials are required. Both lanes are required steps in -`tests-golang.yml`; conformance changes trigger them. The database lane rebuilds +No CipherStash credentials are required. Both lanes are required in +`tests-golang.yml` (the PostgreSQL lane is called by `go:test:postgres`); conformance changes trigger them. The database lane rebuilds SQL from this tree, installs exactly the bundle whose SHA-256 it records, and passes the same connection to both languages. It fails without a database or -required guests. A fast-lane pass alone is not SQL conformance. +required guests. Required mode deliberately applies to every Go test using +`deterministicEQLClient`, including tests outside the enrollment index. A fast-lane pass alone is not SQL conformance. Reports include the native engine and EQL binding package versions, root/EQL lockfile hashes, corpus/enrollment hashes, deterministic guest hashes, executed -requirement test names, and (for PostgreSQL) the installed EQL version, schema -version, PostgreSQL version and actual SQL bundle hash. Development bundles can +requirement test names, and (for PostgreSQL) the installed EQL version and schema name, the expected schema name, PostgreSQL version and actual SQL bundle hash. Development bundles can report `DEV`; their hash distinguishes them. CI uploads reports and fails if none were produced. Locally set `STACK_CONFORMANCE_REPORT_DIR` to retain them; the same report is always printed after success. These describe this checkout diff --git a/conformance/stack-encrypt/v1/eql-json-profiles.json b/conformance/stack-encrypt/v1/eql-json-profiles.json index 38816107e..e829ac1a0 100644 --- a/conformance/stack-encrypt/v1/eql-json-profiles.json +++ b/conformance/stack-encrypt/v1/eql-json-profiles.json @@ -221,6 +221,46 @@ ], "go-postgres": ["TestJSONPredicateMatchInPostgres"] } + }, + { + "id": "json-extraction-v1", + "evidence": { + "engine-reads": [ + "given_the_entry_arrow_returned::the_entry_at_the_path_opens_to_its_scalar" + ], + "go": ["TestJSONSelectorReadsOpenAgainstTheirRequest"], + "postgres": [ + "generated::given_generated_documents_in_postgres::arrow_opens_a_leaf_to_the_value_jsonb_holds" + ] + } + }, + { + "id": "json-subtree-v1", + "evidence": { + "eql-record": ["reads_open_against_the_request_that_prepared_them"], + "engine-reads": [ + "given_the_rows_of_a_read::the_honest_rows_open_to_the_nodes_value_in_any_order" + ], + "postgres": [ + "postgres_every_subtree_read_answers_as_jsonb_does", + "postgres_wildcard_reads_answer_as_jsonb_path_query" + ], + "go-postgres": ["TestJSONDocumentsQueryAndReadInPostgres"] + } + }, + { + "id": "json-tamper-v1", + "evidence": { + "engine-reads": [ + "given_the_rows_of_a_read::a_truncated_subtree_is_refused_by_the_cascade", + "given_the_rows_of_a_read::rows_of_two_documents_are_refused" + ], + "postgres": [ + "postgres_hostile_answers_are_refused_by_the_client", + "postgres_reads_never_answer_wrongly_under_tampering" + ], + "go": ["TestJSONDocumentsDoNotOpenUnderAnotherColumn"] + } } ] } diff --git a/conformance/stack-encrypt/v1/eql-json.json b/conformance/stack-encrypt/v1/eql-json.json index 70d39501a..4d27bb5ea 100644 --- a/conformance/stack-encrypt/v1/eql-json.json +++ b/conformance/stack-encrypt/v1/eql-json.json @@ -41,7 +41,8 @@ }, "reason": "json_predicate_syntax", "construct": "text the grammar does not read", - "at": 16 + "at": 16, + "at_text": "= $v" }, { "id": "like-regex", @@ -52,7 +53,8 @@ }, "reason": "json_predicate_unsupported", "construct": "`like_regex`", - "at": 16 + "at": 16, + "at_text": "like_regex \"secret\"" }, { "id": "arithmetic", @@ -63,7 +65,8 @@ }, "reason": "json_predicate_unsupported", "construct": "arithmetic (`+`, `-`, `*`, `/`, `%`)", - "at": 9 + "at": 9, + "at_text": "+ 1 == 2" }, { "id": "a-method", @@ -74,7 +77,8 @@ }, "reason": "json_predicate_unsupported", "construct": "a method (`.name()`)", - "at": 8 + "at": 8, + "at_text": ".size() == 1" }, { "id": "a-filter-outside-exists", @@ -85,7 +89,8 @@ }, "reason": "json_predicate_unsupported", "construct": "a filter outside `exists`", - "at": 31 + "at": 31, + "at_text": "? (@ == $v) == 1" }, { "id": "in-a-filter", @@ -96,7 +101,8 @@ }, "reason": "json_predicate_unsupported", "construct": "`||` in a filter", - "at": 61 + "at": 61, + "at_text": "|| @.age > 1))" }, { "id": "an-unknown-variable", @@ -107,7 +113,8 @@ }, "reason": "json_predicate_unknown_variable", "construct": "a variable", - "at": 19 + "at": 19, + "at_text": "$missing" }, { "id": "a-value-an-ordering-cannot-take", @@ -118,7 +125,8 @@ }, "reason": "json_predicate_value_type", "construct": "a value", - "at": 11 + "at": 11, + "at_text": "$flag" }, { "id": "an-object", @@ -130,7 +138,8 @@ }, "reason": "json_predicate_value_type", "construct": "a value", - "at": 19 + "at": 19, + "at_text": "$obj" }, { "id": "at-a-wildcard", @@ -141,7 +150,8 @@ }, "reason": "json_predicate_not_equal_at_wildcard", "construct": "`!=` at a path with a wildcard", - "at": 31 + "at": 31, + "at_text": "!= $v" }, { "id": "a-position", @@ -152,7 +162,8 @@ }, "reason": "json_path_position", "construct": "a position (`[2]`)", - "at": 27 + "at": 27, + "at_text": "[2] == $v" }, { "id": "a-filter-with-no-wildcard", @@ -163,7 +174,8 @@ }, "reason": "json_path_no_wildcard", "construct": "a filter on a path with no wildcard", - "at": 7 + "at": 7, + "at_text": "$.address ? (@.postcode == $v))" }, { "id": "a-wildcard-in-a-member", @@ -174,7 +186,8 @@ }, "reason": "json_path_wildcard", "construct": "a wildcard in a filter's member", - "at": 40 + "at": 40, + "at_text": "@.calls[*] == $v))" }, { "id": "a-number-json-cannot-hold", @@ -185,15 +198,16 @@ }, "reason": "json_syntax", "construct": "a value", - "at": 12 + "at": 12, + "at_text": "1e999" }, { "id": "too-deep", "predicate": "((((((((((((((((((((((((((((((((($.a == 1)))))))))))))))))))))))))))))))))", - "vars": {}, "reason": "json_predicate_too_deep", "construct": "nesting", - "at": 33 + "at": 33, + "at_text": "$.a == 1)))))))))))))))))))))))))))))))))" } ] } diff --git a/languages/golang/encrypt/json_conformance_test.go b/languages/golang/encrypt/json_conformance_test.go index 5a5286a24..7c456a1e1 100644 --- a/languages/golang/encrypt/json_conformance_test.go +++ b/languages/golang/encrypt/json_conformance_test.go @@ -21,6 +21,7 @@ type jsonConformanceCorpus struct { Reason string `json:"reason"` Construct string `json:"construct"` At int `json:"at"` + AtText string `json:"at_text"` } `json:"predicate_refusals"` } diff --git a/languages/golang/encrypt/json_match_test.go b/languages/golang/encrypt/json_match_test.go index cea345c9d..8ae6d3bb8 100644 --- a/languages/golang/encrypt/json_match_test.go +++ b/languages/golang/encrypt/json_match_test.go @@ -275,15 +275,21 @@ func TestMatchRefusalsNameTheConstructAndItsOffset(t *testing.T) { cipher := c.DefaultKeyset() f := testusers.EntityFields.CustomFieldValues for _, tc := range jsonConformanceCases(t).PredicateRefusals { - _, err := f.Match(ctx, cipher, tc.Predicate, tc.Vars, encrypt.JSONReadOptions{}) + var vars any + if tc.Vars != nil { + vars = tc.Vars + } + if strings.Index(tc.Predicate, tc.AtText) != tc.At { + t.Fatalf("%s: offset does not name at_text", tc.ID) + } + _, err := f.Match(ctx, cipher, tc.Predicate, vars, encrypt.JSONReadOptions{}) var d *encrypt.Diagnostic if !errors.Is(err, encrypt.ErrEncoding) || !errors.As(err, &d) { t.Errorf("%s: %v, want ErrEncoding with a Diagnostic", tc.ID, err) continue } - want := tc.At - if at, ok := d.At(); d.Reason() != tc.Reason || d.Construct() != tc.Construct || !ok || at != want { - t.Errorf("%s: reason %q, construct %q, at %d (%v); want %q, %q, %d: %v", tc.ID, d.Reason(), d.Construct(), at, ok, tc.Reason, tc.Construct, want, err) + if at, ok := d.At(); d.Reason() != tc.Reason || d.Construct() != tc.Construct || !ok || at != tc.At { + t.Errorf("%s: reason %q, construct %q, at %d (%v); want %q, %q, %d: %v", tc.ID, d.Reason(), d.Construct(), at, ok, tc.Reason, tc.Construct, tc.At, err) } if d.Field() != "custom_field_values" || d.Code != "stack_encrypt::dynamic_predicate" { t.Errorf("%s: field %q, code %q", tc.ID, d.Field(), d.Code) diff --git a/mise.toml b/mise.toml index e7d5cef2e..07a2603aa 100644 --- a/mise.toml +++ b/mise.toml @@ -382,8 +382,8 @@ run = """ #!/usr/bin/env bash set -euo pipefail : "${EQL_TEST_DATABASE_URL:?set EQL_TEST_DATABASE_URL to a libpq connection string for a disposable PostgreSQL}" -mise -C packages/eql run build -psql "$EQL_TEST_DATABASE_URL" -v ON_ERROR_STOP=1 -q -f packages/eql/release/cipherstash-encrypt-v4.sql +# The conformance task owns SQL setup and the JSON PostgreSQL tests. +mise run test:conformance:eql:postgres cd languages/golang # The PostgreSQL tests, named once: the -run pattern and the check below # are both derived from this list. @@ -391,10 +391,6 @@ TESTS=( TestEQLTypesRoundTripAndQueryInPostgres TestEQLOrderMatchesPlaintextInPostgres TestQueriesRunAgainstPostgres - TestJSONDocumentsQueryAndReadInPostgres - TestJSONTypedDocumentsInPostgres - TestJSONElementMatchInPostgres - TestJSONPredicateMatchInPostgres ) pattern="^($(IFS='|'; echo "${TESTS[*]}"))$" # The tests skip without DATABASE_URL and without their guest builds; -v and diff --git a/packages/stack-encrypt/tests/json_operators.rs b/packages/stack-encrypt/tests/json_operators.rs index 2e48fc407..5d4544dbb 100644 --- a/packages/stack-encrypt/tests/json_operators.rs +++ b/packages/stack-encrypt/tests/json_operators.rs @@ -2183,9 +2183,7 @@ mod compiled_match { ) } - /// A refusal names its reason, the construct and its offset in the - /// caller's text, and the field; the input's own shape is refused - /// as the other operators refuse theirs. + /// Shared native/Go predicate refusals preserve structured diagnostics. #[test] fn shared_predicate_refusals() { let cases: Value = serde_json::from_str(include_str!( @@ -2195,7 +2193,15 @@ mod compiled_match { assert_eq!(cases["version"], 1); for case in cases["predicate_refusals"].as_array().unwrap() { let predicate = case["predicate"].as_str().unwrap(); - let error = check(&input(predicate, case["vars"].clone())).unwrap_err(); + assert_eq!( + predicate.find(case["at_text"].as_str().unwrap()), + Some(case["at"].as_u64().unwrap() as usize) + ); + let request = match case.get("vars") { + Some(vars) => input(predicate, vars.clone()), + None => json!({"predicate": predicate}), + }; + let error = check(&request).unwrap_err(); let payload = stack_encrypt::ErrorPayload::payload(&error); assert_eq!( error.reason().map(Reason::as_str), @@ -2210,6 +2216,9 @@ mod compiled_match { } } + /// A refusal names its reason, the construct and its offset in the + /// caller's text, and the field; the input's own shape is refused + /// as the other operators refuse theirs. #[test] fn is_refused_with_its_reason_and_offset() { for (predicate, vars, reason, at) in [ diff --git a/scripts/__tests__/eql-suite-ci.test.mjs b/scripts/__tests__/eql-suite-ci.test.mjs index 491f85d76..526dba64e 100644 --- a/scripts/__tests__/eql-suite-ci.test.mjs +++ b/scripts/__tests__/eql-suite-ci.test.mjs @@ -4,6 +4,7 @@ import { dirname, join, relative, sep } from 'node:path' import yaml from 'js-yaml' import { describe, expect, it } from 'vitest' import { REPO_ROOT } from './lib/repo-root.mjs' +import { includes } from './lib/rust-compiled-files.mjs' import { readWorkflow, WORKFLOW_DIR, workflowFiles } from './lib/workflows.mjs' /** @@ -407,13 +408,15 @@ describe('the relevance filter still selects the imported tree', () => { // The EQL data-plan suite compiles the shared corpus from outside its // subtree. Keep this exception tied to that actual dependency. const sharedCorpus = 'conformance/stack-encrypt/v1/eql-json.json' - const usesSharedCorpus = readFileSync( - join( - REPO_ROOT, - 'packages/eql/tests/encryption/tests/json_tree_data_plan.rs', + const usesSharedCorpus = includes( + readFileSync( + join( + REPO_ROOT, + 'packages/eql/tests/encryption/tests/json_tree_data_plan.rs', + ), + 'utf8', ), - 'utf8', - ).includes(sharedCorpus) + ).some((entry) => entry.literal?.endsWith(`/${sharedCorpus}`)) const offenders = filterPaths.filter( (path) => path !== EQL_WORKFLOW && diff --git a/scripts/__tests__/stack-eql-conformance.test.mjs b/scripts/__tests__/stack-eql-conformance.test.mjs index 0f547ea14..cce8a1f94 100644 --- a/scripts/__tests__/stack-eql-conformance.test.mjs +++ b/scripts/__tests__/stack-eql-conformance.test.mjs @@ -24,6 +24,9 @@ describe('required EQL/JSON conformance', () => { 'json-request-bound-reads-v1', 'json-element-match-v1', 'json-predicate-match-v1', + 'json-extraction-v1', + 'json-subtree-v1', + 'json-tamper-v1', ]) const contract = read('conformance/stack-encrypt/eql-json-contract.md') for (const requirement of index.requirements) @@ -128,6 +131,9 @@ describe('required EQL/JSON conformance', () => { 'json_syntax', 'json_syntax', ]) + expect( + corpus.predicate_refusals.find((c) => c.id === 'too-deep'), + ).not.toHaveProperty('vars') const ids = corpus.predicate_refusals.map((c) => c.id) expect(new Set(ids).size).toBe(15) for (const c of corpus.predicate_refusals) { @@ -135,6 +141,7 @@ describe('required EQL/JSON conformance', () => { expect(c.at).toBeLessThan(Buffer.byteLength(c.predicate)) expect(c.reason).toMatch(/^json_/) expect(c.construct).toBeTruthy() + expect(Buffer.from(c.predicate).subarray(c.at).toString()).toBe(c.at_text) } for (const path of [ 'packages/stack-encrypt/tests/json_operators.rs', @@ -146,16 +153,14 @@ describe('required EQL/JSON conformance', () => { it('runs both required lanes in CI and retains reports with SQL and engine identity', () => { const workflow = readWorkflow('.github/workflows/tests-golang.yml') - for (const task of [ - 'test:conformance:eql', - 'test:conformance:eql:postgres', - ]) { + for (const task of ['test:conformance:eql', 'go:test:postgres']) { const jobs = Object.values(workflow.jobs).filter((job) => job.steps?.some((step) => step.run === `mise run ${task}`), ) expect(jobs).toHaveLength(1) const step = jobs[0].steps.find((step) => step.run === `mise run ${task}`) expect(step['continue-on-error']).toBeUndefined() + expect(jobs[0].env.MISE_ENV).toBe('test') expect(step.env.STACK_CONFORMANCE_REPORT_DIR).toBeTruthy() if (task.endsWith(':postgres')) { expect(jobs[0].services.postgres).toBeDefined() @@ -172,19 +177,18 @@ describe('required EQL/JSON conformance', () => { ), ).toBe(true) } - const script = read('scripts/stack-eql-conformance.mjs') - for (const field of [ - 'sql_bundle_sha256', - 'eql_bindings', - 'engine:', - 'guests', - 'eql_v4.version()', - 'server_version', - ]) - expect(script).toContain(field) - const filters = read('.github/workflows/tests-golang.yml') - expect(filters.match(/- scripts\/stack-eql-conformance.mjs/g)).toHaveLength( - 2, - ) + // Parse YAML paths rather than counting source spellings or indentation. + for (const event of ['push', 'pull_request']) { + expect(workflow.on[event].paths).toContain( + 'scripts/stack-eql-conformance.mjs', + ) + } + const tasks = read('mise.toml') + const postgresTask = tasks + .split('[tasks."go:test:postgres"]')[1] + .split('[tasks.')[0] + expect(postgresTask).toContain('mise run test:conformance:eql:postgres') + expect(postgresTask).not.toMatch(/^psql\s/m) + expect(postgresTask).not.toContain('mise -C packages/eql run build') }) }) diff --git a/scripts/stack-eql-conformance.mjs b/scripts/stack-eql-conformance.mjs index 39eff5806..389dbb566 100644 --- a/scripts/stack-eql-conformance.mjs +++ b/scripts/stack-eql-conformance.mjs @@ -122,13 +122,14 @@ function command(program, args, { capture = false, ...options } = {}) { return result.stdout } -function cargo(args, capture = false) { +function cargo(args, capture = false, workspace = '.') { return command('mise', ['x', '--env', 'test', '--', 'cargo', ...args], { capture, + cwd: join(root, workspace), }) } -function metadata(manifest) { +function metadata(workspace) { return JSON.parse( cargo( [ @@ -138,9 +139,10 @@ function metadata(manifest) { '--format-version', '1', '--manifest-path', - manifest, + 'Cargo.toml', ], true, + workspace, ), ) } @@ -164,10 +166,9 @@ export function main(args = process.argv.slice(2)) { profile: 'eql-json-v1', lane, language, - engine: metadata('Cargo.toml').packages.find( - (p) => p.name === 'stack-encrypt', - ).version, - eql_bindings: metadata('packages/eql/Cargo.toml').packages.find( + engine: metadata('.').packages.find((p) => p.name === 'stack-encrypt') + .version, + eql_bindings: metadata('packages/eql').packages.find( (p) => p.name === 'eql-bindings', ).version, corpus_sha256: hash('conformance/stack-encrypt/v1/eql-json.json'), @@ -197,6 +198,7 @@ export function main(args = process.argv.slice(2)) { 'Set EQL_TEST_DATABASE_URL to a disposable PostgreSQL database', ) const bundle = 'packages/eql/release/cipherstash-encrypt-v4.sql' + report.expected_schema = 'eql_v4' report.sql_bundle_sha256 = hash(bundle) // Install exactly the bytes whose identity is recorded, into the same // database both runners use. Never report a merely adjacent bundle's hash. @@ -211,7 +213,7 @@ export function main(args = process.argv.slice(2)) { '-v', 'ON_ERROR_STOP=1', '-c', - "select json_build_object('postgres', current_setting('server_version'), 'eql', eql_v4.version(), 'schema', 4)", + "select json_build_object('postgres', current_setting('server_version'), 'eql', eql_v4.version(), 'schema', (select nspname from pg_namespace where oid = to_regnamespace('eql_v4')))", ], { capture: true }, ), @@ -224,7 +226,7 @@ export function main(args = process.argv.slice(2)) { const common = [ '--locked', '--manifest-path', - `${suite.workspace}/Cargo.toml`, + 'Cargo.toml', '-p', suite.package, '--test', @@ -234,18 +236,26 @@ export function main(args = process.argv.slice(2)) { lane === 'postgres' ? 'only' : 'default', ] const inventory = JSON.parse( - cargo(['nextest', 'list', ...common, '--message-format', 'json'], true), + cargo( + ['nextest', 'list', ...common, '--message-format', 'json'], + true, + suite.workspace, + ), ) checkRustInventory(inventory, suite) // Run the whole original suite, retaining unindexed regression and property // tests. PostgreSQL tests are intentionally #[ignore] outside this lane. - cargo([ - 'nextest', - 'run', - ...common, - '--success-output', - lane === 'postgres' ? 'immediate' : 'never', - ]) + cargo( + [ + 'nextest', + 'run', + ...common, + '--success-output', + lane === 'postgres' ? 'immediate' : 'never', + ], + false, + suite.workspace, + ) } else { const pattern = `^(${[...suite.tests].sort().join('|')})$` const output = command( From b05d943c0a3bd34e302012940bb54c4e284b59e3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 1 Nov 2026 22:08:32 +1100 Subject: [PATCH 03/15] test(encrypt): enroll lifecycle conformance --- .github/workflows/tests-golang.yml | 15 ++ conformance/stack-encrypt/README.md | 8 + conformance/stack-encrypt/coverage.md | 14 + .../stack-encrypt/lifecycle-contract.md | 107 ++++++++ .../stack-encrypt/v1/lifecycle-profiles.json | 249 ++++++++++++++++++ .../2026-10-31-stack-encrypt-conformance.md | 19 +- mise.toml | 4 + packages/stack-encrypt/tests/heap_hygiene.rs | 31 +++ .../tests/lifecycle_conformance.rs | 181 +++++++++++++ packages/stack-kms/src/futures.rs | 39 +++ .../stack-lifecycle-conformance.test.mjs | 122 +++++++++ scripts/stack-conformance-evidence.mjs | 192 ++++++++++++++ scripts/stack-eql-conformance.mjs | 196 ++------------ scripts/stack-lifecycle-conformance.mjs | 71 +++++ 14 files changed, 1068 insertions(+), 180 deletions(-) create mode 100644 conformance/stack-encrypt/lifecycle-contract.md create mode 100644 conformance/stack-encrypt/v1/lifecycle-profiles.json create mode 100644 packages/stack-encrypt/tests/lifecycle_conformance.rs create mode 100644 scripts/__tests__/stack-lifecycle-conformance.test.mjs create mode 100644 scripts/stack-conformance-evidence.mjs create mode 100644 scripts/stack-lifecycle-conformance.mjs diff --git a/.github/workflows/tests-golang.yml b/.github/workflows/tests-golang.yml index c7f7893e4..7e385aa8a 100644 --- a/.github/workflows/tests-golang.yml +++ b/.github/workflows/tests-golang.yml @@ -61,6 +61,8 @@ on: - scripts/stack-conformance.sh - scripts/stack-conformance-selection.mjs - scripts/stack-eql-conformance.mjs + - scripts/stack-conformance-evidence.mjs + - scripts/stack-lifecycle-conformance.mjs - Cargo.toml - Cargo.lock - .cargo/** @@ -101,6 +103,8 @@ on: - scripts/stack-conformance.sh - scripts/stack-conformance-selection.mjs - scripts/stack-eql-conformance.mjs + - scripts/stack-conformance-evidence.mjs + - scripts/stack-lifecycle-conformance.mjs - Cargo.toml - Cargo.lock - .cargo/** @@ -270,6 +274,17 @@ jobs: echo "RLIMIT_MEMLOCK: $(ulimit -l) KiB" mise run go:test + - name: Required lifecycle and key-I/O conformance + env: + STACK_CONFORMANCE_REPORT_DIR: ${{ runner.temp }}/lifecycle-conformance + run: mise run test:conformance:lifecycle + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: lifecycle-conformance + path: ${{ runner.temp }}/lifecycle-conformance/*.json + if-no-files-found: error + - name: Required EQL and JSON conformance env: STACK_CONFORMANCE_REPORT_DIR: ${{ runner.temp }}/eql-conformance-fast diff --git a/conformance/stack-encrypt/README.md b/conformance/stack-encrypt/README.md index cd6f9dfb0..3c5256c06 100644 --- a/conformance/stack-encrypt/README.md +++ b/conformance/stack-encrypt/README.md @@ -29,6 +29,14 @@ 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. +## Lifecycle, cleanup and key I/O + +The [lifecycle contract](lifecycle-contract.md) maps native/Go ownership, +cancellation, partial-output cleanup, diagnostics, validation-before-I/O and +batching evidence. Run `mise run test:conformance:lifecycle` on a supported Unix +host with the normal and deterministic guests built. Reports retain known +cleanup gaps and unobserved behavior alongside the passing requirements. + ## EQL and JSON operations The [EQL/JSON contract](eql-json-contract.md) enrolls resolver capabilities, diff --git a/conformance/stack-encrypt/coverage.md b/conformance/stack-encrypt/coverage.md index d24dd6f03..6e6453720 100644 --- a/conformance/stack-encrypt/coverage.md +++ b/conformance/stack-encrypt/coverage.md @@ -122,3 +122,17 @@ reports make enrollment observable. This consolidates data and requirements; it does not replace adapter tests or claim whole-database authentication. Fresh JSON artifact exchange, released-version matrices and Python/TypeScript runners remain open. + +## Lifecycle checkpoint + +The [lifecycle contract](lifecycle-contract.md) enrolls native pending/KMS tests, +heap observations, guest ABI cleanup, Go cancellation/memory tests and public SDK +diagnostics. New probes cover unpolled and in-flight drops, provider failure, +cleanup after a late authenticated type failure, and chunk concurrency/drop. +Existing runtime/read/context vectors retain their provider-call counters. + +This adds required execution and explicit observation limits; it removes no +backend-specific tests. The known Go post-decryption shadow-stack gap and native +heap-observer exclusions stay visible in reports. Logical batching is distinct +from downstream chunking and unobserved live HTTP behavior. Python and TypeScript +lifecycle evidence is pending. diff --git a/conformance/stack-encrypt/lifecycle-contract.md b/conformance/stack-encrypt/lifecycle-contract.md new file mode 100644 index 000000000..4f0d811e5 --- /dev/null +++ b/conformance/stack-encrypt/lifecycle-contract.md @@ -0,0 +1,107 @@ +# Lifecycle, cleanup and key-I/O conformance + +`lifecycle-v1` enrolls observable native Rust and Go WASI behavior. The +[index](v1/lifecycle-profiles.json) names existing compiled tests and focused +new lifetime probes. These are backend-specific observations, not another +semantic value corpus and not a promise to erase every plaintext copy in a +process. Python and TypeScript are explicitly not enrolled. + +## Required evidence + +| Requirement | Native evidence | Go evidence and limits | +|---|---|---| +| `lifecycle-ownership-v1` | Actual record operations release fulfilment captures exactly once on success, provider failure and unpolled drop; zipped fulfilments do not share key material. | Client keys are consumed/redacted; explicit close and unreachable-client cleanup release guest memory; calls on a closed client fail. GC cleanup is eventual, so callers should close explicitly. | +| `lifecycle-cancellation-v1` | Dropping a future paused in generation drops the provider future and operation captures; subsequent work on the cipher succeeds. Guest ABI abort spends the handle and drops the operation. | Cancellation before a call sends no request; cancellation between steps or during token fetch leaves unrelated operations usable. A guest step that overruns cancellation closes the client. Discarded late output is wiped. | +| `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue. Existing context cases reject late authentication failures without returning partial output. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | +| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths and has a negative control. Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | +| `lifecycle-diagnostics-v1` | Guest errors discard untrusted foreign cause messages; failed/successful exports do not hand over stale diagnostics. | Structured errors remain available after failures; SDK errors and redacted display/logging expose types or fields rather than plaintext values. | +| `lifecycle-validation-before-io-v1` | Existing runtime/read/context cases count provider calls. Wrong shape, independently detectable context mismatch and oversized descriptors fail before key I/O; lazy assembly stops after local failure. | Host refusals happen before entering the guest, and shared runtime/read cases exercise the uninitialised guest's preflight boundary. Go does not yet expose native provider counters. | +| `lifecycle-logical-batching-v1` | Instrumented `DataKeySource` calls establish one logical generation batch, retrieval grouping by keyset, descriptor ordering and no work for empty batches. | Go uses the same engine, but this profile does not claim independently instrumented Go key-request counts. | +| `lifecycle-provider-chunks-v1` | The actual KMS chunking helper preserves order, handles a final short chunk, fails the whole call on a chunk error, bounds in-flight chunks and releases their futures on cancellation. | This is a native algorithm observation, not a measurement of live HTTP request counts. | + +The drop witnesses belong to fulfilment closures and provider futures in real +record operations. They establish ownership release. They do **not** establish +that arbitrary captured values implement zeroization. The separate heap and +buffer observations provide the narrower byte-cleanup evidence. + +A validation failure detectable only after authentication necessarily retrieves +keys. The profile distinguishes that from shape and context checks that can run +without keys. An error does not imply that no provider request ever occurred. + +## Logical calls and physical requests + +A `Pending` combines requests before awaiting them. For supported assemblies, +generation dispatches one logical `generate_keys` call; retrieval dispatches one +logical `retrieve_keys` call per keyset. Existing instrumented providers observe +those calls and their ordered payloads. + +`StackKms::send_chunked` subsequently splits a logical call according to +`max_keys_per_req` and bounds concurrent chunks by `max_concurrent_reqs`. +The chunk tests observe this helper with controlled callbacks: ten inputs with +chunk size three and concurrency two start only two three-item chunks while +both are pending; dropping the aggregate releases both. This is distinct from +“one HTTP request per batch.” Live service limits, retries and transport timing +are not inferred from the logical counters. + +## Limits carried in every report + +These entries in the index are limitations, not successful requirements: + +- **`go-post-decrypt-shadow-stack` — known gap.** + `TestPlaintextDoesNotRemainInGuestMemoryAfterDecrypt` in + `languages/golang/encrypt/roundtrip_test.go` is explicitly skipped because + decrypted plaintext can remain in the WASM shadow stack in underlying cipher + temporaries. It is not enrolled as passing evidence. The post-encryption scan + cannot establish the post-decryption guarantee. +- **`native-heap-observer-exclusions` — limited observation.** + `packages/stack-encrypt/tests/heap_hygiene.rs` watches marked frees on the + executing thread. Its existing observer excludes a named upstream aws-lc + sealing frame by backtrace and reports excluded frees. It scrubs stack residue + before observing heap frees; it does not prove stack erasure. Its JSON query + paths use borrowed/static path text because owned path copies have separate + cleanup limitations. The lifecycle runner preserves the observer's output. +- **`caller-owned-host-values` — outside the guarantee.** Caller-owned strings, + bytes, objects, returned plaintext, aliases and copies remain the caller's + responsibility. Ownership transfer or protected borrowing must be stated by + each public API. Cancellation cannot retract work already accepted by a remote + service, and dropping a provider future does not prove remote cancellation. +- **`live-provider-http-chunking` — not observed.** Hermetic tests count logical + provider calls and chunk callbacks. They do not attest to actual ZeroKMS + request counts, retry behavior or remote cleanup. +- **`python-typescript-lifecycle` — not enrolled.** Their backends, cancellation + models, host mappings and extension-owned buffers need their own evidence. + +The current required lane supports Linux and macOS. Its native heap observer +requires Unix; unsupported hosts fail explicitly rather than silently skipping +that evidence. This is not Windows lifecycle certification. Memory locking under +OS resource limits, whole-process heap scans, other threads, compiler/register +residue and arbitrary caller-supplied sinks are outside the enrolled guarantees. +Existing broader platform, Miri, fuzz, live and locking tests remain in place. + +## Running and extending the profile + +From the repository root: + +```sh +mise run wasm:guest:build +mise run wasm:guest:build:deterministic +mise run test:conformance:lifecycle +``` + +No CipherStash credentials or database are required. Some Go cancellation tests +start local HTTP stubs. The runner shares inventory/execution checks with EQL +conformance: every enrolled native test must be compiled, selected and not +ignored, and every Go test must pass with no skipped subtests. Fresh Go tests +bypass the test cache. Required fixture-update variables are rejected. + +The required `wasi-check` CI job runs the task and uploads its report, failing +if the report is absent. Reports identify the engine, platform/architecture, +lockfile, guest hashes, enrollment, executed evidence and known limitations. +Set `STACK_CONFORMANCE_REPORT_DIR` locally to retain the same JSON report. + +Add evidence to the existing requirement where possible. Keep language-specific +lifetime probes and implementation regressions: a shared semantic round-trip +cannot replace them. Do not claim a stronger cleanup guarantee by removing a +limitation or accepting a skipped test; supply executable evidence on the +backend being claimed. Secure-buffer adoption and key-copy removal remain +implementation work, distinct from this inventory. diff --git a/conformance/stack-encrypt/v1/lifecycle-profiles.json b/conformance/stack-encrypt/v1/lifecycle-profiles.json new file mode 100644 index 000000000..044210a27 --- /dev/null +++ b/conformance/stack-encrypt/v1/lifecycle-profiles.json @@ -0,0 +1,249 @@ +{ + "version": 1, + "suites": { + "pending": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "features": ["--all-features"], + "lib": true + }, + "lifecycle": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "features": ["--all-features"], + "binary": "lifecycle_conformance" + }, + "heap": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "features": ["--all-features"], + "binary": "heap_hygiene", + "success_output": "immediate" + }, + "runtime": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "features": ["--all-features"], + "binary": "runtime_conformance" + }, + "reads": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "features": ["--all-features"], + "binary": "read_conformance" + }, + "contexts": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "features": ["--all-features"], + "binary": "context_conformance" + }, + "guest-abi": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-guest-abi", + "features": ["--all-features"], + "lib": true + }, + "kms": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-kms", + "features": ["--all-features"], + "lib": true + }, + "go": { + "language": "go", + "lane": "fast", + "package": "./encrypt" + }, + "go-memory": { + "language": "go", + "lane": "fast", + "package": "./internal/guest" + }, + "go-sdk": { + "language": "go", + "lane": "fast", + "package": "./encrypt/gensupport" + } + }, + "requirements": [ + { + "id": "lifecycle-ownership-v1", + "evidence": { + "lifecycle": [ + "dropping_unpolled_work_releases_captures_without_key_io", + "success_and_provider_failure_release_captures_once" + ], + "pending": [ + "target::pending::tests::zipped_fulfilments_never_share_key_material" + ], + "go": [ + "TestClientKeyIsConsumedAndNeverPrinted", + "TestCloseStopsTheCleanup", + "TestUnreachableClientIsReleased", + "TestClosedClientIsState" + ] + } + }, + { + "id": "lifecycle-cancellation-v1", + "evidence": { + "lifecycle": ["cancelling_in_flight_work_drops_provider_and_captures"], + "guest-abi": [ + "step::tests::an_aborted_operation_is_dropped_and_its_handle_spent", + "step::tests::the_wrong_number_of_responses_is_refused_and_drops_the_operation" + ], + "go": [ + "TestACallerThatGivesUpBetweenStepsAbortsTheOperation", + "TestACallWhoseContextHasEndedNeverStarts", + "TestACallCancelledDuringATokenFetchFailsOnlyItself", + "TestAStepThatRunsOnAfterItsContextEndsClosesTheClient", + "TestACallThatSucceedsAfterItsContextEndsFailsWithIt", + "TestAnAbortedInitLeavesNoKeyInGuestMemory" + ] + } + }, + { + "id": "lifecycle-partial-output-v1", + "evidence": { + "heap": ["a_late_read_type_failure_leaves_no_plaintext_on_the_heap"], + "contexts": ["shared_context_boundaries"], + "go": [ + "TestContextBoundaries", + "TestInterruptedResponseBodyIsTransport", + "TestACallThatSucceedsAfterItsContextEndsFailsWithIt" + ], + "go-sdk": ["TestDecryptErrorsHoldNoPlaintext"] + } + }, + { + "id": "lifecycle-buffer-cleanup-v1", + "evidence": { + "heap": [ + "a_record_encrypted_from_a_value_frame_leaves_no_plaintext_on_the_heap", + "a_record_decrypted_with_a_nested_field_leaves_no_plaintext_on_the_heap", + "a_json_document_read_from_a_frame_leaves_no_plaintext_on_the_heap", + "the_spy_sees_an_unwiped_free" + ], + "guest-abi": [ + "buffers::tests::wipe_all_leaves_no_live_buffer", + "last_error::tests::clearing_wipes_and_frees_the_registered_buffer", + "step::tests::pending_requests_are_handed_over_once_and_replaced_wiped", + "value::tests::text_is_wiped_on_drop" + ], + "go": [ + "TestRequestBodyWipesOnClose", + "TestRequestBodyOutlivesTheRoundTrip", + "TestARefusedRequestIsWiped", + "TestPlaintextDoesNotRemainInGuestMemory" + ], + "go-memory": [ + "TestHeapMemoryWipesWhatItAbandons", + "TestGuestMemoryIsFreedOnRuntimeClose", + "TestClientKeyIsOpaqueAndWipes", + "TestMemoryOutlivesACallClosedDuringAHostImport" + ] + } + }, + { + "id": "lifecycle-diagnostics-v1", + "evidence": { + "guest-abi": [ + "last_error::tests::a_foreign_cause_nobody_vouches_for_gives_no_message", + "call::tests::a_failure_never_reports_an_earlier_calls_error", + "call::tests::a_failure_then_a_success_leaves_nothing_to_hand_over" + ], + "go-sdk": [ + "TestDecryptErrorsHoldNoPlaintext", + "TestRedactedHidesSealedFieldsAndSortsShown", + "TestRedactedLogHidesSealedFields" + ], + "go-memory": [ + "TestAFailureCarriesItsDiagnostic", + "TestATrapFetchingTheDetailIsATrap" + ] + } + }, + { + "id": "lifecycle-validation-before-io-v1", + "evidence": { + "runtime": ["shared_record_runtime_profile"], + "reads": ["shared_record_read_profile"], + "pending": [ + "target::pending::tests::an_over_long_descriptor_is_refused_before_any_call", + "target::pending::tests::a_lazy_column_stops_building_after_a_local_failure" + ], + "go": [ + "TestRuntimeConformance", + "TestReadConformance", + "TestMismatchedPlanIsRefusedBeforeTheGuest" + ] + } + }, + { + "id": "lifecycle-logical-batching-v1", + "evidence": { + "runtime": ["shared_record_runtime_profile"], + "pending": [ + "target::pending::tests::one_pending_asking_for_many_keys_is_one_call", + "target::pending::tests::all_merges_a_column_into_one_call_preserving_order", + "target::pending::tests::generate_and_retrieve_are_one_call_each", + "target::pending::tests::retrieves_group_by_keyset_and_return_in_request_order", + "target::pending::tests::all_of_nothing_resolves_empty_without_io", + "target::pending::tests::dispatch_forwards_each_requests_descriptor_in_order" + ] + } + }, + { + "id": "lifecycle-provider-chunks-v1", + "evidence": { + "kms": [ + "futures::tests::test_keeps_the_same_order", + "futures::tests::test_a_failing_chunk_fails_the_whole_call", + "futures::tests::test_works_when_chunks_dont_divide_nicely", + "futures::tests::concurrency_limit_bounds_chunks_and_drop_releases_them" + ] + } + } + ], + "limitations": [ + { + "id": "go-post-decrypt-shadow-stack", + "status": "known-gap", + "evidence": "languages/golang/encrypt/roundtrip_test.go::TestPlaintextDoesNotRemainInGuestMemoryAfterDecrypt" + }, + { + "id": "native-heap-observer-exclusions", + "status": "limited-observation", + "evidence": "packages/stack-encrypt/tests/heap_hygiene.rs" + }, + { + "id": "caller-owned-host-values", + "status": "outside-guarantee" + }, + { + "id": "live-provider-http-chunking", + "status": "not-observed" + }, + { + "id": "python-typescript-lifecycle", + "status": "not-enrolled" + } + ] +} diff --git a/docs/plans/2026-10-31-stack-encrypt-conformance.md b/docs/plans/2026-10-31-stack-encrypt-conformance.md index a633d0320..60ba8a935 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, read and context-composition slices implemented; existing value-kind, query-operator and context-field fixtures enrolled. The complete contract is not frozen. +Date: 2026-10-31. Status: scalar, declaration, initial runtime, read, context, EQL/JSON and lifecycle slices implemented; existing value-kind, query-operator and context-field fixtures enrolled. Wider boundaries, artifact exchange, Python/TypeScript enrollment and release gates remain. 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 @@ -227,7 +227,7 @@ and the wider fresh artifact exchange remain separate planned work. ## EQL/JSON operation checkpoint -Implemented the six requirements in +Implemented the EQL/JSON requirements in `conformance/stack-encrypt/eql-json-contract.md`, enrolling the existing engine, EQL binding, Go SDK and independent PostgreSQL suites. A shared corpus replaces duplicate write/refusal tables; existing typed, generated, property and @@ -240,3 +240,18 @@ and batching requirements. Python/TypeScript enrollment and the broader fresh artifact/version exchange remain separate delivery slices. The EQL/JSON profile does not complete the refactoring track or introduce the future binding/search scope split. + +## Lifecycle and key-I/O checkpoint + +Implemented `lifecycle-v1` with eight named requirements and required native/Go +CI evidence. Reuses the pending, KMS chunking, heap, ABI, cancellation, memory, +diagnostic and shared runtime/read suites. New observations cover unpolled and +in-flight operation drops, provider failure, cleanup of decoded prefixes on +late read errors, and bounded chunk concurrency/cancellation. The runner shares +inventory and no-skip checks with EQL conformance. + +Reports distinguish passing requirements from known residue, observer exclusions, +caller-owned values, unobserved live-provider behavior and unenrolled languages. +This does not complete secure-buffer/key-copy refactoring. Next conformance +slices remain value/refusal/raw-frame gaps, broader fresh artifact exchange, +Python/TypeScript enrollment and explicit release gates/version combinations. diff --git a/mise.toml b/mise.toml index 07a2603aa..265975723 100644 --- a/mise.toml +++ b/mise.toml @@ -444,3 +444,7 @@ mise -C packages/eql run clean mise -C packages/eql run build node scripts/stack-eql-conformance.mjs postgres """ + +[tasks."test:conformance:lifecycle"] +description = "Required native/Go lifecycle, cleanup and key-I/O evidence (Unix; build the normal and deterministic guests first)" +run = "node scripts/stack-lifecycle-conformance.mjs" diff --git a/packages/stack-encrypt/tests/heap_hygiene.rs b/packages/stack-encrypt/tests/heap_hygiene.rs index 001b42c73..e229d412d 100644 --- a/packages/stack-encrypt/tests/heap_hygiene.rs +++ b/packages/stack-encrypt/tests/heap_hygiene.rs @@ -338,6 +338,37 @@ fn a_record_decrypted_with_a_nested_field_leaves_no_plaintext_on_the_heap() { }); } +/// A valid first row is followed by an authenticated value of the wrong type. +/// The failed batch returns no output and releases its decoded prefix cleanly. +#[test] +fn a_late_read_type_failure_leaves_no_plaintext_on_the_heap() { + let rt = runtime(); + let cipher = rt.block_on(common::stack_cipher()); + let keyset = cipher.default_keyset(); + let writer = record::plan(object(vec![spec("value", &["c"], None)]).risky_ref()).unwrap(); + let reader = + record::plan(object(vec![spec("value", &["c"], Some("string"))]).risky_ref()).unwrap(); + let source = frame(vec![ + object(vec![("value", frame(marked("first-row")))]), + object(vec![("value", frame(true))]), + ]); + let sealed = rt.block_on(async { record::encrypt(&keyset, source, &writer).unwrap().await }); + let sealed = sealed.unwrap(); + watched("late authenticated read type failure", || { + let result = rt.block_on(async { + record::decrypt(Scope::Client(&cipher), sealed, &reader, None) + .unwrap() + .await + }); + assert!(matches!( + result, + Err(stack_encrypt::Error::Plan( + stack_encrypt::plan::PlanError::FieldType { .. } + )) + )); + }); +} + #[test] fn a_query_term_leaves_no_plaintext_on_the_heap() { let rt = runtime(); diff --git a/packages/stack-encrypt/tests/lifecycle_conformance.rs b/packages/stack-encrypt/tests/lifecycle_conformance.rs new file mode 100644 index 000000000..5aef1a893 --- /dev/null +++ b/packages/stack-encrypt/tests/lifecycle_conformance.rs @@ -0,0 +1,181 @@ +//! Observe the lifetime of pending work independently of the provider's result. +//! Drop witnesses prove ownership release, not erasure of arbitrary host values. +#![cfg(feature = "dynamic")] + +mod common; + +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::future::IntoFuture; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Waker}; + +use common::frames::frame; +use stack_encrypt::dynamic::{record, ValueFrame}; +use stack_encrypt::{Pending, StackCipher, StackCipherText}; +use stack_kms::{ + DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyError, + GenerateKeyPayload, IdentifiedBy, IndexKey, IndexKeySource, RetrieveKeyPayload, + UnverifiedContext, +}; +use uuid::Uuid; + +#[derive(Default)] +struct Observations { + block: AtomicBool, + fail: AtomicBool, + batches: Mutex>, + provider_drops: Arc, +} + +struct DropWitness(Arc); +impl Drop for DropWitness { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +struct Source { + inner: FakeDataKeySource, + observed: Arc, +} +impl DataKeySource for Source { + async fn generate_keys( + &self, + payloads: Vec>, + keyset: Option, + context: Option>, + ) -> Result, stack_kms::Error> { + self.observed.batches.lock().unwrap().push(payloads.len()); + let _owned_request = DropWitness(self.observed.provider_drops.clone()); + if self.observed.block.load(Ordering::SeqCst) { + std::future::pending::<()>().await; + } + if self.observed.fail.load(Ordering::SeqCst) { + return Err(GenerateKeyError::Unauthorized.into()); + } + self.inner.generate_keys(payloads, keyset, context).await + } + + async fn retrieve_keys( + &self, + payloads: Vec>, + keyset: Option, + context: Option<&UnverifiedContext>, + ) -> Result, stack_kms::Error> { + self.inner.retrieve_keys(payloads, keyset, context).await + } +} +impl IndexKeySource for Source { + async fn load_index_key( + &self, + keyset: Option, + ) -> Result<(Uuid, IndexKey), stack_kms::Error> { + self.inner.load_index_key(keyset).await + } +} + +async fn cipher() -> (StackCipher, Arc) { + let observed = Arc::new(Observations::default()); + let cipher = StackCipher::builder() + .kms(Source { + inner: FakeDataKeySource::new(), + observed: observed.clone(), + }) + .init() + .await + .unwrap(); + (cipher, observed) +} + +fn plan() -> record::Plan { + record::plan( + &vitaminc_aead_frame::serde::to_vec(&serde_json::json!({ + "value": {"context": ["lifecycle", "value"], "outputs": ["c"], "type": "string"} + })) + .unwrap(), + ) + .unwrap() +} +fn rows(count: usize) -> ValueFrame { + frame(vec![BTreeMap::from([("value", "lifecycle-input")]); count]) +} + +// The witness is owned by the real operation's fulfilment, not by the test's +// future. Dropping it therefore observes release of that fulfilment's captures. +fn observed<'a>( + pending: Pending<'a, StackCipherText, Source>, + drops: Arc, +) -> Pending<'a, StackCipherText, Source> { + let witness = DropWitness(drops); + pending.map(move |output| { + drop(witness); + output + }) +} + +#[tokio::test] +async fn dropping_unpolled_work_releases_captures_without_key_io() { + let (cipher, calls) = cipher().await; + let keyset = cipher.default_keyset(); + let drops = Arc::new(AtomicUsize::new(0)); + let pending = observed( + record::encrypt(&keyset, rows(2), &plan()).unwrap(), + drops.clone(), + ); + assert_eq!(drops.load(Ordering::SeqCst), 0); + assert!(calls.batches.lock().unwrap().is_empty()); + drop(pending); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert!(calls.batches.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn cancelling_in_flight_work_drops_provider_and_captures() { + let (cipher, calls) = cipher().await; + calls.block.store(true, Ordering::SeqCst); + let keyset = cipher.default_keyset(); + let drops = Arc::new(AtomicUsize::new(0)); + let mut future = observed( + record::encrypt(&keyset, rows(3), &plan()).unwrap(), + drops.clone(), + ) + .into_future(); + assert!(future + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + .is_pending()); + assert_eq!(*calls.batches.lock().unwrap(), vec![3]); + assert_eq!(drops.load(Ordering::SeqCst), 0); + assert_eq!(calls.provider_drops.load(Ordering::SeqCst), 0); + drop(future); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert_eq!(calls.provider_drops.load(Ordering::SeqCst), 1); + // Cancellation does not poison the cipher or execute the abandoned work. + calls.block.store(false, Ordering::SeqCst); + record::encrypt(&keyset, rows(1), &plan()) + .unwrap() + .await + .unwrap(); + assert_eq!(*calls.batches.lock().unwrap(), vec![3, 1]); +} + +#[tokio::test] +async fn success_and_provider_failure_release_captures_once() { + for fail in [false, true] { + let (cipher, calls) = cipher().await; + calls.fail.store(fail, Ordering::SeqCst); + let keyset = cipher.default_keyset(); + let drops = Arc::new(AtomicUsize::new(0)); + let result = observed( + record::encrypt(&keyset, rows(4), &plan()).unwrap(), + drops.clone(), + ) + .await; + assert_eq!(result.is_err(), fail); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert_eq!(calls.provider_drops.load(Ordering::SeqCst), 1); + assert_eq!(*calls.batches.lock().unwrap(), vec![4]); + } +} diff --git a/packages/stack-kms/src/futures.rs b/packages/stack-kms/src/futures.rs index 7ecc111ac..bb24224f8 100644 --- a/packages/stack-kms/src/futures.rs +++ b/packages/stack-kms/src/futures.rs @@ -90,4 +90,43 @@ mod tests { assert_eq!(input, output); } + #[test] + fn concurrency_limit_bounds_chunks_and_drop_releases_them() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::task::{Context, Waker}; + + struct InFlight(Arc); + impl Drop for InFlight { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } + } + let active = Arc::new(AtomicUsize::new(0)); + let chunks = Arc::new(Mutex::new(Vec::new())); + let input: Vec<_> = (0..10).collect(); + let mut work = Box::pin(map_async_chunked( + &input, + |chunk| { + let active = active.clone(); + let chunks = chunks.clone(); + async move { + active.fetch_add(1, Ordering::SeqCst); + let _in_flight = InFlight(active); + chunks.lock().unwrap().push(chunk.to_vec()); + std::future::pending::, ()>>().await + } + }, + 3, + 2, + )); + assert!(work + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + .is_pending()); + assert_eq!(active.load(Ordering::SeqCst), 2); + assert_eq!(*chunks.lock().unwrap(), vec![vec![0, 1, 2], vec![3, 4, 5]]); + drop(work); + assert_eq!(active.load(Ordering::SeqCst), 0); + } } diff --git a/scripts/__tests__/stack-lifecycle-conformance.test.mjs b/scripts/__tests__/stack-lifecycle-conformance.test.mjs new file mode 100644 index 000000000..42b05ba3a --- /dev/null +++ b/scripts/__tests__/stack-lifecycle-conformance.test.mjs @@ -0,0 +1,122 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + checkRustInventory, + selections, +} from '../stack-conformance-evidence.mjs' +import { REPO_ROOT } from './lib/repo-root.mjs' +import { readWorkflow } from './lib/workflows.mjs' + +const read = (path) => readFileSync(join(REPO_ROOT, path), 'utf8') +const index = JSON.parse( + read('conformance/stack-encrypt/v1/lifecycle-profiles.json'), +) + +describe('required lifecycle evidence', () => { + it('enrolls each guarantee and keeps limitations outside passing evidence', () => { + expect(index.requirements.map((r) => r.id)).toEqual([ + 'lifecycle-ownership-v1', + 'lifecycle-cancellation-v1', + 'lifecycle-partial-output-v1', + 'lifecycle-buffer-cleanup-v1', + 'lifecycle-diagnostics-v1', + 'lifecycle-validation-before-io-v1', + 'lifecycle-logical-batching-v1', + 'lifecycle-provider-chunks-v1', + ]) + const contract = read('conformance/stack-encrypt/lifecycle-contract.md') + for (const r of index.requirements) expect(contract).toContain(r.id) + const selected = selections(index, 'fast', 'all') + expect([...selected.keys()].sort()).toEqual( + Object.keys(index.suites).sort(), + ) + expect( + selected + .get('go') + .tests.has('TestPlaintextDoesNotRemainInGuestMemoryAfterDecrypt'), + ).toBe(false) + expect(index.limitations).toContainEqual( + expect.objectContaining({ + id: 'go-post-decrypt-shadow-stack', + status: 'known-gap', + }), + ) + expect(index.limitations).toContainEqual( + expect.objectContaining({ + id: 'python-typescript-lifecycle', + status: 'not-enrolled', + }), + ) + for (const limitation of index.limitations) + expect(contract).toContain(limitation.id) + }) + + it('checks compiled library names as strictly as integration binaries', () => { + const suite = { + package: 'engine', + lib: true, + lane: 'fast', + tests: new Set(['nested::required']), + } + const inventory = { + 'rust-suites': { + engine: { + status: 'listed', + testcases: { + 'nested::required': { + ignored: false, + 'filter-match': { status: 'matches' }, + }, + }, + }, + }, + } + expect(() => checkRustInventory(inventory, suite)).not.toThrow() + delete inventory['rust-suites'].engine.testcases['nested::required'] + expect(() => checkRustInventory(inventory, suite)).toThrow( + 'Missing or incorrectly ignored', + ) + }) + + it('preserves package boundaries and fails on a missing requirement', () => { + const selected = selections(index, 'fast', 'go') + expect([...selected.values()].map((s) => s.package).sort()).toEqual([ + './encrypt', + './encrypt/gensupport', + './internal/guest', + ]) + const changed = structuredClone(index) + changed.requirements[0].evidence.go = [] + expect(() => selections(changed, 'fast', 'go')).toThrow('Invalid tests') + }) + + it('runs the lifecycle task in required CI and retains its report', () => { + const workflow = readWorkflow('.github/workflows/tests-golang.yml') + const job = workflow.jobs['wasi-check'] + expect(job.env.MISE_ENV).toBe('test') + const step = job.steps.find( + (s) => s.run === 'mise run test:conformance:lifecycle', + ) + expect(step).toBeDefined() + expect(step.if).toBeUndefined() + expect(step['continue-on-error']).toBeUndefined() + expect(step.env.STACK_CONFORMANCE_REPORT_DIR).toBeTruthy() + expect( + job.steps.some( + (s) => + s.uses?.startsWith('actions/upload-artifact@') && + s.with?.name === 'lifecycle-conformance' && + s.with['if-no-files-found'] === 'error', + ), + ).toBe(true) + for (const event of ['push', 'pull_request']) { + for (const path of [ + 'scripts/stack-conformance-evidence.mjs', + 'scripts/stack-lifecycle-conformance.mjs', + 'conformance/stack-encrypt/**', + ]) + expect(workflow.on[event].paths).toContain(path) + } + }) +}) diff --git a/scripts/stack-conformance-evidence.mjs b/scripts/stack-conformance-evidence.mjs new file mode 100644 index 000000000..4ca9bcd7f --- /dev/null +++ b/scripts/stack-conformance-evidence.mjs @@ -0,0 +1,192 @@ +// Execution and non-vacuity checks shared by conformance profiles. +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +export const read = (path) => readFileSync(join(root, path), 'utf8') +export const hash = (path) => + createHash('sha256') + .update(readFileSync(join(root, path))) + .digest('hex') + +export function selections(index, lane, language) { + if ( + index.version !== 1 || + !['fast', 'postgres'].includes(lane) || + !['rust', 'go', 'all'].includes(language) + ) { + throw new Error('Unsupported conformance version, lane or language') + } + const selected = new Map() + const ids = new Set() + for (const requirement of index.requirements) { + if (!/^[a-z][a-z0-9-]+-v1$/.test(requirement.id) || ids.has(requirement.id)) + throw new Error('Invalid or repeated requirement ID') + ids.add(requirement.id) + if (!Object.keys(requirement.evidence).length) + throw new Error(`Empty evidence: ${requirement.id}`) + for (const [id, tests] of Object.entries(requirement.evidence)) { + const suite = index.suites[id] + if ( + !suite || + !['rust', 'go'].includes(suite.language) || + !['fast', 'postgres'].includes(suite.lane) + ) + throw new Error(`Invalid suite: ${id}`) + if ( + !tests.length || + new Set(tests).size !== tests.length || + tests.some( + (name) => + !(suite.language === 'go' ? /^Test\w+$/ : /^[a-zA-Z0-9_:]+$/).test( + name, + ), + ) + ) + throw new Error(`Invalid tests: ${id}`) + if ( + suite.lane !== lane || + (language !== 'all' && language !== suite.language) + ) + continue + if (!selected.has(id)) selected.set(id, { ...suite, tests: new Set() }) + for (const name of tests) selected.get(id).tests.add(name) + } + } + if (!selected.size) throw new Error('No EQL conformance evidence selected') + return selected +} + +export function checkRustInventory(inventory, suite) { + const binary = + inventory['rust-suites'][ + suite.lib ? suite.package : `${suite.package}::${suite.binary}` + ] + if (binary?.status !== 'listed') + throw new Error(`Missing compiled binary: ${suite.binary}`) + for (const name of suite.tests) { + const test = binary.testcases[name] + if ( + !test || + test.ignored !== (suite.lane === 'postgres') || + test['filter-match'].status !== 'matches' + ) { + throw new Error( + `Missing or incorrectly ignored compiled test: ${suite.binary}::${name}`, + ) + } + } +} + +export function checkGoResults(output, tests) { + const passed = new Set() + for (const line of output.trim().split('\n')) { + if (!line) continue + const event = JSON.parse(line) + if (event.Action === 'skip' || event.Action === 'fail') + throw new Error( + `Go conformance ${event.Action}: ${event.Test ?? event.Package}`, + ) + if (event.Action === 'pass' && event.Test) passed.add(event.Test) + } + for (const name of tests) + if (!passed.has(name)) throw new Error(`Go test did not pass: ${name}`) +} + +export function checkEnvironment(env) { + for (const name of [ + 'STACK_CONFORMANCE_UPDATE', + 'STACK_ENCRYPT_UPDATE_FIXTURES', + 'STACK_ENCRYPT_UPDATE_JSON_TREE_VECTORS', + ]) { + if (Object.hasOwn(env, name)) + throw new Error(`Conformance cannot update fixtures: ${name}`) + } +} + +export function command(program, args, { capture = false, ...options } = {}) { + const result = spawnSync(program, args, { + cwd: root, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + stdio: capture ? ['ignore', 'pipe', 'inherit'] : 'inherit', + ...options, + }) + if (result.error) throw result.error + if (result.status !== 0) { + if (capture && result.stdout) process.stderr.write(result.stdout) + throw new Error(`${program} failed (${result.status})`) + } + return result.stdout +} + +export function cargo(args, capture = false, workspace = '.') { + return command('mise', ['x', '--env', 'test', '--', 'cargo', ...args], { + capture, + cwd: join(root, workspace), + }) +} + +export function runSuite(suite, { wholeBinary = false } = {}) { + const lane = suite.lane + if (suite.language === 'rust') { + const common = [ + '--locked', + '--manifest-path', + 'Cargo.toml', + '-p', + suite.package, + ...(suite.lib ? ['--lib'] : ['--test', suite.binary]), + ...suite.features, + '--run-ignored', + lane === 'postgres' ? 'only' : 'default', + ] + if (!wholeBinary) + common.push( + '-E', + [...suite.tests].map((name) => `test(=${name})`).join(' | '), + ) + const inventory = JSON.parse( + cargo( + ['nextest', 'list', ...common, '--message-format', 'json'], + true, + suite.workspace, + ), + ) + checkRustInventory(inventory, suite) + // EQL retains whole binaries. Lifecycle selects enrolled tests from large + // unit suites. PostgreSQL tests are intentionally ignored in the fast lane. + cargo( + [ + 'nextest', + 'run', + ...common, + '--success-output', + suite.success_output ?? (lane === 'postgres' ? 'immediate' : 'never'), + ], + false, + suite.workspace, + ) + } else { + const pattern = `^(${[...suite.tests].sort().join('|')})$` + const output = command( + 'go', + [ + 'test', + '-C', + 'languages/golang', + '-count=1', + '-json', + '-run', + pattern, + suite.package ?? './encrypt', + ], + { capture: true, env: { ...process.env, CGO_ENABLED: '0' } }, + ) + checkGoResults(output, suite.tests) + console.log(`Passed ${suite.tests.size} required Go tests; no skips`) + } +} diff --git a/scripts/stack-eql-conformance.mjs b/scripts/stack-eql-conformance.mjs index 389dbb566..e19811b0b 100644 --- a/scripts/stack-eql-conformance.mjs +++ b/scripts/stack-eql-conformance.mjs @@ -1,133 +1,25 @@ #!/usr/bin/env node // Named evidence over the existing suites. Missing, ignored and skipped tests // are failures; a successful empty selection is not conformance. -import { spawnSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { + cargo, + checkEnvironment, + command, + hash, + read, + runSuite, + selections, +} from './stack-conformance-evidence.mjs' -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') -const read = (path) => readFileSync(join(root, path), 'utf8') -const hash = (path) => - createHash('sha256') - .update(readFileSync(join(root, path))) - .digest('hex') - -export function selections(index, lane, language) { - if ( - index.version !== 1 || - !['fast', 'postgres'].includes(lane) || - !['rust', 'go', 'all'].includes(language) - ) { - throw new Error('Unsupported EQL conformance version, lane or language') - } - const selected = new Map() - const ids = new Set() - for (const requirement of index.requirements) { - if (!/^[a-z][a-z0-9-]+-v1$/.test(requirement.id) || ids.has(requirement.id)) - throw new Error('Invalid or repeated requirement ID') - ids.add(requirement.id) - if (!Object.keys(requirement.evidence).length) - throw new Error(`Empty evidence: ${requirement.id}`) - for (const [id, tests] of Object.entries(requirement.evidence)) { - const suite = index.suites[id] - if ( - !suite || - !['rust', 'go'].includes(suite.language) || - !['fast', 'postgres'].includes(suite.lane) - ) - throw new Error(`Invalid suite: ${id}`) - if ( - !tests.length || - new Set(tests).size !== tests.length || - tests.some( - (name) => - !(suite.language === 'go' ? /^Test\w+$/ : /^[a-zA-Z0-9_:]+$/).test( - name, - ), - ) - ) - throw new Error(`Invalid tests: ${id}`) - if ( - suite.lane !== lane || - (language !== 'all' && language !== suite.language) - ) - continue - if (!selected.has(id)) selected.set(id, { ...suite, tests: new Set() }) - for (const name of tests) selected.get(id).tests.add(name) - } - } - if (!selected.size) throw new Error('No EQL conformance evidence selected') - return selected -} - -export function checkRustInventory(inventory, suite) { - const binary = inventory['rust-suites'][`${suite.package}::${suite.binary}`] - if (binary?.status !== 'listed') - throw new Error(`Missing compiled binary: ${suite.binary}`) - for (const name of suite.tests) { - const test = binary.testcases[name] - if ( - !test || - test.ignored !== (suite.lane === 'postgres') || - test['filter-match'].status !== 'matches' - ) { - throw new Error( - `Missing or incorrectly ignored compiled test: ${suite.binary}::${name}`, - ) - } - } -} - -export function checkGoResults(output, tests) { - const passed = new Set() - for (const line of output.trim().split('\n')) { - if (!line) continue - const event = JSON.parse(line) - if (event.Action === 'skip' || event.Action === 'fail') - throw new Error( - `Go conformance ${event.Action}: ${event.Test ?? event.Package}`, - ) - if (event.Action === 'pass' && event.Test) passed.add(event.Test) - } - for (const name of tests) - if (!passed.has(name)) throw new Error(`Go test did not pass: ${name}`) -} - -export function checkEnvironment(env) { - for (const name of [ - 'STACK_CONFORMANCE_UPDATE', - 'STACK_ENCRYPT_UPDATE_FIXTURES', - 'STACK_ENCRYPT_UPDATE_JSON_TREE_VECTORS', - ]) { - if (Object.hasOwn(env, name)) - throw new Error(`Conformance cannot update fixtures: ${name}`) - } -} - -function command(program, args, { capture = false, ...options } = {}) { - const result = spawnSync(program, args, { - cwd: root, - encoding: 'utf8', - maxBuffer: 64 * 1024 * 1024, - stdio: capture ? ['ignore', 'pipe', 'inherit'] : 'inherit', - ...options, - }) - if (result.error) throw result.error - if (result.status !== 0) { - if (capture && result.stdout) process.stderr.write(result.stdout) - throw new Error(`${program} failed (${result.status})`) - } - return result.stdout -} - -function cargo(args, capture = false, workspace = '.') { - return command('mise', ['x', '--env', 'test', '--', 'cargo', ...args], { - capture, - cwd: join(root, workspace), - }) -} +export { + checkEnvironment, + checkGoResults, + checkRustInventory, + selections, +} from './stack-conformance-evidence.mjs' function metadata(workspace) { return JSON.parse( @@ -222,59 +114,7 @@ export function main(args = process.argv.slice(2)) { } for (const [id, suite] of selected) { console.log(`EQL conformance: ${id} (${suite.tests.size} required tests)`) - if (suite.language === 'rust') { - const common = [ - '--locked', - '--manifest-path', - 'Cargo.toml', - '-p', - suite.package, - '--test', - suite.binary, - ...suite.features, - '--run-ignored', - lane === 'postgres' ? 'only' : 'default', - ] - const inventory = JSON.parse( - cargo( - ['nextest', 'list', ...common, '--message-format', 'json'], - true, - suite.workspace, - ), - ) - checkRustInventory(inventory, suite) - // Run the whole original suite, retaining unindexed regression and property - // tests. PostgreSQL tests are intentionally #[ignore] outside this lane. - cargo( - [ - 'nextest', - 'run', - ...common, - '--success-output', - lane === 'postgres' ? 'immediate' : 'never', - ], - false, - suite.workspace, - ) - } else { - const pattern = `^(${[...suite.tests].sort().join('|')})$` - const output = command( - 'go', - [ - 'test', - '-C', - 'languages/golang', - '-count=1', - '-json', - '-run', - pattern, - './encrypt', - ], - { capture: true, env: { ...process.env, CGO_ENABLED: '0' } }, - ) - checkGoResults(output, suite.tests) - console.log(`Passed ${suite.tests.size} required Go tests; no skips`) - } + runSuite(suite, { wholeBinary: true }) report.evidence[id] = [...suite.tests].sort() } console.log(JSON.stringify(report, null, 2)) diff --git a/scripts/stack-lifecycle-conformance.mjs b/scripts/stack-lifecycle-conformance.mjs new file mode 100644 index 000000000..a50113bc4 --- /dev/null +++ b/scripts/stack-lifecycle-conformance.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + cargo, + checkEnvironment, + hash, + read, + runSuite, + selections, +} from './stack-conformance-evidence.mjs' + +export function main(args = process.argv.slice(2)) { + const [language = 'all'] = args + if (args.length > 1) + throw new Error('Usage: stack-lifecycle-conformance.mjs [rust|go|all]') + checkEnvironment(process.env) + if (!['linux', 'darwin'].includes(process.platform)) + throw new Error( + 'The current lifecycle profile requires a Unix heap observer; this platform is not enrolled', + ) + const path = 'conformance/stack-encrypt/v1/lifecycle-profiles.json' + const index = JSON.parse(read(path)) + const selected = selections(index, 'fast', language) + process.env.STACK_CONFORMANCE_REQUIRED = '1' + delete process.env.STACK_CONFORMANCE_INPUT + delete process.env.STACK_CONFORMANCE_OUTPUT + const metadata = JSON.parse( + cargo(['metadata', '--locked', '--no-deps', '--format-version', '1'], true), + ) + const report = { + profile: 'lifecycle-v1', + language, + platform: process.platform, + architecture: process.arch, + engine: metadata.packages.find((p) => p.name === 'stack-encrypt').version, + enrollment_sha256: hash(path), + lock_sha256: hash('Cargo.lock'), + limitations: index.limitations, + evidence: {}, + } + if (language !== 'rust') { + report.guests = Object.fromEntries( + [ + 'encrypt/wasm/stack_encrypt_guest.wasm', + 'encrypt/testdata/stack_encrypt_guest_deterministic.wasm', + ].map((name) => [name, hash(`languages/golang/${name}`)]), + ) + } + for (const [id, suite] of selected) { + console.log( + `Lifecycle conformance: ${id} (${suite.tests.size} required tests)`, + ) + runSuite(suite) + report.evidence[id] = [...suite.tests].sort() + } + console.log(JSON.stringify(report, null, 2)) + if (process.env.STACK_CONFORMANCE_REPORT_DIR) { + mkdirSync(process.env.STACK_CONFORMANCE_REPORT_DIR, { recursive: true }) + writeFileSync( + join( + process.env.STACK_CONFORMANCE_REPORT_DIR, + `lifecycle-${language}.json`, + ), + `${JSON.stringify(report, null, 2)}\n`, + ) + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) main() From 64f51635c889d0ff42939a42a56327acce1273c9 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 1 Nov 2026 22:10:37 +1100 Subject: [PATCH 04/15] fix(ci): isolate EQL tool setup environment --- .github/workflows/tests-golang.yml | 2 ++ scripts/__tests__/eql-suite-ci.test.mjs | 6 +++++- scripts/__tests__/stack-eql-conformance.test.mjs | 9 +++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests-golang.yml b/.github/workflows/tests-golang.yml index c7f7893e4..8fd23b164 100644 --- a/.github/workflows/tests-golang.yml +++ b/.github/workflows/tests-golang.yml @@ -148,11 +148,13 @@ jobs: # Native EQL conformance uses the EQL workspace's toolchain. Install it # before root setup, so ordinary guest commands retain the root pin. + # Its local DATABASE_URL must not enable database tests in this job. - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 with: version: 2026.4.0 install: true working_directory: packages/eql + env: false cache: true cache_key_prefix: mise-v1-ubuntu-2204 diff --git a/scripts/__tests__/eql-suite-ci.test.mjs b/scripts/__tests__/eql-suite-ci.test.mjs index 526dba64e..faace15f0 100644 --- a/scripts/__tests__/eql-suite-ci.test.mjs +++ b/scripts/__tests__/eql-suite-ci.test.mjs @@ -549,7 +549,11 @@ describe('the shared Rust cache is pointed and saved correctly', () => { // Held non-empty, so a change to `isEqlJob` cannot empty the check. expect(EQL_RUST_CACHE_STEPS.length).toBeGreaterThan(0) const wrong = EQL_RUST_CACHE_STEPS.filter( - ({ step }) => step?.with?.workspaces !== CARGO_WORKSPACE, + ({ step }) => + !String(step?.with?.workspaces ?? '') + .split('\n') + .map((line) => line.split('->')[0].trim()) + .includes(CARGO_WORKSPACE), ).map((entry) => stepLabel(entry, `workspaces: ${entry.step?.with?.workspaces}`), ) diff --git a/scripts/__tests__/stack-eql-conformance.test.mjs b/scripts/__tests__/stack-eql-conformance.test.mjs index cce8a1f94..e6e6c7f47 100644 --- a/scripts/__tests__/stack-eql-conformance.test.mjs +++ b/scripts/__tests__/stack-eql-conformance.test.mjs @@ -153,6 +153,15 @@ describe('required EQL/JSON conformance', () => { it('runs both required lanes in CI and retains reports with SQL and engine identity', () => { const workflow = readWorkflow('.github/workflows/tests-golang.yml') + const eqlSetup = workflow.jobs['wasi-check'].steps.find( + (step) => + step.uses?.startsWith('jdx/mise-action@') && + step.with?.working_directory === 'packages/eql', + ) + // Installing the EQL tools must not export its local DATABASE_URL into + // the hermetic Go job, which has no PostgreSQL service. + expect(eqlSetup).toBeDefined() + expect(eqlSetup.with.env).toBe(false) for (const task of ['test:conformance:eql', 'go:test:postgres']) { const jobs = Object.values(workflow.jobs).filter((job) => job.steps?.some((step) => step.run === `mise run ${task}`), From 7a3dec911294b76650292c51c6ffc8e7a070d9d3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 00:12:40 +1100 Subject: [PATCH 05/15] fix(ci): select cached cargo-binstall shim --- mise.test.toml | 4 ++++ scripts/__tests__/crates-ci.test.mjs | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/mise.test.toml b/mise.test.toml index 57e4acb9a..3a25a7589 100644 --- a/mise.test.toml +++ b/mise.test.toml @@ -11,6 +11,10 @@ # (cargo@0.96 needs rustc 1.93; the Ubuntu runners ship 1.92). Bump a pin # deliberately, after checking it still builds with the runners' rustc. [tools] +# EQL setup can leave a cargo-binstall shim in the shared mise cache. Select +# its prebuilt installer here too: an unselected shim is still found on PATH +# by the cargo backend, then fails before it can install any test tools. +"cargo-binstall" = "1.26.2" "cargo:cargo-nextest" = "0.9.146" # Coverage → CRAP (Change Risk Anti-Patterns) metric tooling. `cargo llvm-cov` # produces an LCOV file that `cargo crap` scores; see `mise run crap:stack-auth`. diff --git a/scripts/__tests__/crates-ci.test.mjs b/scripts/__tests__/crates-ci.test.mjs index 4d33fe72d..f4dcc5113 100644 --- a/scripts/__tests__/crates-ci.test.mjs +++ b/scripts/__tests__/crates-ci.test.mjs @@ -34,6 +34,19 @@ const readRepo = (relPath) => readFileSync(join(REPO_ROOT, relPath), 'utf8') const ROOT_MISE = 'mise.toml' const IMPORTED_WORKFLOW_DIR = '.github/imported-workflows' +describe('the test toolchain installs from a shared mise cache', () => { + it('selects the prebuilt cargo-binstall before installing cargo tools', () => { + // EQL installs cargo-binstall. A restored shim with no selected version + // makes mise's cargo backend fail instead of falling back to cargo install. + const tools = readRepo('mise.test.toml') + .split(/^\[tools\]\s*$/m)[1] + .split(/^\[/m)[0] + expect(tools).toMatch(/^"cargo:cargo-nextest"\s*=/m) + expect(tools).toMatch(/^"cargo-binstall"\s*=\s*"\d+\.\d+\.\d+"\s*$/m) + expect(tools).not.toMatch(/^"cargo:cargo-binstall"\s*=/m) + }) +}) + // --------------------------------------------------------------------------- // The task graph // --------------------------------------------------------------------------- From 65d4937412c8e71ee0a9105bf8a9f479dce8b48d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 01:00:27 +1100 Subject: [PATCH 06/15] fix(ci): repair CRAP and cached Rust runtimes --- .github/workflows/crap-crates.yml | 16 ++++++++++++---- .github/workflows/tests-golang.yml | 10 ++++++++++ scripts/__tests__/crates-ci.test.mjs | 14 ++++++++++++++ scripts/__tests__/stack-eql-conformance.test.mjs | 16 ++++++++++++++++ 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/.github/workflows/crap-crates.yml b/.github/workflows/crap-crates.yml index 8a325ab1a..79020aa40 100644 --- a/.github/workflows/crap-crates.yml +++ b/.github/workflows/crap-crates.yml @@ -56,10 +56,12 @@ env: CARGO_NET_GIT_FETCH_WITH_CLI: true NEXTEST_PROFILE: ci +# cargo-crap 0.6.1's prebuilt GNU/Linux binary needs GLIBC 2.39 (Ubuntu 24.04). +# Keep the runner and its mise cache on that image. The gate itself is unchanged. jobs: crap-stack-auth: name: CRAP (stack-auth) - runs-on: blacksmith-16vcpu-ubuntu-2204 + runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 45 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -74,16 +76,19 @@ jobs: working_directory: . cache: true # Must name runs-on's image; see scripts/__tests__/workflow-mise-cache-image.test.mjs - cache_key_prefix: mise-v1-ubuntu-2204 + cache_key_prefix: mise-v1-ubuntu-2404 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - name: Check the CRAP executable before collecting coverage + run: cargo crap --version + - name: Run the CRAP gate run: mise run crap:stack-auth -- --format github crap-stack-encrypt: name: CRAP (stack-encrypt) - runs-on: blacksmith-16vcpu-ubuntu-2204 + runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 45 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -97,9 +102,12 @@ jobs: working_directory: . cache: true # Must name runs-on's image; see scripts/__tests__/workflow-mise-cache-image.test.mjs - cache_key_prefix: mise-v1-ubuntu-2204 + cache_key_prefix: mise-v1-ubuntu-2404 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - name: Check the CRAP executable before collecting coverage + run: cargo crap --version + - name: Run the CRAP gate run: mise run crap:stack-encrypt -- --format github diff --git a/.github/workflows/tests-golang.yml b/.github/workflows/tests-golang.yml index 8fd23b164..62bd9453a 100644 --- a/.github/workflows/tests-golang.yml +++ b/.github/workflows/tests-golang.yml @@ -461,6 +461,16 @@ jobs: # Must name runs-on's image; see scripts/__tests__/workflow-mise-cache-image.test.mjs cache_key_prefix: mise-v1-ubuntu-2204 + # mise caches Rust symlinks, not ~/.rustup. A cache hit can leave a + # toolchain directory without cargo and still report "all tools installed". + # Reconcile both workspaces' configured toolchains before cargo is used. + - name: Restore Rust toolchains behind the cached mise links + run: | + mise install --force rust + mise -C packages/eql install --force rust + mise x -- cargo --version + mise -C packages/eql x -- cargo --version + # `mise run build` compiles eql-codegen to write the bundle. - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: diff --git a/scripts/__tests__/crates-ci.test.mjs b/scripts/__tests__/crates-ci.test.mjs index f4dcc5113..72c504612 100644 --- a/scripts/__tests__/crates-ci.test.mjs +++ b/scripts/__tests__/crates-ci.test.mjs @@ -35,6 +35,20 @@ const ROOT_MISE = 'mise.toml' const IMPORTED_WORKFLOW_DIR = '.github/imported-workflows' describe('the test toolchain installs from a shared mise cache', () => { + it('runs the prebuilt CRAP executable on a compatible image before coverage', () => { + const workflow = readWorkflow('.github/workflows/crap-crates.yml') + for (const name of ['crap-stack-auth', 'crap-stack-encrypt']) { + const job = workflow.jobs[name] + expect(job['runs-on']).toMatch(/ubuntu-2404$/) + const probe = job.steps.findIndex((s) => s.run === 'cargo crap --version') + const gate = job.steps.findIndex((s) => + s.run?.startsWith('mise run crap:'), + ) + expect(probe).toBeGreaterThan(-1) + expect(gate).toBeGreaterThan(probe) + } + }) + it('selects the prebuilt cargo-binstall before installing cargo tools', () => { // EQL installs cargo-binstall. A restored shim with no selected version // makes mise's cargo backend fail instead of falling back to cargo install. diff --git a/scripts/__tests__/stack-eql-conformance.test.mjs b/scripts/__tests__/stack-eql-conformance.test.mjs index e6e6c7f47..c2b5082ac 100644 --- a/scripts/__tests__/stack-eql-conformance.test.mjs +++ b/scripts/__tests__/stack-eql-conformance.test.mjs @@ -172,6 +172,22 @@ describe('required EQL/JSON conformance', () => { expect(jobs[0].env.MISE_ENV).toBe('test') expect(step.env.STACK_CONFORMANCE_REPORT_DIR).toBeTruthy() if (task.endsWith(':postgres')) { + const steps = jobs[0].steps + const repair = steps.findIndex((s) => + s.run?.includes('mise install --force rust'), + ) + const cache = steps.findIndex((s) => + s.uses?.startsWith('Swatinem/rust-cache@'), + ) + expect(repair).toBeGreaterThan(-1) + expect(cache).toBeGreaterThan(repair) + expect(steps[repair].run).toContain( + 'mise -C packages/eql install --force rust', + ) + expect(steps[repair].run).toContain('mise x -- cargo --version') + expect(steps[repair].run).toContain( + 'mise -C packages/eql x -- cargo --version', + ) expect(jobs[0].services.postgres).toBeDefined() expect(step.env.EQL_TEST_DATABASE_URL).toContain( 'job.services.postgres.ports', From ffd5d2b1938d62d7556f21e16a23749d32652a46 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 23:20:04 +1100 Subject: [PATCH 07/15] chore(deps): move the Rust workspaces to vitaminc 0.8.1 vitaminc 0.8.1 is the first release of vitaminc-aead-frame with the storage profile (`vitaminc_aead_frame::storage`, feature `serde`): a versioned, self-describing encoding for bytes that are kept, which the Go SDK already writes and reads for its opaque structs through vcffi. The next commit builds stack-encrypt's `OpaqueBlob` on it, so the family moves together: all of the vitaminc crates must share one version or the aead crate is duplicated in the graph. Upstream's changelogs for 0.8.1 state that nothing 0.8.0 wrote or read changes: the leaf format, the transport frame and every existing ciphertext are unaffected. The only other change is a transport serde fix for a newtype map key. Every lockfile that links these crates moves with the root: the EQL workspace (eql-bindings depends on stack-encrypt by path), both Go guests and the three fuzz crates, each of which CI checks with `--locked` or builds from. The caret `"0.8.0"` pins in those manifests move to `"0.8.1"` so they say what their locks hold. The EQL ORE timing generator pins `=0.8.0` on its own and does not link stack-encrypt; it is untouched. Refs #1484 --- Cargo.lock | 157 ++++++++++----------- Cargo.toml | 23 ++-- languages/golang/auth/guest/Cargo.lock | 100 +++++++------- languages/golang/auth/guest/Cargo.toml | 2 +- languages/golang/encrypt/guest/Cargo.lock | 151 ++++++++++---------- packages/eql/Cargo.lock | 159 +++++++++++----------- packages/eql/tests/encryption/Cargo.toml | 2 +- packages/stack-auth/fuzz/Cargo.lock | 82 +++++------ packages/stack-encrypt/fuzz/Cargo.lock | 147 ++++++++++---------- packages/stack-encrypt/fuzz/Cargo.toml | 4 +- packages/stack-kms/fuzz/Cargo.lock | 86 ++++++------ 11 files changed, 460 insertions(+), 453 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e14762064..9ab8a2c57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -565,7 +565,7 @@ dependencies = [ "subtle", "thiserror 1.0.69", "vitaminc-ore", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -1519,7 +1519,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "tokio", "tower-service", "tracing", @@ -2311,12 +2311,12 @@ dependencies = [ "rand 0.10.1", "subtle-ng", "thiserror 1.0.69", - "vitaminc-context 0.8.0", + "vitaminc-context 0.8.1", "vitaminc-hmac", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -2542,7 +2542,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.18", "tokio", "tracing", @@ -2581,7 +2581,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", "windows-sys 0.52.0", ] @@ -3361,8 +3361,8 @@ dependencies = [ "tracing-subscriber", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "web-time", "zeroize", "zerokms-protocol", @@ -3384,7 +3384,7 @@ dependencies = [ "tempfile", "tokio", "url", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -3433,15 +3433,15 @@ dependencies = [ "tokio", "trybuild", "uuid", - "vitaminc 0.8.0", - "vitaminc-aead 0.8.0", + "vitaminc 0.8.1", + "vitaminc-aead 0.8.1", "vitaminc-aead-frame", - "vitaminc-encrypt 0.8.0", + "vitaminc-encrypt 0.8.1", "vitaminc-hmac", "vitaminc-normalize", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", "zerokms-protocol", ] @@ -3467,9 +3467,9 @@ dependencies = [ "proptest", "serde_json", "thiserror 1.0.69", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.8.1", "vitaminc-aead-frame", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -3502,8 +3502,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "zeroize", "zerokms-protocol", ] @@ -4265,16 +4265,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "fd347492314ce981cd9fba0361e07d240e4e4a2e12e0107a7b86cd16abb9f8aa" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-encrypt 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", + "vitaminc-traits 0.8.1", ] [[package]] @@ -4294,9 +4294,9 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "e723c592529da633fe7ba94d95904a9b6832e7995fc3f1d0da17bdbf8587c7a1" dependencies = [ "bytes", "chrono", @@ -4304,10 +4304,10 @@ dependencies = [ "rust_decimal", "serde", "thiserror 2.0.18", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -4324,9 +4324,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "553187b663af2a88f50cddc6fc4b5e58f045aa5f7296075cf33a56c972ff6b2b" dependencies = [ "proc-macro2", "quote", @@ -4335,12 +4335,13 @@ dependencies = [ [[package]] name = "vitaminc-aead-frame" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0299777255171712a296d59164691dc1e93d10bddaab6e248fede1790dfd2e" +checksum = "3a65349bb75a9ecc9fd4f057e5a6aac964bea68e57d03d55118a22455002a424" dependencies = [ "serde", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -4356,12 +4357,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "0179df09c5d8cab1c9d9f104eb807812851d1a36f97212542fb61f29e01646e4" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -4380,72 +4381,72 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "265383e1184382f22570fa5be60db8b4f6b2321c21a2c5bf08a858705c4c8828" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] [[package]] name = "vitaminc-hmac" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5fa0ae8bbad85ae30fb5c91b0b72b694d59bf2c2c1e7ad799eda66fdbd67e7c" +checksum = "e4a244886bff4915e15000a69f5a10f14410cabb30c445e3d585057c06a9ce1a" dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] [[package]] name = "vitaminc-normalize" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a2d8875e0b8b35b7a4f9dd698bbd13c989837cea564b5a4cbb5371b78fa937" +checksum = "d820d507ca7cce81772e38c1277b26acc422a4fd99402e6d9d49c412a5a135f0" dependencies = [ "thiserror 2.0.18", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", ] [[package]] name = "vitaminc-ore" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05518b4d35cb0c592dad59afb11b8e883ea252449b9ae47ff5ddeff0ed49f75" +checksum = "6b80bf6789c1e2e1c192a255a9e2f6c3e7ffbce2fcb61d60edecee6ffc2f71ef" dependencies = [ "thiserror 2.0.18", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916688478273ca3e5c6805b355534ce9263688391cd4ab23b08bef760039d325" +checksum = "e86cfcbfb4089f5bf334723cbf106f7d0d4709abf0ad12aaf49d6cf89f3e78de" dependencies = [ "mutants", "thiserror 2.0.18", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -4467,9 +4468,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "81704a2c5bb14d108dd4aa20b4124d4b0c47a2695fc6330131cf0897350737aa" dependencies = [ "bitvec", "digest 0.11.3", @@ -4478,7 +4479,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.18", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.8.1", "zeroize", ] @@ -4495,9 +4496,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "4eacd4b9ad7ae5196ee117944325aead3508da45d4c7ad73f682e64d62b09bb7" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -4522,16 +4523,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "7f9bc57a6532bfa847fa5093c2c39bdc333d1c0b724f982cf3c240e8b091d491" dependencies = [ "chacha20", "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random-derives 0.8.1", "zeroize", ] @@ -4548,9 +4549,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "73e700b3a8e01ee5130939fbdc4dadb38bc4d4160c5f1d5f7fd139bab0491dfa" dependencies = [ "proc-macro2", "quote", @@ -4575,17 +4576,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a090c4d967d90bc3c609b73a3629e27ca752e0a5a62a5b00e0ea91f04617c60d" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -4843,7 +4844,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d3234daee..e4a1f1ef8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -139,18 +139,21 @@ web-time = "1.1" zeroize = { version = "1.8.1", features = ["derive"] } # This is needed because lock_api 0.4.6 was breaking things and the branch forces the use of 0.4.12 temp-env = { git = "https://github.com/cipherstash/temp-env", branch = "main" } -vitaminc = { version = "0.8.0", features = ["random", "protected", "encrypt"] } -vitaminc-aead = "0.8.0" +vitaminc = { version = "0.8.1", features = ["random", "protected", "encrypt"] } +vitaminc-aead = "0.8.1" # The transport frame and ciphertext codec every language binding carries -# across its FFI boundary. Optional in stack-encrypt (the `dynamic` feature). -vitaminc-aead-frame = "0.8.0" -vitaminc-encrypt = "0.8.0" -vitaminc-hmac = "0.8.0" +# across its FFI boundary, and the storage profile (`storage`, feature +# `serde`) a whole value is encoded with before it is sealed as one leaf +# (`stack_encrypt::OpaqueBlob`). 0.8.1 is the first release with the storage +# profile; the Go SDK writes and reads the same profile through `vcffi`. +vitaminc-aead-frame = "0.8.1" +vitaminc-encrypt = "0.8.1" +vitaminc-hmac = "0.8.1" # Normalized equality terms (Unicode 16 NFC text, one integer encoding for # every width), which `vitaminc-prf`'s `normalize` feature derives over. -vitaminc-normalize = "0.8.0" +vitaminc-normalize = "0.8.1" # The text order key: the system ADR-0002's fold, pinned to Unicode 16, # which text ORE and OPE terms are derived over (ADR-0009). -vitaminc-ore = "0.8.0" -vitaminc-prf = { version = "0.8.0", features = ["normalize"] } -vitaminc-protected = "0.8.0" +vitaminc-ore = "0.8.1" +vitaminc-prf = { version = "0.8.1", features = ["normalize"] } +vitaminc-protected = "0.8.1" diff --git a/languages/golang/auth/guest/Cargo.lock b/languages/golang/auth/guest/Cargo.lock index 95eb9372e..5695286e9 100644 --- a/languages/golang/auth/guest/Cargo.lock +++ b/languages/golang/auth/guest/Cargo.lock @@ -633,7 +633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1613,7 +1613,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1807,8 +1807,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "web-time", "zeroize", "zerokms-protocol", @@ -1828,7 +1828,7 @@ dependencies = [ "tempfile", "tokio", "url", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -1839,9 +1839,9 @@ dependencies = [ "miette", "serde_json", "thiserror 1.0.69", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.8.1", "vitaminc-aead-frame", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -1925,7 +1925,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2260,16 +2260,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "fd347492314ce981cd9fba0361e07d240e4e4a2e12e0107a7b86cd16abb9f8aa" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-encrypt 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", + "vitaminc-traits 0.8.1", ] [[package]] @@ -2289,18 +2289,18 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "e723c592529da633fe7ba94d95904a9b6832e7995fc3f1d0da17bdbf8587c7a1" dependencies = [ "bytes", "futures-util", "serde", "thiserror 2.0.20", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -2317,9 +2317,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "553187b663af2a88f50cddc6fc4b5e58f045aa5f7296075cf33a56c972ff6b2b" dependencies = [ "proc-macro2", "quote", @@ -2328,11 +2328,11 @@ dependencies = [ [[package]] name = "vitaminc-aead-frame" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0299777255171712a296d59164691dc1e93d10bddaab6e248fede1790dfd2e" +checksum = "3a65349bb75a9ecc9fd4f057e5a6aac964bea68e57d03d55118a22455002a424" dependencies = [ - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.8.1", ] [[package]] @@ -2347,12 +2347,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "0179df09c5d8cab1c9d9f104eb807812851d1a36f97212542fb61f29e01646e4" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -2371,15 +2371,15 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "265383e1184382f22570fa5be60db8b4f6b2321c21a2c5bf08a858705c4c8828" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -2402,9 +2402,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "81704a2c5bb14d108dd4aa20b4124d4b0c47a2695fc6330131cf0897350737aa" dependencies = [ "bitvec", "digest 0.11.3", @@ -2413,7 +2413,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.20", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.8.1", "zeroize", ] @@ -2430,9 +2430,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "4eacd4b9ad7ae5196ee117944325aead3508da45d4c7ad73f682e64d62b09bb7" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2457,16 +2457,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "7f9bc57a6532bfa847fa5093c2c39bdc333d1c0b724f982cf3c240e8b091d491" dependencies = [ "chacha20", "getrandom 0.4.3", "rand 0.10.3", "thiserror 2.0.20", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random-derives 0.8.1", "zeroize", ] @@ -2483,9 +2483,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "73e700b3a8e01ee5130939fbdc4dadb38bc4d4160c5f1d5f7fd139bab0491dfa" dependencies = [ "proc-macro2", "quote", @@ -2510,17 +2510,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a090c4d967d90bc3c609b73a3629e27ca752e0a5a62a5b00e0ea91f04617c60d" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.20", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] diff --git a/languages/golang/auth/guest/Cargo.toml b/languages/golang/auth/guest/Cargo.toml index e3fd3cca4..4b654fd89 100644 --- a/languages/golang/auth/guest/Cargo.toml +++ b/languages/golang/auth/guest/Cargo.toml @@ -50,7 +50,7 @@ tempfile = "3" # Native stack-auth refreshes run in a Tokio task, including with a mock transport. tokio = { version = "1.47.1", default-features = false, features = ["macros", "rt"] } # `Controlled::risky_ref`, to read a codec value back in the tests. -vitaminc-protected = "0.8.0" +vitaminc-protected = "0.8.1" [profile.release] # Smaller .wasm; the guest is IO-bound on the FFI copy and the file reads, diff --git a/languages/golang/encrypt/guest/Cargo.lock b/languages/golang/encrypt/guest/Cargo.lock index 81cbcb3c3..d870d68a2 100644 --- a/languages/golang/encrypt/guest/Cargo.lock +++ b/languages/golang/encrypt/guest/Cargo.lock @@ -366,7 +366,7 @@ dependencies = [ "subtle", "thiserror 1.0.69", "vitaminc-ore", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -752,7 +752,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1470,12 +1470,12 @@ dependencies = [ "rand 0.10.2", "subtle-ng", "thiserror 1.0.69", - "vitaminc-context 0.8.0", + "vitaminc-context 0.8.1", "vitaminc-hmac", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -2110,8 +2110,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "web-time", "zeroize", "zerokms-protocol", @@ -2136,15 +2136,15 @@ dependencies = [ "stack-kms", "thiserror 1.0.69", "uuid", - "vitaminc 0.8.0", - "vitaminc-aead 0.8.0", + "vitaminc 0.8.1", + "vitaminc-aead 0.8.1", "vitaminc-aead-frame", - "vitaminc-encrypt 0.8.0", + "vitaminc-encrypt 0.8.1", "vitaminc-hmac", "vitaminc-normalize", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -2184,9 +2184,9 @@ dependencies = [ "miette", "serde_json", "thiserror 1.0.69", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.8.1", "vitaminc-aead-frame", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -2212,8 +2212,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "zeroize", "zerokms-protocol", ] @@ -2688,16 +2688,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "fd347492314ce981cd9fba0361e07d240e4e4a2e12e0107a7b86cd16abb9f8aa" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-encrypt 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", + "vitaminc-traits 0.8.1", ] [[package]] @@ -2717,18 +2717,18 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "e723c592529da633fe7ba94d95904a9b6832e7995fc3f1d0da17bdbf8587c7a1" dependencies = [ "bytes", "futures-util", "serde", "thiserror 2.0.20", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -2745,9 +2745,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "553187b663af2a88f50cddc6fc4b5e58f045aa5f7296075cf33a56c972ff6b2b" dependencies = [ "proc-macro2", "quote", @@ -2756,12 +2756,13 @@ dependencies = [ [[package]] name = "vitaminc-aead-frame" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0299777255171712a296d59164691dc1e93d10bddaab6e248fede1790dfd2e" +checksum = "3a65349bb75a9ecc9fd4f057e5a6aac964bea68e57d03d55118a22455002a424" dependencies = [ "serde", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -2777,12 +2778,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "0179df09c5d8cab1c9d9f104eb807812851d1a36f97212542fb61f29e01646e4" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -2801,72 +2802,72 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "265383e1184382f22570fa5be60db8b4f6b2321c21a2c5bf08a858705c4c8828" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] [[package]] name = "vitaminc-hmac" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5fa0ae8bbad85ae30fb5c91b0b72b694d59bf2c2c1e7ad799eda66fdbd67e7c" +checksum = "e4a244886bff4915e15000a69f5a10f14410cabb30c445e3d585057c06a9ce1a" dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] [[package]] name = "vitaminc-normalize" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a2d8875e0b8b35b7a4f9dd698bbd13c989837cea564b5a4cbb5371b78fa937" +checksum = "d820d507ca7cce81772e38c1277b26acc422a4fd99402e6d9d49c412a5a135f0" dependencies = [ "thiserror 2.0.20", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", ] [[package]] name = "vitaminc-ore" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05518b4d35cb0c592dad59afb11b8e883ea252449b9ae47ff5ddeff0ed49f75" +checksum = "6b80bf6789c1e2e1c192a255a9e2f6c3e7ffbce2fcb61d60edecee6ffc2f71ef" dependencies = [ "thiserror 2.0.20", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916688478273ca3e5c6805b355534ce9263688391cd4ab23b08bef760039d325" +checksum = "e86cfcbfb4089f5bf334723cbf106f7d0d4709abf0ad12aaf49d6cf89f3e78de" dependencies = [ "mutants", "thiserror 2.0.20", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -2888,9 +2889,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "81704a2c5bb14d108dd4aa20b4124d4b0c47a2695fc6330131cf0897350737aa" dependencies = [ "bitvec", "digest 0.11.3", @@ -2899,7 +2900,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.20", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.8.1", "zeroize", ] @@ -2916,9 +2917,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "4eacd4b9ad7ae5196ee117944325aead3508da45d4c7ad73f682e64d62b09bb7" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2943,16 +2944,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "7f9bc57a6532bfa847fa5093c2c39bdc333d1c0b724f982cf3c240e8b091d491" dependencies = [ "chacha20", "getrandom 0.4.3", "rand 0.10.2", "thiserror 2.0.20", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random-derives 0.8.1", "zeroize", ] @@ -2969,9 +2970,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "73e700b3a8e01ee5130939fbdc4dadb38bc4d4160c5f1d5f7fd139bab0491dfa" dependencies = [ "proc-macro2", "quote", @@ -2996,17 +2997,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a090c4d967d90bc3c609b73a3629e27ca752e0a5a62a5b00e0ea91f04617c60d" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.20", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -3093,7 +3094,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index a71c1d4e3..c2e1e2d23 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -640,7 +640,7 @@ dependencies = [ "subtle", "thiserror 1.0.69", "vitaminc-ore", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -1223,7 +1223,7 @@ dependencies = [ "stack-kms", "tokio", "uuid", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.8.1", ] [[package]] @@ -1272,7 +1272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2667,12 +2667,12 @@ dependencies = [ "rand 0.10.1", "subtle-ng", "thiserror 1.0.69", - "vitaminc-context 0.8.0", + "vitaminc-context 0.8.1", "vitaminc-hmac", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -3037,7 +3037,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3465,7 +3465,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3522,7 +3522,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4148,8 +4148,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "web-time", "zeroize", "zerokms-protocol", @@ -4176,15 +4176,15 @@ dependencies = [ "stack-kms", "thiserror 1.0.69", "uuid", - "vitaminc 0.8.0", - "vitaminc-aead 0.8.0", + "vitaminc 0.8.1", + "vitaminc-aead 0.8.1", "vitaminc-aead-frame", - "vitaminc-encrypt 0.8.0", + "vitaminc-encrypt 0.8.1", "vitaminc-hmac", "vitaminc-normalize", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -4219,8 +4219,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "zeroize", "zerokms-protocol", ] @@ -4369,10 +4369,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4975,16 +4975,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "fd347492314ce981cd9fba0361e07d240e4e4a2e12e0107a7b86cd16abb9f8aa" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-encrypt 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", + "vitaminc-traits 0.8.1", ] [[package]] @@ -5017,9 +5017,9 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "e723c592529da633fe7ba94d95904a9b6832e7995fc3f1d0da17bdbf8587c7a1" dependencies = [ "bytes", "chrono", @@ -5027,10 +5027,10 @@ dependencies = [ "rust_decimal", "serde", "thiserror 2.0.20", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -5047,9 +5047,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "553187b663af2a88f50cddc6fc4b5e58f045aa5f7296075cf33a56c972ff6b2b" dependencies = [ "proc-macro2", "quote", @@ -5058,12 +5058,13 @@ dependencies = [ [[package]] name = "vitaminc-aead-frame" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0299777255171712a296d59164691dc1e93d10bddaab6e248fede1790dfd2e" +checksum = "3a65349bb75a9ecc9fd4f057e5a6aac964bea68e57d03d55118a22455002a424" dependencies = [ "serde", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -5079,12 +5080,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "0179df09c5d8cab1c9d9f104eb807812851d1a36f97212542fb61f29e01646e4" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -5117,72 +5118,72 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "265383e1184382f22570fa5be60db8b4f6b2321c21a2c5bf08a858705c4c8828" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] [[package]] name = "vitaminc-hmac" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5fa0ae8bbad85ae30fb5c91b0b72b694d59bf2c2c1e7ad799eda66fdbd67e7c" +checksum = "e4a244886bff4915e15000a69f5a10f14410cabb30c445e3d585057c06a9ce1a" dependencies = [ "hmac 0.13.0", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] [[package]] name = "vitaminc-normalize" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a2d8875e0b8b35b7a4f9dd698bbd13c989837cea564b5a4cbb5371b78fa937" +checksum = "d820d507ca7cce81772e38c1277b26acc422a4fd99402e6d9d49c412a5a135f0" dependencies = [ "thiserror 2.0.20", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", ] [[package]] name = "vitaminc-ore" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05518b4d35cb0c592dad59afb11b8e883ea252449b9ae47ff5ddeff0ed49f75" +checksum = "6b80bf6789c1e2e1c192a255a9e2f6c3e7ffbce2fcb61d60edecee6ffc2f71ef" dependencies = [ "thiserror 2.0.20", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916688478273ca3e5c6805b355534ce9263688391cd4ab23b08bef760039d325" +checksum = "e86cfcbfb4089f5bf334723cbf106f7d0d4709abf0ad12aaf49d6cf89f3e78de" dependencies = [ "mutants", "thiserror 2.0.20", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -5219,9 +5220,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "81704a2c5bb14d108dd4aa20b4124d4b0c47a2695fc6330131cf0897350737aa" dependencies = [ "bitvec", "digest 0.11.3", @@ -5230,7 +5231,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.20", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.8.1", "zeroize", ] @@ -5258,9 +5259,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "4eacd4b9ad7ae5196ee117944325aead3508da45d4c7ad73f682e64d62b09bb7" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -5299,16 +5300,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "7f9bc57a6532bfa847fa5093c2c39bdc333d1c0b724f982cf3c240e8b091d491" dependencies = [ "chacha20", "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.20", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random-derives 0.8.1", "zeroize", ] @@ -5336,9 +5337,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "73e700b3a8e01ee5130939fbdc4dadb38bc4d4160c5f1d5f7fd139bab0491dfa" dependencies = [ "proc-macro2", "quote", @@ -5379,17 +5380,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a090c4d967d90bc3c609b73a3629e27ca752e0a5a62a5b00e0ea91f04617c60d" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.20", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -5658,7 +5659,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/packages/eql/tests/encryption/Cargo.toml b/packages/eql/tests/encryption/Cargo.toml index 787f19abc..dd0dc9d42 100644 --- a/packages/eql/tests/encryption/Cargo.toml +++ b/packages/eql/tests/encryption/Cargo.toml @@ -31,7 +31,7 @@ stack-encrypt = { path = "../../../stack-encrypt", default-features = false, fea # `plaintext::Sink`, for a plaintext type with its own `Encrypt` impl; and # chrono's and rust_decimal's own sources, which seal the leaf a date, # timestamp or decimal from any other producer seals. -vitaminc-aead = { version = "0.8.0", features = ["chrono", "rust_decimal"] } +vitaminc-aead = { version = "0.8.1", features = ["chrono", "rust_decimal"] } chrono = { version = "0.4.44", default-features = false, features = ["std"] } rust_decimal = { version = "1", default-features = false, features = ["std"] } stack-kms = { path = "../../../stack-kms", default-features = false, features = ["test-support"] } diff --git a/packages/stack-auth/fuzz/Cargo.lock b/packages/stack-auth/fuzz/Cargo.lock index b24b8daa9..45485b8a1 100644 --- a/packages/stack-auth/fuzz/Cargo.lock +++ b/packages/stack-auth/fuzz/Cargo.lock @@ -2626,8 +2626,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "web-time", "zeroize", "zerokms-protocol", @@ -3181,16 +3181,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "fd347492314ce981cd9fba0361e07d240e4e4a2e12e0107a7b86cd16abb9f8aa" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-encrypt 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", + "vitaminc-traits 0.8.1", ] [[package]] @@ -3210,18 +3210,18 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "e723c592529da633fe7ba94d95904a9b6832e7995fc3f1d0da17bdbf8587c7a1" dependencies = [ "bytes", "futures-util", "serde", "thiserror 2.0.18", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -3238,9 +3238,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "553187b663af2a88f50cddc6fc4b5e58f045aa5f7296075cf33a56c972ff6b2b" dependencies = [ "proc-macro2", "quote", @@ -3259,12 +3259,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "0179df09c5d8cab1c9d9f104eb807812851d1a36f97212542fb61f29e01646e4" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -3283,15 +3283,15 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "265383e1184382f22570fa5be60db8b4f6b2321c21a2c5bf08a858705c4c8828" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -3314,9 +3314,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "81704a2c5bb14d108dd4aa20b4124d4b0c47a2695fc6330131cf0897350737aa" dependencies = [ "bitvec", "digest 0.11.3", @@ -3325,7 +3325,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.18", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.8.1", "zeroize", ] @@ -3342,9 +3342,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "4eacd4b9ad7ae5196ee117944325aead3508da45d4c7ad73f682e64d62b09bb7" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -3369,16 +3369,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "7f9bc57a6532bfa847fa5093c2c39bdc333d1c0b724f982cf3c240e8b091d491" dependencies = [ "chacha20", "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random-derives 0.8.1", "zeroize", ] @@ -3395,9 +3395,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "73e700b3a8e01ee5130939fbdc4dadb38bc4d4160c5f1d5f7fd139bab0491dfa" dependencies = [ "proc-macro2", "quote", @@ -3422,17 +3422,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a090c4d967d90bc3c609b73a3629e27ca752e0a5a62a5b00e0ea91f04617c60d" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] diff --git a/packages/stack-encrypt/fuzz/Cargo.lock b/packages/stack-encrypt/fuzz/Cargo.lock index a4dd39004..90e55cef4 100644 --- a/packages/stack-encrypt/fuzz/Cargo.lock +++ b/packages/stack-encrypt/fuzz/Cargo.lock @@ -375,7 +375,7 @@ dependencies = [ "subtle", "thiserror 1.0.69", "vitaminc-ore", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -1487,12 +1487,12 @@ dependencies = [ "rand 0.10.1", "subtle-ng", "thiserror 1.0.69", - "vitaminc-context 0.8.0", + "vitaminc-context 0.8.1", "vitaminc-hmac", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -2066,8 +2066,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "web-time", "zeroize", "zerokms-protocol", @@ -2092,15 +2092,15 @@ dependencies = [ "stack-kms", "thiserror 1.0.69", "uuid", - "vitaminc 0.8.0", - "vitaminc-aead 0.8.0", + "vitaminc 0.8.1", + "vitaminc-aead 0.8.1", "vitaminc-aead-frame", - "vitaminc-encrypt 0.8.0", + "vitaminc-encrypt 0.8.1", "vitaminc-hmac", "vitaminc-normalize", "vitaminc-ore", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -2122,8 +2122,8 @@ dependencies = [ "serde_json", "stack-encrypt", "uuid", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", ] [[package]] @@ -2148,8 +2148,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "zeroize", "zerokms-protocol", ] @@ -2594,16 +2594,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "fd347492314ce981cd9fba0361e07d240e4e4a2e12e0107a7b86cd16abb9f8aa" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-encrypt 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", + "vitaminc-traits 0.8.1", ] [[package]] @@ -2623,18 +2623,18 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "e723c592529da633fe7ba94d95904a9b6832e7995fc3f1d0da17bdbf8587c7a1" dependencies = [ "bytes", "futures-util", "serde", "thiserror 2.0.18", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -2651,9 +2651,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "553187b663af2a88f50cddc6fc4b5e58f045aa5f7296075cf33a56c972ff6b2b" dependencies = [ "proc-macro2", "quote", @@ -2662,12 +2662,13 @@ dependencies = [ [[package]] name = "vitaminc-aead-frame" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0299777255171712a296d59164691dc1e93d10bddaab6e248fede1790dfd2e" +checksum = "3a65349bb75a9ecc9fd4f057e5a6aac964bea68e57d03d55118a22455002a424" dependencies = [ "serde", - "vitaminc-aead 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", "zeroize", ] @@ -2683,12 +2684,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "0179df09c5d8cab1c9d9f104eb807812851d1a36f97212542fb61f29e01646e4" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -2707,72 +2708,72 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "265383e1184382f22570fa5be60db8b4f6b2321c21a2c5bf08a858705c4c8828" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] [[package]] name = "vitaminc-hmac" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5fa0ae8bbad85ae30fb5c91b0b72b694d59bf2c2c1e7ad799eda66fdbd67e7c" +checksum = "e4a244886bff4915e15000a69f5a10f14410cabb30c445e3d585057c06a9ce1a" dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] [[package]] name = "vitaminc-normalize" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a2d8875e0b8b35b7a4f9dd698bbd13c989837cea564b5a4cbb5371b78fa937" +checksum = "d820d507ca7cce81772e38c1277b26acc422a4fd99402e6d9d49c412a5a135f0" dependencies = [ "thiserror 2.0.18", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", ] [[package]] name = "vitaminc-ore" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05518b4d35cb0c592dad59afb11b8e883ea252449b9ae47ff5ddeff0ed49f75" +checksum = "6b80bf6789c1e2e1c192a255a9e2f6c3e7ffbce2fcb61d60edecee6ffc2f71ef" dependencies = [ "thiserror 2.0.18", "unicode-general-category", "unicode-normalization", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916688478273ca3e5c6805b355534ce9263688391cd4ab23b08bef760039d325" +checksum = "e86cfcbfb4089f5bf334723cbf106f7d0d4709abf0ad12aaf49d6cf89f3e78de" dependencies = [ "mutants", "thiserror 2.0.18", - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", "vitaminc-normalize", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -2794,9 +2795,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "81704a2c5bb14d108dd4aa20b4124d4b0c47a2695fc6330131cf0897350737aa" dependencies = [ "bitvec", "digest 0.11.3", @@ -2805,7 +2806,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.18", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.8.1", "zeroize", ] @@ -2822,9 +2823,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "4eacd4b9ad7ae5196ee117944325aead3508da45d4c7ad73f682e64d62b09bb7" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2849,16 +2850,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "7f9bc57a6532bfa847fa5093c2c39bdc333d1c0b724f982cf3c240e8b091d491" dependencies = [ "chacha20", "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random-derives 0.8.1", "zeroize", ] @@ -2875,9 +2876,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "73e700b3a8e01ee5130939fbdc4dadb38bc4d4160c5f1d5f7fd139bab0491dfa" dependencies = [ "proc-macro2", "quote", @@ -2902,17 +2903,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a090c4d967d90bc3c609b73a3629e27ca752e0a5a62a5b00e0ea91f04617c60d" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] diff --git a/packages/stack-encrypt/fuzz/Cargo.toml b/packages/stack-encrypt/fuzz/Cargo.toml index 8b47f231a..e3f8722ee 100644 --- a/packages/stack-encrypt/fuzz/Cargo.toml +++ b/packages/stack-encrypt/fuzz/Cargo.toml @@ -17,9 +17,9 @@ cargo-fuzz = true [dependencies] # `Controlled`, to take a passthrough node's frame out of its wrapper: the # same line stack-encrypt builds on. -vitaminc-protected = "0.8.0" +vitaminc-protected = "0.8.1" # The sinks the record harness writes its plans and passthroughs through. -vitaminc-aead = "0.8.0" +vitaminc-aead = "0.8.1" libfuzzer-sys = "0.4" # Structure-aware inputs for `check_record` and `plan_build`: the mirror # types derive `Arbitrary`, so the fuzzer mutates trees and plans, not bytes. diff --git a/packages/stack-kms/fuzz/Cargo.lock b/packages/stack-kms/fuzz/Cargo.lock index 049141916..4532e4a4d 100644 --- a/packages/stack-kms/fuzz/Cargo.lock +++ b/packages/stack-kms/fuzz/Cargo.lock @@ -1964,8 +1964,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "web-time", "zeroize", "zerokms-protocol", @@ -1992,8 +1992,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.8.0", - "vitaminc-protected 0.8.0", + "vitaminc 0.8.1", + "vitaminc-protected 0.8.1", "zeroize", "zerokms-protocol", ] @@ -2411,16 +2411,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea37168a603d605732d0bf0672586139ab91736f767d7b85e173c84dcee19054" +checksum = "fd347492314ce981cd9fba0361e07d240e4e4a2e12e0107a7b86cd16abb9f8aa" dependencies = [ - "vitaminc-aead 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-encrypt 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", - "vitaminc-traits 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-encrypt 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", + "vitaminc-traits 0.8.1", ] [[package]] @@ -2440,18 +2440,18 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9bb97fd543ee6a2b168e257ec69d0d274a775b1eaa3f856b5ac2941b01891c1" +checksum = "e723c592529da633fe7ba94d95904a9b6832e7995fc3f1d0da17bdbf8587c7a1" dependencies = [ "bytes", "futures-util", "serde", "thiserror 2.0.18", - "vitaminc-aead-derive 0.8.0", - "vitaminc-context 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead-derive 0.8.1", + "vitaminc-context 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -2468,9 +2468,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0845a44c07ddc882ed47a0e92e04fc83496220f054b589ad0cbd215c5009e5b7" +checksum = "553187b663af2a88f50cddc6fc4b5e58f045aa5f7296075cf33a56c972ff6b2b" dependencies = [ "proc-macro2", "quote", @@ -2489,12 +2489,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a630079ba2d50680ee5048addd430eda30613559809dd6d383551e6d3d299826" +checksum = "0179df09c5d8cab1c9d9f104eb807812851d1a36f97212542fb61f29e01646e4" dependencies = [ "mutants", - "vitaminc-protected 0.8.0", + "vitaminc-protected 0.8.1", ] [[package]] @@ -2513,15 +2513,15 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db45e073f92ae8ca27f8fe14270bc34c75e767559c2347098dacca95180d1ddd" +checksum = "265383e1184382f22570fa5be60db8b4f6b2321c21a2c5bf08a858705c4c8828" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.8.0", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-aead 0.8.1", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] @@ -2544,9 +2544,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6edc22562a9bbff0ba9704a2c43670b2c7cb1637524c045c3ba6436ed00917f" +checksum = "81704a2c5bb14d108dd4aa20b4124d4b0c47a2695fc6330131cf0897350737aa" dependencies = [ "bitvec", "digest 0.11.3", @@ -2555,7 +2555,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.18", - "vitaminc-protected-derive 0.8.0", + "vitaminc-protected-derive 0.8.1", "zeroize", ] @@ -2572,9 +2572,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cce3a7d0ce6f57c0e82345f20e87f3737cccd3a70aec2a2ab49f68a3a5e24a" +checksum = "4eacd4b9ad7ae5196ee117944325aead3508da45d4c7ad73f682e64d62b09bb7" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2599,16 +2599,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb253f783ce6f31a55a92f5a9f0526109b8caa6a465edb94244fd344a078c11" +checksum = "7f9bc57a6532bfa847fa5093c2c39bdc333d1c0b724f982cf3c240e8b091d491" dependencies = [ "chacha20", "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random-derives 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random-derives 0.8.1", "zeroize", ] @@ -2625,9 +2625,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0b3615936d6dded4b775856f4b0fa229d745d183f0b3754ac80035f16c27d9" +checksum = "73e700b3a8e01ee5130939fbdc4dadb38bc4d4160c5f1d5f7fd139bab0491dfa" dependencies = [ "proc-macro2", "quote", @@ -2652,17 +2652,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9098ee4b13446ef959583ee2f3c384df571b453179104f7b4960ffddf65ee9" +checksum = "a090c4d967d90bc3c609b73a3629e27ca752e0a5a62a5b00e0ea91f04617c60d" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected 0.8.0", - "vitaminc-random 0.8.0", + "vitaminc-protected 0.8.1", + "vitaminc-random 0.8.1", "zeroize", ] From 0fdd26632af87f680140fa6733af4e9362d2dfb8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 23:21:50 +1100 Subject: [PATCH 08/15] feat(stack-encrypt)!: add OpaqueBlob whole-value encryption The owned scalar API seals one scalar as one `Ciphertext` and refuses a composite, by design: a `Serialize` fallback would decide the stored bytes of every struct silently. That left no way to encrypt a whole value as one ciphertext. `OpaqueBlob` is that way, and it is explicit: wrap to encrypt, name the wrapper to decrypt, `into_inner` to take the value. The encoding is Vitamin C's storage profile, version 1, through `vitaminc_aead_frame::storage`: a `VCS` header and version byte inside the plaintext (so encrypted and authenticated with the value), explicit `Some` markers, typed map keys, a frozen specification and a golden corpus. It is the format the Go SDK writes for its opaque structs through vcffi, so the two SDKs share one stored format and this crate adds no second wire format. The encoded blob is sealed as one bytes leaf of the unchanged `SealedValue` v1 envelope through the same path as a scalar: one data key, no new KMS request shape, no new AAD. Decrypting authenticates first and decodes after. A leaf that does not open is `Aead`; a leaf of another kind is `OpenedType`; bytes that are not a blob this type reads are the new `Error::Blob(BlobError)`, one variant and code per class (unwritable, not a blob, unsupported version, malformed, mismatch), each a refusal of an authentic ciphertext. No message or payload names a value. To let a blob implement the sealed capabilities, `OpaqueEncode` and `OpaqueDecode` now carry the operation in their sealed supertraits (`Encode::seal`, `Decode::open`) and `OpaqueDecode` drops vitaminc's `Decrypt` as a supertrait: a blob opens through a bytes leaf and decodes after, and the sink's error type cannot carry the blob's refusal classes. Both traits are sealed, so the only code affected is generic code bounded on `OpaqueDecode` that also used `Decrypt`'s methods. `Zeroizing<_>` implements both traits as well, which gives the scalar API its opt-in drop-guarded result without a new adapter. Ownership follows the scalar contract and the limits are stated. The owned source is guarded, encoded into a `Protected` buffer, planned and wiped before the pending is returned, on success and on an encoding refusal (which requests no key); the opened bytes are `Protected` while they are read. The returned value is an ordinary owned value that nothing wipes on drop. The ciphertext's length is the encoded length plus a constant, nothing is constant time, and a derived `Deserialize` that built some fields and refuses a later one drops them unwiped; the heap observer pins that last one as a negative control rather than hiding it. Exchange evidence is fixed vectors: the blob this crate writes is pinned and read by a Go test, and two Go-written blobs (produced once with `vcffi.MarshalStorage`) are read here through the codec and through a sealed leaf under Go's `/value`. A fresh sealed exchange through the conformance task remains open and is listed as such in the coverage map. Closes #1484 Refs #1468 Refs #1480 --- conformance/stack-encrypt/coverage.md | 4 +- .../stack-encrypt/lifecycle-contract.md | 18 +- .../stack-encrypt/v1/lifecycle-profiles.json | 91 ++- .../gensupport/gensupport_internal_test.go | 42 ++ packages/stack-encrypt/CHANGELOG.md | 61 ++ packages/stack-encrypt/Cargo.toml | 17 +- ...-is-one-storage-blob-sealed-as-one-leaf.md | 129 ++++ .../stack-encrypt/examples/opaque_blob.rs | 107 +++ packages/stack-encrypt/src/blob.rs | 618 +++++++++++++++ packages/stack-encrypt/src/cipher.rs | 11 + packages/stack-encrypt/src/codes.rs | 16 +- packages/stack-encrypt/src/lib.rs | 39 +- packages/stack-encrypt/src/scalar.rs | 125 +++- .../stack-encrypt/src/target/operations.rs | 12 +- packages/stack-encrypt/tests/heap_hygiene.rs | 150 ++++ packages/stack-encrypt/tests/opaque_blob.rs | 704 ++++++++++++++++++ .../ui/opaque_blob_requires_the_wrapper.rs | 23 + .../opaque_blob_requires_the_wrapper.stderr | 28 + .../tests/ui/opaque_blob_requires_zeroize.rs | 21 + .../ui/opaque_blob_requires_zeroize.stderr | 59 ++ .../ui/scalar_collections_and_borrows.stderr | 4 +- .../tests/ui/scalar_nullable.stderr | 4 +- 22 files changed, 2225 insertions(+), 58 deletions(-) create mode 100644 packages/stack-encrypt/docs/adr/0011-a-whole-value-is-one-storage-blob-sealed-as-one-leaf.md create mode 100644 packages/stack-encrypt/examples/opaque_blob.rs create mode 100644 packages/stack-encrypt/src/blob.rs create mode 100644 packages/stack-encrypt/tests/opaque_blob.rs create mode 100644 packages/stack-encrypt/tests/ui/opaque_blob_requires_the_wrapper.rs create mode 100644 packages/stack-encrypt/tests/ui/opaque_blob_requires_the_wrapper.stderr create mode 100644 packages/stack-encrypt/tests/ui/opaque_blob_requires_zeroize.rs create mode 100644 packages/stack-encrypt/tests/ui/opaque_blob_requires_zeroize.stderr diff --git a/conformance/stack-encrypt/coverage.md b/conformance/stack-encrypt/coverage.md index 6e6453720..15a35fb7c 100644 --- a/conformance/stack-encrypt/coverage.md +++ b/conformance/stack-encrypt/coverage.md @@ -7,12 +7,12 @@ Paths are relative to the repository root. New profile cases run from `v1/`. | Requirement | Existing evidence | Remaining work | |---|---|---| | Scalar value widths and lossless transport | Initial scalar cases and [value-kind requirements](fixture-contract.md#value-kind-requirements), executed by the existing Rust and generated Go runners | Complete per-kind boundary matrices, bytes and broader floats; Python/TS mappings | -| Owned opaque ciphertext and storage envelope | `packages/stack-encrypt/tests/scalar.rs`, `frozen_bytes.rs`; initial cross-opening cases | Blob profile; encoded targets; supported version matrix | +| Owned opaque ciphertext and storage envelope | `packages/stack-encrypt/tests/scalar.rs`, `frozen_bytes.rs`; initial cross-opening cases. Whole-value blobs: `packages/stack-encrypt/tests/opaque_blob.rs` (round trips, the bytes leaf shared with the structural writer, a pinned storage blob and a pinned sealed ciphertext, refusals by class after authentication, source wipe) and `languages/golang/encrypt/gensupport/gensupport_internal_test.go::TestOpaqueReadsARustWrittenBlob`; the storage profile itself is pinned upstream by Vitamin C's golden corpus | Encoded targets; supported version matrix; a fresh sealed exchange of a blob with Go (Go seals, Rust opens, and back) through the conformance task | | Record declaration and lowering | [Declaration grammar](declaration-grammar.md); `v1/declarations.json` shared by native Rust and Go; native normalization observations; existing record lowering suites | EQL-resolver profile; SDK-to-declaration mappings; runtime source/read shapes | | Context binding and composition | `packages/stack-encrypt/tests/target.rs`, `descriptor.rs`, `record_lowering.rs`; `languages/golang/encrypt/context_field_test.go`; initial wrong-context cases | Shared composition vectors; expected-context and required-input matrix | | Query terms and diagnostics | [Query-operator requirements](fixture-contract.md#query-operator-requirements), existing Rust/Go runners and initial equality/query-error cases | Common rejection corpus for every operator, capability and input shape; wider kind/index combinations | | Missing, SQL NULL and sealed null | `packages/stack-encrypt/src/dynamic/record.rs` null tests; `languages/golang/encrypt/null_test.go` | Explicit language mappings and common missing/null fixtures | -| Fresh cross-language storage | `.github/workflows/tests-golang.yml` exports fresh Go EQL rows for Rust; initial scalar artifact exchange | Every producer/consumer/backend pair and supported released versions | +| Fresh cross-language storage | `.github/workflows/tests-golang.yml` exports fresh Go EQL rows for Rust; initial scalar artifact exchange; fixed storage-blob vectors read by both Rust (`opaque_blob.rs`) and Go (`TestOpaqueReadsARustWrittenBlob`) | Every producer/consumer/backend pair and supported released versions; fresh sealed blob exchange | | JSON queries, extraction and tampering | `packages/stack-encrypt/tests/json_tree_vectors.rs`; `packages/eql/tests/encryption/tests/json_tree.rs`; `languages/golang/encrypt/json_tree_test.go`, `json_postgres_test.go` | Formal operation profile; JSON vectors consumed by all runners | | Element and predicate matching | Go `json_elements_test.go`, `json_match_test.go` and corresponding PostgreSQL tests; Rust/SQL predicate suites | Map exact supported grammar and documented semantic differences to case IDs | | Generated SDK declarations | Go stashgen and generated-code fixture tests | Python and TypeScript public-API runners; compiler/type-checker cases | diff --git a/conformance/stack-encrypt/lifecycle-contract.md b/conformance/stack-encrypt/lifecycle-contract.md index 4f0d811e5..bea4b2761 100644 --- a/conformance/stack-encrypt/lifecycle-contract.md +++ b/conformance/stack-encrypt/lifecycle-contract.md @@ -10,12 +10,12 @@ process. Python and TypeScript are explicitly not enrolled. | Requirement | Native evidence | Go evidence and limits | |---|---|---| -| `lifecycle-ownership-v1` | Actual record operations release fulfilment captures exactly once on success, provider failure and unpolled drop; zipped fulfilments do not share key material. | Client keys are consumed/redacted; explicit close and unreachable-client cleanup release guest memory; calls on a closed client fail. GC cleanup is eventual, so callers should close explicitly. | +| `lifecycle-ownership-v1` | Actual record operations release fulfilment captures exactly once on success, provider failure and unpolled drop; zipped fulfilments do not share key material. An owned `OpaqueBlob` source is wiped before its pending is returned, on success and on an encoding refusal. | Client keys are consumed/redacted; explicit close and unreachable-client cleanup release guest memory; calls on a closed client fail. GC cleanup is eventual, so callers should close explicitly. | | `lifecycle-cancellation-v1` | Dropping a future paused in generation drops the provider future and operation captures; subsequent work on the cipher succeeds. Guest ABI abort spends the handle and drops the operation. | Cancellation before a call sends no request; cancellation between steps or during token fetch leaves unrelated operations usable. A guest step that overruns cancellation closes the client. Discarded late output is wiped. | -| `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue. Existing context cases reject late authentication failures without returning partial output. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | -| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths and has a negative control. Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | +| `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue. Existing context cases reject late authentication failures without returning partial output. An `OpaqueBlob` refused before its first field is built leaves no marked residue; a refusal after some fields are built is the `native-partial-decode-fields` limit below. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | +| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths and has a negative control. It also checks an `OpaqueBlob`'s encoded buffer (which regrows while it holds plaintext), its planned leaf and its opened buffer, and pins the partial-decode limit as a second negative control. Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | | `lifecycle-diagnostics-v1` | Guest errors discard untrusted foreign cause messages; failed/successful exports do not hand over stale diagnostics. | Structured errors remain available after failures; SDK errors and redacted display/logging expose types or fields rather than plaintext values. | -| `lifecycle-validation-before-io-v1` | Existing runtime/read/context cases count provider calls. Wrong shape, independently detectable context mismatch and oversized descriptors fail before key I/O; lazy assembly stops after local failure. | Host refusals happen before entering the guest, and shared runtime/read cases exercise the uninitialised guest's preflight boundary. Go does not yet expose native provider counters. | +| `lifecycle-validation-before-io-v1` | Existing runtime/read/context cases count provider calls. Wrong shape, independently detectable context mismatch and oversized descriptors fail before key I/O; lazy assembly stops after local failure. An `OpaqueBlob` whose `Serialize` refuses requests no key; on decrypt the leaf authenticates (one retrieval) before any blob byte is read, and tampering is `Aead` however the bytes would decode. | Host refusals happen before entering the guest, and shared runtime/read cases exercise the uninitialised guest's preflight boundary. Go does not yet expose native provider counters. | | `lifecycle-logical-batching-v1` | Instrumented `DataKeySource` calls establish one logical generation batch, retrieval grouping by keyset, descriptor ordering and no work for empty batches. | Go uses the same engine, but this profile does not claim independently instrumented Go key-request counts. | | `lifecycle-provider-chunks-v1` | The actual KMS chunking helper preserves order, handles a final short chunk, fails the whole call on a chunk error, bounds in-flight chunks and releases their futures on cancellation. | This is a native algorithm observation, not a measurement of live HTTP request counts. | @@ -53,6 +53,16 @@ These entries in the index are limitations, not successful requirements: decrypted plaintext can remain in the WASM shadow stack in underlying cipher temporaries. It is not enrolled as passing evidence. The post-encryption scan cannot establish the post-decryption guarantee. +- **`native-partial-decode-fields` — known gap.** + `opaque_blob::a_partial_decode_drops_the_fields_it_built_unwiped` in + `packages/stack-encrypt/tests/heap_hygiene.rs` is a `should_panic` negative + control: a derived `Deserialize` that has built a `String` field of an + `OpaqueBlob` and then refuses a later field drops that field as an + ordinary value, unwiped, because no `T` was constructed to wipe. The opened + buffer the fields were copied from is wiped. This is serde's construction + order, stated in the `blob` module docs; a `T` whose fields wipe on drop + closes it for those fields. It is enrolled as the observation of a limit, + not as passing cleanup evidence. - **`native-heap-observer-exclusions` — limited observation.** `packages/stack-encrypt/tests/heap_hygiene.rs` watches marked frees on the executing thread. Its existing observer excludes a named upstream aws-lc diff --git a/conformance/stack-encrypt/v1/lifecycle-profiles.json b/conformance/stack-encrypt/v1/lifecycle-profiles.json index 044210a27..63a5bb205 100644 --- a/conformance/stack-encrypt/v1/lifecycle-profiles.json +++ b/conformance/stack-encrypt/v1/lifecycle-profiles.json @@ -6,7 +6,9 @@ "lane": "fast", "workspace": ".", "package": "stack-encrypt", - "features": ["--all-features"], + "features": [ + "--all-features" + ], "lib": true }, "lifecycle": { @@ -14,7 +16,9 @@ "lane": "fast", "workspace": ".", "package": "stack-encrypt", - "features": ["--all-features"], + "features": [ + "--all-features" + ], "binary": "lifecycle_conformance" }, "heap": { @@ -22,7 +26,9 @@ "lane": "fast", "workspace": ".", "package": "stack-encrypt", - "features": ["--all-features"], + "features": [ + "--all-features" + ], "binary": "heap_hygiene", "success_output": "immediate" }, @@ -31,7 +37,9 @@ "lane": "fast", "workspace": ".", "package": "stack-encrypt", - "features": ["--all-features"], + "features": [ + "--all-features" + ], "binary": "runtime_conformance" }, "reads": { @@ -39,7 +47,9 @@ "lane": "fast", "workspace": ".", "package": "stack-encrypt", - "features": ["--all-features"], + "features": [ + "--all-features" + ], "binary": "read_conformance" }, "contexts": { @@ -47,7 +57,9 @@ "lane": "fast", "workspace": ".", "package": "stack-encrypt", - "features": ["--all-features"], + "features": [ + "--all-features" + ], "binary": "context_conformance" }, "guest-abi": { @@ -55,7 +67,9 @@ "lane": "fast", "workspace": ".", "package": "stack-guest-abi", - "features": ["--all-features"], + "features": [ + "--all-features" + ], "lib": true }, "kms": { @@ -63,7 +77,9 @@ "lane": "fast", "workspace": ".", "package": "stack-kms", - "features": ["--all-features"], + "features": [ + "--all-features" + ], "lib": true }, "go": { @@ -80,6 +96,16 @@ "language": "go", "lane": "fast", "package": "./encrypt/gensupport" + }, + "opaque": { + "language": "rust", + "lane": "fast", + "workspace": ".", + "package": "stack-encrypt", + "features": [ + "--all-features" + ], + "binary": "opaque_blob" } }, "requirements": [ @@ -98,13 +124,19 @@ "TestCloseStopsTheCleanup", "TestUnreachableClientIsReleased", "TestClosedClientIsState" + ], + "opaque": [ + "the_owned_source_is_wiped_before_await_or_discard", + "an_encoding_refusal_wipes_the_source_and_requests_no_key" ] } }, { "id": "lifecycle-cancellation-v1", "evidence": { - "lifecycle": ["cancelling_in_flight_work_drops_provider_and_captures"], + "lifecycle": [ + "cancelling_in_flight_work_drops_provider_and_captures" + ], "guest-abi": [ "step::tests::an_aborted_operation_is_dropped_and_its_handle_spent", "step::tests::the_wrong_number_of_responses_is_refused_and_drops_the_operation" @@ -122,14 +154,21 @@ { "id": "lifecycle-partial-output-v1", "evidence": { - "heap": ["a_late_read_type_failure_leaves_no_plaintext_on_the_heap"], - "contexts": ["shared_context_boundaries"], + "heap": [ + "a_late_read_type_failure_leaves_no_plaintext_on_the_heap", + "opaque_blob::an_opaque_blob_of_another_shape_leaves_no_plaintext_on_the_heap" + ], + "contexts": [ + "shared_context_boundaries" + ], "go": [ "TestContextBoundaries", "TestInterruptedResponseBodyIsTransport", "TestACallThatSucceedsAfterItsContextEndsFailsWithIt" ], - "go-sdk": ["TestDecryptErrorsHoldNoPlaintext"] + "go-sdk": [ + "TestDecryptErrorsHoldNoPlaintext" + ] } }, { @@ -139,7 +178,10 @@ "a_record_encrypted_from_a_value_frame_leaves_no_plaintext_on_the_heap", "a_record_decrypted_with_a_nested_field_leaves_no_plaintext_on_the_heap", "a_json_document_read_from_a_frame_leaves_no_plaintext_on_the_heap", - "the_spy_sees_an_unwiped_free" + "the_spy_sees_an_unwiped_free", + "opaque_blob::an_opaque_blob_encrypted_leaves_no_plaintext_on_the_heap", + "opaque_blob::an_opaque_blob_decrypted_leaves_no_plaintext_on_the_heap", + "opaque_blob::a_partial_decode_drops_the_fields_it_built_unwiped" ], "guest-abi": [ "buffers::tests::wipe_all_leaves_no_live_buffer", @@ -183,8 +225,12 @@ { "id": "lifecycle-validation-before-io-v1", "evidence": { - "runtime": ["shared_record_runtime_profile"], - "reads": ["shared_record_read_profile"], + "runtime": [ + "shared_record_runtime_profile" + ], + "reads": [ + "shared_record_read_profile" + ], "pending": [ "target::pending::tests::an_over_long_descriptor_is_refused_before_any_call", "target::pending::tests::a_lazy_column_stops_building_after_a_local_failure" @@ -193,13 +239,21 @@ "TestRuntimeConformance", "TestReadConformance", "TestMismatchedPlanIsRefusedBeforeTheGuest" + ], + "opaque": [ + "an_encoding_refusal_wipes_the_source_and_requests_no_key", + "the_owned_source_is_wiped_when_context_validation_fails", + "authentication_comes_before_decoding", + "malformed_and_mismatched_blobs_are_refused_by_class_after_authentication" ] } }, { "id": "lifecycle-logical-batching-v1", "evidence": { - "runtime": ["shared_record_runtime_profile"], + "runtime": [ + "shared_record_runtime_profile" + ], "pending": [ "target::pending::tests::one_pending_asking_for_many_keys_is_one_call", "target::pending::tests::all_merges_a_column_into_one_call_preserving_order", @@ -233,6 +287,11 @@ "status": "limited-observation", "evidence": "packages/stack-encrypt/tests/heap_hygiene.rs" }, + { + "id": "native-partial-decode-fields", + "status": "known-gap", + "evidence": "packages/stack-encrypt/tests/heap_hygiene.rs::opaque_blob::a_partial_decode_drops_the_fields_it_built_unwiped" + }, { "id": "caller-owned-host-values", "status": "outside-guarantee" diff --git a/languages/golang/encrypt/gensupport/gensupport_internal_test.go b/languages/golang/encrypt/gensupport/gensupport_internal_test.go index cb770bd87..f5dd78e3e 100644 --- a/languages/golang/encrypt/gensupport/gensupport_internal_test.go +++ b/languages/golang/encrypt/gensupport/gensupport_internal_test.go @@ -3,6 +3,7 @@ package gensupport import ( "bytes" "context" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -580,3 +581,44 @@ func TestEncryptNullOnlyForASealedNull(t *testing.T) { t.Fatalf("age: EncryptNull = %v", err) } } + +// TestOpaqueReadsARustWrittenBlob is the Go half of the storage exchange +// with Rust's stack-encrypt: the blob its OpaqueBlob writes +// (pinned as RUST_BLOB in packages/stack-encrypt/tests/opaque_blob.rs, +// where this struct's Go-written bytes are read in turn) opens into the +// Go shape, Rust's Some marker included. The two SDKs share the storage +// profile, so an opaque column is one format whichever wrote it. +func TestOpaqueReadsARustWrittenBlob(t *testing.T) { + type profile struct { + Name string `vc:"name"` + Age uint32 `vc:"age"` + Tags []string `vc:"tags"` + Nickname *string `vc:"nickname"` + } + rust, err := hex.DecodeString("56435301f104000000040000006e616d650a0c000000416461204c6f76656c6163650300000061676506240000000400000074616773f0020000000a050000006d617468730a07000000656e67696e6573080000006e69636b6e616d65f50a03000000616461") + if err != nil { + t.Fatal(err) + } + var got profile + if err := Opaque(Values{OpaqueField: rust}, &got); err != nil { + t.Fatalf("Opaque of a Rust-written blob: %v", err) + } + if got.Name != "Ada Lovelace" || got.Age != 36 || !reflect.DeepEqual(got.Tags, []string{"maths", "engines"}) || got.Nickname == nil || *got.Nickname != "ada" { + t.Fatalf("Opaque of a Rust-written blob = %+v", got) + } + // Rust writes every Some with the storage profile's marker; Go writes + // a non-nil pointer as the bare value. The bytes differ in that one + // byte, and both read as the same value here. + nick := "ada" + ours, err := vcffi.MarshalStorage(profile{Name: "Ada Lovelace", Age: 36, Tags: []string{"maths", "engines"}, Nickname: &nick}) + if err != nil { + t.Fatal(err) + } + if len(ours)+1 != len(rust) { + t.Fatalf("Go wrote %d bytes, Rust %d; expected to differ by the one Some marker", len(ours), len(rust)) + } + var back profile + if err := Opaque(Values{OpaqueField: ours}, &back); err != nil || !reflect.DeepEqual(back, got) { + t.Fatalf("Opaque of the Go-written blob: %v %+v", err, back) + } +} diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 74b5d3df6..4b35ab87b 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking +- **`OpaqueDecode` no longer has vitaminc's `Decrypt` as a supertrait.** The + trait is sealed, so this changes nothing for a caller naming a type that + implements it; generic code bounded on `P: OpaqueDecode` that also used + `P::sink()` or another `Decrypt` method adds a `P: Decrypt` bound. The + capability moved into the sealed trait so that `OpaqueBlob`, which + opens through a bytes leaf and decodes after, can implement it. - 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. @@ -18,6 +24,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. +### Added + +- **`OpaqueBlob`: encrypt a whole value as one opaque `Ciphertext`** + (#1484). `keyset.encrypt(OpaqueBlob::new(value))` encodes any + `Serialize + Zeroize` value with Vitamin C's versioned storage profile + (`vitaminc_aead_frame::storage`, version 1: a `VCS` header and version + byte inside the plaintext, explicit `Some` markers, typed map keys, a + frozen specification and golden corpus) and seals the encoding as one + bytes leaf of the unchanged `SealedValue` v1 envelope, through the same + path as a scalar: one data key, one ciphertext, no new KMS request shape. + `keyset.decrypt::>(ct)` authenticates the leaf, then reads + it back into any `DeserializeOwned + Zeroize` type; `into_inner()` takes + the value. It is the Go SDK's opaque-struct format (`vcffi.MarshalStorage`), + so a Go-written opaque column opens in Rust and a Rust-written one in Go + (fixed vectors in both test suites). There is still no `Serialize` + fallback: a struct handed to `encrypt` without the wrapper does not + compile (`tests/ui/opaque_blob_requires_the_wrapper.rs`), and neither + does an `OpaqueBlob` of a type that is not `Zeroize`. `OpaqueBlob::encode` + and `decode` expose the codec itself, `blob::HEADER` and `blob::VERSION` + the discriminator. The `blob` module docs give the supported types, the + schema-evolution rules (serde's), what the ciphertext's length reveals (the + encoded length plus a constant: no padding, no constant-time claim) and + what is wiped; ADR-0011 records the decision. +- **`Error::Blob(BlobError)`**, transparent, one variant and code per class + of refusal: `blob_unwritable` (the value cannot be encoded; raised before + any key is requested), and after the leaf authenticated, `blob_not_a_blob`, + `blob_unsupported_version` (a later storage version: upgrade the reader), + `blob_malformed` and `blob_mismatch` (a well-formed blob of another shape or + type). Every decrypt-side `Blob` error means the ciphertext was authentic; + tampering and a wrong context are still `Aead`, and a blob opened as a + scalar or a scalar as a blob is `OpenedType`. No message or payload names a + value; a mismatch's payload names the declared field. +- **`Zeroizing` as the opt-in drop-guarded result** of the scalar API: + `OpaqueDecode` is implemented for `Zeroizing

` of any decodable `P`, so + `keyset.decrypt::>>(ct)` (or + `Zeroizing`) returns the opened value wrapped and wiped on drop. A + plain `P` is still an ordinary owned value that nothing wipes on drop; + `Zeroize` is the bound, not a guard. `OpaqueEncode` is implemented for + `Zeroizing` too. +- **The owned source is wiped on an encoding refusal** as on success, before + the pending is returned and with no key requested; the owned blob buffer is + `Protected` while it is planned and on decrypt while it is read. The heap + observer covers blob encrypt, decrypt and a refused shape, and pins as a + negative control the one stated limit: a derived `Deserialize` that built + some fields and refuses a later one drops those fields unwiped, because no + `T` existed to wipe. + +### Changed + +- The vitaminc crates move from 0.8.0 to 0.8.1, the first release with the + storage profile. Nothing 0.8.0 wrote or read changes: the leaf format, the + transport frame and every existing ciphertext are unaffected. + `vitaminc-aead-frame` is now built unconditionally (with `serde`), where it + was behind the `dynamic` feature; that feature adds no dependency now. + ## [0.3.0] - 2026-10-27 ### Breaking diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 27e75f80b..134cfc8a3 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -53,9 +53,12 @@ rand = { version = "0.10.1", default-features = false } # `future::join` and `join_all`: a pending's dispatch loads the keysets its # JSON documents need beside the data-key retrieval, in one await. futures = { version = "0.3.25", default-features = false } -# `dynamic` only: serde over the transport frame, which reads a binding's +# Two things: the storage profile (`storage`, behind `serde`) that +# `OpaqueBlob` encodes a whole value with before sealing it as one leaf, +# the same versioned bytes the Go SDK writes through `vcffi.MarshalStorage`; +# and, for `dynamic`, serde over the transport frame, which reads a binding's # control data (statistics, options, a plan) into typed messages. -vitaminc-aead-frame = { workspace = true, optional = true, features = ["serde"] } +vitaminc-aead-frame = { workspace = true, features = ["serde"] } serde = { workspace = true } # The order terms' serde: lowercase hex in JSON, raw bytes in a binary # format, through serdect's constant-time hex. @@ -109,8 +112,10 @@ default = ["http"] http = ["dep:stack-auth", "stack-auth/http", "stack-kms/http"] # `stack_encrypt::dynamic`: encrypt and decrypt values whose types are known # only at runtime, as an FFI binding's are. Off by default — it is only of -# use to a binding author. Its transport frames are vitaminc-aead's own. -dynamic = ["dep:vitaminc-aead-frame"] +# use to a binding author. Its transport frames are vitaminc-aead's own, and +# the crate they come from is always built (the storage profile `OpaqueBlob` +# uses lives there), so the feature adds no dependency. +dynamic = [] # Conversions between chrono's `NaiveDate` / `DateTime` and # `value::Date` / `value::Timestamp`. chrono = ["dep:chrono", "vitaminc-aead/chrono"] @@ -180,3 +185,7 @@ all-features = true [[example]] name = "scalar" required-features = ["http"] + +[[example]] +name = "opaque_blob" +required-features = ["http"] diff --git a/packages/stack-encrypt/docs/adr/0011-a-whole-value-is-one-storage-blob-sealed-as-one-leaf.md b/packages/stack-encrypt/docs/adr/0011-a-whole-value-is-one-storage-blob-sealed-as-one-leaf.md new file mode 100644 index 000000000..57d11a2de --- /dev/null +++ b/packages/stack-encrypt/docs/adr/0011-a-whole-value-is-one-storage-blob-sealed-as-one-leaf.md @@ -0,0 +1,129 @@ +--- +status: accepted +date: 2026-11-02 +extends: ADR-0003, ADR-0010 +--- + +# A whole value is one storage blob, sealed as one leaf, and only when named + +The owned scalar API (`KeysetCipher::encrypt`) seals one scalar as one +`Ciphertext` and refuses everything else: there is no `Serialize` fallback, +so a struct handed to `encrypt` does not compile. That leaves a real need +unmet. An application often has a value it wants stored as **one** +ciphertext, under one data key, with nothing inside it searchable: a card, +a profile, a settings object. The structural API seals a struct as one leaf +per field, which is a different thing (one key per field, a tree of +ciphertexts, a record shape to store), and a searchable JSON document is a +third. This ADR records how a whole value becomes one ciphertext. + +## Decision + +**`OpaqueBlob` is the one way to encrypt a composite value as one +ciphertext, and it is explicit.** `encrypt(OpaqueBlob::new(value))` and +`decrypt::>(ct)`. The wrapper implements the sealed +`OpaqueEncode` / `OpaqueDecode` capabilities; `T` does not, and no blanket +impl over `Serialize` is added. A reviewer can find every whole-value +ciphertext in a codebase by searching for the name. + +**The encoding is Vitamin C's storage profile, version 1, and nothing +else.** `vitaminc_aead_frame::storage` is the library-owned codec: a +self-describing binary encoding built from the sealed leaf's own scalar +tags and the transport frame's containers, with a header (`VCS` and a +version byte), a marker for a present `Option`, typed map keys and a frozen +specification pinned by a golden corpus. It is the format the Go SDK +already writes and reads for its opaque structs (`vcffi.MarshalStorage`, +`UnmarshalStorage`), so a Go-written opaque column and a Rust `OpaqueBlob` +are one stored format, and this crate adds no second wire format. The +transport serde codec in the same crate is not used: it is ephemeral by +design and carries no compatibility commitment. + +**The encoded blob is sealed as one bytes leaf of the ordinary envelope, +through the scalar path.** No new envelope, no new KMS request shape, no +new AAD: the ciphertext is exactly what sealing a `Vec` of the encoded +bytes produces, and `SealedValue` v1 is unchanged. The header is inside the +plaintext, so the discriminator and the version are encrypted and +authenticated with the value, and nothing about a blob is visible before +decryption. + +**Decryption authenticates before it decodes, and refusals are told +apart.** The leaf opens first; only then does the profile's reader see the +bytes, and it validates the whole blob before `T`'s `Deserialize` sees any +of it. A leaf that does not open is `Error::Aead`. A leaf that opens but is +not a bytes leaf is `Error::OpenedType`. Bytes that are not a blob this +type reads are `Error::Blob`, one `BlobError` variant and one code per +class: not a blob, an unsupported (later) version, malformed, or a +well-formed blob of another shape. Every `Error::Blob` is a refusal of an +authentic ciphertext, consistent with ADR-0010: the data was written as +something else, not tampered with. + +**The version is the profile's, not the application's.** `blob::VERSION` +names the byte grammar and the serde mapping together; version 1 is frozen +and a reader reports a later version apart from corruption. An +application's own schema evolves through `T` and serde's attributes +(`default`, `alias`, `Option`), and an application that must tell its own +versions apart carries that in the value. + +**Ownership and cleanup follow the scalar API's contract.** `T: Zeroize` is +required to encrypt and to decrypt. On encrypt the owned source is guarded, +encoded into a buffer the profile returns `Protected`, planned, and wiped +before the pending is returned; an encoding refusal wipes it too and +requests no key. On decrypt the opened bytes are held `Protected` until the +reader returns. The returned `OpaqueBlob` is an ordinary owned value +and is **not wiped on drop**; `Zeroizing>` is the opt-in +drop-guarded result, implemented for `Zeroizing` of any decodable type +rather than as a new adapter. + +## What is not claimed + +- **Constant time.** Neither serde's impls nor the profile's writer and + reader are constant time, and `OpaqueBlob` does not make them so. The + cryptography is unchanged; the encoding runs on plaintext the caller + already holds. +- **Length hiding.** AES-GCM keeps the plaintext's length and the profile + adds no padding, so the ciphertext's length is the encoded length plus a + constant, and the encoded length depends on string lengths, element + counts and whether each `Option` is present. The same disclosure a + `String` scalar makes; padding is the application's. +- **Complete erasure.** A derived `Deserialize` that built some fields and + refuses a later one drops those fields unwiped, because no `T` existed to + wipe; `tests/heap_hygiene.rs` holds this as a negative control rather + than hiding it. A `Copy` value moved out of leaves its bytes behind, and + compiler temporaries are not wiped. The promise covers the owned source, + the encoded buffer, the opened buffer and the library's copies. +- **Searchability or partial reads.** A blob is opaque. Nothing inside it + can be queried or read on its own; that is what the structural API and + the JSON index are for. + +## Alternatives considered + +- **A blanket `impl OpaqueEncode for T: Serialize`.** Rejected: it decides + the stored bytes of every composite silently, lets a `Vec` or a + struct become one ciphertext by accident, and cannot be told apart from + the structural API at a call site. +- **The transport serde codec.** Rejected: it is lockstep transport with + no compatibility commitment, and it collapses `Some(None)` and keeps no + map-key kinds. The storage profile exists because of exactly these gaps. +- **MessagePack or another general format.** Rejected upstream, where the + profile's specification records why: no header or version, options that + collapse, struct layout left to each library, repeated keys and integer + widths left to the reader, and plaintext copies left in buffers. +- **A `Decrypt` impl so a blob can be a field of a structural record.** + Deferred: the sink's error type cannot carry the blob's refusal classes, + so it would report every blob error as a shape mismatch. The owned API + is the contract this ADR settles; a field-level blob is separate work. + +## Consequences + +- `OpaqueEncode` and `OpaqueDecode` stay sealed and gain `OpaqueBlob` + and `Zeroizing<_>` implementors. `OpaqueDecode` no longer requires + vitaminc's `Decrypt`, since a blob opens through a bytes leaf and decodes + after; the sealed `open` is the capability instead. +- `Error` gains `Blob(BlobError)`, transparent, with five codes under + `stack_encrypt::blob_*`. +- The crate depends on `vitaminc-aead-frame` with its `serde` feature + unconditionally (0.8.1, the first release with the profile); the + `dynamic` feature no longer gates that dependency. +- The conformance coverage map enrolls the fixed blob vectors, the + Rust/Go storage exchange and the heap observations under the existing + requirements rather than a new profile; a fresh sealed exchange with Go + (Go seals, Rust opens, and back) is still open and listed as such. diff --git a/packages/stack-encrypt/examples/opaque_blob.rs b/packages/stack-encrypt/examples/opaque_blob.rs new file mode 100644 index 000000000..42ae07503 --- /dev/null +++ b/packages/stack-encrypt/examples/opaque_blob.rs @@ -0,0 +1,107 @@ +//! Encrypt a whole struct as one ciphertext with `OpaqueBlob`. +//! +//! Run with ZeroKMS credentials: `cargo run -p stack-encrypt --example opaque_blob`. +//! Authenticate with `npx stash auth login`, or configure the `CS_*` credentials +//! described in the crate's quick start. Initialization fails without credentials. +use serde::{Deserialize, Serialize}; +use stack_encrypt::{BlobError, Ciphertext, Error, OpaqueBlob, StackCipher}; +use zeroize::{Zeroize, Zeroizing}; + +/// An ordinary application struct. `Serialize` and `Deserialize` give it a +/// storage encoding; `Zeroize` lets the library wipe the owned source once +/// it has been encoded, and lets a caller wipe a decrypted copy. +#[derive(Serialize, Deserialize, Zeroize, PartialEq, Debug)] +struct Profile { + name: String, + age: u32, + tags: Vec, + nickname: Option, +} + +/// The same data in a later shape: a field added with a default and one +/// renamed under an alias. An old blob still reads into it. +#[derive(Serialize, Deserialize, Zeroize, PartialEq, Debug)] +struct ProfileV2 { + name: String, + age: u32, + #[serde(alias = "tags")] + labels: Vec, + nickname: Option, + #[serde(default)] + verified: bool, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + let cipher = StackCipher::new().await?; + let keyset = cipher.default_keyset(); + + let profile = Profile { + name: "Ada Lovelace".into(), + age: 36, + tags: vec!["maths".into(), "engines".into()], + nickname: Some("ada".into()), + }; + + // One ciphertext for the whole value: one data key, one leaf. The owned + // struct is consumed and wiped before the request is awaited. + let encrypted = keyset + .encrypt_with_context(OpaqueBlob::new(profile), ("users", "profile")) + .await?; + + // Store and restore it like any other ciphertext. + let stored = encrypted.to_bytes(); + let restored = Ciphertext::from_bytes(&stored)?; + + // Decrypt to the wrapper and take the value out. The result is an + // ordinary owned value: nothing wipes it on drop. + let opened: OpaqueBlob = keyset + .decrypt_with_context(restored.clone(), ("users", "profile")) + .await?; + let profile = opened.into_inner(); + assert_eq!(profile.name, "Ada Lovelace"); + + // Or decrypt to `Zeroizing>`, the opt-in drop guard: the + // value is wiped when `guarded` goes out of scope. + let guarded: Zeroizing> = keyset + .decrypt_with_context(restored.clone(), ("users", "profile")) + .await?; + assert_eq!(guarded.get().age, 36); + drop(guarded); + + // Schema evolution is serde's: the blob written as a `Profile` reads + // into the later `ProfileV2`. + let later: OpaqueBlob = keyset + .decrypt_with_context(restored.clone(), ("users", "profile")) + .await?; + assert_eq!(later.get().labels, ["maths", "engines"]); + assert!(!later.get().verified); + + // The errors tell an authentic blob of another shape from tampering. + // A type the blob does not fit is a mismatch after authentication... + #[derive(Deserialize, Zeroize)] + struct Other { + #[allow(dead_code)] + id: u64, + } + match keyset + .decrypt_with_context::>(restored.clone(), ("users", "profile")) + .await + { + Err(Error::Blob(BlobError::Mismatch(_))) => {} + other => panic!("expected a blob mismatch, got {other:?}"), + } + // ...while the wrong context never reaches the blob at all. + match keyset + .decrypt_with_context::>(restored, ("users", "other")) + .await + { + // ZeroKMS refuses the key under the wrong context; a key source + // that ignores descriptors would fail the AEAD instead. + Err(Error::Kms(_) | Error::Aead) => {} + other => panic!("expected a refusal before decoding, got {other:?}"), + } + + println!("encrypted, stored, reopened and evolved one opaque blob"); + Ok(()) +} diff --git a/packages/stack-encrypt/src/blob.rs b/packages/stack-encrypt/src/blob.rs new file mode 100644 index 000000000..24f891033 --- /dev/null +++ b/packages/stack-encrypt/src/blob.rs @@ -0,0 +1,618 @@ +//! A whole value as one ciphertext: [`OpaqueBlob`]. +//! +//! The scalar API ([`KeysetCipher::encrypt`]) seals one scalar as one +//! [`Ciphertext`], and deliberately refuses everything else: a struct, a +//! `Vec`, a map. There is no `Serialize` fallback, because a fallback would +//! decide the stored bytes of every composite silently. `OpaqueBlob` is +//! the explicit way to say "encrypt this whole value as one ciphertext": +//! +//! ``` +//! use serde::{Deserialize, Serialize}; +//! use stack_encrypt::{OpaqueBlob, StackCipher}; +//! use zeroize::Zeroize; +//! +//! #[derive(Serialize, Deserialize, Zeroize, PartialEq, Debug)] +//! struct Profile { +//! name: String, +//! age: u32, +//! tags: Vec, +//! nickname: Option, +//! } +//! +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +//! let cipher = StackCipher::builder() +//! .kms(stack_encrypt::kms::FakeDataKeySource::new()) +//! .init() +//! .await?; +//! let keyset = cipher.default_keyset(); +//! +//! let profile = Profile { +//! name: "Ada Lovelace".into(), +//! age: 36, +//! tags: vec!["maths".into(), "engines".into()], +//! nickname: Some("ada".into()), +//! }; +//! // One ciphertext, one data key, one leaf: `encrypt` consumes the value +//! // and wipes it before the pending request is returned. +//! let ciphertext = keyset +//! .encrypt_with_context(OpaqueBlob::new(profile), ("users", "profile")) +//! .await?; +//! +//! let opened: OpaqueBlob = keyset +//! .decrypt_with_context(ciphertext, ("users", "profile")) +//! .await?; +//! assert_eq!(opened.get().name, "Ada Lovelace"); +//! assert_eq!(opened.into_inner().tags.len(), 2); +//! # Ok::<(), stack_encrypt::Error>(()) +//! # }).unwrap(); +//! ``` +//! +//! What this is not: it is not the structural API, which seals a struct as +//! a tree of leaves, one data key and one ciphertext per field +//! ([`KeysetCipher::encrypt_tree`], the derives); it is not a batch, which +//! is many values and many ciphertexts in one request +//! ([`Pending::all`](crate::Pending::all)); and it is not a searchable +//! document ([`json`](crate::json)). A blob is one opaque value: nothing +//! inside it can be queried, and it is read back whole or not at all. +//! +//! # The codec +//! +//! The value is encoded with Vitamin C's **storage profile**, version 1 +//! (`vitaminc_aead_frame::storage`), and the encoded bytes are sealed as +//! one **bytes leaf** of the ordinary [`SealedValue`] v1 envelope. Nothing +//! else is new: no second envelope, no new key request, no new AAD. The +//! encoded blob is what the ciphertext would hold if the caller had sealed +//! a `Vec` holding those bytes. +//! +//! The profile is a self-describing binary encoding built from the sealed +//! leaf's own scalar tags and the transport frame's containers, with four +//! things added for bytes that are kept: a header naming the profile and +//! its version, a marker for a present `Option`, map keys that keep their +//! kind, and a compatibility commitment. Its specification is normative and +//! lives with the crate that implements it (`STORAGE.md` in +//! `vitaminc-aead-frame`), pinned by a golden corpus every implementation +//! must pass. The Go SDK writes and reads the same profile through `vcffi`: +//! a Go opaque struct and a Rust `OpaqueBlob` are one stored format. +//! +//! Every blob starts with [`HEADER`]: the ASCII bytes `VCS`, then the +//! version byte [`VERSION`]. The header is inside the plaintext, so it is +//! encrypted with the value and authenticated by the leaf's AEAD tag: it is +//! the authenticated discriminator that tells a blob from any other bytes +//! leaf, and its version from any later one. Nothing about a blob, its +//! version included, is visible before decryption. +//! +//! ## Supported types +//! +//! `T` is any `serde::Serialize` type to encrypt and any +//! `serde::de::DeserializeOwned` type to decrypt, in both cases also +//! [`Zeroize`] (see [Cleanup](#cleanup)). The mapping is serde's data +//! model, with these rules: +//! +//! | serde | blob node | +//! |------------------------------------|-----------------------------------------------------| +//! | `bool` | bool | +//! | `i8`…`i128`, `u8`…`u128` | an integer of that exact width (`u8` is admitted) | +//! | `f32`, `f64` | a float of that width, by its bits (NaN kept) | +//! | `char`, `&str`, `String` | text (UTF-8) | +//! | bytes (`serde_bytes`) | bytes; a `Vec` without it is an array of `u8` | +//! | `()`, unit struct, `None` | null | +//! | `Some(v)` | a presence marker, then `v` | +//! | sequence, tuple, tuple struct | array | +//! | struct | object, by field name | +//! | map | map; keys must be integers, text or bytes | +//! | enum | externally tagged: a unit variant is its name, any other an object of one entry | +//! +//! Refused when writing ([`BlobError::Unwritable`]): nesting deeper than +//! 128 levels, text or bytes longer than `u32::MAX`, a map key of another +//! kind, a repeated key, a container whose declared length is not the +//! number of children it wrote, or a value its own `Serialize` refused. +//! The profile carries plain scalars only: the logical types the sealed +//! leaf has (a date, a timestamp, a decimal, a UUID) are carried as what +//! their serde impls write (text, usually), not as those leaf kinds. +//! +//! Blobs are not canonical. Equal values of different types write +//! different bytes (`1u8` and `1u32`), a `HashMap` writes in its iteration +//! order, and a reader accepts forms a writer does not produce. Compare +//! values, not blobs. +//! +//! ## Versioning and schema evolution +//! +//! [`VERSION`] names the byte grammar and the serde mapping together. It is +//! the profile's version, not the application's, and version 1 is frozen: +//! no release changes what a version 1 blob reads as. A reader meeting a +//! version it does not read reports [`BlobError::UnsupportedVersion`], +//! distinct from a corrupt blob, so "upgrade the reader" and "the data is +//! damaged" are different errors. A new version will be introduced in two +//! steps, readers first, writers later, and this crate's changelog will say +//! which version a release writes. +//! +//! The application's own schema evolves through the type, and serde's +//! attributes govern what an old blob does against a new `T`: +//! +//! | Change to `T` | An old blob | +//! |---------------------------------------------|------------------------------------------------------| +//! | Add a field with `#[serde(default)]` | reads, with the default | +//! | Add a field without a default | is refused: [`BlobError::Mismatch`] | +//! | Remove a field | reads, unless `T` denies unknown fields | +//! | Rename a field | reads with `#[serde(alias = "…")]` | +//! | `F` to `Option` | reads as `Some` | +//! | `Option` to `F` | is refused if the blob held `Some` | +//! | Add an enum variant | reads; an older reader refuses the new variant | +//! | Widen an integer | reads; narrowing reads only values that fit | +//! +//! An application that needs to tell its own versions apart carries that in +//! the value (a version field, or an enum of versions). The blob header +//! never changes because `T` did. +//! +//! ## Decode errors, and when they can occur +//! +//! Decryption **authenticates before it decodes**. The leaf opens first, +//! under its key and context; only then does the profile's reader see the +//! bytes. So the errors tell three things apart: +//! +//! - [`Error::Aead`]: the leaf did not open. Wrong key, wrong context, or +//! altered bytes; the blob was never looked at. +//! - [`Error::OpenedType`]: the leaf opened but is not a bytes leaf: it was +//! sealed as a `String` or an integer, say, not as a blob. +//! - [`Error::Blob`]: the leaf opened as bytes and they are not a blob this +//! type reads: not a blob at all ([`BlobError::NotABlob`]), a later +//! version ([`BlobError::UnsupportedVersion`]), a blob that breaks a rule +//! of version 1 ([`BlobError::Malformed`]), or a well-formed blob that is +//! not a `T` ([`BlobError::Mismatch`]). +//! +//! All three are refusals of an authentic ciphertext, so an `Error::Blob` +//! never says the data was tampered with; it says the data was written as +//! something else. The whole blob is validated before `T`'s `Deserialize` +//! sees any of it: every length is checked against the end of the blob, so +//! a forged length allocates nothing, and a `Deserialize` impl never builds +//! part of a value from a malformed blob. No refusal names a value or a map +//! key; a mismatch names the declared field it is about. +//! +//! ## What the ciphertext reveals +//! +//! The leaf is AES-256-GCM, which keeps the plaintext's length, and the +//! profile adds no padding. A blob ciphertext is therefore the encoded +//! length plus a fixed overhead, and the encoded length is a function of +//! the value: the length of each string, the number of elements in each +//! sequence and map, and whether each `Option` is `Some` (the marker and +//! the value) or `None` (one null byte). Two profiles that differ only in +//! a nickname being present seal to ciphertexts of different lengths. This +//! is the same disclosure a `String` scalar makes of its length; a value +//! whose size must not be visible needs padding the application adds, as a +//! fixed-length field of its own. +//! +//! Neither serde's derived impls nor the profile's writer and reader are +//! constant time, and nothing here claims otherwise. Encoding takes time +//! that depends on the value, as serializing any value does. The +//! cryptography is Vitamin C's and is unchanged; what this module adds runs +//! before the seal and after the open, on plaintext the caller already +//! holds. +//! +//! ## Cleanup +//! +//! On encrypt, the owned `OpaqueBlob` is guarded in [`Protected`] by the +//! owned conversion before anything runs, encoded through a borrow into a +//! buffer the profile's writer returns `Protected` (it grows into fresh +//! allocations and wipes the old ones, writes a `Display` value straight +//! into the blob, and wipes what it wrote if writing fails), and that +//! buffer is planned into the leaf and dropped, wiped, before `encrypt` +//! returns its pending. The source is wiped through `T: Zeroize` at the +//! same point, on success and on an encoding refusal alike. Nothing of the +//! plaintext travels in the pending. +//! +//! On decrypt, the opened bytes are held `Protected` while the reader runs, +//! and wiped when it returns, whether it returned a value or a refusal. The +//! reader borrows text and bytes from that buffer rather than copying them. +//! The value it returns is an ordinary owned `OpaqueBlob`: the caller +//! owns it, and **nothing wipes it on drop**. `T: Zeroize` is the bound +//! that makes wiping possible, not a guard that performs it. To have the +//! result wiped when it is dropped, decrypt to `Zeroizing>` +//! ([`OpaqueDecode`] is implemented for `Zeroizing` +//! of any decodable type), or give `T` a `Drop` of its own. +//! +//! One limit is a property of serde, and is stated rather than solved: a +//! derived `Deserialize` that has read some fields and then refuses a later +//! one drops the fields it built as ordinary values, without `T::zeroize`, +//! because no `T` ever existed to wipe. The profile's up-front validation +//! means this happens only for a well-formed blob of another shape +//! ([`BlobError::Mismatch`]), never for a malformed one, and the bytes +//! those fields were copied from are wiped with the buffer. A type whose +//! fields are themselves wiped on drop (`Zeroizing`, a +//! `#[derive(ZeroizeOnDrop)]` type) closes it for those fields. +//! +//! A Rust move is not an erasure: a `Copy` field leaves its bytes where it +//! was moved from, and compiler temporaries are not wiped. The promise here +//! covers the owned source, the encoded buffer, the opened buffer and the +//! library's own copies, not every byte a program's stack once held. +//! +//! # Go +//! +//! The Go SDK's opaque struct (a `stashgen` declaration with one `value` +//! field, sealed as a storage blob under the context `/value`) is +//! this format: a Rust program opens such a column with +//! `decrypt_with_context::>(ct, ("", "value"))`, +//! with `T`'s serde field names matching the Go struct's `vc` tags, and +//! Go reads a Rust-written blob the same way. Two qualifications, both the +//! Go writer's: a nil slice or map is written as an empty one, and a +//! non-nil pointer is written as the bare value (Rust writes every `Some` +//! with its marker; both readers read either form). +//! +//! [`Error::Aead`]: crate::Error::Aead +//! [`Error::OpenedType`]: crate::Error::OpenedType +//! [`Error::Blob`]: crate::Error::Blob +//! [`KeysetCipher::encrypt`]: crate::KeysetCipher::encrypt +//! [`KeysetCipher::encrypt_tree`]: crate::KeysetCipher::encrypt_tree +//! [`Ciphertext`]: crate::Ciphertext +//! [`SealedValue`]: crate::SealedValue + +use std::fmt; + +use serde::de::DeserializeOwned; +use serde::Serialize; +use vitaminc_aead_frame::serde::PathSegment; +use vitaminc_aead_frame::storage::{self, ErrorKind}; +use vitaminc_protected::{Controlled, Protected}; +use zeroize::Zeroize; + +use crate::scalar::sealed; +use crate::target::Pending; +use crate::{CipherText, Ciphertext, ContextPiece, KeysetCipher, OpaqueDecode, OpaqueEncode}; +use crate::{SealedValue, StackCipher}; + +/// The four bytes every blob starts with: `VCS`, then [`VERSION`]. Inside +/// the plaintext, so encrypted and authenticated with the value. +pub const HEADER: [u8; 4] = storage::HEADER; + +/// The storage profile version this release writes, and the only one it +/// reads. A blob of a later version is [`BlobError::UnsupportedVersion`]. +pub const VERSION: u8 = storage::VERSION; + +/// A whole value, encrypted as one opaque [`Ciphertext`]. +/// +/// Wrap the value to encrypt it (`keyset.encrypt(OpaqueBlob::new(v))`) +/// and name the wrapper to decrypt it +/// (`keyset.decrypt::>(ct)`), then [`into_inner`]. The +/// module docs give the format, its errors, what it reveals and what is +/// cleaned up. +/// +/// `Debug` prints no plaintext. The wrapper is an ordinary owned value: +/// it is not wiped on drop (decrypt to `Zeroizing>` for +/// that), and it has no serde impls of its own, so that a blob is never +/// written by accident where a `T` was meant. +/// +/// [`into_inner`]: OpaqueBlob::into_inner +#[derive(Clone, PartialEq, Eq, Default)] +pub struct OpaqueBlob(T); + +impl OpaqueBlob { + /// Wrap a value to encrypt it whole. + pub const fn new(value: T) -> Self { + Self(value) + } + + /// The value. + pub fn into_inner(self) -> T { + self.0 + } + + /// A borrow of the value. + pub const fn get(&self) -> &T { + &self.0 + } + + /// A mutable borrow of the value. + pub fn get_mut(&mut self) -> &mut T { + &mut self.0 + } +} + +impl From for OpaqueBlob { + fn from(value: T) -> Self { + Self(value) + } +} + +impl fmt::Debug for OpaqueBlob { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("OpaqueBlob(***)") + } +} + +impl Zeroize for OpaqueBlob { + fn zeroize(&mut self) { + self.0.zeroize(); + } +} + +impl OpaqueBlob { + /// The value as a storage blob: the plaintext the ciphertext seals, + /// starting with [`HEADER`]. Returned `Protected`, and wiped when it is + /// dropped. Encrypting does this itself; it is here for a caller that + /// needs the bytes, to check them against another writer's, say. + /// + /// # Errors + /// + /// [`BlobError::Unwritable`] for a value the profile cannot hold. + pub fn encode(&self) -> Result>, BlobError> { + storage::to_vec(&self.0).map_err(BlobError::Unwritable) + } +} + +impl OpaqueBlob { + /// Read a value from a storage blob: what decrypting does to the opened + /// bytes. The caller's `bytes` are borrowed and not wiped; the opened + /// buffer decrypting reads from is. + /// + /// # Errors + /// + /// One [`BlobError`] per class: not a blob, a later version, malformed, + /// or a well-formed blob that is not a `T`. + pub fn decode(bytes: &[u8]) -> Result { + storage::from_slice::(bytes) + .map(Self) + .map_err(|error| BlobError::reading(error, bytes)) + } +} + +impl sealed::Encode for OpaqueBlob { + fn seal<'a, K: 'static>( + &self, + cipher: &'a KeysetCipher<'_, K>, + context: ContextPiece<'static>, + ) -> Pending<'a, Ciphertext, K> { + let blob = match self.encode() { + Ok(blob) => blob, + Err(error) => return Pending::failed(cipher, error.into()), + }; + // Planned as one bytes leaf, synchronously: the plaintext is copied + // into the pending's own guarded leaf here, and `blob` is wiped when + // this returns. The caller's source is wiped by the owned conversion + // that called us. + crate::target::core::encrypt_opaque(blob.risky_ref().as_slice(), cipher, context) + .try_map(Ciphertext::from_tree) + } +} +impl OpaqueEncode for OpaqueBlob {} + +impl sealed::Decode for OpaqueBlob { + fn open<'a, K: 'static>( + leaf: SealedValue, + cipher: &'a StackCipher, + context: ContextPiece<'static>, + ) -> Pending<'a, Self, K> { + // Authenticate first: the leaf opens as bytes, into a `Protected` + // buffer, and only then is it read as a blob. A leaf of another kind + // is `OpenedType`; a leaf that does not open is `Aead`. The buffer + // is wiped when `try_map`'s closure returns, value or refusal. + crate::target::core::open_opaque::>, K, _>( + CipherText::Single(leaf), + cipher, + context, + ) + .try_map(|bytes| Self::decode(bytes.risky_ref()).map_err(crate::Error::from)) + } +} +impl OpaqueDecode for OpaqueBlob {} + +/// Why an [`OpaqueBlob`] could not be written, or an authenticated +/// ciphertext did not read as one. One variant per class of refusal, each +/// with its own code, and none names a value: the profile's own errors +/// name kinds and declared field names only, and are kept as the source. +/// +/// On decrypt, every variant means the ciphertext **was authentic**: the +/// leaf opened under its key and context before the blob was looked at. +/// See the [module docs](self#decode-errors-and-when-they-can-occur). +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] +pub enum BlobError { + /// The value cannot be written as a storage blob: nested deeper than + /// 128 levels, text or bytes too long for a `u32` length, a map key + /// that is not an integer, text or bytes, a repeated key, or a value its + /// own `Serialize` refused. Raised before any key is requested. + #[error("the value cannot be written as a storage blob")] + #[diagnostic( + code(stack_encrypt::blob_unwritable), + help("A blob holds serde's data model with integer, text or bytes map keys, distinct keys, and at most 128 levels of nesting. The source error names the kind refused and the declared field it is under.") + )] + Unwritable(#[source] storage::Error), + /// The ciphertext opened as bytes, but they do not start with + /// [`HEADER`]'s magic: the value was sealed as plain bytes, not as a + /// blob. + #[error("the ciphertext opened, but it does not hold a storage blob")] + #[diagnostic( + code(stack_encrypt::blob_not_a_blob), + help("The ciphertext is authentic and holds bytes that are not a storage blob. Open it as the type it was sealed as; a `Vec` sealed through the structural API is not an `OpaqueBlob`.") + )] + NotABlob, + /// The ciphertext opened and holds a blob of a storage version this + /// build does not read. The blob is not corrupt: a later release reads + /// it, so the reader needs upgrading. + #[error("the ciphertext opened, but it holds a storage blob of version {version}, which this build does not read")] + #[diagnostic( + code(stack_encrypt::blob_unsupported_version), + help("Upgrade stack-encrypt to a release that reads this storage version. The ciphertext is authentic and the blob is intact.") + )] + UnsupportedVersion { + /// The version byte the blob carries. + version: u8, + }, + /// The ciphertext opened and holds a blob of a version this build + /// reads, whose body breaks a rule of that version: truncated, trailing + /// bytes, an unknown tag, a repeated key, nested too deep. Found before + /// the type reads anything. + #[error("the ciphertext opened, but its storage blob is malformed")] + #[diagnostic( + code(stack_encrypt::blob_malformed), + help("The ciphertext is authentic, so the blob was written this way: by a writer that does not follow the storage profile, or by sealing bytes that happen to start with its header. It cannot be read by any version 1 reader.") + )] + Malformed(#[source] storage::Error), + /// The ciphertext opened and holds a well-formed blob, but not a value + /// of the type it was opened as: a missing field, text where a number + /// was expected, an unknown variant, an integer that does not fit. The + /// value was written as another type, or an older or newer shape of + /// this one. + #[error("the ciphertext opened and holds a storage blob, but not a value of the type it was opened as")] + #[diagnostic( + code(stack_encrypt::blob_mismatch), + help("The ciphertext is authentic. Open it as the type it was written as, or make the type read the old shape (`#[serde(default)]`, `#[serde(alias = \"…\")]`, an `Option`). The source error names the declared field the refusal is about, never the value.") + )] + Mismatch(#[source] storage::Error), +} + +impl BlobError { + /// The profile's refusal of `bytes`, in this crate's classes. + fn reading(error: storage::Error, bytes: &[u8]) -> Self { + match error.kind() { + ErrorKind::NotABlob => Self::NotABlob, + ErrorKind::UnsupportedVersion => Self::UnsupportedVersion { + // The reader found a header and refused its version, so the + // byte is there; the fallback is for a reader that changes + // its mind about what counts as a header. + version: bytes.get(HEADER.len() - 1).copied().unwrap_or(0), + }, + ErrorKind::Mismatch => Self::Mismatch(error), + // The reader never says `Unwritable`. A class a later profile + // release adds is one this build has no reading for, which is + // what a malformed blob is to it. + ErrorKind::Malformed | ErrorKind::Unwritable | _ => Self::Malformed(error), + } + } + + /// The declared field names and positions from the blob's root to the + /// node the refusal is about, as the source error records them. A map + /// key, which is data, is never among them: such an entry is recorded + /// by its position. + fn path(error: &storage::Error) -> serde_json::Value { + error + .path() + .iter() + .map(|segment| match segment { + PathSegment::Key(key) => serde_json::Value::from(key.as_str()), + PathSegment::Index(index) => serde_json::Value::from(*index), + PathSegment::Entry(index) => serde_json::json!({ "entry": index }), + }) + .collect() + } +} + +impl crate::ErrorPayload for BlobError { + fn payload(&self) -> serde_json::Map { + use crate::diagnostic::payload; + match self { + Self::Unwritable(error) | Self::Malformed(error) | Self::Mismatch(error) => { + payload([("path", Self::path(error))]) + } + Self::NotABlob => serde_json::Map::new(), + Self::UnsupportedVersion { version } => payload([ + ("version", (*version).into()), + ("supported", VERSION.into()), + ]), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ErrorPayload; + use serde::Deserialize; + + #[derive(Serialize, Deserialize, Zeroize, PartialEq, Debug)] + struct Pair { + left: String, + right: u32, + } + + #[test] + fn a_blob_starts_with_the_header_and_reads_back() { + let pair = OpaqueBlob::new(Pair { + left: "l".into(), + right: 7, + }); + let blob = pair.encode().unwrap(); + assert_eq!(&blob.risky_ref()[..4], &HEADER); + assert_eq!(HEADER, *b"VCS\x01"); + assert_eq!(OpaqueBlob::::decode(blob.risky_ref()).unwrap(), pair); + } + + #[test] + fn every_class_of_refusal_is_its_own_variant() { + let blob = OpaqueBlob::new(Pair { + left: "l".into(), + right: 7, + }) + .encode() + .unwrap(); + let read = |bytes: &[u8]| OpaqueBlob::::decode(bytes).unwrap_err(); + + assert!(matches!(read(b""), BlobError::NotABlob)); + assert!(matches!(read(b"VC"), BlobError::NotABlob)); + assert!(matches!(read(b"{\"left\":\"l\"}"), BlobError::NotABlob)); + let later = read(b"VCS\x02\x00"); + assert!(matches!( + later, + BlobError::UnsupportedVersion { version: 2 } + )); + assert_eq!(later.payload()["version"], 2); + assert_eq!(later.payload()["supported"], 1); + assert!(matches!( + read(&blob.risky_ref()[..blob.risky_ref().len() - 1]), + BlobError::Malformed(_) + )); + assert!(matches!(read(b"VCS\x01\xff"), BlobError::Malformed(_))); + + // A well-formed blob of another shape names the declared field. + let other = OpaqueBlob::new(Pair { + left: "l".into(), + right: 7, + }); + #[derive(Serialize, Zeroize)] + struct Renamed { + left: String, + wrong: u32, + } + let renamed = OpaqueBlob::new(Renamed { + left: "l".into(), + wrong: 7, + }) + .encode() + .unwrap(); + let mismatch = read(renamed.risky_ref()); + assert!(matches!(mismatch, BlobError::Mismatch(_))); + let shown = format!("{mismatch} / {:?}", mismatch.payload()); + assert!(!shown.contains("\"l\""), "{shown}"); + drop(other); + } + + #[test] + fn debug_shows_no_plaintext() { + let blob = OpaqueBlob::new(Pair { + left: "secret".into(), + right: 7, + }); + assert_eq!(format!("{blob:?}"), "OpaqueBlob(***)"); + } + + #[test] + fn an_unwritable_value_is_refused_with_its_path() { + use std::collections::BTreeMap; + #[derive(Serialize, Zeroize)] + struct Keyed { + #[zeroize(skip)] + by_float: BTreeMap>, + #[zeroize(skip)] + deep: serde_json::Value, + } + let mut deep = serde_json::Value::Null; + for _ in 0..130 { + deep = serde_json::Value::Array(vec![deep]); + } + let value = OpaqueBlob::new(Keyed { + by_float: BTreeMap::new(), + deep, + }); + let error = value.encode().unwrap_err(); + assert!(matches!(error, BlobError::Unwritable(_))); + assert_eq!(error.payload()["path"][0], "deep"); + } +} diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 3a1b43b1b..0efa88ef0 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -399,6 +399,16 @@ pub enum Error { #[error(transparent)] #[diagnostic(transparent)] Plan(#[from] crate::plan::PlanError), + /// An [`OpaqueBlob`](crate::OpaqueBlob) could not be written as a + /// storage blob, or an authenticated ciphertext did not hold one its + /// type reads. Transparent: the message, code and help are the + /// [`BlobError`](crate::BlobError)'s, one per class of refusal. On + /// decrypt it is raised only after the leaf authenticated, so it never + /// stands in for [`Aead`](Self::Aead): the ciphertext is genuine and + /// holds a blob of another version, shape or type. + #[error(transparent)] + #[diagnostic(transparent)] + Blob(#[from] crate::BlobError), } /// How many separator-delimited parts a descriptor has: what an error about @@ -414,6 +424,7 @@ impl crate::ErrorPayload for Error { Self::Kms(error) => error.payload(), Self::Term(error) => error.payload(), Self::Plan(error) => error.payload(), + Self::Blob(error) => error.payload(), Self::KeyCountMismatch { expected, received } => payload([ ("expected", (*expected).into()), ("received", (*received).into()), diff --git a/packages/stack-encrypt/src/codes.rs b/packages/stack-encrypt/src/codes.rs index 77c45aa03..f2af81fd8 100644 --- a/packages/stack-encrypt/src/codes.rs +++ b/packages/stack-encrypt/src/codes.rs @@ -11,7 +11,7 @@ mod tests { use crate::diagnostic::is_code_of; use crate::sem::{MatchOptions, TermBytesError, TermError}; use crate::target::IndexSpec; - use crate::{Descriptor, Error, LabelError, LeafBytesError, PlanError}; + use crate::{BlobError, Descriptor, Error, LabelError, LeafBytesError, PlanError}; /// One row per variant of an enum, written `pattern => value`. The /// patterns are the arms of a match with no wildcard, so a variant with @@ -39,6 +39,12 @@ mod tests { rows.into_iter().map(crate::diagnostic::named) } + /// A storage profile error, from reading `bytes` as a `u32`: the + /// profile's errors have no public constructor, so one is provoked. + fn storage_error(bytes: &[u8]) -> vitaminc_aead_frame::storage::Error { + vitaminc_aead_frame::storage::from_slice::(bytes).unwrap_err() + } + /// One of every variant of every error type here. fn every_variant() -> Vec { let cause = || Box::new(std::io::Error::other("cause")); @@ -79,6 +85,14 @@ mod tests { Error::NoKeyset => Error::NoKeyset, Error::NotOpened => Error::NotOpened, Error::Plan(_) => Error::Plan(PlanError::NoContext), + Error::Blob(_) => Error::Blob(BlobError::NotABlob), + ])); + errors.extend(boxed(variants![ + BlobError::Unwritable(_) => BlobError::Unwritable(storage_error(b"VCS\x01\x0a\x01\x00\x00\x00")), + BlobError::NotABlob => BlobError::NotABlob, + BlobError::UnsupportedVersion { .. } => BlobError::UnsupportedVersion { version: 2 }, + BlobError::Malformed(_) => BlobError::Malformed(storage_error(b"VCS\x01\xff")), + BlobError::Mismatch(_) => BlobError::Mismatch(storage_error(b"VCS\x01\x0a\x01\x00\x00\x00x")), ])); errors.extend(boxed(variants![ LeafBytesError::UnknownVersion(_) => LeafBytesError::UnknownVersion(9), diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index fa25af1c8..61f9d9ad9 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -220,7 +220,8 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! The scalar API accepts owned [`OpaqueEncode`] values. It wipes the owned source //! before returning the pending request, including on planning failure. A //! successful decrypt returns an ordinary owned value: the caller is then -//! responsible for its cleanup. Copy types may still have copies held by the +//! responsible for its cleanup, and may ask for `Zeroizing

` in place of +//! `P` to have it wiped on drop. Copy types may still have copies held by the //! caller, and this is not a guarantee about every compiler-generated copy. //! //! For target-directed conversion, import [`EncryptInto`] and [`DecryptFrom`]: @@ -239,12 +240,42 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); //! ``` //! +//! # Whole values as one ciphertext +//! +//! A struct, a `Vec` or a map is not a scalar, and `encrypt` refuses it: +//! there is no generic `Serialize` fallback and no implicit `Vec` blob. +//! To encrypt a whole value as **one** ciphertext, name it: wrap it in +//! [`OpaqueBlob`], which encodes it with Vitamin C's versioned storage +//! profile (the format the Go SDK's opaque structs use too) and seals the +//! encoding as one leaf through the same path as a scalar. The [`blob`] +//! module documents the format, its errors, what the ciphertext's length +//! reveals and what is wiped. +//! +//! ``` +//! # use serde::{Deserialize, Serialize}; +//! # use zeroize::Zeroize; +//! # #[derive(Serialize, Deserialize, Zeroize)] +//! # struct Card { number: String, expiry: (u8, u16) } +//! # async fn example() -> Result<(), stack_encrypt::Error> { +//! use stack_encrypt::{OpaqueBlob, StackCipher}; +//! let cipher = StackCipher::builder() +//! .kms(stack_encrypt::kms::FakeDataKeySource::new()).init().await?; +//! let keyset = cipher.default_keyset(); +//! let card = Card { number: "4111 1111 1111 1111".into(), expiry: (12, 2031) }; +//! let ct = keyset.encrypt_with_context(OpaqueBlob::new(card), "users/42/card").await?; +//! let card: OpaqueBlob = keyset.decrypt_with_context(ct, "users/42/card").await?; +//! assert_eq!(card.get().expiry, (12, 2031)); +//! # Ok(()) +//! # } +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); +//! ``` +//! //! Existing structural plans and derives still use [`StackCipherText`], a tree //! of sealed leaves. Their low-level direct entry points are //! [`KeysetCipher::encrypt_tree`] and [`KeysetCipher::decrypt_tree`]; their //! borrowed conversion helpers live in [`target::legacy`]. They are separate -//! from the owned scalar API. There is no generic `Serialize` fallback and -//! no implicit `Vec` blob. +//! from the owned scalar API: a struct sealed through them is one ciphertext +//! per field, and a struct in an [`OpaqueBlob`] is one ciphertext. //! //! # What is authenticated //! @@ -292,8 +323,10 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are /// makes its requests. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +pub mod blob; pub mod cipher; mod scalar; +pub use blob::{BlobError, OpaqueBlob}; pub use scalar::{Ciphertext, OpaqueDecode, OpaqueEncode}; #[cfg(test)] mod codes; diff --git a/packages/stack-encrypt/src/scalar.rs b/packages/stack-encrypt/src/scalar.rs index b5aadf1f8..1e48be395 100644 --- a/packages/stack-encrypt/src/scalar.rs +++ b/packages/stack-encrypt/src/scalar.rs @@ -4,25 +4,54 @@ use crate::target::{ DecryptFrom, DecryptSource, Decryption, EncryptInto, EncryptTarget, Encryption, OptionalContext, Pending, ScalarContext, }; -use crate::{CipherText, Decrypt, Error, KeysetCipher, LeafBytesError, SealedValue}; +use crate::{CipherText, ContextPiece, Error, KeysetCipher, LeafBytesError, SealedValue}; use vitaminc_aead::plaintext::{Encrypt, Sink}; use vitaminc_protected::OpaqueDebug; -use zeroize::Zeroize; +use zeroize::{Zeroize, Zeroizing}; -mod sealed { - pub trait Encode { +pub(crate) mod sealed { + use crate::target::Pending; + use crate::{Ciphertext, ContextPiece, KeysetCipher, SealedValue, StackCipher}; + + /// How a value becomes one sealed leaf. Planning, and for a blob the + /// encoding before it, runs synchronously inside this call, so the + /// pending it returns holds no plaintext the caller still owns. + pub trait Encode: Sized { + fn seal<'a, K: 'static>( + &self, + cipher: &'a KeysetCipher<'_, K>, + context: ContextPiece<'static>, + ) -> Pending<'a, Ciphertext, K>; + } + + /// A scalar's own Vitamin C encoding, by reference: what `ScalarRef` + /// forwards to. + pub(crate) trait Scalar { const DEPTH: Option; const FIXED: Option; fn visit(&self, sink: S) -> Result; } - pub trait Decode {} + + /// How one sealed leaf opens to a value. The leaf authenticates inside + /// the pending, before any decoding the type adds on top. + pub trait Decode: Sized { + fn open<'a, K: 'static>( + leaf: SealedValue, + cipher: &'a StackCipher, + context: ContextPiece<'static>, + ) -> Pending<'a, Self, K>; + } } /// A plaintext type that encrypts as one opaque [`Ciphertext`]. /// /// Implemented for `bool`, the fixed-width signed integers, the unsigned -/// integers from `u16` through `u128`, `f32`, `f64` and `String`. The trait is -/// sealed: collections and borrowed values deliberately do not implement it. +/// integers from `u16` through `u128`, `f32`, `f64` and `String`; for +/// [`OpaqueBlob`](crate::OpaqueBlob), which encodes a whole `Serialize` +/// value as one storage blob first; and for `Zeroizing` of any of those. The +/// trait is sealed: collections and borrowed values deliberately do not +/// implement it, and there is no blanket `Serialize` fallback. A composite +/// value is encrypted as one ciphertext only by naming `OpaqueBlob`. /// `u8` waits on separating Vitamin C's byte-buffer and element /// representations, and must not make `Vec` an implicit single /// ciphertext. @@ -33,33 +62,84 @@ pub trait OpaqueEncode: sealed::Encode + Zeroize + Send + 'static {} /// A plaintext type that one opaque [`Ciphertext`] decrypts to: the types /// that implement [`OpaqueEncode`]. Sealed, like it. -pub trait OpaqueDecode: sealed::Decode + Decrypt + Zeroize + Send + 'static {} +/// +/// A successful decrypt returns an ordinary owned value, and nothing wipes +/// it when it is dropped: `Zeroize` is the bound, not a drop guard. Ask for +/// `Zeroizing

` in place of `P` to have the result wiped on drop. +pub trait OpaqueDecode: sealed::Decode + Zeroize + Send + 'static {} macro_rules! opaque { ($($ty:ty),* $(,)?) => {$( - impl sealed::Encode for $ty { + impl sealed::Scalar for $ty { const DEPTH: Option = <&$ty as Encrypt>::DEPTH; const FIXED: Option = <&$ty as Encrypt>::FIXED; fn visit(&self, sink: S) -> Result { Encrypt::visit(self, sink) } } - impl sealed::Decode for $ty {} + impl sealed::Encode for $ty { + fn seal<'a, K: 'static>( + &self, + cipher: &'a KeysetCipher<'_, K>, + context: ContextPiece<'static>, + ) -> Pending<'a, Ciphertext, K> { + crate::target::core::encrypt_opaque(ScalarRef(self), cipher, context) + .try_map(Ciphertext::from_tree) + } + } + impl sealed::Decode for $ty { + fn open<'a, K: 'static>( + leaf: SealedValue, + cipher: &'a crate::StackCipher, + context: ContextPiece<'static>, + ) -> Pending<'a, Self, K> { + crate::target::core::open_opaque(CipherText::Single(leaf), cipher, context) + } + } impl OpaqueEncode for $ty {} impl OpaqueDecode for $ty {} )*}; } opaque!(bool, i8, i16, i32, i64, i128, u16, u32, u64, u128, f32, f64, String); +/// A `Zeroizing` encrypts as its `S` does: the way to hand the scalar +/// API a value that is wiped even if it is dropped before it is consumed. +impl sealed::Encode for Zeroizing { + fn seal<'a, K: 'static>( + &self, + cipher: &'a KeysetCipher<'_, K>, + context: ContextPiece<'static>, + ) -> Pending<'a, Ciphertext, K> { + S::seal(self, cipher, context) + } +} +impl OpaqueEncode for Zeroizing {} + +/// Decrypting to `Zeroizing

` is the opt-in drop-guarded result: the +/// value is opened as a `P` and wrapped before it is returned, so dropping +/// it wipes it. The wrap is a move, and a move does not erase what a `Copy` +/// value left on the stack; the guard covers the value's own storage (a +/// `String`'s buffer, a struct's fields) from then on. +impl sealed::Decode for Zeroizing

{ + fn open<'a, K: 'static>( + leaf: SealedValue, + cipher: &'a crate::StackCipher, + context: ContextPiece<'static>, + ) -> Pending<'a, Self, K> { + P::open(leaf, cipher, context).map(Zeroizing::new) + } +} +impl OpaqueDecode for Zeroizing

{} + pub(crate) struct ScalarRef<'a, S>(pub &'a S); // Vitamin C 0.8 implements Encrypt for &Protected, but not ProtectedRef. // This bridge preserves its encoding metadata while the owned conversion keeps // the source in Protected. Replace it when the encoder accepts protected borrows. -impl Encrypt for ScalarRef<'_, S> { - const DEPTH: Option = ::DEPTH; - const FIXED: Option = ::FIXED; +impl Encrypt for ScalarRef<'_, S> { + const DEPTH: Option = ::DEPTH; + const FIXED: Option = ::FIXED; fn visit(self, sink: V) -> Result { - sealed::Encode::visit(self.0, sink) + sealed::Scalar::visit(self.0, sink) } } @@ -98,6 +178,19 @@ impl Ciphertext { } } } +/// The one sealed leaf, as the structural API holds it. The bytes are the +/// same: this is how a caller holding a `CipherText::Single` from +/// [`KeysetCipher::encrypt_tree`] opens it through the owned API. +impl From for Ciphertext { + fn from(leaf: SealedValue) -> Self { + Self(leaf) + } +} +impl From for SealedValue { + fn from(ciphertext: Ciphertext) -> Self { + ciphertext.0 + } +} /// Human-readable formats store the canonical bytes as padded standard /// Base64; binary formats store them as bytes. impl serde::Serialize for Ciphertext { @@ -159,9 +252,7 @@ impl DecryptSource

for Ciphertext { type ContextInput = OptionalContext; fn decryption(self, context: Self::ContextInput) -> Decryption { let context = context.piece(); - Decryption::from_open(move |cipher| { - crate::target::core::open_opaque(CipherText::Single(self.0), cipher, context) - }) + Decryption::from_open(move |cipher| P::open(self.0, cipher, context)) } } impl KeysetCipher<'_, K> { diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index cc50c253e..cb878c1af 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -462,12 +462,15 @@ pub fn ciphertext<'s, S: crate::Encrypt + 's, K: 'static, M: ConsumeSource<'s, S ), } } -/// Encrypt a supported scalar as one opaque ciphertext, permitting empty context. +/// Encrypt a supported scalar, or an [`OpaqueBlob`](crate::OpaqueBlob), as +/// one opaque ciphertext, permitting empty context. /// /// This operation borrows its source. The owned [`EncryptInto`](super::EncryptInto) /// conversions supply a [`Protected`](vitaminc_protected::Protected) owner and /// wipe it after planning. Calling [`KeysetCipher::run`] directly leaves source /// cleanup with the caller; this operation alone does not wipe that borrow. +/// A blob is encoded here too, synchronously, so an encoding refusal is the +/// pending's result and no plaintext outlives this call in the pending. /// Mapping the encrypted output preserves the scalar's optional-context policy; /// it does not turn this into an EQL ciphertext or term operation. A wrapper may /// require caller context with [`RequiredContext`](super::RequiredContext), or @@ -477,12 +480,7 @@ pub fn scalar_ciphertext<'s, S: crate::OpaqueEncode, K: 'static>( Encryption { build: Box::new( move |source, cipher, context: super::OptionalContext| { - super::core::encrypt_opaque( - crate::scalar::ScalarRef(source), - cipher, - context.piece(), - ) - .try_map(crate::Ciphertext::from_tree) + crate::scalar::sealed::Encode::seal(source, cipher, context.piece()) }, ), } diff --git a/packages/stack-encrypt/tests/heap_hygiene.rs b/packages/stack-encrypt/tests/heap_hygiene.rs index e229d412d..5e59c77a6 100644 --- a/packages/stack-encrypt/tests/heap_hygiene.rs +++ b/packages/stack-encrypt/tests/heap_hygiene.rs @@ -595,6 +595,156 @@ fn the_spy_sees_an_unwiped_free() { watched("control", || drop(text)); } +/// `OpaqueBlob`: the owned source, the storage blob it is encoded into +/// (which grows past its first allocation while it already holds the +/// marker), the planned leaf, and on decrypt the opened buffer the blob +/// is read from, all freed wiped. The opened value is the caller's and is +/// wiped by hand before the watch ends; a refusal of another shape, found +/// before any field is built, leaves nothing to wipe. +mod opaque_blob { + use super::*; + + use serde::{Deserialize, Serialize}; + use stack_encrypt::{Error, OpaqueBlob}; + use zeroize::Zeroize; + + #[derive(Serialize, Deserialize, Zeroize)] + struct Doc { + title: String, + notes: Vec, + /// Written after the marked fields, so the blob's buffer regrows + /// while it holds them. + body: String, + n: u32, + } + + fn doc() -> OpaqueBlob { + OpaqueBlob::new(Doc { + title: marked("title"), + notes: vec![marked("note-1"), marked("note-2")], + body: "x".repeat(8 * 1024), + n: 7, + }) + } + + #[test] + fn an_opaque_blob_encrypted_leaves_no_plaintext_on_the_heap() { + let rt = runtime(); + let cipher = rt.block_on(common::stack_cipher()); + let keyset = cipher.default_keyset(); + let source = doc(); + watched("OpaqueBlob encrypt", || { + rt.block_on(async { + keyset + .encrypt_with_context(source, "docs/body") + .await + .unwrap() + }); + }); + } + + #[test] + fn an_opaque_blob_decrypted_leaves_no_plaintext_on_the_heap() { + let rt = runtime(); + let cipher = rt.block_on(common::stack_cipher()); + let keyset = cipher.default_keyset(); + let sealed = rt.block_on(async { + keyset + .encrypt_with_context(doc(), "docs/body") + .await + .unwrap() + }); + watched("OpaqueBlob decrypt", || { + let mut opened = rt.block_on(async { + keyset + .decrypt_with_context::>(sealed, "docs/body") + .await + .unwrap() + }); + // The opened value is an ordinary owned value, holding the + // plaintext until the caller wipes it. + assert!(opened + .get() + .title + .as_bytes() + .windows(MARK.len()) + .any(|w| w == MARK)); + opened.zeroize(); + drop(opened); + }); + } + + /// Refused before `Deserialize` builds a field: the first field of the + /// target does not read the blob's text, so nothing of the marked value + /// is built, and the opened buffer is wiped with the refusal. + #[test] + fn an_opaque_blob_of_another_shape_leaves_no_plaintext_on_the_heap() { + let rt = runtime(); + let cipher = rt.block_on(common::stack_cipher()); + let keyset = cipher.default_keyset(); + let sealed = rt.block_on(async { + keyset + .encrypt_with_context(doc(), "docs/body") + .await + .unwrap() + }); + #[derive(Deserialize, Zeroize)] + #[allow(dead_code)] + struct Other { + title: u32, + } + watched("OpaqueBlob decrypt (mismatch)", || { + let result = rt.block_on(async { + keyset + .decrypt_with_context::>(sealed, "docs/body") + .await + }); + assert!(matches!( + result, + Err(Error::Blob(stack_encrypt::BlobError::Mismatch(_))) + )); + }); + } + + /// The limit the blob docs state, held as a negative control: a derived + /// `Deserialize` that built a `String` field and then refuses a later + /// field drops that `String` as an ordinary value, unwiped, because no + /// `T` was ever constructed to wipe. The opened buffer itself is wiped; + /// what leaks is serde's partial value. A type whose fields wipe on drop + /// closes it; this test documents that the library does not. + #[test] + #[should_panic(expected = "freed with plaintext in them")] + fn a_partial_decode_drops_the_fields_it_built_unwiped() { + let rt = runtime(); + let cipher = rt.block_on(common::stack_cipher()); + let keyset = cipher.default_keyset(); + let sealed = rt.block_on(async { + keyset + .encrypt_with_context(doc(), "docs/body") + .await + .unwrap() + }); + #[derive(Deserialize, Zeroize)] + #[allow(dead_code)] + struct Later { + title: String, + notes: Vec, + body: u32, + } + watched("OpaqueBlob decrypt (partial)", || { + let result = rt.block_on(async { + keyset + .decrypt_with_context::>(sealed, "docs/body") + .await + }); + assert!(matches!( + result, + Err(Error::Blob(stack_encrypt::BlobError::Mismatch(_))) + )); + }); + } +} + /// The JSON index: sealing a document under either root option, opening it /// whole (from the sealed root, and by walking its chain with the root /// open), opening a subtree read's rows and an extracted entry, and diff --git a/packages/stack-encrypt/tests/opaque_blob.rs b/packages/stack-encrypt/tests/opaque_blob.rs new file mode 100644 index 000000000..78372988e --- /dev/null +++ b/packages/stack-encrypt/tests/opaque_blob.rs @@ -0,0 +1,704 @@ +//! `OpaqueBlob`: a whole value as one opaque ciphertext. Round trips, +//! the leaf it shares with the structural writer and the Go SDK, fixed +//! vectors, refusals by class after authentication, and the owned source's +//! cleanup. No service credentials are needed. +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use stack_encrypt::blob::{HEADER, VERSION}; +use stack_encrypt::{ + BlobError, CipherText, Ciphertext, Controlled, DecryptFrom, EncryptInto, Error, OpaqueBlob, + SealedValue, +}; +use zeroize::{Zeroize, Zeroizing}; + +mod common; + +#[derive(Serialize, Deserialize, Zeroize, Clone, PartialEq, Debug)] +struct Profile { + name: String, + age: u32, + tags: Vec, + nickname: Option, +} + +fn ada() -> Profile { + Profile { + name: "Ada Lovelace".into(), + age: 36, + tags: vec!["maths".into(), "engines".into()], + nickname: Some("ada".into()), + } +} + +fn unhex(hex: &str) -> Vec { + (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()) + .collect() +} + +/// `ada()` as a version 1 storage blob, written by this crate. The same +/// hex is read by the Go SDK's `TestOpaqueReadsARustWrittenBlob` +/// (`languages/golang/encrypt/gensupport`), which is the other half of the +/// exchange: Rust writes, Go reads. +const RUST_BLOB: &str = "56435301f104000000040000006e616d650a0c000000416461204c6f76656c6163650300000061676506240000000400000074616773f0020000000a050000006d617468730a07000000656e67696e6573080000006e69636b6e616d65f50a03000000616461"; + +/// `ada()` as the Go SDK writes it, through `vcffi.MarshalStorage` over a +/// struct with `vc:"name"`, `vc:"age"`, `vc:"tags"` and `vc:"nickname"` +/// tags, the nickname a non-nil `*string`. Go writes a non-nil pointer as +/// the bare value, where Rust writes `Some` with its marker: the two blobs +/// differ in one byte, and both readers read both. +const GO_BLOB_SOME: &str = "56435301f104000000040000006e616d650a0c000000416461204c6f76656c6163650300000061676506240000000400000074616773f0020000000a050000006d617468730a07000000656e67696e6573080000006e69636b6e616d650a03000000616461"; + +/// The same, with the nickname a nil pointer: a null. +const GO_BLOB_NONE: &str = "56435301f104000000040000006e616d650a0c000000416461204c6f76656c6163650300000061676506240000000400000074616773f0020000000a050000006d617468730a07000000656e67696e6573080000006e69636b6e616d6500"; + +#[tokio::test] +async fn a_struct_round_trips_as_one_ciphertext() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + + let ct = keyset.encrypt(OpaqueBlob::new(ada())).await.unwrap(); + let opened: OpaqueBlob = keyset.decrypt(ct).await.unwrap(); + assert_eq!(opened.into_inner(), ada()); + + let ct = keyset + .encrypt_with_context(OpaqueBlob::new(ada()), ("users", "profile")) + .await + .unwrap(); + let opened = keyset + .decrypt_with_context::>(ct.clone(), ("users", "profile")) + .await + .unwrap(); + assert_eq!(opened.get(), &ada()); + + // The owned conversions are the same path. + let ct2: Ciphertext = OpaqueBlob::new(ada()) + .encrypt_into_with_context(&keyset, ("users", "profile")) + .await + .unwrap(); + let opened = + OpaqueBlob::::decrypt_from_with_context(ct2, &keyset, ("users", "profile")) + .await + .unwrap(); + assert_eq!(opened.into_inner(), ada()); + let ct3: Ciphertext = OpaqueBlob::new(ada()).encrypt_into(&keyset).await.unwrap(); + assert_eq!( + OpaqueBlob::::decrypt_from(ct3, &keyset) + .await + .unwrap() + .into_inner(), + ada() + ); +} + +/// One blob is one data key: the whole struct costs one generate call, +/// where the structural writer would mint one per leaf. +#[tokio::test] +async fn a_blob_is_one_data_key() { + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset.encrypt(OpaqueBlob::new(ada())).await.unwrap(); + assert_eq!(generates.load(Ordering::SeqCst), 1); + let _: OpaqueBlob = keyset.decrypt(ct).await.unwrap(); + assert_eq!(retrieves.load(Ordering::SeqCst), 1); +} + +/// The blob is a bytes leaf of the ordinary envelope: what the structural +/// writer seals for a `Vec` holding the encoded bytes, and what the Go +/// SDK's engine seals for an opaque struct. Each side opens the other's. +#[tokio::test] +async fn a_blob_is_one_bytes_leaf_of_the_structural_writer() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + + // Structural writer of the encoded bytes -> OpaqueBlob reader. + let encoded = OpaqueBlob::new(ada()).encode().unwrap(); + assert_eq!(&encoded.risky_ref()[..4], &HEADER); + let tree = keyset + .encrypt_tree(encoded.risky_ref().clone(), "users/profile") + .await + .unwrap(); + let CipherText::Single(leaf) = tree else { + panic!("a Vec is one leaf") + }; + let opened = keyset + .decrypt_with_context::>(Ciphertext::from(leaf), "users/profile") + .await + .unwrap(); + assert_eq!(opened.into_inner(), ada()); + + // OpaqueBlob writer -> structural reader of the bytes. + let ct = keyset + .encrypt_with_context(OpaqueBlob::new(ada()), "users/profile") + .await + .unwrap(); + let bytes: Vec = cipher + .decrypt(CipherText::Single(SealedValue::from(ct)), "users/profile") + .await + .unwrap(); + assert_eq!(&bytes[..4], &HEADER); + assert_eq!( + OpaqueBlob::::decode(&bytes).unwrap().into_inner(), + ada() + ); +} + +/// The bytes this crate writes for `ada()` are pinned: a change here is a +/// change to what every stored blob holds, and to what Go reads. +#[test] +fn the_blob_this_crate_writes_is_pinned() { + let encoded = OpaqueBlob::new(ada()).encode().unwrap(); + assert_eq!(encoded.risky_ref(), &unhex(RUST_BLOB)); + assert_eq!(VERSION, 1); + assert_eq!(encoded.risky_ref()[3], VERSION); +} + +/// Go writes, Rust reads: the Go SDK's bytes for the same struct, both +/// with and without the optional field, read as the Rust value through the +/// codec and through a sealed ciphertext opened as an `OpaqueBlob`. The +/// Go engine seals an opaque struct as a bytes leaf under +/// `/value`, which is what `encrypt_tree` of the bytes does here. +#[tokio::test] +async fn a_go_written_blob_opens_in_rust() { + let with_nickname = OpaqueBlob::::decode(&unhex(GO_BLOB_SOME)).unwrap(); + assert_eq!(with_nickname.into_inner(), ada()); + let without = OpaqueBlob::::decode(&unhex(GO_BLOB_NONE)).unwrap(); + assert_eq!( + without.into_inner(), + Profile { + nickname: None, + ..ada() + } + ); + // Go's `Some` is the bare value; Rust's carries the marker. One byte. + assert_eq!(unhex(GO_BLOB_SOME).len() + 1, unhex(RUST_BLOB).len()); + + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let tree = keyset + .encrypt_tree(unhex(GO_BLOB_SOME), ("users", "value")) + .await + .unwrap(); + let CipherText::Single(leaf) = tree else { + panic!("bytes are one leaf") + }; + let opened = keyset + .decrypt_with_context::>(Ciphertext::from(leaf), ("users", "value")) + .await + .unwrap(); + assert_eq!(opened.into_inner(), ada()); +} + +/// One fixed data key, IV and key tag for every request, so a ciphertext +/// sealed once can be pinned and opened by any later build. +struct FixedKeySource(stack_kms::FakeDataKeySource); +const FIXED_KEY: [u8; 32] = *b"fixed-data-key-for-pinned-blob!!"; +const FIXED_IV: [u8; 16] = *b"fixed-iv-pinned!"; +const FIXED_TAG: &[u8] = b"fixed-key-tag"; +impl stack_kms::DataKeySource for FixedKeySource { + async fn generate_keys( + &self, + payloads: Vec>, + _: Option, + _: Option>, + ) -> Result, stack_kms::Error> { + Ok(payloads + .into_iter() + .map(|payload| stack_kms::DataKeyWithTag { + key: stack_kms::DataKey { + iv: FIXED_IV, + key: FIXED_KEY, + }, + tag: FIXED_TAG.to_vec(), + decryption_policy: payload.decryption_policy, + }) + .collect()) + } + async fn retrieve_keys( + &self, + payloads: Vec>, + _: Option, + _: Option<&stack_kms::UnverifiedContext>, + ) -> Result, stack_kms::Error> { + Ok(payloads + .iter() + .map(|_| stack_kms::DataKey { + iv: FIXED_IV, + key: FIXED_KEY, + }) + .collect()) + } +} +impl stack_kms::IndexKeySource for FixedKeySource { + async fn load_index_key( + &self, + keyset: Option, + ) -> Result<(uuid::Uuid, stack_kms::IndexKey), stack_kms::Error> { + self.0.load_index_key(keyset).await + } +} + +async fn fixed_cipher() -> stack_encrypt::StackCipher { + stack_encrypt::StackCipher::builder() + .kms(FixedKeySource(stack_kms::FakeDataKeySource::new())) + .init() + .await + .unwrap() +} + +/// `OpaqueBlob::new(ada())` sealed under `"users/profile"` over +/// [`FixedKeySource`] by the build that introduced `OpaqueBlob`. Vitamin C +/// draws the AEAD nonce itself, so the ciphertext is not reproducible; it is +/// pinned so a later build is shown to open what this one wrote. +const PINNED_CIPHERTEXT: &str = "010000000000000000000000000000000066697865642d69762d70696e6e6564210d0066697865642d6b65792d7461670232e68c36bf5fa752e06eea1f8625c96dd4aa0ea1eceaf8b2414db5c09282bbc92a653bab98e7e715db1c56b063d461fa50297f1473be2faf29413c7bc9b292e6186a60661dccddd63576fc98e22bf32202f000251b49d1fbdb67bf1bcf1a8c666d4de9dabe7a1cbf5b67834c3ece6c458d7ceb7435ce5dcd68b216c326a4d933cb748c"; + +#[tokio::test] +async fn a_pinned_blob_ciphertext_opens() { + let cipher = fixed_cipher().await; + let keyset = cipher.default_keyset(); + let bytes = unhex(PINNED_CIPHERTEXT); + let ct = Ciphertext::from_bytes(&bytes).unwrap(); + assert_eq!(ct.key_tag(), FIXED_TAG); + assert_eq!(ct.to_bytes(), bytes); + let opened = keyset + .decrypt_with_context::>(ct.clone(), "users/profile") + .await + .unwrap(); + assert_eq!(opened.into_inner(), ada()); + // Under no context, or the wrong one, it never reaches the blob. + assert!(matches!( + keyset.decrypt::>(ct.clone()).await, + Err(Error::Aead) + )); + assert!(matches!( + keyset + .decrypt_with_context::>(ct, "users/other") + .await, + Err(Error::Aead) + )); +} + +/// Tampering is refused by the AEAD before any byte of the blob is read, +/// however the bytes would have decoded; and the leaf's kind is bound, so +/// a scalar leaf opened as a blob, or a blob as a scalar, is a shape +/// refusal of an authentic ciphertext, never a blob error. +#[tokio::test] +async fn authentication_comes_before_decoding() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt_with_context(OpaqueBlob::new(ada()), "users/profile") + .await + .unwrap(); + + let mut bytes = ct.to_bytes(); + *bytes.last_mut().unwrap() ^= 1; + let tampered = Ciphertext::from_bytes(&bytes).unwrap(); + assert!(matches!( + keyset + .decrypt_with_context::>(tampered, "users/profile") + .await, + Err(Error::Aead) + )); + assert!(matches!( + keyset + .decrypt_with_context::>(ct.clone(), "users/other") + .await, + Err(Error::Aead) + )); + + // A blob opened as a String: the leaf is bytes, not text. + assert!(matches!( + keyset + .decrypt_with_context::(ct, "users/profile") + .await, + Err(Error::OpenedType { .. }) + )); + // A String opened as a blob: the leaf is text, not bytes. + let text = keyset + .encrypt_with_context("VCS\u{1}".to_owned(), "users/profile") + .await + .unwrap(); + assert!(matches!( + keyset + .decrypt_with_context::>(text, "users/profile") + .await, + Err(Error::OpenedType { .. }) + )); +} + +#[derive(Serialize, Deserialize, Zeroize, PartialEq, Debug)] +struct Pair { + left: String, + right: u32, +} + +/// Bytes sealed as a leaf that are not a blob this type reads are refused +/// by class, each after the leaf authenticated and each retrieving its key +/// once. The classes are the storage profile's, and the ciphertext is +/// genuine in every case: the data was written as something else. +#[tokio::test] +async fn malformed_and_mismatched_blobs_are_refused_by_class_after_authentication() { + let (cipher, _, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let open = |bytes: Vec| async { + let CipherText::Single(leaf) = keyset.encrypt_tree(bytes, "users/profile").await.unwrap() + else { + panic!("bytes are one leaf") + }; + keyset + .decrypt_with_context::>(Ciphertext::from(leaf), "users/profile") + .await + .unwrap_err() + }; + let blob = OpaqueBlob::new(ada()).encode().unwrap(); + let blob = blob.risky_ref(); + + assert!(matches!( + open(b"hello".to_vec()).await, + Error::Blob(BlobError::NotABlob) + )); + assert!(matches!( + open(b"{\"name\":\"Ada\"}".to_vec()).await, + Error::Blob(BlobError::NotABlob) + )); + let mut later = blob.clone(); + later[3] = VERSION + 1; + assert!(matches!( + open(later).await, + Error::Blob(BlobError::UnsupportedVersion { version }) if version == VERSION + 1 + )); + assert!(matches!( + open(blob[..blob.len() - 3].to_vec()).await, + Error::Blob(BlobError::Malformed(_)) + )); + let mut trailing = blob.clone(); + trailing.push(0); + assert!(matches!( + open(trailing).await, + Error::Blob(BlobError::Malformed(_)) + )); + let pair = OpaqueBlob::new(Pair { + left: "l".into(), + right: 1, + }) + .encode() + .unwrap(); + let mismatch = open(pair.risky_ref().clone()).await; + assert!(matches!(mismatch, Error::Blob(BlobError::Mismatch(_)))); + // Every refusal came after a retrieval: authentication first. + assert_eq!(retrieves.load(Ordering::SeqCst), 6); + // The refusal names the declared field, never the value. + let shown = format!("{mismatch} {mismatch:?}"); + assert!(!shown.contains("\"l\""), "{shown}"); +} + +/// The source's own `Serialize` can refuse. The refusal is the pending's +/// result, no key is requested, and the owned source is wiped all the same. +struct Refusing { + secret: String, + wiped: Arc, +} +impl Serialize for Refusing { + fn serialize(&self, _: S) -> Result { + Err(serde::ser::Error::custom( + "this value refuses to be written", + )) + } +} +impl Zeroize for Refusing { + fn zeroize(&mut self) { + self.secret.zeroize(); + self.wiped.store(true, Ordering::SeqCst); + } +} +impl Drop for Refusing { + fn drop(&mut self) { + assert!( + self.secret.is_empty(), + "owned source dropped without wiping" + ); + } +} + +#[tokio::test] +async fn an_encoding_refusal_wipes_the_source_and_requests_no_key() { + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let wiped = Arc::new(AtomicBool::new(false)); + let pending = keyset.encrypt(OpaqueBlob::new(Refusing { + secret: "secret".into(), + wiped: wiped.clone(), + })); + assert!(wiped.load(Ordering::SeqCst)); + assert!(matches!( + pending.await, + Err(Error::Blob(BlobError::Unwritable(_))) + )); + assert_eq!(generates.load(Ordering::SeqCst), 0); +} + +/// A source that records being wiped; deliberately not `Clone`. +#[derive(Serialize)] +struct Tracked { + value: String, + #[serde(skip)] + wiped: Arc, +} +impl Zeroize for Tracked { + fn zeroize(&mut self) { + self.value.zeroize(); + self.wiped.store(true, Ordering::SeqCst); + } +} +impl Drop for Tracked { + fn drop(&mut self) { + assert!(self.value.is_empty(), "owned source dropped without wiping"); + } +} +fn tracked() -> (OpaqueBlob, Arc) { + let wiped = Arc::new(AtomicBool::new(false)); + ( + OpaqueBlob::new(Tracked { + value: "secret".into(), + wiped: wiped.clone(), + }), + wiped, + ) +} + +/// The owned source is encoded and wiped before `encrypt` returns, so a +/// pending that is dropped unpolled, or awaited, carries no plaintext of +/// the caller's and makes no key request until it is polled. +#[tokio::test] +async fn the_owned_source_is_wiped_before_await_or_discard() { + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + + let (source, wiped) = tracked(); + let pending = keyset.encrypt(source); + assert!(wiped.load(Ordering::SeqCst)); + assert_eq!(generates.load(Ordering::SeqCst), 0); + drop(pending); + assert_eq!(generates.load(Ordering::SeqCst), 0); + + let (source, wiped) = tracked(); + let pending = keyset.encrypt_with_context(source, "users/secret"); + assert!(wiped.load(Ordering::SeqCst)); + let ct = pending.await.unwrap(); + assert_eq!(generates.load(Ordering::SeqCst), 1); + #[derive(Deserialize, Zeroize)] + struct Read { + value: String, + } + assert_eq!( + keyset + .decrypt_with_context::>(ct, "users/secret") + .await + .unwrap() + .into_inner() + .value, + "secret" + ); +} + +#[tokio::test] +async fn the_owned_source_is_wiped_when_context_validation_fails() { + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let (source, wiped) = tracked(); + let pending = keyset.encrypt_with_context(source, "x".repeat(65536)); + assert!(wiped.load(Ordering::SeqCst)); + assert!(matches!( + pending.await, + Err(Error::DescriptorTooLong { .. }) + )); + assert_eq!(generates.load(Ordering::SeqCst), 0); +} + +/// `Zeroizing` is the opt-in drop guard on both sides: a `Zeroizing` +/// encrypts as its `S`, and asking for `Zeroizing

` returns the opened +/// value wrapped so it is wiped on drop. Blobs and scalars alike. +#[tokio::test] +async fn zeroizing_is_the_opt_in_drop_guard_on_both_sides() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + + let ct = keyset + .encrypt(Zeroizing::new(OpaqueBlob::new(ada()))) + .await + .unwrap(); + let guarded: Zeroizing> = keyset.decrypt(ct.clone()).await.unwrap(); + assert_eq!(guarded.get(), &ada()); + // The same ciphertext opens unguarded too: the guard is the caller's + // choice of result type, not part of the stored value. + let plain: OpaqueBlob = keyset.decrypt(ct).await.unwrap(); + assert_eq!(plain.into_inner(), ada()); + + let ct = keyset + .encrypt(Zeroizing::new("text".to_owned())) + .await + .unwrap(); + let text: Zeroizing = keyset.decrypt(ct).await.unwrap(); + assert_eq!(text.as_str(), "text"); +} + +/// Schema evolution is serde's: a default for a new field, an alias for a +/// renamed one, and an `Option` for one that may be absent all read an old +/// blob; a new required field does not. +#[tokio::test] +async fn schema_evolution_follows_serde() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset.encrypt(OpaqueBlob::new(ada())).await.unwrap(); + + #[derive(Deserialize, Zeroize, PartialEq, Debug)] + struct ProfileV2 { + name: String, + age: u64, + #[serde(alias = "tags")] + labels: Vec, + nickname: Option, + #[serde(default)] + verified: bool, + #[serde(default)] + title: Option, + } + let later = keyset + .decrypt::>(ct.clone()) + .await + .unwrap() + .into_inner(); + assert_eq!( + later, + ProfileV2 { + name: "Ada Lovelace".into(), + age: 36, + labels: vec!["maths".into(), "engines".into()], + nickname: Some("ada".into()), + verified: false, + title: None, + } + ); + + #[derive(Deserialize, Zeroize)] + #[allow(dead_code)] + struct Stricter { + name: String, + age: u8, + tags: Vec, + nickname: Option, + verified: bool, + } + assert!(matches!( + keyset.decrypt::>(ct.clone()).await, + Err(Error::Blob(BlobError::Mismatch(_))) + )); + #[derive(Deserialize, Zeroize)] + #[allow(dead_code)] + struct Narrower { + name: String, + age: u8, + } + // Narrowing reads values that fit; the blob holds 36. + assert_eq!( + keyset + .decrypt::>(ct) + .await + .unwrap() + .into_inner() + .age, + 36 + ); +} + +/// The ciphertext is the encoded length plus a fixed overhead: a present +/// optional field, a longer string, another element each show in it. The +/// documented disclosure, executable. +#[tokio::test] +async fn the_ciphertext_length_is_the_encoded_length_plus_a_constant() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let overhead = |profile: Profile| async { + let blob = OpaqueBlob::new(profile.clone()).encode().unwrap(); + let ct = keyset.encrypt(OpaqueBlob::new(profile)).await.unwrap(); + (blob.risky_ref().len(), ct.to_bytes().len()) + }; + let (with_len, with_ct) = overhead(ada()).await; + let (without_len, without_ct) = overhead(Profile { + nickname: None, + ..ada() + }) + .await; + // `Some("ada")`: marker, text tag, u32 length, three bytes; `None`: one + // null byte. + assert_eq!(with_len - without_len, 1 + 1 + 4 + 3 - 1); + assert_eq!(with_ct - without_ct, with_len - without_len); + assert_eq!(with_ct - with_len, without_ct - without_len); +} + +#[tokio::test] +async fn debug_and_refusals_show_no_plaintext() { + let blob = OpaqueBlob::new(ada()); + assert_eq!(format!("{blob:?}"), "OpaqueBlob(***)"); + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset.encrypt(blob).await.unwrap(); + assert!(format!("{ct:?}").ends_with("Ciphertext(\"***\")")); + let error = keyset.decrypt::>(ct).await.unwrap_err(); + let shown = format!( + "{error} / {error:?} / {:?}", + stack_encrypt::ErrorPayload::payload(&error) + ); + for word in ["Ada", "Lovelace", "maths", "engines", "ada"] { + assert!(!shown.contains(word), "{shown}"); + } +} + +/// A `Vec` is not a blob and a blob is not a `Vec`: neither +/// implements the other's capability, so the stored kind is never chosen +/// by accident. (The compile-fail half is `tests/ui/opaque_blob_*.rs`.) +#[tokio::test] +async fn a_blob_of_bytes_is_still_a_blob() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + #[derive(Serialize, Deserialize, Zeroize, PartialEq, Debug)] + struct Raw(#[serde(with = "serde_bytes_shim")] Vec); + mod serde_bytes_shim { + pub fn serialize(v: &[u8], s: S) -> Result { + s.serialize_bytes(v) + } + pub fn deserialize<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + struct V; + impl serde::de::Visitor<'_> for V { + type Value = Vec; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("bytes") + } + fn visit_bytes(self, v: &[u8]) -> Result, E> { + Ok(v.to_vec()) + } + } + d.deserialize_bytes(V) + } + } + let raw = Raw(b"hello".to_vec()); + let ct = keyset.encrypt(OpaqueBlob::new(raw)).await.unwrap(); + // Opened as bytes, the leaf holds a blob, not "hello". + let bytes: Vec = cipher + .decrypt(CipherText::Single(SealedValue::from(ct.clone())), ()) + .await + .unwrap(); + assert_eq!(&bytes[..4], &HEADER); + assert_eq!( + keyset + .decrypt::>(ct) + .await + .unwrap() + .into_inner(), + Raw(b"hello".to_vec()) + ); +} diff --git a/packages/stack-encrypt/tests/ui/opaque_blob_requires_the_wrapper.rs b/packages/stack-encrypt/tests/ui/opaque_blob_requires_the_wrapper.rs new file mode 100644 index 000000000..dd10c96bf --- /dev/null +++ b/packages/stack-encrypt/tests/ui/opaque_blob_requires_the_wrapper.rs @@ -0,0 +1,23 @@ +//! A struct is not a scalar: `encrypt` refuses it however it serializes. +//! There is no `Serialize` fallback; a whole value is one ciphertext only +//! when the caller names `OpaqueBlob`. +use serde::Serialize; +use stack_encrypt::{KeysetCipher, OpaqueBlob}; +use stack_kms::FakeDataKeySource; +use zeroize::Zeroize; + +#[derive(Serialize, Zeroize)] +struct Profile { + name: String, + age: u32, +} + +async fn encrypt(keyset: &KeysetCipher<'_, FakeDataKeySource>, profile: Profile) { + let _refused = keyset.encrypt(profile).await.unwrap(); +} + +async fn encrypt_wrapped(keyset: &KeysetCipher<'_, FakeDataKeySource>, profile: Profile) { + let _sealed = keyset.encrypt(OpaqueBlob::new(profile)).await.unwrap(); +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/opaque_blob_requires_the_wrapper.stderr b/packages/stack-encrypt/tests/ui/opaque_blob_requires_the_wrapper.stderr new file mode 100644 index 000000000..ab6727458 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/opaque_blob_requires_the_wrapper.stderr @@ -0,0 +1,28 @@ +error[E0277]: the trait bound `Profile: OpaqueEncode` is not satisfied + --> tests/ui/opaque_blob_requires_the_wrapper.rs:16:35 + | +16 | let _refused = keyset.encrypt(profile).await.unwrap(); + | ------- ^^^^^^^ unsatisfied trait bound + | | + | required by a bound introduced by this call + | +help: the trait `OpaqueEncode` is not implemented for `Profile` + --> tests/ui/opaque_blob_requires_the_wrapper.rs:10:1 + | +10 | struct Profile { + | ^^^^^^^^^^^^^^ + = help: the following other types implement trait `OpaqueEncode`: + OpaqueBlob + Zeroizing + bool + f32 + f64 + i128 + i16 + i32 + and $N others +note: required by a bound in `stack_encrypt::scalar::>::encrypt` + --> src/scalar.rs + | + | pub fn encrypt(&self, value: S) -> Pending<'_, Ciphertext, K> { + | ^^^^^^^^^^^^ required by this bound in `stack_encrypt::scalar::>::encrypt` diff --git a/packages/stack-encrypt/tests/ui/opaque_blob_requires_zeroize.rs b/packages/stack-encrypt/tests/ui/opaque_blob_requires_zeroize.rs new file mode 100644 index 000000000..c016e6e95 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/opaque_blob_requires_zeroize.rs @@ -0,0 +1,21 @@ +//! `OpaqueBlob` needs `T: Zeroize` to encrypt and to decrypt: the owned +//! source is wiped once it is encoded, and the bound is what makes a +//! decrypted value wipeable. `Serialize` alone is not enough. +use serde::{Deserialize, Serialize}; +use stack_encrypt::{Ciphertext, KeysetCipher, OpaqueBlob}; +use stack_kms::FakeDataKeySource; + +#[derive(Serialize, Deserialize)] +struct Unwiped { + name: String, +} + +async fn encrypt(keyset: &KeysetCipher<'_, FakeDataKeySource>, value: Unwiped) { + let _refused = keyset.encrypt(OpaqueBlob::new(value)).await.unwrap(); +} + +async fn decrypt(keyset: &KeysetCipher<'_, FakeDataKeySource>, ct: Ciphertext) { + let _refused: OpaqueBlob = keyset.decrypt(ct).await.unwrap(); +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/opaque_blob_requires_zeroize.stderr b/packages/stack-encrypt/tests/ui/opaque_blob_requires_zeroize.stderr new file mode 100644 index 000000000..65ad10ec0 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/opaque_blob_requires_zeroize.stderr @@ -0,0 +1,59 @@ +error[E0277]: the trait bound `Unwiped: zeroize::DefaultIsZeroes` is not satisfied + --> tests/ui/opaque_blob_requires_zeroize.rs:14:35 + | +14 | let _refused = keyset.encrypt(OpaqueBlob::new(value)).await.unwrap(); + | ------- ^^^^^^^^^^^^^^^^^^^^^^ unsatisfied trait bound + | | + | required by a bound introduced by this call + | +help: the trait `zeroize::DefaultIsZeroes` is not implemented for `Unwiped` + --> tests/ui/opaque_blob_requires_zeroize.rs:9:1 + | + 9 | struct Unwiped { + | ^^^^^^^^^^^^^^ + = help: the following other types implement trait `zeroize::DefaultIsZeroes`: + () + PhantomPinned + bool + char + f32 + f64 + i128 + i16 + and $N others + = note: required for `Unwiped` to implement `zeroize::Zeroize` + = note: required for `OpaqueBlob` to implement `OpaqueEncode` +note: required by a bound in `stack_encrypt::scalar::>::encrypt` + --> src/scalar.rs + | + | pub fn encrypt(&self, value: S) -> Pending<'_, Ciphertext, K> { + | ^^^^^^^^^^^^ required by this bound in `stack_encrypt::scalar::>::encrypt` + +error[E0277]: the trait bound `Unwiped: zeroize::DefaultIsZeroes` is not satisfied + --> tests/ui/opaque_blob_requires_zeroize.rs:18:48 + | +18 | let _refused: OpaqueBlob = keyset.decrypt(ct).await.unwrap(); + | ^^^^^^^ unsatisfied trait bound + | +help: the trait `zeroize::DefaultIsZeroes` is not implemented for `Unwiped` + --> tests/ui/opaque_blob_requires_zeroize.rs:9:1 + | + 9 | struct Unwiped { + | ^^^^^^^^^^^^^^ + = help: the following other types implement trait `zeroize::DefaultIsZeroes`: + () + PhantomPinned + bool + char + f32 + f64 + i128 + i16 + and $N others + = note: required for `Unwiped` to implement `zeroize::Zeroize` + = note: required for `OpaqueBlob` to implement `OpaqueDecode` +note: required by a bound in `stack_encrypt::scalar::>::decrypt` + --> src/scalar.rs + | + | pub fn decrypt(&self, ciphertext: Ciphertext) -> Pending<'_, P, K> { + | ^^^^^^^^^^^^ required by this bound in `stack_encrypt::scalar::>::decrypt` diff --git a/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr b/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr index f8bde2374..b7ca5de46 100644 --- a/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr +++ b/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr @@ -7,6 +7,7 @@ error[E0277]: the trait bound `Vec: OpaqueEncode` is not satisfied | required by a bound introduced by this call | = help: the following other types implement trait `OpaqueEncode`: + OpaqueBlob bool f32 f64 @@ -14,7 +15,6 @@ error[E0277]: the trait bound `Vec: OpaqueEncode` is not satisfied i16 i32 i64 - i8 and $N others note: required by a bound in `stack_encrypt::scalar::>::encrypt` --> src/scalar.rs @@ -31,6 +31,7 @@ error[E0277]: the trait bound `Vec: OpaqueEncode` is not satisfied | required by a bound introduced by this call | = help: the following other types implement trait `OpaqueEncode`: + OpaqueBlob bool f32 f64 @@ -38,7 +39,6 @@ error[E0277]: the trait bound `Vec: OpaqueEncode` is not satisfied i16 i32 i64 - i8 and $N others note: required by a bound in `stack_encrypt::scalar::>::encrypt` --> src/scalar.rs diff --git a/packages/stack-encrypt/tests/ui/scalar_nullable.stderr b/packages/stack-encrypt/tests/ui/scalar_nullable.stderr index c4647de38..6430b6316 100644 --- a/packages/stack-encrypt/tests/ui/scalar_nullable.stderr +++ b/packages/stack-encrypt/tests/ui/scalar_nullable.stderr @@ -7,6 +7,7 @@ error[E0277]: the trait bound `Option: OpaqueEncode` is not satisfied | required by a bound introduced by this call | = help: the following other types implement trait `OpaqueEncode`: + OpaqueBlob bool f32 f64 @@ -14,7 +15,6 @@ error[E0277]: the trait bound `Option: OpaqueEncode` is not satisfied i16 i32 i64 - i8 and $N others note: required by a bound in `stack_encrypt::scalar::>::encrypt` --> src/scalar.rs @@ -31,6 +31,7 @@ error[E0277]: the trait bound `Option: OpaqueEncode` is not satisfied | required by a bound introduced by this call | = help: the following other types implement trait `OpaqueEncode`: + OpaqueBlob bool f32 f64 @@ -38,7 +39,6 @@ error[E0277]: the trait bound `Option: OpaqueEncode` is not satisfied i16 i32 i64 - i8 and $N others note: required by a bound in `stack_encrypt::scalar::>::encrypt` --> src/scalar.rs From a8fa3179ae87deb5d7ce37cfb8ae7053487913c3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 23:35:59 +1100 Subject: [PATCH 09/15] chore(deps): keep the lockfiles' other edges where they were Moving the vitaminc family to 0.8.1 also re-resolved some dependency edges that have nothing to do with vitaminc, each to a version that was already in the lock: hyper-util, quinn and quinn-udp to socket2 0.6.5 in the root lock, tempfile to getrandom 0.4.2 in the EQL lock, and several windows-sys edges in the root, EQL and Go guest locks. Put those edges back as they were, so the bump changes only the vitaminc entries, as its commit message says. Every edited lock still passes `cargo metadata --locked`. --- Cargo.lock | 8 ++++---- languages/golang/auth/guest/Cargo.lock | 6 +++--- languages/golang/encrypt/guest/Cargo.lock | 4 ++-- packages/eql/Cargo.lock | 14 +++++++------- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ab8a2c57..1bcfb05d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1519,7 +1519,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -2542,7 +2542,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.5", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -2581,7 +2581,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2 0.5.10", "tracing", "windows-sys 0.52.0", ] @@ -4844,7 +4844,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/languages/golang/auth/guest/Cargo.lock b/languages/golang/auth/guest/Cargo.lock index 5695286e9..75886f8a2 100644 --- a/languages/golang/auth/guest/Cargo.lock +++ b/languages/golang/auth/guest/Cargo.lock @@ -633,7 +633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1613,7 +1613,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1925,7 +1925,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/languages/golang/encrypt/guest/Cargo.lock b/languages/golang/encrypt/guest/Cargo.lock index d870d68a2..3fddac588 100644 --- a/languages/golang/encrypt/guest/Cargo.lock +++ b/languages/golang/encrypt/guest/Cargo.lock @@ -752,7 +752,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3094,7 +3094,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index c2e1e2d23..e988c4cbd 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -1272,7 +1272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3037,7 +3037,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -3465,7 +3465,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3522,7 +3522,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4369,10 +4369,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5659,7 +5659,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] From 1ac3e358adf52edcfe28958788f537d0fbe786b0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 23:36:08 +1100 Subject: [PATCH 10/15] fix(stack-encrypt): refuse a blob's context before encoding it A scalar's context is checked before the value is planned. A blob was encoded first and only then reached that check, so a context no request could carry still cost a plaintext copy in the encoded buffer, and an unwritable value under such a context was reported as a blob error rather than the context error a scalar gives. Check the descriptor before encoding, and pin the order with a test: a value whose `Serialize` refuses, under an oversized context, is refused as `DescriptorTooLong`, with the source wiped and no key requested. Also drop a value the blob unit test built only to drop it. --- packages/stack-encrypt/src/blob.rs | 11 +++++----- packages/stack-encrypt/tests/opaque_blob.rs | 23 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/stack-encrypt/src/blob.rs b/packages/stack-encrypt/src/blob.rs index 24f891033..1c9e2389b 100644 --- a/packages/stack-encrypt/src/blob.rs +++ b/packages/stack-encrypt/src/blob.rs @@ -360,6 +360,12 @@ impl sealed::Encode for OpaqueBlob { cipher: &'a KeysetCipher<'_, K>, context: ContextPiece<'static>, ) -> Pending<'a, Ciphertext, K> { + // Refuse a context no request could carry before the value is + // encoded, as a scalar is: no plaintext copy is made for a request + // that cannot be sent, and the refusal is the same as a scalar's. + if let Err(error) = crate::Descriptor::from_piece(&context).check() { + return Pending::failed(cipher, error); + } let blob = match self.encode() { Ok(blob) => blob, Err(error) => return Pending::failed(cipher, error.into()), @@ -562,10 +568,6 @@ mod tests { assert!(matches!(read(b"VCS\x01\xff"), BlobError::Malformed(_))); // A well-formed blob of another shape names the declared field. - let other = OpaqueBlob::new(Pair { - left: "l".into(), - right: 7, - }); #[derive(Serialize, Zeroize)] struct Renamed { left: String, @@ -581,7 +583,6 @@ mod tests { assert!(matches!(mismatch, BlobError::Mismatch(_))); let shown = format!("{mismatch} / {:?}", mismatch.payload()); assert!(!shown.contains("\"l\""), "{shown}"); - drop(other); } #[test] diff --git a/packages/stack-encrypt/tests/opaque_blob.rs b/packages/stack-encrypt/tests/opaque_blob.rs index 78372988e..419e783c3 100644 --- a/packages/stack-encrypt/tests/opaque_blob.rs +++ b/packages/stack-encrypt/tests/opaque_blob.rs @@ -519,6 +519,29 @@ async fn the_owned_source_is_wiped_when_context_validation_fails() { assert_eq!(generates.load(Ordering::SeqCst), 0); } +/// The context is checked before the value is encoded, as a scalar's is: a +/// value that would refuse to encode, under a context no request can +/// carry, is refused for the context, so no encoding ran. +#[tokio::test] +async fn the_context_is_refused_before_the_value_is_encoded() { + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let wiped = Arc::new(AtomicBool::new(false)); + let pending = keyset.encrypt_with_context( + OpaqueBlob::new(Refusing { + secret: "secret".into(), + wiped: wiped.clone(), + }), + "x".repeat(65536), + ); + assert!(wiped.load(Ordering::SeqCst)); + assert!(matches!( + pending.await, + Err(Error::DescriptorTooLong { .. }) + )); + assert_eq!(generates.load(Ordering::SeqCst), 0); +} + /// `Zeroizing` is the opt-in drop guard on both sides: a `Zeroizing` /// encrypts as its `S`, and asking for `Zeroizing

` returns the opened /// value wrapped so it is wiped on drop. Blobs and scalars alike. From 495f672462a98174bf0411ae5c36cd63a7392a6d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 23:36:19 +1100 Subject: [PATCH 11/15] test(stack-encrypt): witness the Zeroizing result and leaked blob buffers Two cleanup claims had no test that could fail. The heap spy scans blocks as they are freed, so a buffer that is never freed passes it: replacing the decode step's drop of the opened buffer with `mem::forget` left every blob test green. `watched_for_leaks` also records the blocks allocated during the watch and fails if one still live at its end holds the marker. It is opt-in, since a watch that returns plaintext keeps it live on purpose, and the three blob heap tests use it. `the_spy_sees_a_leaked_block` is its negative control. The `mem::forget` breakage now fails two of them. `zeroizing_is_the_opt_in_drop_guard_on_both_sides` only round-tripped. `only_a_zeroizing_result_is_wiped_on_drop` counts wipes: a decrypted `OpaqueBlob` is not wiped when dropped, and a decrypted `Zeroizing>` is wiped exactly once. --- packages/stack-encrypt/tests/heap_hygiene.rs | 125 +++++++++++++++++-- packages/stack-encrypt/tests/opaque_blob.rs | 44 +++++++ 2 files changed, 162 insertions(+), 7 deletions(-) diff --git a/packages/stack-encrypt/tests/heap_hygiene.rs b/packages/stack-encrypt/tests/heap_hygiene.rs index 5e59c77a6..67423abc0 100644 --- a/packages/stack-encrypt/tests/heap_hygiene.rs +++ b/packages/stack-encrypt/tests/heap_hygiene.rs @@ -34,6 +34,13 @@ //! copy left in its dead frame would be picked up the same way by a future //! built later at that depth (see [`holds_key`]). //! +//! A free is what the spy sees, so a block that is never freed (a +//! `mem::forget`, a `Box::leak`) passes [`watched`]. [`watched_for_leaks`] +//! closes that for the tests that use it: it also records every block +//! allocated during the watch, and fails if one still live when the watch +//! ends holds the marker. It is opt-in because a watch that returns +//! plaintext to its caller keeps that plaintext live on purpose. +//! //! Unix only: the spy scans a freed block with libc's `memmem`, not as a //! Rust slice (see [`holds_mark`]). #![cfg(all(feature = "dynamic", unix))] @@ -43,6 +50,7 @@ mod common; use std::alloc::{GlobalAlloc, Layout, System}; use std::cell::Cell; use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use common::frames::{frame, object}; @@ -72,6 +80,30 @@ static UPSTREAM: Mutex = Mutex::new(0); /// allocation: a fixed array behind a lock. static KEY: Mutex> = Mutex::new(None); +/// How many blocks [`watched_for_leaks`] can track at once. A watch that +/// outgrows it fails rather than passing on a partial record. +const LIVE_SLOTS: usize = 16 * 1024; + +/// The blocks allocated on the watched thread during a +/// [`watched_for_leaks`] watch and not yet freed, as `(address, size)`. A +/// fixed array behind a lock: recording one must not allocate. +struct Live { + blocks: [(usize, usize); LIVE_SLOTS], + len: usize, + overflowed: bool, +} + +static LIVE: Mutex = Mutex::new(Live { + blocks: [(0, 0); LIVE_SLOTS], + len: 0, + overflowed: false, +}); + +/// Whether [`LIVE`] is recording. Read on every allocation and free, on +/// every thread, so a block the watched thread allocated is forgotten +/// wherever it is freed. +static TRACKING: AtomicBool = AtomicBool::new(false); + /// The vitaminc frame whose free is excluded (see the module docs). const UPSTREAM_SEAL: &str = "vitaminc_encrypt::backend::aws_lc::CipherKey::seal"; @@ -143,7 +175,23 @@ unsafe impl GlobalAlloc for Spy { // owner left it, and a block that is only partly written (a boxed // future's state, a `Vec`'s spare capacity) would otherwise show // what an earlier, excluded free left there and be blamed for it. - System.alloc_zeroed(layout) + let ptr = System.alloc_zeroed(layout); + if !ptr.is_null() + && TRACKING.load(Ordering::SeqCst) + && WATCHING.with(Cell::get) + && !BUSY.with(Cell::get) + { + if let Ok(mut live) = LIVE.lock() { + if live.len < LIVE_SLOTS { + let at = live.len; + live.blocks[at] = (ptr as usize, layout.size()); + live.len += 1; + } else { + live.overflowed = true; + } + } + } + ptr } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { @@ -168,6 +216,15 @@ unsafe impl GlobalAlloc for Spy { BUSY.with(|busy| busy.set(false)); } } + if TRACKING.load(Ordering::SeqCst) { + if let Ok(mut live) = LIVE.lock() { + let len = live.len; + if let Some(at) = live.blocks[..len].iter().position(|b| b.0 == ptr as usize) { + live.blocks[at] = live.blocks[len - 1]; + live.len -= 1; + } + } + } System.dealloc(ptr, layout) } } @@ -214,6 +271,46 @@ fn watched(what: &str, f: impl FnOnce() -> T) -> T { out } +/// [`watched`], and also fail if a block allocated during the watch is +/// still live when it ends and holds the marker: plaintext leaked rather +/// than freed, which the free-time scan cannot see. `f` returns nothing, +/// so nothing it built is meant to outlive it. +fn watched_for_leaks(what: &str, f: impl FnOnce()) { + { + let mut live = LIVE.lock().unwrap(); + live.len = 0; + live.overflowed = false; + } + TRACKING.store(true, Ordering::SeqCst); + // Stop recording whether `watched` returns or panics. + struct Stop; + impl Drop for Stop { + fn drop(&mut self) { + TRACKING.store(false, Ordering::SeqCst); + } + } + let stop = Stop; + watched(what, f); + drop(stop); + let live = LIVE.lock().unwrap(); + assert!( + !live.overflowed, + "{what}: more than {LIVE_SLOTS} blocks were live at once; raise LIVE_SLOTS" + ); + let leaked: Vec = live.blocks[..live.len] + .iter() + // SAFETY: every recorded block is still allocated: a free removes + // its record while `TRACKING` is on, and it was on until `stop`. + .filter(|(ptr, size)| unsafe { holds_mark(*ptr as *const u8, *size) }) + .map(|(_, size)| *size) + .collect(); + assert!( + leaked.is_empty(), + "{what}: {} heap blocks allocated during the watch are still live with plaintext in them (sizes {leaked:?})", + leaked.len() + ); +} + fn marked(tail: &str) -> String { format!("{}-{tail}", std::str::from_utf8(MARK).unwrap()) } @@ -595,12 +692,26 @@ fn the_spy_sees_an_unwiped_free() { watched("control", || drop(text)); } +/// The leak half's negative control: a marked block allocated during the +/// watch and never freed is seen by [`watched_for_leaks`], though the +/// free-time scan alone passes it. +#[test] +#[should_panic(expected = "still live with plaintext in them")] +fn the_spy_sees_a_leaked_block() { + let text = marked("control"); + watched("control (leak, free-time scan only)", || { + std::mem::forget(text.clone()) + }); + watched_for_leaks("control (leak)", || std::mem::forget(text.clone())); +} + /// `OpaqueBlob`: the owned source, the storage blob it is encoded into /// (which grows past its first allocation while it already holds the /// marker), the planned leaf, and on decrypt the opened buffer the blob -/// is read from, all freed wiped. The opened value is the caller's and is -/// wiped by hand before the watch ends; a refusal of another shape, found -/// before any field is built, leaves nothing to wipe. +/// is read from, all freed wiped, and none of them leaked rather than +/// freed. The opened value is the caller's and is wiped by hand before the +/// watch ends; a refusal of another shape, found before any field is +/// built, leaves nothing to wipe. mod opaque_blob { use super::*; @@ -633,7 +744,7 @@ mod opaque_blob { let cipher = rt.block_on(common::stack_cipher()); let keyset = cipher.default_keyset(); let source = doc(); - watched("OpaqueBlob encrypt", || { + watched_for_leaks("OpaqueBlob encrypt", || { rt.block_on(async { keyset .encrypt_with_context(source, "docs/body") @@ -654,7 +765,7 @@ mod opaque_blob { .await .unwrap() }); - watched("OpaqueBlob decrypt", || { + watched_for_leaks("OpaqueBlob decrypt", || { let mut opened = rt.block_on(async { keyset .decrypt_with_context::>(sealed, "docs/body") @@ -693,7 +804,7 @@ mod opaque_blob { struct Other { title: u32, } - watched("OpaqueBlob decrypt (mismatch)", || { + watched_for_leaks("OpaqueBlob decrypt (mismatch)", || { let result = rt.block_on(async { keyset .decrypt_with_context::>(sealed, "docs/body") diff --git a/packages/stack-encrypt/tests/opaque_blob.rs b/packages/stack-encrypt/tests/opaque_blob.rs index 419e783c3..2542da49d 100644 --- a/packages/stack-encrypt/tests/opaque_blob.rs +++ b/packages/stack-encrypt/tests/opaque_blob.rs @@ -542,6 +542,50 @@ async fn the_context_is_refused_before_the_value_is_encoded() { assert_eq!(generates.load(Ordering::SeqCst), 0); } +/// Counts its own wipes, so a test can see whether a decrypted value was +/// wiped when it was dropped. Used by one test only. +static WITNESS_WIPES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +#[derive(Serialize, Deserialize)] +struct Witness { + value: String, +} +impl Zeroize for Witness { + fn zeroize(&mut self) { + self.value.zeroize(); + WITNESS_WIPES.fetch_add(1, Ordering::SeqCst); + } +} + +/// The drop witness for both halves of the result contract: a decrypted +/// `OpaqueBlob` is an ordinary value that nothing wipes on drop, and a +/// decrypted `Zeroizing>` is wiped exactly once when it is +/// dropped, through `T::zeroize`. +#[tokio::test] +async fn only_a_zeroizing_result_is_wiped_on_drop() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt(OpaqueBlob::new(Witness { + value: "secret".into(), + })) + .await + .unwrap(); + // Encrypting wiped the source; count from here. + let before = WITNESS_WIPES.load(Ordering::SeqCst); + + let plain: OpaqueBlob = keyset.decrypt(ct.clone()).await.unwrap(); + assert_eq!(plain.get().value, "secret"); + drop(plain); + assert_eq!(WITNESS_WIPES.load(Ordering::SeqCst), before); + + let guarded: Zeroizing> = keyset.decrypt(ct).await.unwrap(); + assert_eq!(guarded.get().value, "secret"); + assert_eq!(WITNESS_WIPES.load(Ordering::SeqCst), before); + drop(guarded); + assert_eq!(WITNESS_WIPES.load(Ordering::SeqCst), before + 1); +} + /// `Zeroizing` is the opt-in drop guard on both sides: a `Zeroizing` /// encrypts as its `S`, and asking for `Zeroizing

` returns the opened /// value wrapped so it is wiped on drop. Blobs and scalars alike. From ad718ca2d49aa940b75c6fc1a045cafd86fcf3af Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 23:36:19 +1100 Subject: [PATCH 12/15] test(conformance): restore the profiles' format and enroll the new tests The blob enrollment had reformatted every array in `lifecycle-profiles.json`, which `biome format` rejects and `pnpm run code:check` gates on. It is back in Biome's form. The partial-decode negative control was listed as evidence for `lifecycle-buffer-cleanup-v1` while the contract calls it a known gap, "not passing cleanup evidence". It now appears only as the `native-partial-decode-fields` limitation, as the Go known gap does. The new tests are enrolled: the leak control under buffer cleanup, the `Zeroizing` drop witness under ownership, and the context-first refusal under validation before I/O. The contract text says what each adds. --- .../stack-encrypt/lifecycle-contract.md | 6 +- .../stack-encrypt/v1/lifecycle-profiles.json | 68 ++++++------------- 2 files changed, 23 insertions(+), 51 deletions(-) diff --git a/conformance/stack-encrypt/lifecycle-contract.md b/conformance/stack-encrypt/lifecycle-contract.md index bea4b2761..17a42a9db 100644 --- a/conformance/stack-encrypt/lifecycle-contract.md +++ b/conformance/stack-encrypt/lifecycle-contract.md @@ -10,12 +10,12 @@ process. Python and TypeScript are explicitly not enrolled. | Requirement | Native evidence | Go evidence and limits | |---|---|---| -| `lifecycle-ownership-v1` | Actual record operations release fulfilment captures exactly once on success, provider failure and unpolled drop; zipped fulfilments do not share key material. An owned `OpaqueBlob` source is wiped before its pending is returned, on success and on an encoding refusal. | Client keys are consumed/redacted; explicit close and unreachable-client cleanup release guest memory; calls on a closed client fail. GC cleanup is eventual, so callers should close explicitly. | +| `lifecycle-ownership-v1` | Actual record operations release fulfilment captures exactly once on success, provider failure and unpolled drop; zipped fulfilments do not share key material. An owned `OpaqueBlob` source is wiped before its pending is returned, on success and on an encoding refusal. A decrypted `Zeroizing>` is wiped once on drop, and a plain `OpaqueBlob` is not. | Client keys are consumed/redacted; explicit close and unreachable-client cleanup release guest memory; calls on a closed client fail. GC cleanup is eventual, so callers should close explicitly. | | `lifecycle-cancellation-v1` | Dropping a future paused in generation drops the provider future and operation captures; subsequent work on the cipher succeeds. Guest ABI abort spends the handle and drops the operation. | Cancellation before a call sends no request; cancellation between steps or during token fetch leaves unrelated operations usable. A guest step that overruns cancellation closes the client. Discarded late output is wiped. | | `lifecycle-partial-output-v1` | A batch with a valid first row and a later authenticated type mismatch releases its decoded prefix without marked heap residue. Existing context cases reject late authentication failures without returning partial output. An `OpaqueBlob` refused before its first field is built leaves no marked residue; a refusal after some fields are built is the `native-partial-decode-fields` limit below. | Generated and raw batch APIs return no partial rows on failure; interrupted HTTP bodies fail; cancelled output is wiped. SDK conversion diagnostics contain no decrypted values. This does not assert erasure of partially populated caller-owned Go objects. | -| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths and has a negative control. It also checks an `OpaqueBlob`'s encoded buffer (which regrows while it holds plaintext), its planned leaf and its opened buffer, and pins the partial-decode limit as a second negative control. Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | +| `lifecycle-buffer-cleanup-v1` | The existing allocator observer checks selected write/read/JSON parse paths and has a negative control. It also checks an `OpaqueBlob`'s encoded buffer (which regrows while it holds plaintext), its planned leaf and its opened buffer, and for those blob paths that no marked block allocated during the operation is still live at its end (a leak the free-time scan cannot see), with its own negative control. The partial-decode limit is pinned separately and is not enrolled here (see below). Guest ABI tests check buffer-registry cleanup, error-buffer release and protected text drop. | Request bodies survive until their owner closes them, then wipe; refused requests wipe; heap growth wipes abandoned backing arrays; guest runtime close releases memory. Post-encryption memory scans are supplementary observations. | | `lifecycle-diagnostics-v1` | Guest errors discard untrusted foreign cause messages; failed/successful exports do not hand over stale diagnostics. | Structured errors remain available after failures; SDK errors and redacted display/logging expose types or fields rather than plaintext values. | -| `lifecycle-validation-before-io-v1` | Existing runtime/read/context cases count provider calls. Wrong shape, independently detectable context mismatch and oversized descriptors fail before key I/O; lazy assembly stops after local failure. An `OpaqueBlob` whose `Serialize` refuses requests no key; on decrypt the leaf authenticates (one retrieval) before any blob byte is read, and tampering is `Aead` however the bytes would decode. | Host refusals happen before entering the guest, and shared runtime/read cases exercise the uninitialised guest's preflight boundary. Go does not yet expose native provider counters. | +| `lifecycle-validation-before-io-v1` | Existing runtime/read/context cases count provider calls. Wrong shape, independently detectable context mismatch and oversized descriptors fail before key I/O; lazy assembly stops after local failure. An `OpaqueBlob` whose `Serialize` refuses requests no key, and an oversized context is refused before the value is encoded; on decrypt the leaf authenticates (one retrieval) before any blob byte is read, and tampering is `Aead` however the bytes would decode. | Host refusals happen before entering the guest, and shared runtime/read cases exercise the uninitialised guest's preflight boundary. Go does not yet expose native provider counters. | | `lifecycle-logical-batching-v1` | Instrumented `DataKeySource` calls establish one logical generation batch, retrieval grouping by keyset, descriptor ordering and no work for empty batches. | Go uses the same engine, but this profile does not claim independently instrumented Go key-request counts. | | `lifecycle-provider-chunks-v1` | The actual KMS chunking helper preserves order, handles a final short chunk, fails the whole call on a chunk error, bounds in-flight chunks and releases their futures on cancellation. | This is a native algorithm observation, not a measurement of live HTTP request counts. | diff --git a/conformance/stack-encrypt/v1/lifecycle-profiles.json b/conformance/stack-encrypt/v1/lifecycle-profiles.json index 63a5bb205..41ffd2894 100644 --- a/conformance/stack-encrypt/v1/lifecycle-profiles.json +++ b/conformance/stack-encrypt/v1/lifecycle-profiles.json @@ -6,9 +6,7 @@ "lane": "fast", "workspace": ".", "package": "stack-encrypt", - "features": [ - "--all-features" - ], + "features": ["--all-features"], "lib": true }, "lifecycle": { @@ -16,9 +14,7 @@ "lane": "fast", "workspace": ".", "package": "stack-encrypt", - "features": [ - "--all-features" - ], + "features": ["--all-features"], "binary": "lifecycle_conformance" }, "heap": { @@ -26,9 +22,7 @@ "lane": "fast", "workspace": ".", "package": "stack-encrypt", - "features": [ - "--all-features" - ], + "features": ["--all-features"], "binary": "heap_hygiene", "success_output": "immediate" }, @@ -37,9 +31,7 @@ "lane": "fast", "workspace": ".", "package": "stack-encrypt", - "features": [ - "--all-features" - ], + "features": ["--all-features"], "binary": "runtime_conformance" }, "reads": { @@ -47,9 +39,7 @@ "lane": "fast", "workspace": ".", "package": "stack-encrypt", - "features": [ - "--all-features" - ], + "features": ["--all-features"], "binary": "read_conformance" }, "contexts": { @@ -57,9 +47,7 @@ "lane": "fast", "workspace": ".", "package": "stack-encrypt", - "features": [ - "--all-features" - ], + "features": ["--all-features"], "binary": "context_conformance" }, "guest-abi": { @@ -67,9 +55,7 @@ "lane": "fast", "workspace": ".", "package": "stack-guest-abi", - "features": [ - "--all-features" - ], + "features": ["--all-features"], "lib": true }, "kms": { @@ -77,9 +63,7 @@ "lane": "fast", "workspace": ".", "package": "stack-kms", - "features": [ - "--all-features" - ], + "features": ["--all-features"], "lib": true }, "go": { @@ -102,9 +86,7 @@ "lane": "fast", "workspace": ".", "package": "stack-encrypt", - "features": [ - "--all-features" - ], + "features": ["--all-features"], "binary": "opaque_blob" } }, @@ -127,16 +109,15 @@ ], "opaque": [ "the_owned_source_is_wiped_before_await_or_discard", - "an_encoding_refusal_wipes_the_source_and_requests_no_key" + "an_encoding_refusal_wipes_the_source_and_requests_no_key", + "only_a_zeroizing_result_is_wiped_on_drop" ] } }, { "id": "lifecycle-cancellation-v1", "evidence": { - "lifecycle": [ - "cancelling_in_flight_work_drops_provider_and_captures" - ], + "lifecycle": ["cancelling_in_flight_work_drops_provider_and_captures"], "guest-abi": [ "step::tests::an_aborted_operation_is_dropped_and_its_handle_spent", "step::tests::the_wrong_number_of_responses_is_refused_and_drops_the_operation" @@ -158,17 +139,13 @@ "a_late_read_type_failure_leaves_no_plaintext_on_the_heap", "opaque_blob::an_opaque_blob_of_another_shape_leaves_no_plaintext_on_the_heap" ], - "contexts": [ - "shared_context_boundaries" - ], + "contexts": ["shared_context_boundaries"], "go": [ "TestContextBoundaries", "TestInterruptedResponseBodyIsTransport", "TestACallThatSucceedsAfterItsContextEndsFailsWithIt" ], - "go-sdk": [ - "TestDecryptErrorsHoldNoPlaintext" - ] + "go-sdk": ["TestDecryptErrorsHoldNoPlaintext"] } }, { @@ -179,9 +156,9 @@ "a_record_decrypted_with_a_nested_field_leaves_no_plaintext_on_the_heap", "a_json_document_read_from_a_frame_leaves_no_plaintext_on_the_heap", "the_spy_sees_an_unwiped_free", + "the_spy_sees_a_leaked_block", "opaque_blob::an_opaque_blob_encrypted_leaves_no_plaintext_on_the_heap", - "opaque_blob::an_opaque_blob_decrypted_leaves_no_plaintext_on_the_heap", - "opaque_blob::a_partial_decode_drops_the_fields_it_built_unwiped" + "opaque_blob::an_opaque_blob_decrypted_leaves_no_plaintext_on_the_heap" ], "guest-abi": [ "buffers::tests::wipe_all_leaves_no_live_buffer", @@ -225,12 +202,8 @@ { "id": "lifecycle-validation-before-io-v1", "evidence": { - "runtime": [ - "shared_record_runtime_profile" - ], - "reads": [ - "shared_record_read_profile" - ], + "runtime": ["shared_record_runtime_profile"], + "reads": ["shared_record_read_profile"], "pending": [ "target::pending::tests::an_over_long_descriptor_is_refused_before_any_call", "target::pending::tests::a_lazy_column_stops_building_after_a_local_failure" @@ -243,6 +216,7 @@ "opaque": [ "an_encoding_refusal_wipes_the_source_and_requests_no_key", "the_owned_source_is_wiped_when_context_validation_fails", + "the_context_is_refused_before_the_value_is_encoded", "authentication_comes_before_decoding", "malformed_and_mismatched_blobs_are_refused_by_class_after_authentication" ] @@ -251,9 +225,7 @@ { "id": "lifecycle-logical-batching-v1", "evidence": { - "runtime": [ - "shared_record_runtime_profile" - ], + "runtime": ["shared_record_runtime_profile"], "pending": [ "target::pending::tests::one_pending_asking_for_many_keys_is_one_call", "target::pending::tests::all_merges_a_column_into_one_call_preserving_order", From f8065611f1ca70fed47dd5528c05783e7df9ee8f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 23:36:19 +1100 Subject: [PATCH 13/15] docs(stack-encrypt): list the SealedValue conversions in the changelog `From for Ciphertext` and the reverse are new public API and were missing from [Unreleased]. The cleanup bullet also now covers the context check before encoding and the heap observer's leak watch. --- packages/stack-encrypt/CHANGELOG.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 4b35ab87b..483afbf1a 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -63,10 +63,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 plain `P` is still an ordinary owned value that nothing wipes on drop; `Zeroize` is the bound, not a guard. `OpaqueEncode` is implemented for `Zeroizing` too. +- **`From for Ciphertext`, and back.** The bytes are the same: + a `CipherText::Single` leaf from `encrypt_tree` opens through the owned + API, and an owned `Ciphertext` hands its leaf to the structural API. - **The owned source is wiped on an encoding refusal** as on success, before - the pending is returned and with no key requested; the owned blob buffer is - `Protected` while it is planned and on decrypt while it is read. The heap - observer covers blob encrypt, decrypt and a refused shape, and pins as a + the pending is returned and with no key requested; a context no request + can carry is refused before the value is encoded, as a scalar's is; the + owned blob buffer is `Protected` while it is planned and on decrypt while + it is read. The heap observer covers blob encrypt, decrypt and a refused + shape, checks those paths for marked blocks leaked rather than freed (an + opt-in `watched_for_leaks`, with its own negative control), and pins as a negative control the one stated limit: a derived `Deserialize` that built some fields and refuses a later one drops those fields unwiped, because no `T` existed to wipe. From f90867ba7047ea38b350ec2b4903fb6f3cedbea0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 23:54:43 +1100 Subject: [PATCH 14/15] docs(stack-encrypt): state the Kms and Aead outcomes of a wrong blob context ZeroKMS refuses a wrong context's descriptor before the AEAD runs, so it surfaces as Error::Kms; only a key source that ignores descriptors reaches Error::Aead. Say so in the blob module docs and the changelog, as the crate docs do, and limit the authentic-ciphertext claim to OpenedType and Blob. --- packages/stack-encrypt/CHANGELOG.md | 9 ++++++--- packages/stack-encrypt/src/blob.rs | 21 ++++++++++++++------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 483afbf1a..665d39eff 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -52,9 +52,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 any key is requested), and after the leaf authenticated, `blob_not_a_blob`, `blob_unsupported_version` (a later storage version: upgrade the reader), `blob_malformed` and `blob_mismatch` (a well-formed blob of another shape or - type). Every decrypt-side `Blob` error means the ciphertext was authentic; - tampering and a wrong context are still `Aead`, and a blob opened as a - scalar or a scalar as a blob is `OpenedType`. No message or payload names a + type). Every decrypt-side `Blob` error means the ciphertext was authentic. + Tampering is still `Aead`; a wrong context is refused by ZeroKMS before + the AEAD runs and surfaces as `Kms` (only a key source that ignores + descriptors, such as `FakeDataKeySource`, lets it reach the AEAD as + `Aead`); and a blob opened as a scalar or a scalar as a blob is + `OpenedType`. No message or payload names a value; a mismatch's payload names the declared field. - **`Zeroizing` as the opt-in drop-guarded result** of the scalar API: `OpaqueDecode` is implemented for `Zeroizing

` of any decodable `P`, so diff --git a/packages/stack-encrypt/src/blob.rs b/packages/stack-encrypt/src/blob.rs index 1c9e2389b..6f869cf31 100644 --- a/packages/stack-encrypt/src/blob.rs +++ b/packages/stack-encrypt/src/blob.rs @@ -148,10 +148,15 @@ //! //! Decryption **authenticates before it decodes**. The leaf opens first, //! under its key and context; only then does the profile's reader see the -//! bytes. So the errors tell three things apart: -//! -//! - [`Error::Aead`]: the leaf did not open. Wrong key, wrong context, or -//! altered bytes; the blob was never looked at. +//! bytes. A context other than the one the blob was sealed under is +//! normally refused before that: ZeroKMS binds each data key to its +//! context's descriptor and denies the retrieve, so it surfaces as +//! [`Error::Kms`] and nothing is decrypted. Past key retrieval, the errors +//! tell three things apart: +//! +//! - [`Error::Aead`]: the leaf did not open. Altered bytes, the wrong key, +//! or a wrong context under a key source that ignores descriptors +//! (`FakeDataKeySource`, in tests); the blob was never looked at. //! - [`Error::OpenedType`]: the leaf opened but is not a bytes leaf: it was //! sealed as a `String` or an integer, say, not as a blob. //! - [`Error::Blob`]: the leaf opened as bytes and they are not a blob this @@ -160,9 +165,10 @@ //! of version 1 ([`BlobError::Malformed`]), or a well-formed blob that is //! not a `T` ([`BlobError::Mismatch`]). //! -//! All three are refusals of an authentic ciphertext, so an `Error::Blob` -//! never says the data was tampered with; it says the data was written as -//! something else. The whole blob is validated before `T`'s `Deserialize` +//! [`Error::OpenedType`] and [`Error::Blob`] are refusals of an authentic +//! ciphertext, so neither says the data was tampered with; each says the +//! data was written as something else. Only [`Error::Aead`] (or +//! [`Error::Kms`]) leaves the ciphertext's authenticity unestablished. The whole blob is validated before `T`'s `Deserialize` //! sees any of it: every length is checked against the end of the blob, so //! a forged length allocates nothing, and a `Deserialize` impl never builds //! part of a value from a malformed blob. No refusal names a value or a map @@ -238,6 +244,7 @@ //! with its marker; both readers read either form). //! //! [`Error::Aead`]: crate::Error::Aead +//! [`Error::Kms`]: crate::Error::Kms //! [`Error::OpenedType`]: crate::Error::OpenedType //! [`Error::Blob`]: crate::Error::Blob //! [`KeysetCipher::encrypt`]: crate::KeysetCipher::encrypt From a7fa6be22d2cecf73cc3c47eac52a0455b08da3f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Nov 2026 23:54:43 +1100 Subject: [PATCH 15/15] test(stack-encrypt): hold the heap spy's gate across a leak watch watched_for_leaks reset LIVE and set TRACKING before watched took GATE, so under threaded cargo test another watch could erase its records or turn tracking off mid-watch. Take the gate first and run a gate-free inner watch, and release LIVE before asserting so the negative control does not poison it for later watches. --- packages/stack-encrypt/tests/heap_hygiene.rs | 48 +++++++++++++++----- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/stack-encrypt/tests/heap_hygiene.rs b/packages/stack-encrypt/tests/heap_hygiene.rs index 67423abc0..529ce30f4 100644 --- a/packages/stack-encrypt/tests/heap_hygiene.rs +++ b/packages/stack-encrypt/tests/heap_hygiene.rs @@ -250,7 +250,23 @@ fn scrub_stack() { /// Run `f` with this thread watched, and fail if any block it freed held /// the marker. fn watched(what: &str, f: impl FnOnce() -> T) -> T { - let _gate = GATE.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let _gate = gate(); + watched_in_gate(what, f) +} + +/// Take [`GATE`], which serializes every watch: the thread-local flags +/// keep other threads' allocations out of a watch, but [`FOUND`], +/// [`UPSTREAM`], [`LIVE`] and [`TRACKING`] are shared, and a test running +/// alongside on another thread (plain `cargo test`) would otherwise reset +/// or switch them off mid-watch. A test that panicked holding it (a +/// `should_panic` control) poisons it; the state it guards is reset on +/// entry, so the poison is ignored. +fn gate() -> std::sync::MutexGuard<'static, ()> { + GATE.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// [`watched`], for a caller already holding [`GATE`]. +fn watched_in_gate(what: &str, f: impl FnOnce() -> T) -> T { FOUND.lock().unwrap().clear(); *UPSTREAM.lock().unwrap() = 0; scrub_stack(); @@ -276,6 +292,9 @@ fn watched(what: &str, f: impl FnOnce() -> T) -> T { /// than freed, which the free-time scan cannot see. `f` returns nothing, /// so nothing it built is meant to outlive it. fn watched_for_leaks(what: &str, f: impl FnOnce()) { + // Held from the reset of `LIVE` to the last read of it, so no other + // watch can clear this one's records or turn `TRACKING` off under it. + let _gate = gate(); { let mut live = LIVE.lock().unwrap(); live.len = 0; @@ -290,20 +309,27 @@ fn watched_for_leaks(what: &str, f: impl FnOnce()) { } } let stop = Stop; - watched(what, f); + watched_in_gate(what, f); drop(stop); - let live = LIVE.lock().unwrap(); + // Read under the lock and released before asserting, so a failing + // assertion (the negative control's, by design) does not poison + // `LIVE` for the watches that follow in the same process. + let (overflowed, leaked) = { + let live = LIVE.lock().unwrap(); + let leaked: Vec = live.blocks[..live.len] + .iter() + // SAFETY: every recorded block is still allocated: a free + // removes its record while `TRACKING` is on, and it was on + // until `stop`. + .filter(|(ptr, size)| unsafe { holds_mark(*ptr as *const u8, *size) }) + .map(|(_, size)| *size) + .collect(); + (live.overflowed, leaked) + }; assert!( - !live.overflowed, + !overflowed, "{what}: more than {LIVE_SLOTS} blocks were live at once; raise LIVE_SLOTS" ); - let leaked: Vec = live.blocks[..live.len] - .iter() - // SAFETY: every recorded block is still allocated: a free removes - // its record while `TRACKING` is on, and it was on until `stop`. - .filter(|(ptr, size)| unsafe { holds_mark(*ptr as *const u8, *size) }) - .map(|(_, size)| *size) - .collect(); assert!( leaked.is_empty(), "{what}: {} heap blocks allocated during the watch are still live with plaintext in them (sizes {leaked:?})",