diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22b4370..ff61ba3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,14 @@ on: permissions: contents: read +# A newer push to the same pull request makes the older run's result moot, so the +# older run is cancelled rather than left holding runners. A push to main is never +# cancelled: its group is its own commit. A red-first commit's run is cancelled by +# the next push too, so read its test job before pushing the fix. +concurrency: + group: ci-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: CARGO_TERM_COLOR: always # One archive snapshot for every apt step in this file. Bump it deliberately, @@ -126,9 +134,10 @@ jobs: - name: one integration-test binary per crate run: ./scripts/one-test-binary-per-crate.sh - # The teeth harness runs as four shards (the teeth jobs below). This lists - # what each shard would run and requires the four to run every case exactly - # once. It runs no case and needs no cargo (invariant 113). + # The teeth harness runs as shards (the teeth jobs below). This reads their + # count from this file, lists what each shard would run and requires the + # shards to run every case exactly once. It runs no case and needs no cargo + # (invariant 113). - name: the teeth shards run every case exactly once run: ./scripts/teeth-shards-cover-every-case.sh @@ -254,19 +263,21 @@ jobs: run: cargo test # The teeth harness is the slowest step in this repository, 30 to 60 minutes as - # one job, so it runs as four shards of the same case list. Shard i runs the - # cases at positions i, i+4, ..., and scripts/teeth-shards-cover-every-case.sh - # requires the four together to run each case exactly once (invariant 113). - # Standard runners: this repository is public, so they cost nothing. + # one job, so it runs as eight shards of the same case list. Shard i runs the + # cases at positions i, i+8, ..., and scripts/teeth-shards-cover-every-case.sh + # requires them together to run each case exactly once (invariant 113). The + # count is written here only, in the matrix, TEETH_SHARD and the name, and that + # gate reads it from here and requires the three to agree. Standard runners: this + # repository is public, so they cost nothing. teeth: - name: teeth (shard ${{ matrix.shard }} of 4) + name: teeth (shard ${{ matrix.shard }} of 8) runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4] + shard: [1, 2, 3, 4, 5, 6, 7, 8] env: - TEETH_SHARD: ${{ matrix.shard }}/4 + TEETH_SHARD: ${{ matrix.shard }}/8 steps: # fetch-depth: 0 here too: this job runs gates-have-teeth.sh, whose # stated-numbers cases call the version-badge check, and a checkout @@ -325,7 +336,7 @@ jobs: run: ./scripts/audit.sh # The required check. Its name stays, because branch protection names it. It - # passes only when the audit job and all four teeth shards passed: a skipped + # passes only when the audit job and every teeth shard passed: a skipped # or cancelled need reads as not success, so it fails here too. security: name: security (cargo audit) @@ -333,13 +344,13 @@ jobs: if: always() runs-on: ubuntu-24.04 steps: - - name: audit and all four teeth shards passed + - name: audit and every teeth shard passed env: AUDIT: ${{ needs.audit.result }} TEETH: ${{ needs.teeth.result }} run: | echo "cargo audit: $AUDIT" >>"$GITHUB_STEP_SUMMARY" - echo "teeth, four shards: $TEETH" >>"$GITHUB_STEP_SUMMARY" + echo "teeth, every shard: $TEETH" >>"$GITHUB_STEP_SUMMARY" test "$AUDIT" = success test "$TEETH" = success diff --git a/.secretscan-allow b/.secretscan-allow index 17ba411..1b36eeb 100644 --- a/.secretscan-allow +++ b/.secretscan-allow @@ -1,6 +1,7 @@ # Exceptions for the estate secret scan (estate-gates invariant 23). Each # names a fingerprint and a path, never a value, and says why it is not a secret. 33ea101ddbe7c33b crates/cloud/src/audit_sign.rs # a PEM header around deliberate garbage, in the test that the parser rejects it +3021d90eb9437b2d crates/cloud/src/audit_sign.rs # RFC 6979 appendix A.2.5's published P-256 test key in PKCS#8 form, in the test that every accepted key encoding loads; a public test vector that signs nothing 3021d90eb9437b2d crates/cloud/tests/it/oidc.rs # an RSA key generated for the OIDC integration tests; it signs nothing outside them 743554670c6065b3 crates/core/src/dlp.rs # the DLP detector's own fixture: an AWS key id shape it must catch and redact 743554670c6065b3 crates/core/src/injection.rs # the injection redactor tests feed the DLP fixture shapes diff --git a/CLAUDE.md b/CLAUDE.md index 6f917dc..e4ea2f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1921,6 +1921,8 @@ A PDP holding the token and the proof together can check the proof's freshness, 141. **A delegation proof names one of the public origins the operator configured, and a `Host` header never stands in for one.** A gateway reached at more than one address (a public name and an internal one) must accept a proof addressed to any of them, and must refuse one addressed to anything else. `TOKENFUSE_DELEGATION_ORIGINS`, a comma-separated list, is read beside `TOKENFUSE_DELEGATION_URL` (one origin, unchanged); `TOKENFUSE_MCP_PROOF_ORIGINS` is read beside `TOKENFUSE_MCP_PROOF_URL` at the broker's proof door. The single origin and the list are one set. Every entry is normalised as wardryx's `normalizeOrigin` does, and an entry that is not `scheme://host[:port]` with nothing after the port refuses to start, naming its variable; so does a list naming no origin at all. A proof is accepted for a route when its `htu`, without its query, is one configured origin plus that route's path. A fragment, user information, another path or an unlisted origin is refused. The check picks the origin from the `htu` read before the signature is checked (`dpop::origin::expected_url`), and the verifier then checks the signature over that same claim, so nothing can change it after the choice. `@claude 2026-10-19`, choices under delegated authority, open to reversal: the list and the single origin are a union; an empty entry refuses the list rather than being skipped; and the verifier refuses a fragment in an `htu` (RFC 9449 section 4.3), which changes every door, not only the new list. This amends the single origin of invariant 30 and the `htu` rule of invariant 31 without changing either for a deployment that names one origin. *(test: `dpop::origin::tests`, `chainproof::origin_list_tests::the_single_origin_and_the_list_are_one_set_and_a_bad_entry_names_its_variable`, `tests/it/mcp_door.rs` (the list and fragment tests), `tests/it/delegation_origins.rs` (eight over the real router, both doors); scenarios: `features/a-proof-names-one-of-the-public-origins.feature`, eleven, each bound. Not a script gate: held by `cargo test`.)* +142. **A message the MCP broker cannot judge is refused whole and never forwarded: a JSON-RPC batch, anything that is not one JSON object, and an object whose `method` is not a string.** Every check the broker makes keys on `method` read from one JSON object: the Wardryx decision, the identity requirement, the drift refusal, the taint gate, the raw-secret and PII scans of the arguments, the `tool_call` record, and the poisoning and rug-pull scans of a `tools/list` answer. A JSON-RPC batch is an array, so its `method` read as empty, every one of those was skipped, and the array was forwarded as it came. Measured 2026-10-26 against the v1.19.0 release: a batch of two `tools/call`, admitted by an agent token, reached the upstream with no policy decision and no `tool_call` record; v1.20.0 has the same code path. `@decided 2026-10-26`, paraphrased: a batch is refused whole rather than judged element by element. MCP removed batching in its 2025-06-18 revision and its HTTP transport carries one message per POST, so a client on the current protocol loses nothing; one that still sends batches, as 2025-03-26 allowed, must send each message on its own. `process_call`, which both transports reach, refuses first, before an upstream is resolved or a secret read (`unjudgeable`): JSON-RPC `-32600` with a null id, HTTP 400 on the HTTP transport, and one warning in the log. The refusal is not on the agent-event bus: no event type fits a message that names no tool, and inventing one needs the agent-passport registry first, the reasoning invariant 135 gives for its own refusal. `@claude 2026-10-26`, under delegated authority, open to reversal: a `method` that is present and not a string is refused the same way, because it reads as empty here and may read as something else upstream; a message with no `method` at all is a JSON-RPC response, a client answering the server's own request, and is still forwarded. *(test: `tests/it/mcp_broker.rs`: `a_batch_of_tool_calls_is_refused_and_nothing_reaches_the_upstream`, `the_stdio_transport_refuses_a_batch_too`, `a_message_the_broker_cannot_read_as_one_request_is_refused`, and the must-not-fire case `a_single_request_and_a_clients_response_still_reach_the_upstream`; scenarios: `features/a-batch-cannot-carry-a-call-past-the-gate.feature`, four, each bound. Not a script gate: held by `cargo test`.)* + ## Decisions that have no gate yet This list is debt, and it is here to stay visible rather than to be tidy. @@ -7383,22 +7385,29 @@ is public, so a literal publishes somebody's username to everyone who reads it. 112. **A call the gateway answered just before it stops reaches the Cloud: the stop delivers what it holds, bounded, and names what it could not deliver.** On SIGTERM or SIGINT the server stops taking calls first; `flush_before_exit` then delivers the queue (in order) and the buffer within `EXIT_FLUSH_WITHIN`, waits for a push already on the wire, and starts nothing new while stopping. What is still undelivered at the bound is logged with its count. A SIGKILL, an out-of-memory kill or a lost node still loses what was not yet pushed, at most the last flush interval of calls plus the queue; a local spool is a follow-up, because replaying it needs the Cloud to recognise a record it already holds. `@decided 2026-10-14`: spend reports are made durable across a stop, and this change takes the delivery-before-exit half. *(test: `gateway::cloudsink::the_exit_flush_delivers_the_queue_then_the_buffer_in_order`, `gateway::cloudsink::the_exit_flush_is_bounded_and_says_what_it_could_not_deliver`, `gateway::cloudsink::a_push_on_the_wire_at_exit_is_counted_when_it_never_lands`, `tests/it/spend_reports_survive_a_stop.rs`: `a_call_served_just_before_sigterm_reaches_the_cloud`, `a_stop_while_the_cloud_does_not_answer_says_what_was_not_delivered`. Scenarios: `features/a-served-call-reaches-the-cloud-when-the-gateway-stops.feature`.)* -113. **The teeth harness runs as four CI shards, and the shards together run every case exactly once.** +113. **The teeth harness runs as CI shards, the shards together run every case exactly once, and their count is written once, in the workflow.** One job took 30 to 60 minutes, the slowest step in this repository. `TEETH_SHARD=i/N` runs the cases at - positions `i`, `i+N`, ... of the list; `TEETH_LIST=1` prints the names through the same filter and runs - none; a shard that runs no case says it measured nothing. `scripts/teeth-shards-cover-every-case.sh` lists - the full harness and each of the four shards, and requires that no name is listed twice, that no shard is - empty, and that the shards together are the full list: nothing dropped, nothing run in two shards. The - required check `security (cargo audit)` is an aggregate: it passes only when the `cargo audit` job and all - four shards pass. `@claude` 2026-10-14, the split and the aggregate, under delegated authority; open to - reversal. - - Not covered: the shard count of four is written twice (the workflow matrix and the gate's `shards=4`) and - nothing checks that they agree; the wall time is measured in CI, not here; the four shards compile the same - dependencies, each in its own runner. + positions `i`, `i+N`, ... of the list; `TEETH_LIST=1` prints the names through the same filter and runs none; + a shard that runs no case says it measured nothing. `scripts/teeth-shards-cover-every-case.sh` reads N from + `.github/workflows/ci.yml`, where the teeth job's matrix, its `TEETH_SHARD` divisor and its name each state + it, and refuses when the three disagree or the matrix is not `1..N`. It then lists the full harness and each + of the N shards, and requires that no name is listed twice, that no shard is empty, and that the shards + together are the full list: nothing dropped, nothing run in two shards. The required check `security (cargo + audit)` is an aggregate: it passes only when the `cargo audit` job and every shard pass. `@claude` 2026-10-14, + the split and the aggregate, under delegated authority; open to reversal. `@decided 2026-10-26`, paraphrased: + eight shards instead of four, for wall time. The four took 11.6 to 19.3 minutes each on main (`@measured` CI + run 39356312313, 2026-10-20), and the cases alone added up to 54.7 minutes across the four (`@measured` from + the per-case timestamps in the teeth logs of CI run 40259164936, 2026-10-26), 9.0 of them in five + `stated-numbers` cases, because each runs the full suite. The same change makes CI cancel a pull request's + older run when a newer push arrives (`concurrency` in `ci.yml`); a push to main is never cancelled. + + Not covered: the wall time with eight shards is measured in CI, not here; the shards compile the same + dependencies, each in its own runner; and how many jobs run at once is bounded by the account's concurrency + limit, which this repository cannot see, so two pull requests in flight may queue. *(gate: `scripts/teeth-shards-cover-every-case.sh`; teeth cases in `scripts/gates-have-teeth.sh`: the real harness partitions, a filter that drops every fifth case, a filter that runs a case in every shard up to its - own, and a harness that is gone)* + own, a harness that is gone, a matrix that skips a shard, a divisor left at four under a matrix of eight, and + the matrix gone from the workflow)* 114. **A DLP finding the scanner lets through is one `dlp_shadow` on the bus per scanner, kinds and counts, never the text, and only when neither scanner refused the prompt.** agent-passport SPEC 6.2 registered diff --git a/Cargo.lock b/Cargo.lock index f585755..85c49dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,7 +136,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -147,7 +147,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -645,9 +645,9 @@ dependencies = [ [[package]] name = "base16ct" -version = "0.2.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" [[package]] name = "base64" @@ -749,6 +749,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "borsh" version = "1.7.0" @@ -989,6 +998,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cobs" version = "0.3.0" @@ -1050,9 +1065,9 @@ dependencies = [ [[package]] name = "const-oid" -version = "0.9.6" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] name = "const-random" @@ -1118,6 +1133,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1138,27 +1159,27 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.136.3" +version = "0.136.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6d504c8c7ba3f3accaf8e98b7096beb98e1a271404357744c7e9e730c6865ff" +checksum = "c9c587da2696fbaa58e7a2fa2e5bfdd365d0928219f36992304ac1f15e5bc254" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.136.3" +version = "0.136.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c065757905596bc06e82969ebd7b92d691b7fd710a7e9e8181ae0c30dab588d0" +checksum = "f28a60027b7556b680b051651291053ec381edf4ffbc01784997ae51a64269bb" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.136.3" +version = "0.136.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca7162cd4d4fc5f9f2f88d1459d939882203d93a8c5a19ef2c405b2e82ef13f" +checksum = "62bfdb846acebfd591d601994ea283c98d525ac0bc9a84ca3f2c36bcf5b69ff1" dependencies = [ "cranelift-entity", "wasmtime-internal-core", @@ -1166,9 +1187,9 @@ dependencies = [ [[package]] name = "cranelift-bitset" -version = "0.136.3" +version = "0.136.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec2ba2d4f3d959ec4023a10266c3dcfb28381d1938d507a9a5e1abb78bbcc88" +checksum = "aa9f8cc1080739d5f72b4ee7e00aff7d589f1284beaf0a19472c07878be7f299" dependencies = [ "serde", "serde_derive", @@ -1177,9 +1198,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.136.3" +version = "0.136.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51a64cb821d5fed56bdd6053acd36fa928cd82b70bf6b19b8b14b8b27d047b9a" +checksum = "d85417d493ecec833b5134546b4a252c78cc0bd5be11729a35d25dcff9e4e70b" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -1200,7 +1221,7 @@ dependencies = [ "rustc-hash", "serde", "serde_derive", - "sha2", + "sha2 0.10.9", "smallvec", "target-lexicon", "wasmtime-internal-core", @@ -1208,9 +1229,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.136.3" +version = "0.136.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142be1e267a7497ba3c2b5a829eaf5387b92b9bcdba6e0a6020f1ffccae7f2b8" +checksum = "d01c8ed0098360bae69ad6b82952d4247516799ce8050d45ad168d6689f4eae6" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -1221,24 +1242,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.136.3" +version = "0.136.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e41f75410da6cce8601330137d73718f679db81cb790a33de3871b5c90a0de3" +checksum = "04f9b86d809cec9c4e88b201f1b3ed3b0347674c9ccfbb8c355228669e34be81" [[package]] name = "cranelift-control" -version = "0.136.3" +version = "0.136.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2616c6d01c14921ac9289ef65eb7702d9c760dfdc4537a8ee5bf38ea2832c6" +checksum = "ce182038fb10b719841e65fdf5deeee258c684f63439f5be680c2be059274b58" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.136.3" +version = "0.136.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a03e8b44c4877cf2fb21e5788d514af6fd291a066ed9ae37adf114a25fecb4aa" +checksum = "7bca812b03a7b942796391f25bc02aa70a1a007fee3ce2756490c788429e9c39" dependencies = [ "cranelift-bitset", "serde", @@ -1248,9 +1269,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.136.3" +version = "0.136.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366ff7cb014bd08752210eba55a650969f8db9b52670d69d2ea0b23e1e9e3edf" +checksum = "a0c30563d2aef95e8d1a00a920199c99117b350339599f0e9487a7a1cf5d59de" dependencies = [ "cranelift-codegen", "hashbrown 0.17.1", @@ -1261,15 +1282,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.136.3" +version = "0.136.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea6526ebf9064fff8ce3b1e26afad7027f9e6bb0e45d2b9200e4cbab8343345" +checksum = "9c54184333a275698edb1d3bdff226131a25d47133d792aa716e47e32b8b7f05" [[package]] name = "cranelift-native" -version = "0.136.3" +version = "0.136.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b44d323f3e5108a8a5bc61f3acd227285ece066e19afe9aece211d66b34a64ea" +checksum = "eb67a4c8a3180b5f1d77dffcd6317c05386ced42e5a31c7004523a9d99dc85c1" dependencies = [ "cranelift-codegen", "libc", @@ -1278,9 +1299,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.136.3" +version = "0.136.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc173168f291ff3b95b5d0769d6a05b1eaef5e6d74c2600affab8396b4e7b420" +checksum = "b533db1679b4b728568bd490da6533cf50974c48a4d7534e78f1e0e02bc39769" [[package]] name = "crc32fast" @@ -1357,12 +1378,16 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" -version = "0.5.5" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +checksum = "d811ea23fdfbcce2e2d5132344d471abdb8f901ac496a0893a3df92e70525e52" dependencies = [ - "generic-array", - "rand_core 0.6.4", + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", "subtle", "zeroize", ] @@ -1377,6 +1402,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + [[package]] name = "csscolorparser" version = "0.6.2" @@ -1408,6 +1444,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctutils" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a" +dependencies = [ + "cmov", + "subtle", +] + [[package]] name = "darling" version = "0.20.11" @@ -2159,9 +2205,9 @@ checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" [[package]] name = "der" -version = "0.7.10" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" dependencies = [ "const-oid", "pem-rfc7468", @@ -2254,12 +2300,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dirs" version = "6.0.0" @@ -2278,7 +2335,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2315,16 +2372,17 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "ecdsa" -version = "0.16.9" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ "der", - "digest", + "digest 0.11.3", "elliptic-curve", "rfc6979", - "signature", + "signature 3.0.0", "spki", + "zeroize", ] [[package]] @@ -2335,19 +2393,20 @@ checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elliptic-curve" -version = "0.13.8" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "crypto-common 0.2.2", + "digest 0.11.3", "ff", - "generic-array", "group", + "hybrid-array", "pem-rfc7468", "pkcs8", - "rand_core 0.6.4", + "rand_core 0.10.1", "sec1", "subtle", "zeroize", @@ -2413,7 +2472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2502,11 +2561,11 @@ dependencies = [ [[package]] name = "ff" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" dependencies = [ - "rand_core 0.6.4", + "rand_core 0.10.1", "subtle", ] @@ -2736,7 +2795,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", - "zeroize", ] [[package]] @@ -2808,12 +2866,12 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "group" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ "ff", - "rand_core 0.6.4", + "rand_core 0.10.1", "subtle", ] @@ -2935,7 +2993,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -2989,6 +3056,17 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "subtle", + "typenum", + "zeroize", +] + [[package]] name = "hyper" version = "1.10.1" @@ -3024,7 +3102,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.8", + "webpki-roots 1.0.11", ] [[package]] @@ -3369,7 +3447,7 @@ dependencies = [ "pem", "serde", "serde_json", - "signature", + "signature 2.2.0", "simple_asn1", "zeroize", ] @@ -3832,7 +3910,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4111,21 +4189,22 @@ checksum = "c41d7757331aef2d04b9cb09b45583a59217628beaf91895b7e76187b6e8c088" dependencies = [ "flate2", "pkg-config", - "sha2", + "sha2 0.10.9", "tar", "ureq", ] [[package]] name = "p256" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ "ecdsa", "elliptic-curve", + "primefield", "primeorder", - "sha2", + "sha2 0.11.1", ] [[package]] @@ -4233,9 +4312,9 @@ dependencies = [ [[package]] name = "pem-rfc7468" -version = "0.7.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" dependencies = [ "base64ct", ] @@ -4286,7 +4365,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -4399,9 +4478,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkcs8" -version = "0.10.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ "der", "spki", @@ -4477,13 +4556,31 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "primeorder" -version = "0.13.6" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" dependencies = [ "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", ] [[package]] @@ -4545,9 +4642,9 @@ dependencies = [ [[package]] name = "pulley-interpreter" -version = "49.0.3" +version = "49.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "def59f28ccaf2cb4f8dfaac83197fd7d56a4470a70e4f2589454c731370101c5" +checksum = "5cd3de43f4f4cff536de8902097edbdf749321b97dda548f36a37918eb85bba3" dependencies = [ "cranelift-bitset", "log", @@ -4557,9 +4654,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "49.0.3" +version = "49.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1834d3b3ef994f266a0c9ed29a8214f2c2faaa9b1c44babfa57566bb05af83" +checksum = "8319a8fa5269b4895957f8795861c42822bd26218e495f70060a963323d94847" dependencies = [ "proc-macro2", "quote", @@ -5089,17 +5186,17 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.8", + "webpki-roots 1.0.11", ] [[package]] name = "rfc6979" -version = "0.4.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" dependencies = [ - "hmac", - "subtle", + "crypto-bigint", + "hmac 0.13.0", ] [[package]] @@ -5199,7 +5296,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5239,9 +5336,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -5305,14 +5402,14 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" [[package]] name = "sec1" -version = "0.7.3" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct", + "ctutils", "der", - "generic-array", - "pkcs8", + "hybrid-array", "subtle", "zeroize", ] @@ -5422,6 +5519,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -5430,7 +5537,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -5485,10 +5603,19 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -5556,7 +5683,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5572,9 +5699,9 @@ dependencies = [ [[package]] name = "spki" -version = "0.7.3" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" dependencies = [ "base64ct", "der", @@ -5765,7 +5892,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5787,7 +5914,7 @@ dependencies = [ "parking_lot", "rustix", "signal-hook", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5836,7 +5963,7 @@ dependencies = [ "pest", "pest_derive", "phf 0.11.3", - "sha2", + "sha2 0.10.9", "signal-hook", "siphasher", "terminfo", @@ -5999,7 +6126,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tokenfuse-core", "tokenfuse-delegation", "tokenfuse-dpop", @@ -6037,7 +6164,7 @@ dependencies = [ "regex", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "thiserror 2.0.18", ] @@ -6050,7 +6177,7 @@ dependencies = [ "p256", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tokenfuse-dpop", ] @@ -6062,7 +6189,7 @@ dependencies = [ "jsonwebtoken", "p256", "serde_json", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -6079,14 +6206,14 @@ dependencies = [ "fastembed", "futures", "getrandom 0.2.17", - "hmac", + "hmac 0.12.1", "http-body-util", "p256", "ratatui", "reqwest", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "subtle", "thiserror 2.0.18", "tokenfuse-cluster", @@ -6731,9 +6858,9 @@ dependencies = [ [[package]] name = "wasmtime" -version = "49.0.3" +version = "49.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fbdd39f5c66f63ba6b5b31391b324b7ade7237d29c866cdb561330118ef436e" +checksum = "36abb25de5518707e5ba96b586366b09db168d2d4976cb9dac57265a317497ea" dependencies = [ "addr2line", "async-trait", @@ -6769,9 +6896,9 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "49.0.3" +version = "49.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e915c27826de4f7b4411b61fd6db32ca448657a5136b8c8e8874a15e90cecbf4" +checksum = "92f16a4d09fb1fbf2dcfeddb9818e1517a258e13b505b3bfdadcf47a19bf31ca" dependencies = [ "anyhow", "cpp_demangle", @@ -6788,7 +6915,7 @@ dependencies = [ "semver", "serde", "serde_derive", - "sha2", + "sha2 0.10.9", "smallvec", "target-lexicon", "wasm-encoder 0.258.3", @@ -6800,15 +6927,15 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-util" -version = "49.0.3" +version = "49.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e754a3ff5690c34eb4713bd7c65f60dd607ead4e39042cbf52ac8011de14455b" +checksum = "4006009fc3598af4e895ab18c6c33d2c6b7c657b1bd0a755ca7ee1abef6f0c16" [[package]] name = "wasmtime-internal-core" -version = "49.0.3" +version = "49.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba00c5db77adb0749ee059068e5a417bfac3d8969f5a3479d9bd1ca8c1ba248d" +checksum = "947a732ad659f42c10ec639706fc8cb1f068dd9a55eebde8a1f24dfd06dfa525" dependencies = [ "hashbrown 0.17.1", "libm", @@ -6817,9 +6944,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cranelift" -version = "49.0.3" +version = "49.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97ceeb4325712a253f463e50a69b5a547fbbecd52e45612360c543eaa25a03ff" +checksum = "fbfb8b455d524939e03df42e3e749f8e26e26ba2acd8b08fa00b3bda55686750" dependencies = [ "cranelift-codegen", "cranelift-control", @@ -6843,9 +6970,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "49.0.3" +version = "49.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb3f21bc32b5382ea95f83ce9a5f2f62b68f1b25a37d0802a41a65236eae5ae" +checksum = "bd8aa8711f68b2cdd94c2b85ba5c8317edaa1ac4ee0a0534b957123680239307" dependencies = [ "cc", "libc", @@ -6857,9 +6984,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "49.0.3" +version = "49.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e786f9925126e0d2e466c5308e572e8b6226f4047596228a3d63d99528a78949" +checksum = "e873e4504faf50aa8ce255d6ea7210d87b39d5f379c2486782e2c5f179c70ca8" dependencies = [ "cc", "wasmtime-internal-versioned-export-macros", @@ -6867,9 +6994,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "49.0.3" +version = "49.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92cb1dd5da78f5b21d65e91063bdd8c2eb343c1f077a1dcbc29003d4a794abb0" +checksum = "197ab1fda5da958a5c0b8a97eb241beafc48ac568b82ef4f12f38d6866c74173" dependencies = [ "libc", "wasmtime-internal-core", @@ -6878,9 +7005,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-unwinder" -version = "49.0.3" +version = "49.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0243d244f6dd07ccd114e6bb099e8dd1197b47de5c054576adc9483f80f65d8" +checksum = "5577f3a5a979a7c3b1b23c1e9f13e70a8583acc8828471ec56f282117fc4952b" dependencies = [ "cranelift-codegen", "log", @@ -6890,9 +7017,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "49.0.3" +version = "49.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20fa5616cd875ec78f5b92020fa47ced7c8bb923079185c8475751098b8c4a69" +checksum = "f6399c9c4050ed431cf66e21c05fadd6f9731db44476f452f8c7142b4597b883" dependencies = [ "proc-macro2", "quote", @@ -6947,14 +7074,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.8", + "webpki-roots 1.0.11", ] [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] @@ -6983,7 +7110,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ "getrandom 0.3.4", "mac_address", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "uuid", ] @@ -7059,7 +7186,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7318,6 +7445,18 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + [[package]] name = "writeable" version = "0.6.3" diff --git a/PROGRESS.md b/PROGRESS.md index 4322066..1faa574 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -304,7 +304,7 @@ comparison in #132. **Counts re-measured 2026-10-05**, each by the command named, because the set here once said 100 where the workspace ran 747 and nothing had been watching: -`cargo test --all` runs **2414 passing** (core 469, dpop 29, delegation 75, gateway 1497, cloud 343, umbrella 1, by `cargo test -p `). The door origin lists (invariant 141, 2026-10-19) add 23 to the 2391 below, counted from the diff against main and not measured again here: 6 in `dpop::origin`, 1 in `gateway::chainproof`, and 16 in `tests/it` (8 in `delegation_origins`, 8 in `mcp_door`). @measured `cargo test --all --no-fail-fast` on this branch after merging origin/main (8ed7744, PR #468 merged), 2026-10-19 as root: 2389 passed and 2 failed. The two failures are the tests that need a 0500 directory (`events_export_startup`, `events::tests::an_unwritable_directory_is_named_at_warn_and_the_export_is_off`), which invariant 78 records as root-only; as any other user they pass, so the CI figure is 2391. The breakdown counts the same binaries per crate: core 460 lib and 9 integration, cloud 203 lib and 140 integration, gateway 993 lib and 487 integration (the root-only test counted in the lib figure), dpop 23, delegation 75, umbrella 1. Against main's 2364 the branch adds 27: the static-key allow-list and the agent token to the policy plane (invariants 138 to 140) add 26 to `gateway`, 11 in `gateway::agenttoken` and `wardryx` and 15 in `tests/it` (`static_key_allow_list`, `static_key_allow_list_startup`, `mcp_agent_token`); the identity-source cache key adds 1 in `gateway::wardryx`, and the governed wide-bind agreement of invariant 137 adds 1 in `tests/it/static_key_allow_list_startup`. The governed profile of invariants 136 and 137 is already in main's figure and is not counted here. The governed profile (invariant 136, 2026-10-19) adds 29 to `gateway`: 8 in `defaults`, 3 in `adminkeys` and 18 in `tests/it/governed_profile.rs`. Its broker and no-mode firewall follow-up adds 11 more: 7 in `tests/it/governed_profile_scope.rs`, 3 in `firewall::absent_mode_tests` and 1 in `defaults`. Earlier totals quoted in this section are dated history of how each count grew, not the current figure. The CORS allow-list, the digested device credentials and org keys, and the dashboard's fragment key (invariants 131 to 133) add 28 to `cloud`, all new tests, counted from the diff against main. The CORS allow-list, the digested device credentials and org keys, and the dashboard's +`cargo test --all` runs **2421 passing** (core 469, dpop 29, delegation 75, gateway 1502, cloud 345, umbrella 1, by `cargo test -p `). The refusal of a message the MCP broker cannot judge (invariant 142, 2026-10-26) adds 4 to the 2417 below, all in `tests/it/mcp_broker.rs`, counted from the diff against main; this branch was not run locally, and CI's `stated-numbers.sh` is the measurement. The p256 0.14 upgrade (2026-10-26) adds 3 RFC 6979 vector tests to the 2414 below: 1 in `gateway::checkpoints` and 2 in `cloud` (`devices`, `audit_sign`). @measured `cargo test --all --no-fail-fast` on that branch, 2026-10-26 as root: 2415 passed and the two tests that need a 0500 directory failed, as invariant 78 records; both passed when their binaries were run again under `setpriv` with `dac_override` and `dac_read_search` dropped. Per binary: core 460 lib and 9 integration, cloud 205 lib and 140 integration, gateway 995 lib and 503 integration, dpop 29, delegation 75, umbrella 1. The door origin lists (invariant 141, 2026-10-19) add 23 to the 2391 below, counted from the diff against main and not measured again here: 6 in `dpop::origin`, 1 in `gateway::chainproof`, and 16 in `tests/it` (8 in `delegation_origins`, 8 in `mcp_door`). @measured `cargo test --all --no-fail-fast` on this branch after merging origin/main (8ed7744, PR #468 merged), 2026-10-19 as root: 2389 passed and 2 failed. The two failures are the tests that need a 0500 directory (`events_export_startup`, `events::tests::an_unwritable_directory_is_named_at_warn_and_the_export_is_off`), which invariant 78 records as root-only; as any other user they pass, so the CI figure is 2391. The breakdown counts the same binaries per crate: core 460 lib and 9 integration, cloud 203 lib and 140 integration, gateway 993 lib and 487 integration (the root-only test counted in the lib figure), dpop 23, delegation 75, umbrella 1. Against main's 2364 the branch adds 27: the static-key allow-list and the agent token to the policy plane (invariants 138 to 140) add 26 to `gateway`, 11 in `gateway::agenttoken` and `wardryx` and 15 in `tests/it` (`static_key_allow_list`, `static_key_allow_list_startup`, `mcp_agent_token`); the identity-source cache key adds 1 in `gateway::wardryx`, and the governed wide-bind agreement of invariant 137 adds 1 in `tests/it/static_key_allow_list_startup`. The governed profile of invariants 136 and 137 is already in main's figure and is not counted here. The governed profile (invariant 136, 2026-10-19) adds 29 to `gateway`: 8 in `defaults`, 3 in `adminkeys` and 18 in `tests/it/governed_profile.rs`. Its broker and no-mode firewall follow-up adds 11 more: 7 in `tests/it/governed_profile_scope.rs`, 3 in `firewall::absent_mode_tests` and 1 in `defaults`. Earlier totals quoted in this section are dated history of how each count grew, not the current figure. The CORS allow-list, the digested device credentials and org keys, and the dashboard's fragment key (invariants 131 to 133) add 28 to `cloud`, all new tests, counted from the diff against main. The CORS allow-list, the digested device credentials and org keys, and the dashboard's fragment key (invariants 131 to 133) add 28 to `cloud`, all of them new tests: 9 in its library and 19 in `tests/it` (8 CORS, 2 startup refusal, 7 device credentials, nonces and the restart second, 2 dashboard), counted from the diff against main (2276, so 2304). @measured `cargo test --all --no-fail-fast` diff --git a/README.md b/README.md index 6bad1a7..6d980c3 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ > The kill-switch isn't a dashboard button you press after the fact - it's an HTTP 402 the gateway returns mid-run, before the provider bills you. ![release](https://img.shields.io/badge/release-v1.20.0-brightgreen) -![tests](https://img.shields.io/badge/tests-2414-brightgreen) +![tests](https://img.shields.io/badge/tests-2421-brightgreen) ![image](https://img.shields.io/badge/ghcr.io-tokenfuse-blue?logo=docker) ![license](https://img.shields.io/badge/license-Apache--2.0-blue) ![core](https://img.shields.io/badge/core-Rust-orange) diff --git a/crates/cloud/Cargo.toml b/crates/cloud/Cargo.toml index b67fe77..da92bfb 100644 --- a/crates/cloud/Cargo.toml +++ b/crates/cloud/Cargo.toml @@ -27,7 +27,7 @@ utoipa = { version = "5", features = ["axum_extras"] } # Device pairing + hardware-backed signed mutations, and the signed audit # manifest (ES256 = ECDSA P-256/SHA-256). `pkcs8`/`pem` let the audit signing # key load from a PKCS#8/SEC1 PEM or DER (see `audit_sign.rs`). -p256 = { version = "0.13", features = ["ecdsa", "pkcs8", "pem"] } +p256 = { version = "0.14", features = ["ecdsa", "pkcs8", "pem"] } sha2.workspace = true base64.workspace = true getrandom = "0.2" @@ -50,7 +50,7 @@ http-body-util = "0.1" # The signed-audit-manifest tests re-derive the canonical bytes (core) and # verify the ES256 signature independently (p256), exactly as an auditor would. tokenfuse-core.workspace = true -p256 = { version = "0.13", features = ["ecdsa"] } +p256 = { version = "0.14", features = ["ecdsa"] } base64 = "0.22" [features] diff --git a/crates/cloud/src/audit_keyryx.rs b/crates/cloud/src/audit_keyryx.rs index f97e9dd..cbc6f5a 100644 --- a/crates/cloud/src/audit_keyryx.rs +++ b/crates/cloud/src/audit_keyryx.rs @@ -456,14 +456,14 @@ fn verify_answer(raw: &[u8], bytes: &[u8]) -> Result Ok(KeyryxSignature { kid: kid.to_string(), signature_b64: STANDARD.encode(sig.to_bytes()), - public_key_b64: STANDARD.encode(public.to_encoded_point(false).as_bytes()), + public_key_b64: STANDARD.encode(public.to_sec1_point(false).as_bytes()), }) } /// The RFC 7638 thumbprint of a P-256 key, by the one function this estate /// names a key with ([`tokenfuse_dpop::thumbprint_of_json`]). fn thumbprint(public: &VerifyingKey) -> Result { - let point = public.to_encoded_point(false); + let point = public.to_sec1_point(false); let (Some(x), Some(y)) = (point.x(), point.y()) else { return Err(NotSigned::same( "keyryx's answer carries no P-256 public key", diff --git a/crates/cloud/src/audit_keyryx/tests.rs b/crates/cloud/src/audit_keyryx/tests.rs index a0876e3..7188066 100644 --- a/crates/cloud/src/audit_keyryx/tests.rs +++ b/crates/cloud/src/audit_keyryx/tests.rs @@ -55,7 +55,7 @@ fn answer_from(key: &SigningKey, kid: &str, alg: &str, bytes: &[u8], compressed: "alg": alg, "kid": kid, "signature_b64": STANDARD.encode(sig.to_bytes()), - "public_key_b64": STANDARD.encode(key.verifying_key().to_encoded_point(compressed).as_bytes()), + "public_key_b64": STANDARD.encode(key.verifying_key().to_sec1_point(compressed).as_bytes()), }) .to_string() .into_bytes() @@ -282,7 +282,7 @@ fn a_public_key_that_did_not_sign_is_refused() { let mut v: serde_json::Value = serde_json::from_slice(&answer("ES256", bytes, false)).unwrap(); let other = SigningKey::from_slice(&[0x22u8; 32]).unwrap(); v["public_key_b64"] = STANDARD - .encode(other.verifying_key().to_encoded_point(false).as_bytes()) + .encode(other.verifying_key().to_sec1_point(false).as_bytes()) .into(); let why = verify_answer(v.to_string().as_bytes(), bytes).unwrap_err(); assert!(why.reason.contains("does not verify"), "{why:?}"); diff --git a/crates/cloud/src/audit_sign.rs b/crates/cloud/src/audit_sign.rs index d19a486..889c55f 100644 --- a/crates/cloud/src/audit_sign.rs +++ b/crates/cloud/src/audit_sign.rs @@ -152,7 +152,7 @@ pub fn build_signed_manifest( ) -> AuditManifest { let tip = ChainTip::of(entries); let sig: Signature = key.sign(&tip.signing_bytes(org, now_ms)); - let public_key_b64 = STANDARD.encode(key.verifying_key().to_encoded_point(false).as_bytes()); + let public_key_b64 = STANDARD.encode(key.verifying_key().to_sec1_point(false).as_bytes()); AuditManifest { org: org.to_string(), tip_seq: tip.tip_seq, @@ -273,14 +273,55 @@ mod tests { let key = parse_signing_key(&encoded).expect("parse raw scalar"); // Signs the same as the direct scalar key. assert_eq!( - key.verifying_key().to_encoded_point(false).as_bytes(), - test_key() - .verifying_key() - .to_encoded_point(false) - .as_bytes() + key.verifying_key().to_sec1_point(false).as_bytes(), + test_key().verifying_key().to_sec1_point(false).as_bytes() ); } + /// RFC 6979 appendix A.2.5's P-256 key in every encoding + /// [`AUDIT_SIGNING_KEY_ENV`] accepts, each one written out by Python + /// `cryptography` 50.0.1 from the RFC's scalar (2026-10-26), never by the + /// curve library under test. Every one must load as that key and sign the + /// empty-chain manifest for `acme` at 42 ms to the same bytes: the public key + /// is SEC1 uncompressed 04||Ux||Uy, the signature raw r||s, both standard + /// base64. The signature was computed by the same library with RFC 6979 + /// deterministic ECDSA over `acme␟0␟␟0␟42`, and that library reproduces the + /// RFC's own "sample" signature for this key. + #[test] + fn the_rfc_6979_key_loads_from_every_accepted_encoding_and_signs_a_known_manifest() { + const PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgya+p2EW6dRZrXCFX +Z7HWk05Qw9s26JsSe4piKxIPZyGhRANCAARg/tS6JVqdMclh63TGNW1owEm4kjth ++mzmaWIuYPKftnkD/hAIuLyZpBrp6VYovGTy8bIMLX6fUXejwpTURiKZ +-----END PRIVATE KEY-----"; + const SEC1_PEM: &str = "-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIMmvqdhFunUWa1whV2ex1pNOUMPbNuibEnuKYisSD2choAoGCCqGSM49 +AwEHoUQDQgAEYP7UuiVanTHJYet0xjVtaMBJuJI7Yfps5mliLmDyn7Z5A/4QCLi8 +maQa6elWKLxk8vGyDC1+n1F3o8KU1EYimQ== +-----END EC PRIVATE KEY-----"; + const PKCS8_DER_B64: &str = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgya+p2EW6dRZrXCFXZ7HWk05Qw9s26JsSe4piKxIPZyGhRANCAARg/tS6JVqdMclh63TGNW1owEm4kjth+mzmaWIuYPKftnkD/hAIuLyZpBrp6VYovGTy8bIMLX6fUXejwpTURiKZ"; + const SEC1_DER_B64: &str = "MHcCAQEEIMmvqdhFunUWa1whV2ex1pNOUMPbNuibEnuKYisSD2choAoGCCqGSM49AwEHoUQDQgAEYP7UuiVanTHJYet0xjVtaMBJuJI7Yfps5mliLmDyn7Z5A/4QCLi8maQa6elWKLxk8vGyDC1+n1F3o8KU1EYimQ=="; + const SCALAR_B64: &str = "ya+p2EW6dRZrXCFXZ7HWk05Qw9s26JsSe4piKxIPZyE="; + const PUBLIC_KEY_B64: &str = + "BGD+1LolWp0xyWHrdMY1bWjASbiSO2H6bOZpYi5g8p+2eQP+EAi4vJmkGunpVii8ZPLxsgwtfp9Rd6PClNRGIpk="; + const SIGNATURE_B64: &str = + "eRt/XRM+xbJI6Y7bXWdfjdO/sNb9wZgb50dUSi+df19EjX3i3Jzmr1iY5Jhkrl9YzC7FLyqLVQBG9Xsxvcaz2g=="; + + for (name, encoded) in [ + ("pkcs8 pem", PKCS8_PEM), + ("sec1 pem", SEC1_PEM), + ("pkcs8 der", PKCS8_DER_B64), + ("sec1 der", SEC1_DER_B64), + ("raw scalar", SCALAR_B64), + ] { + let key = parse_signing_key(encoded).unwrap_or_else(|| panic!("{name} must load")); + let m = build_signed_manifest("acme", &[], &key, 42); + assert_eq!(m.public_key_b64, PUBLIC_KEY_B64, "{name}"); + assert_eq!(m.signature_b64, SIGNATURE_B64, "{name}"); + assert!(manifest_verifies(&m), "{name}"); + } + } + #[test] fn garbage_key_material_is_none() { assert!(parse_signing_key("").is_none()); diff --git a/crates/cloud/src/devices.rs b/crates/cloud/src/devices.rs index 73fcedb..f9c4d3f 100644 --- a/crates/cloud/src/devices.rs +++ b/crates/cloud/src/devices.rs @@ -109,7 +109,7 @@ mod tests { fn test_key() -> (SigningKey, String) { let sk = SigningKey::from_slice(&[0x11u8; 32]).expect("valid scalar"); let vk = sk.verifying_key(); - let pubkey_b64 = STANDARD.encode(vk.to_encoded_point(false).as_bytes()); + let pubkey_b64 = STANDARD.encode(vk.to_sec1_point(false).as_bytes()); (sk, pubkey_b64) } @@ -147,4 +147,30 @@ mod tests { assert!(!verify_signature("not-base64!!", "x", "y")); assert!(!verify_signature("", "x", "")); } + + /// RFC 6979 appendix A.2.5's P-256 public point and its deterministic + /// SHA-256 signature over "sample", in the encodings a device sends: SEC1 + /// (uncompressed and compressed) and raw r||s, standard base64. Encoded + /// outside this repository with Python `cryptography` 50.0.1 from the RFC's + /// hex (2026-10-26), never by the curve library under test. The same + /// signature with its halves swapped, or in DER, is refused: the wire form is + /// raw r||s and nothing else. + #[test] + fn a_published_rfc_6979_signature_verifies_and_no_other_encoding_of_it_does() { + const UNCOMPRESSED: &str = + "BGD+1LolWp0xyWHrdMY1bWjASbiSO2H6bOZpYi5g8p+2eQP+EAi4vJmkGunpVii8ZPLxsgwtfp9Rd6PClNRGIpk="; + const COMPRESSED: &str = "A2D+1LolWp0xyWHrdMY1bWjASbiSO2H6bOZpYi5g8p+2"; + const RS: &str = + "79SLKqy2qP0RQN2c1F6B1p0sh3tWqvmRw00OqE6vNxb3yxyULWV8QdQ2x6G24p9l8+kA27mv9AZNxKsvhDrNqA=="; + const SR: &str = + "98sclC1lfEHUNsehtuKfZfPpANu5r/QGTcSrL4Q6zajv1IsqrLao/RFA3ZzUXoHWnSyHe1aq+ZHDTQ6oTq83Fg=="; + const DER: &str = + "MEYCIQDv1IsqrLao/RFA3ZzUXoHWnSyHe1aq+ZHDTQ6oTq83FgIhAPfLHJQtZXxB1DbHobbin2Xz6QDbua/0Bk3Eqy+EOs2o"; + + assert!(verify_signature(UNCOMPRESSED, "sample", RS)); + assert!(verify_signature(COMPRESSED, "sample", RS)); + assert!(!verify_signature(UNCOMPRESSED, "test", RS)); + assert!(!verify_signature(UNCOMPRESSED, "sample", SR)); + assert!(!verify_signature(UNCOMPRESSED, "sample", DER)); + } } diff --git a/crates/cloud/tests/it/audit_manifest_keyryx.rs b/crates/cloud/tests/it/audit_manifest_keyryx.rs index e118128..9501e82 100644 --- a/crates/cloud/tests/it/audit_manifest_keyryx.rs +++ b/crates/cloud/tests/it/audit_manifest_keyryx.rs @@ -120,7 +120,7 @@ async fn sign( "kid": kid, "signature_b64": STANDARD.encode(sig.to_bytes()), "public_key_b64": STANDARD.encode( - stub.key.verifying_key().to_encoded_point(false).as_bytes() + stub.key.verifying_key().to_sec1_point(false).as_bytes() ), })), ) @@ -262,7 +262,7 @@ async fn a_manifest_keyryx_signed_verifies_and_names_its_kid() { assert_eq!(m["kid"], KEYRYX_KID, "the kid keyryx gives its key"); assert_eq!(m["entry_count"], 1); assert!(verifies(&m), "the manifest must verify offline: {m}"); - let keyryx_key = STANDARD.encode(stub.key.verifying_key().to_encoded_point(false).as_bytes()); + let keyryx_key = STANDARD.encode(stub.key.verifying_key().to_sec1_point(false).as_bytes()); assert_eq!(m["public_key_b64"], keyryx_key, "signed by keyryx's key"); let calls = stub.calls.lock().unwrap(); diff --git a/crates/cloud/tests/it/device_tokens.rs b/crates/cloud/tests/it/device_tokens.rs index fd8c0e1..d33b2af 100644 --- a/crates/cloud/tests/it/device_tokens.rs +++ b/crates/cloud/tests/it/device_tokens.rs @@ -52,7 +52,7 @@ fn state_over(store: Arc) -> AppState { fn keypair() -> (SigningKey, String) { let sk = SigningKey::from_slice(&[0x33u8; 32]).expect("valid scalar"); - let pubkey_b64 = STANDARD.encode(sk.verifying_key().to_encoded_point(false).as_bytes()); + let pubkey_b64 = STANDARD.encode(sk.verifying_key().to_sec1_point(false).as_bytes()); (sk, pubkey_b64) } diff --git a/crates/cloud/tests/it/pairing.rs b/crates/cloud/tests/it/pairing.rs index 2146e8a..17d1252 100644 --- a/crates/cloud/tests/it/pairing.rs +++ b/crates/cloud/tests/it/pairing.rs @@ -33,7 +33,7 @@ fn state() -> AppState { fn keypair() -> (SigningKey, String) { let sk = SigningKey::from_slice(&[0x11u8; 32]).expect("valid scalar"); - let pubkey_b64 = STANDARD.encode(sk.verifying_key().to_encoded_point(false).as_bytes()); + let pubkey_b64 = STANDARD.encode(sk.verifying_key().to_sec1_point(false).as_bytes()); (sk, pubkey_b64) } diff --git a/crates/cluster/Cargo.lock b/crates/cluster/Cargo.lock index 33ff43f..79287c0 100644 --- a/crates/cluster/Cargo.lock +++ b/crates/cluster/Cargo.lock @@ -1504,9 +1504,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -2198,9 +2198,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] diff --git a/crates/delegation/Cargo.toml b/crates/delegation/Cargo.toml index 559fa87..03c3458 100644 --- a/crates/delegation/Cargo.toml +++ b/crates/delegation/Cargo.toml @@ -16,7 +16,7 @@ description = "Offline verification of an RFC 8693 delegation token, sender-cons [dependencies] tokenfuse-dpop.workspace = true base64 = { workspace = true, optional = true } -p256 = { version = "0.13", features = ["ecdsa"], optional = true } +p256 = { version = "0.14", features = ["ecdsa"], optional = true } jsonwebtoken.workspace = true serde.workspace = true serde_json.workspace = true @@ -32,4 +32,4 @@ sha2.workspace = true # The tests mint real ES256 tokens and verify them as an auditor would, rather # than trusting this crate to check its own output. Already in the lockfile via # `cloud`'s dev-dependencies, so nothing new enters it. -p256 = { version = "0.13", features = ["ecdsa"] } +p256 = { version = "0.14", features = ["ecdsa"] } diff --git a/crates/delegation/src/testing.rs b/crates/delegation/src/testing.rs index 3a70691..7e0898b 100644 --- a/crates/delegation/src/testing.rs +++ b/crates/delegation/src/testing.rs @@ -36,11 +36,11 @@ impl Default for Key { impl Key { pub fn new() -> Self { Key { - signing: p256::ecdsa::SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng), + signing: ::generate(), } } pub fn jwk_value(&self, kid: Option<&str>) -> serde_json::Value { - let point = self.signing.verifying_key().to_encoded_point(false); + let point = self.signing.verifying_key().to_sec1_point(false); let mut v = serde_json::json!({ "kty": "EC", "crv": "P-256", "x": b64(point.x().unwrap()), "y": b64(point.y().unwrap()), diff --git a/crates/dpop/Cargo.toml b/crates/dpop/Cargo.toml index b4734b0..0ac5e0b 100644 --- a/crates/dpop/Cargo.toml +++ b/crates/dpop/Cargo.toml @@ -24,4 +24,4 @@ serde_json.workspace = true # These tests mint real proofs and verify them through the real code path, # rather than asserting against a stub of the wire. Same key type and same # crate the Cloud's delegation tests already use. -p256 = { version = "0.13", features = ["ecdsa"] } +p256 = { version = "0.14", features = ["ecdsa"] } diff --git a/crates/dpop/src/lib.rs b/crates/dpop/src/lib.rs index b1eb801..8fbe013 100644 --- a/crates/dpop/src/lib.rs +++ b/crates/dpop/src/lib.rs @@ -505,13 +505,11 @@ yyuNSwNVs/LTnw7nI9Ius8M= impl Key { fn new() -> Self { Key { - signing: p256::ecdsa::SigningKey::random( - &mut p256::elliptic_curve::rand_core::OsRng, - ), + signing: ::generate(), } } fn jwk_value(&self) -> serde_json::Value { - let point = self.signing.verifying_key().to_encoded_point(false); + let point = self.signing.verifying_key().to_sec1_point(false); serde_json::json!({ "kty": "EC", "crv": "P-256", "x": b64(point.x().unwrap()), "y": b64(point.y().unwrap()), diff --git a/crates/gateway/Cargo.toml b/crates/gateway/Cargo.toml index a3c2b33..d06c54d 100644 --- a/crates/gateway/Cargo.toml +++ b/crates/gateway/Cargo.toml @@ -25,9 +25,11 @@ thiserror.workspace = true # built. subtle = "2.6" # The MCP broker binds a session id it relays to the upstream and the -# credential that opened it (crate::mcpsession, invariant 77). All three are -# already in Cargo.lock through crates/dpop, crates/delegation and crates/cloud; -# naming them here adds direct edges, not crates. +# credential that opened it (crate::mcpsession, invariant 77). sha2 and +# getrandom are already in Cargo.lock through crates/dpop and crates/cloud. +# hmac 0.12 arrived through p256 0.13's rfc6979 until p256 0.14 (which uses +# hmac 0.13); since then this line is the only edge that keeps hmac 0.12 in +# the lockfile. hmac = "0.12" sha2.workspace = true getrandom = "0.2" @@ -36,7 +38,7 @@ getrandom = "0.2" # keyryx's proof of possession. Both were dev-dependencies for the MCP door's # tests and are in Cargo.lock through crates/cloud and crates/delegation, so # nothing new enters the tree. -p256 = { version = "0.13", features = ["ecdsa"] } +p256 = { version = "0.14", features = ["ecdsa"] } base64 = "0.22" tokio = { workspace = true, features = ["io-std", "io-util"] } axum.workspace = true diff --git a/crates/gateway/src/checkpoints.rs b/crates/gateway/src/checkpoints.rs index 88059a2..4513490 100644 --- a/crates/gateway/src/checkpoints.rs +++ b/crates/gateway/src/checkpoints.rs @@ -250,7 +250,7 @@ impl HeldKey { } fn from_signing_key(sk: SigningKey) -> Result { - let point = sk.verifying_key().to_encoded_point(false); + let point = sk.verifying_key().to_sec1_point(false); let (Some(x), Some(y)) = (point.x(), point.y()) else { return Err("the public key has no coordinates".into()); }; diff --git a/crates/gateway/src/checkpoints/tests.rs b/crates/gateway/src/checkpoints/tests.rs index 33d983f..8a2606e 100644 --- a/crates/gateway/src/checkpoints/tests.rs +++ b/crates/gateway/src/checkpoints/tests.rs @@ -1032,3 +1032,60 @@ fn hostile_streams_signed_with_a_real_key_verify() { "the sweep signed {signed} records, which measured too little" ); } + +/// RFC 6979 appendix A.2.5's P-256 key: the private scalar, the public point +/// (Ux, Uy) and the deterministic SHA-256 signatures over "sample" and "test", +/// r then s. Written out from the RFC, never computed here, so an expectation +/// cannot come from the curve library under test. +const RFC6979_D: &str = "c9afa9d845ba75166b5c215767b1d6934e50c3db36e89b127b8a622b120f6721"; +const RFC6979_UX: &str = "60fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb6"; +const RFC6979_UY: &str = "7903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299"; +const RFC6979_SAMPLE_RS: &str = "efd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716\ + f7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8"; +const RFC6979_TEST_RS: &str = "f1abb023518351cd71d881567b1ea663ed3efcf6c5132b354f28d3b0b7d38367\ + 019f4113742a2b14bd25926b49c649155f267e60d3814b4c0cc84250e46f0083"; + +fn unhex(s: &str) -> Vec { + let s: String = s.split_whitespace().collect(); + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() +} + +/// The bytes this key puts on the wire, pinned against a published vector: the +/// JWK's `x` and `y` (base64url, no padding, of the 32-byte big-endian +/// coordinates), its `kid` (the RFC 7638 thumbprint of that JWK) and the raw +/// r||s signature. The base64url text and the thumbprint were computed outside +/// this repository, with Python `cryptography` 50.0.1 over the RFC's point +/// (2026-10-26), which also verified both RFC signatures under it. A curve +/// library that changes how it encodes a point or a signature fails here, not +/// in a verifier in another repository. +#[test] +fn the_rfc_6979_key_puts_its_published_point_and_signatures_on_the_wire() { + let secret: [u8; 32] = unhex(RFC6979_D).try_into().unwrap(); + let key = HeldKey::from_secret(&secret).unwrap(); + let jwk = key.public_jwk(); + + assert_eq!(jwk["x"], "YP7UuiVanTHJYet0xjVtaMBJuJI7Yfps5mliLmDyn7Y"); + assert_eq!(jwk["y"], "eQP-EAi4vJmkGunpVii8ZPLxsgwtfp9Rd6PClNRGIpk"); + let x = b64url().decode(jwk["x"].as_str().unwrap()).unwrap(); + let y = b64url().decode(jwk["y"].as_str().unwrap()).unwrap(); + assert_eq!((hex(&x), hex(&y)), (RFC6979_UX.into(), RFC6979_UY.into())); + + let kid = "DOvxvJiAdIqVWIkFt5hDtCunXLF0BV4-JGv4f-ALSm0"; + assert_eq!(key.kid(), kid); + assert_eq!(jwk["kid"], kid); + + assert_eq!( + hex(&key.sign_raw(b"sample")), + hex(&unhex(RFC6979_SAMPLE_RS)) + ); + assert_eq!(hex(&key.sign_raw(b"test")), hex(&unhex(RFC6979_TEST_RS))); + assert!(verifying_key(jwk) + .verify( + b"sample", + &Signature::from_slice(&unhex(RFC6979_SAMPLE_RS)).unwrap() + ) + .is_ok()); +} diff --git a/crates/gateway/src/mcpbroker.rs b/crates/gateway/src/mcpbroker.rs index b50ca8c..1aaaae9 100644 --- a/crates/gateway/src/mcpbroker.rs +++ b/crates/gateway/src/mcpbroker.rs @@ -708,6 +708,42 @@ fn rpc_error(id: &Value, code: i64, message: &str) -> Value { }) } +/// JSON-RPC's own code for a message that is not a valid request object. +const INVALID_REQUEST_RPC_CODE: i64 = -32600; + +/// A batch, refused whole (invariant 142). MCP removed batching in its +/// 2025-06-18 revision, so the remedy is the one the protocol already expects. +const BATCH_RPC_MESSAGE: &str = + "invalid request: a JSON-RPC batch is not accepted; send each message on its own"; + +/// A bare string, number, boolean or null: no message at all. +const NOT_AN_OBJECT_RPC_MESSAGE: &str = "invalid request: not a JSON-RPC object"; + +/// An object whose `method` is there and is not a string. +const METHOD_RPC_MESSAGE: &str = "invalid request: the JSON-RPC method is not a string"; + +/// Why `req` cannot be judged, or `None` when it is one message the broker +/// reads the way an upstream will (invariant 142). +/// +/// Every check this broker makes keys on `method` read from one JSON object: +/// the Wardryx decision, the identity requirement, drift, the taint gate, the +/// argument scans, the `tool_call` record, and the `tools/list` scans. A batch +/// is an array, so its `method` read as empty, all of them were skipped, and +/// the array was forwarded as it came. A `method` that is not a string reads as +/// empty here and may read as something else upstream. An object with no +/// `method` at all is a JSON-RPC response, a client answering the server's own +/// request, and is still carried. +fn unjudgeable(req: &Value) -> Option<&'static str> { + match req { + Value::Object(message) => match message.get("method") { + Some(method) if !method.is_string() => Some(METHOD_RPC_MESSAGE), + _ => None, + }, + Value::Array(_) => Some(BATCH_RPC_MESSAGE), + _ => Some(NOT_AN_OBJECT_RPC_MESSAGE), + } +} + /// `" (reason)"` when the PDP gave a reason, else empty -- so a block message /// reads cleanly whether or not Wardryx explained itself. fn reason_suffix(reason: &Option) -> String { @@ -1711,6 +1747,18 @@ pub async fn process(st: &BrokerState, req: Value, ctx: &CallContext) -> Value { /// HTTP status, whether the message was a notification, and the session id /// the upstream named (invariant 77). pub async fn process_call(st: &BrokerState, req: Value, ctx: &CallContext) -> Outcome { + // First, before an upstream is resolved or a secret read (invariant 142): + // both transports arrive here, and a message whose `method` cannot be read + // from one object would otherwise pass every check below unjudged. + if let Some(why) = unjudgeable(&req) { + tracing::warn!(reason = %why, "mcp broker: refused a message it cannot judge; nothing was forwarded"); + return Outcome { + reply: rpc_error(&Value::Null, INVALID_REQUEST_RPC_CODE, why), + notification: false, + status: Some(axum::http::StatusCode::BAD_REQUEST), + upstream_session: None, + }; + } // A notification is a request with no `id`; JSON-RPC answers it with // nothing at all. let notification = req.get("method").is_some() && req.get("id").is_none(); diff --git a/crates/gateway/tests/it/mcp_broker.rs b/crates/gateway/tests/it/mcp_broker.rs index a0ee155..331b57d 100644 --- a/crates/gateway/tests/it/mcp_broker.rs +++ b/crates/gateway/tests/it/mcp_broker.rs @@ -1627,11 +1627,11 @@ struct ProofKey { impl ProofKey { fn new() -> Self { ProofKey { - signing: p256::ecdsa::SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng), + signing: ::generate(), } } fn jwk(&self) -> Value { - let point = self.signing.verifying_key().to_encoded_point(false); + let point = self.signing.verifying_key().to_sec1_point(false); json!({"kty": "EC", "crv": "P-256", "x": b64(point.x().unwrap()), "y": b64(point.y().unwrap())}) } /// A proof for `POST {origin}/`, which is where these tests post. @@ -4626,3 +4626,200 @@ async fn arguments_of_exactly_12_kib_are_sent_whole_and_one_byte_more_is_truncat ); assert_eq!(upstream_seen.lock().unwrap().len(), 2); } + +// --------------------------------------------------------------------------- +// A message the broker cannot judge is never forwarded (invariant 142). +// +// The gate reads `method` from one JSON-RPC object. A batch is an array, so it +// has no `method` the broker could read: every check keyed on `tools/call` was +// skipped and the array went to the upstream as it came. A batch of two +// `tools/call` reached the upstream with no policy decision and no `tool_call` +// record. MCP removed batching in its 2025-06-18 revision, and its HTTP +// transport carries one message per POST, so a batch is refused whole. +// --------------------------------------------------------------------------- + +fn a_batch_of_two_tool_calls() -> Value { + json!([ + { + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "name": "shell_exec", "arguments": { "cmd": "id" } } + }, + { + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": { "name": "gh_api", "arguments": { "auth": "Bearer {{secret:gh}}" } } + } + ]) +} + +/// The PDP denies every tool here, so a batch that reached the upstream can +/// only have gone around the gate. The assertion that matters is the first: +/// the upstream saw nothing. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_batch_of_tool_calls_is_refused_and_nothing_reaches_the_upstream() { + let (seen, upstream_router) = recording_upstream(); + let upstream = spawn_server(upstream_router).await; + let (pdp, asked) = recording_pdp("deny").await; + let broker_url = spawn_server(broker_cfg( + upstream, + ScanMode::Off, + tokenfuse_core::DlpMode::Off, + None, + Default::default(), + a_wardryx(WardryxMode::Enforce, pdp), + )) + .await; + tokio::time::sleep(Duration::from_millis(150)).await; + + let (status, _, body) = post_with( + &broker_url, + &[("x-fuse-agent-id", "agent://acme.example/bot")], + &a_batch_of_two_tool_calls(), + ) + .await; + + let forwarded = seen.lock().expect("upstream log").clone(); + assert!( + forwarded.is_empty(), + "a batch must not reach the upstream around the policy gate: {forwarded:?}" + ); + assert_eq!(status, reqwest::StatusCode::BAD_REQUEST, "{body}"); + let err = as_json(&body); + assert_eq!(err["error"]["code"], json!(-32600), "{err}"); + assert_eq!(err["id"], Value::Null, "{err}"); + assert!( + err["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("batch"), + "the refusal has to say what was refused: {err}" + ); + assert!( + asked.lock().unwrap().is_empty(), + "nothing in a refused batch is put to the PDP" + ); +} + +/// stdio reaches the same `process_call`, and there an enforcing gate also +/// refuses an unattributed `tools/call`; a batch skipped that refusal as well. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_stdio_transport_refuses_a_batch_too() { + let (seen, upstream_router) = recording_upstream(); + let upstream = spawn_server(upstream_router).await; + let pdp = stub_pdp("deny").await; + let state = broker_state( + upstream, + ScanMode::Off, + tokenfuse_core::DlpMode::Off, + None, + Default::default(), + a_wardryx(WardryxMode::Enforce, pdp), + ); + tokio::time::sleep(Duration::from_millis(150)).await; + + let out = stdio_lines(state, &[a_batch_of_two_tool_calls()]).await; + + let forwarded = seen.lock().expect("upstream log").clone(); + assert!( + forwarded.is_empty(), + "a batch must not reach the upstream over stdio: {forwarded:?}" + ); + assert_eq!(out.len(), 1, "{out:?}"); + assert_eq!(out[0]["error"]["code"], json!(-32600), "{out:?}"); + assert_eq!(out[0]["id"], Value::Null, "{out:?}"); +} + +/// The same rule one step out: whatever the broker cannot read as one request +/// with a string `method` is refused, because the gate keys on that string and +/// an upstream may read the message differently. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_message_the_broker_cannot_read_as_one_request_is_refused() { + let (seen, upstream_router) = recording_upstream(); + let upstream = spawn_server(upstream_router).await; + let pdp = stub_pdp("deny").await; + let broker_url = spawn_server(broker_cfg( + upstream, + ScanMode::Off, + tokenfuse_core::DlpMode::Off, + None, + Default::default(), + a_wardryx(WardryxMode::Enforce, pdp), + )) + .await; + tokio::time::sleep(Duration::from_millis(150)).await; + + for message in [ + json!("tools/call"), + json!(42), + Value::Null, + json!({ + "jsonrpc": "2.0", "id": 3, "method": ["tools/call"], + "params": { "name": "shell_exec", "arguments": {} } + }), + json!({ + "jsonrpc": "2.0", "id": 4, "method": { "name": "tools/call" }, + "params": { "name": "shell_exec", "arguments": {} } + }), + ] { + let (status, _, body) = post_with( + &broker_url, + &[("x-fuse-agent-id", "agent://acme.example/bot")], + &message, + ) + .await; + let forwarded = seen.lock().expect("upstream log").clone(); + assert!( + forwarded.is_empty(), + "{message} must not reach the upstream: {forwarded:?}" + ); + assert_eq!( + status, + reqwest::StatusCode::BAD_REQUEST, + "{message} was not refused: {body}" + ); + assert_eq!( + as_json(&body)["error"]["code"], + json!(-32600), + "{message}: {body}" + ); + } +} + +/// The refusal must not fire on what the broker can read. One request still +/// passes, and so does a client's answer to a server's own request (a JSON-RPC +/// response, as elicitation sends): it has an `id` and a `result` and no +/// `method`, and the HTTP transport carries it in a POST like any other +/// message. A fix that refused every message without a `method` would break it. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_single_request_and_a_clients_response_still_reach_the_upstream() { + let (seen, upstream_router) = recording_upstream(); + let upstream = spawn_server(upstream_router).await; + let (pdp, asked) = recording_pdp("allow").await; + let broker_url = spawn_server(broker_cfg( + upstream, + ScanMode::Off, + tokenfuse_core::DlpMode::Off, + None, + Default::default(), + a_wardryx(WardryxMode::Enforce, pdp), + )) + .await; + tokio::time::sleep(Duration::from_millis(150)).await; + let agent = [("x-fuse-agent-id", "agent://acme.example/bot")]; + + let call = json!({ + "jsonrpc": "2.0", "id": 5, "method": "tools/call", + "params": { "name": "gh_api", "arguments": {} } + }); + let (status, _, body) = post_with(&broker_url, &agent, &call).await; + assert_eq!(status, reqwest::StatusCode::OK, "{body}"); + assert!(as_json(&body).get("error").is_none(), "{body}"); + assert_eq!(asked.lock().unwrap().len(), 1, "{body}"); + + let answer = json!({ "jsonrpc": "2.0", "id": "srv-1", "result": { "action": "accept" } }); + let (status, _, body) = post_with(&broker_url, &agent, &answer).await; + assert_ne!(status, reqwest::StatusCode::BAD_REQUEST, "{body}"); + + let forwarded = seen.lock().expect("upstream log").clone(); + assert_eq!(forwarded.len(), 2, "{forwarded:?}"); + assert_eq!(forwarded[1], answer, "{forwarded:?}"); +} diff --git a/crates/gateway/tests/it/mcp_door.rs b/crates/gateway/tests/it/mcp_door.rs index df41059..e36efef 100644 --- a/crates/gateway/tests/it/mcp_door.rs +++ b/crates/gateway/tests/it/mcp_door.rs @@ -31,12 +31,12 @@ struct Key { impl Key { fn new() -> Self { Key { - signing: p256::ecdsa::SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng), + signing: ::generate(), } } fn jwk(&self) -> serde_json::Value { - let point = self.signing.verifying_key().to_encoded_point(false); + let point = self.signing.verifying_key().to_sec1_point(false); serde_json::json!({ "kty": "EC", "crv": "P-256", "x": b64(point.x().unwrap()), "y": b64(point.y().unwrap()), diff --git a/features/a-batch-cannot-carry-a-call-past-the-gate.feature b/features/a-batch-cannot-carry-a-call-past-the-gate.feature new file mode 100644 index 0000000..d4af3a4 --- /dev/null +++ b/features/a-batch-cannot-carry-a-call-past-the-gate.feature @@ -0,0 +1,55 @@ +Feature: A message the broker cannot judge is never forwarded + + The broker judges a call by reading `method` from one JSON-RPC object. A + JSON-RPC batch is an array of messages, so it has no `method` of its own: + every check keyed on `tools/call` was skipped, and the array went to the + upstream as it came. That skipped the policy decision, the identity check, + the drift refusal, the taint gate, the raw-secret scan of the arguments and + the tool_call record, and a batched `tools/list` answer was never scanned + for poisoned descriptions. + + Measured 2026-10-26 against the v1.19.0 release: a batch of two + `tools/call`, admitted by an agent token, reached the upstream with no + policy decision and no tool_call record. v1.20.0 has the same code path. + + The Model Context Protocol removed batching in its 2025-06-18 revision, and + its HTTP transport carries one message per POST. So the broker refuses a + batch whole, with the JSON-RPC Invalid Request code and nothing forwarded, + rather than judging each element. A client that still sends batches has to + send each message on its own. + + Background: + Given an MCP credential-broker whose policy gate is enforcing + And an upstream that records every message it receives + + # @test:a_batch_of_tool_calls_is_refused_and_nothing_reaches_the_upstream + Scenario: A batch of tool calls is refused over HTTP + Given a policy that denies every tool + When an attributed caller posts a batch of two tool calls + Then the answer is HTTP 400 with the Invalid Request code and no id + And the refusal says it refused a batch + And the upstream received nothing + And the policy point was never asked + + # @test:the_stdio_transport_refuses_a_batch_too + Scenario: The stdio transport refuses the same batch + Given the broker runs over stdio, where no caller is attributed + When the same batch arrives as one line + Then the one answer is the Invalid Request code with no id + And the upstream received nothing + + # @test:a_message_the_broker_cannot_read_as_one_request_is_refused + Scenario: A message that is not one readable request is refused + When a caller posts a bare string, a number, null, or an object whose + method is not a string + Then each is refused with HTTP 400 and the Invalid Request code + And the upstream received none of them + + # @test:a_single_request_and_a_clients_response_still_reach_the_upstream + Scenario: One request, and a client's answer to the server, still pass + Given a policy that allows the tool + When an attributed caller posts one tool call + And then posts its answer to a request the server made, with an id and a + result and no method + Then the tool call is decided once and answered + And the upstream received both messages, the answer exactly as sent diff --git a/features/the-p256-wire-format-is-unchanged.feature b/features/the-p256-wire-format-is-unchanged.feature new file mode 100644 index 0000000..d86546e --- /dev/null +++ b/features/the-p256-wire-format-is-unchanged.feature @@ -0,0 +1,63 @@ +Feature: The P-256 bytes on the wire are unchanged by a curve library upgrade + + The cloud, the delegation fixture, the DPoP tests and the gateway's + checkpoint signer use the RustCrypto `p256` crate. Moving it from 0.13 to + 0.14 (with `elliptic-curve` 0.14, `ecdsa` 0.17 and `pkcs8` 0.11) removes + `crypto-bigint` 0.5.5 and its advisory, RUSTSEC-2026-0387, from the + lockfile. The upgrade renames APIs (`to_encoded_point` became + `to_sec1_point`, keys are made through the `Generate` trait), and none of + that may change a byte another system reads: a JWK's `x` and `y`, an + RFC 7638 thumbprint, a raw r||s ES256 signature, a SEC1 public key, or the + PKCS#8 and SEC1 private keys an operator already holds. + + @decided 2026-10-26: the advisories are fixed by upgrading, not by an + ignore in `.cargo/audit.toml`. + + The expectations below come from RFC 6979 appendix A.2.5 (a P-256 key, its + public point and its deterministic SHA-256 signatures), with the base64 + forms, the thumbprint and the PEM and DER encodings computed outside this + repository by Python `cryptography` 50.0.1. Each scenario's test passes on + both p256 0.13.2 and p256 0.14.0. + + What this does NOT cover: the APNs provider JWT, which uses the same + signing primitive but mints at the current time and has no fixed-vector + test; and signatures from real Apple Secure Enclave keys, which no test + holds. p256 keeps `NORMALIZE_S = false` in 0.14, so a high-S signature + still verifies, as it did in 0.13; that was read in the crate source, not + run. + + # @test:the_rfc_6979_key_puts_its_published_point_and_signatures_on_the_wire + Scenario: The checkpoint key's JWK and signatures are the published bytes + Given the gateway's checkpoint key built from the RFC 6979 P-256 scalar + When it writes its public JWK and signs "sample" and "test" + Then the JWK's x and y are the RFC's coordinates in base64url without padding + And its kid is the RFC 7638 thumbprint computed outside this repository + And each signature is the RFC's r followed by s, 64 bytes + + # @test:a_published_rfc_6979_signature_verifies_and_no_other_encoding_of_it_does + Scenario: A device signature in the published encoding verifies and no other does + Given the RFC 6979 public point in uncompressed and compressed SEC1 + And the RFC's signature over "sample" as raw r||s + When the cloud verifies a device signature + Then the raw r||s signature verifies under both encodings of the key + And the same signature with its halves swapped is refused + And the same signature in DER is refused + + # @test:the_rfc_6979_key_loads_from_every_accepted_encoding_and_signs_a_known_manifest + Scenario: Every accepted encoding of an audit signing key loads and signs the same manifest + Given the RFC 6979 key as PKCS#8 PEM, SEC1 PEM, PKCS#8 DER, SEC1 DER and a raw scalar + When each is loaded as the audit signing key and signs the same empty-chain manifest + Then the manifest's public key is SEC1 uncompressed and the same for all five + And the manifest's signature is the deterministic one computed outside this repository + + # @test:a_kid_that_is_the_keys_thumbprint_is_taken + Scenario: A keyryx key's thumbprint is the one keyryx's own code computes + Given three P-256 keys whose thumbprints were measured from keyryx's Go code + When the cloud takes the thumbprint of each key keyryx sends + Then each thumbprint is the measured one, whichever SEC1 encoding was sent + + # @test:the_committed_fixture_is_what_the_writer_writes_byte_for_byte + Scenario: The committed checkpoint fixture is reproduced byte for byte + Given the checkpoint fixture written and signed before the upgrade + When the writer writes the same stream with the same key + Then every record and every signature is byte for byte the committed one diff --git a/scripts/gates-have-teeth.sh b/scripts/gates-have-teeth.sh index 7bec05c..fcde15d 100755 --- a/scripts/gates-have-teeth.sh +++ b/scripts/gates-have-teeth.sh @@ -222,7 +222,7 @@ py() { printf 'import sys\ndef edit(p, a, b):\n s = open(p).read()\n asser # planted faults edit a COPY of this file, never this one: a case must not rewrite # the harness that is running it. The copy exists only after the edit, so these # cases skip the baseline (fail_env); the unmutated harness is judged by the first. -run_case "teeth-shards: the four shards together run every case once" pass \ +run_case "teeth-shards: the shards together run every case once" pass \ "./scripts/teeth-shards-cover-every-case.sh" \ "pass" @@ -245,6 +245,23 @@ run_case "teeth-shards: a harness that is gone, so the shards measured nothing" "pass" \ "measured nothing" +# The shard count is read from ci.yml, where the matrix, TEETH_SHARD and the job +# name each state it. These edit the workflow itself, which `restore` puts back. +run_case "teeth-shards: a matrix that skips a shard disagrees with its divisor" fail \ + "./scripts/teeth-shards-cover-every-case.sh" \ + "$(py 'edit(".github/workflows/ci.yml", "shard: [1, 2, 3, 4, 5, 6, 7, 8]", "shard: [1, 2, 3, 4, 5, 6, 7, 9]")')" \ + "disagrees with itself" + +run_case "teeth-shards: a divisor left at four under a matrix of eight" fail \ + "./scripts/teeth-shards-cover-every-case.sh" \ + "$(py 'edit(".github/workflows/ci.yml", "TEETH_SHARD: ${{ matrix.shard }}/8", "TEETH_SHARD: ${{ matrix.shard }}/4")')" \ + "disagrees with itself" + +run_case "teeth-shards: the matrix gone from the workflow, so the count measured nothing" fail \ + "./scripts/teeth-shards-cover-every-case.sh" \ + "$(py 'edit(".github/workflows/ci.yml", "shard: [1, 2, 3, 4, 5, 6, 7, 8]", "shard: all")')" \ + "measured nothing" + # --- invariant 1: core stays dependency-minimal ---------------------------- run_case "core-deps: a dependency core is not allowed" fail \ diff --git a/scripts/teeth-shards-cover-every-case.sh b/scripts/teeth-shards-cover-every-case.sh index 9f1c5b4..492210a 100755 --- a/scripts/teeth-shards-cover-every-case.sh +++ b/scripts/teeth-shards-cover-every-case.sh @@ -1,33 +1,67 @@ #!/usr/bin/env bash -# Checks that the four CI shards of scripts/gates-have-teeth.sh run every case -# exactly once: no case name is listed twice, no shard is empty, and the shards -# together are the full list, with nothing dropped and nothing run in two shards. +# Checks that the CI shards of scripts/gates-have-teeth.sh run every case exactly +# once: no case name is listed twice, no shard is empty, and the shards together +# are the full list, with nothing dropped and nothing run in two shards. The shard +# count is read from the workflow, where the teeth job's matrix, the divisor each +# shard filters by and the job's name all state it, and the three must agree. # # WHY THIS EXISTS # -# The harness took 30 to 60 minutes as one job, so ci.yml runs it as four shards -# (TEETH_SHARD=i/4). A filter that drops a case from every shard, or lets two +# The harness took 30 to 60 minutes as one job, so ci.yml runs it as shards +# (TEETH_SHARD=i/N). A filter that drops a case from every shard, or lets two # shards both run it, turns the harness green while a gate goes untested: each # shard passes on the cases it does run, so nothing else would show the gap. # +# The count was written twice until 2026-10-26, as the matrix and as `shards=4` +# here, and nothing checked that they agreed: a matrix of eight with a gate of four +# would have proved a partition nobody ran. It is now written in the workflow only. +# # HOW IT CHECKS WITHOUT RUNNING A CASE # # TEETH_LIST=1 makes the harness print the names its run would run, through the # same filter, and stop. It mutates nothing, so this needs no cargo and no clean # tree. # -# The optional argument is the harness to check. It defaults to the real one; the -# planted-fault cases in gates-have-teeth.sh pass a copy with a broken filter. +# The optional arguments are the harness and the workflow to check. They default +# to the real ones; the planted-fault cases in gates-have-teeth.sh pass a copy of +# the harness with a broken filter, and edit the workflow itself. set -uo pipefail cd "$(dirname "$0")/.." harness="${1:-scripts/gates-have-teeth.sh}" -shards=4 +workflow="${2:-.github/workflows/ci.yml}" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT +if [ ! -f "$workflow" ]; then + printf 'FAIL: %s does not exist, so the shard count measured nothing.\n' "$workflow" + exit 1 +fi + +# The one value a pattern captures in the workflow, or nothing when it matches no +# line or more than one. +one() { + local got + got="$(sed -nE "s#$1#\\1#p" "$workflow")" + [ "$(printf '%s' "$got" | grep -c .)" -eq 1 ] && printf '%s' "$got" +} +matrix="$(one '^[[:space:]]+shard: \[([0-9, ]+)\][[:space:]]*$' | tr -d ' ')" +shards="$(one '^[[:space:]]+TEETH_SHARD: \$\{\{ matrix\.shard \}\}/([0-9]+)[[:space:]]*$')" +named="$(one '^[[:space:]]+name: teeth \(shard \$\{\{ matrix\.shard \}\} of ([0-9]+)\)[[:space:]]*$')" +if [ -z "$matrix" ] || [ -z "$shards" ] || [ -z "$named" ]; then + printf 'FAIL: %s has no single teeth matrix, TEETH_SHARD divisor and shard name, so the shard count measured nothing.\n' "$workflow" + exit 1 +fi +expected="" +for i in $(seq 1 "$shards"); do expected="${expected:+$expected,}$i"; done +if [ "$matrix" != "$expected" ] || [ "$named" != "$shards" ]; then + printf 'FAIL: the shard count in %s disagrees with itself: matrix [%s], TEETH_SHARD divisor %s, job name "of %s".\n' \ + "$workflow" "$matrix" "$shards" "$named" + exit 1 +fi + if [ ! -f "$harness" ]; then printf 'FAIL: %s does not exist, so the shards measured nothing.\n' "$harness" exit 1 @@ -86,4 +120,4 @@ if [ "$fail" -ne 0 ]; then exit 1 fi -printf 'ok: %s cases, %s shards with%s cases, each run by exactly one shard\n' "$full" "$shards" "$sizes" +printf 'ok: %s cases, %s shards (from %s) with%s cases, each run by exactly one shard\n' "$full" "$shards" "$workflow" "$sizes"