From b2fc69a446e0acb4d0210b313bf2d27bc53ea150 Mon Sep 17 00:00:00 2001 From: Yurii Kostiuk Date: Mon, 26 Oct 2026 02:21:43 +0000 Subject: [PATCH 1/7] test(mcp-broker): a batch and an unreadable message must not reach the upstream Four cases for the MCP broker, added before the fix so CI shows them red on the current code: - a JSON-RPC batch of two tools/call over HTTP is refused with 400 and -32600, nothing reaches the upstream and the PDP is never asked, with a policy that denies every tool; - the stdio transport refuses the same batch; - a bare string, a number, null, and an object whose method is not a string are refused the same way; - one request and a client's JSON-RPC response (id and result, no method) still reach the upstream, so the fix cannot refuse too much. Scenarios in features/a-batch-cannot-carry-a-call-past-the-gate.feature, each bound to its test. README badge and PROGRESS.md count 2418. Co-Authored-By: Claude Opus 5.5 --- PROGRESS.md | 2 +- README.md | 2 +- crates/gateway/tests/it/mcp_broker.rs | 197 ++++++++++++++++++ ...-cannot-carry-a-call-past-the-gate.feature | 55 +++++ 4 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 features/a-batch-cannot-carry-a-call-past-the-gate.feature diff --git a/PROGRESS.md b/PROGRESS.md index 4322066..b048ae8 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 **2418 passing** (core 469, dpop 29, delegation 75, gateway 1501, cloud 343, 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 2414 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 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..fd4aaf2 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-2418-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/gateway/tests/it/mcp_broker.rs b/crates/gateway/tests/it/mcp_broker.rs index a0ee155..d92eb06 100644 --- a/crates/gateway/tests/it/mcp_broker.rs +++ b/crates/gateway/tests/it/mcp_broker.rs @@ -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/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 From 81753da6e838967a5580acb97bb6498ba5d43a23 Mon Sep 17 00:00:00 2001 From: Yurii Kostiuk Date: Mon, 26 Oct 2026 02:27:08 +0000 Subject: [PATCH 2/7] fix(mcp-broker): refuse a JSON-RPC batch and any message it cannot judge Every check the broker makes keys on `method` read from one JSON object. A batch is an array, so its method read as empty and every tools/call check was skipped: the policy decision, the identity requirement, drift, the taint gate, the argument scans and the tool_call record, and a batched tools/list answer was never scanned. The array was forwarded as it came, over HTTP and stdio alike. process_call, which both transports reach, now refuses first, before an upstream is resolved or a secret read: a message that is not one JSON object, or whose method is present and not a string, gets JSON-RPC -32600 with a null id and HTTP 400. MCP removed batching in its 2025-06-18 revision. A JSON-RPC response (no method) is still carried. Invariant 142 in CLAUDE.md. The previous commit's tests failed on the unfixed broker in CI (run 40258757907: three refused-case tests red at the "nothing reached the upstream" assertion, the must-not-fire case green). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 ++ crates/gateway/src/mcpbroker.rs | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 6f917dc..09a92e5 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. 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(); From 45f1e19232b363ff10030cffafd8314a925e8933 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 03:22:00 +0000 Subject: [PATCH 3/7] fix(deps): patch rustls-webpki, webpki-roots and wasmtime for four advisories rustls-webpki 0.103.15 -> 0.103.16 (RUSTSEC-2026-0382) and webpki-roots 1.0.8 -> 1.0.11 (RUSTSEC-2026-0381) in both lockfiles, the workspace and crates/cluster. wasmtime 49.0.3 -> 49.0.4 (RUSTSEC-2026-0378, RUSTSEC-2026-0379) in the workspace lockfile only, staying on 49.x; the cranelift and pulley crates move with it from 0.136.3 / 49.0.3 to 0.136.4 / 49.0.4. Lockfile-only: no manifest changes, no new crate. crypto-bigint 0.5.5 (RUSTSEC-2026-0387) is the fifth advisory and needs the p256 0.14 migration in the next commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKcmLyFYX5vcY81ze4tHQ1 --- Cargo.lock | 122 +++++++++++++++++++------------------- crates/cluster/Cargo.lock | 8 +-- 2 files changed, 65 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f585755..b7f5b2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1138,27 +1138,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 +1166,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 +1177,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", @@ -1208,9 +1208,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 +1221,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 +1248,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 +1261,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 +1278,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" @@ -2413,7 +2413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3024,7 +3024,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.8", + "webpki-roots 1.0.11", ] [[package]] @@ -4545,9 +4545,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 +4557,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,7 +5089,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.8", + "webpki-roots 1.0.11", ] [[package]] @@ -5199,7 +5199,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5239,9 +5239,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", @@ -5765,7 +5765,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6731,9 +6731,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 +6769,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", @@ -6800,15 +6800,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 +6817,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 +6843,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 +6857,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 +6867,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 +6878,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 +6890,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 +6947,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", ] @@ -7059,7 +7059,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.59.0", ] [[package]] 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", ] From b6a35fffe6977c8e8dd3598031d6646920928fcc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 03:22:36 +0000 Subject: [PATCH 4/7] fix(deps): move p256 to 0.14, dropping crypto-bigint 0.5.5 (RUSTSEC-2026-0387) p256 0.13 is pulled only by this workspace's own crates (cloud, delegation, dpop, gateway). Moving all six manifest lines to 0.14 brings elliptic-curve 0.14.3, ecdsa 0.17.0, pkcs8 0.11, sec1 0.8, signature 3.0 and rfc6979 0.6, and replaces crypto-bigint 0.5.5 with 0.7.9, so `cargo tree -i crypto-bigint --all-features --target all` no longer shows a 0.5.x. The code changes are two renames, nothing else: - `VerifyingKey::to_encoded_point(compress)` is `to_sec1_point(compress)` in ecdsa 0.17; `x()`, `y()` and `as_bytes()` on the result are unchanged. - `SigningKey::random(&mut OsRng)` is deprecated, and rand_core is 0.10 with no `OsRng`; the test fixtures that made a random key now call `::generate()`. Production key generation (gateway checkpoints) already drew bytes with getrandom 0.2 and `SigningKey::from_slice`, which is unchanged. `Signature::from_slice`, `to_bytes`, `VerifyingKey::from_sec1_bytes`, `SecretKey::from_pkcs8_pem` / `from_sec1_pem` / `from_pkcs8_der` / `from_sec1_der` keep their names and signatures. P-256 keeps `NORMALIZE_S = false`, so a high-S signature still verifies and signing still does not normalise s, as in 0.13. One rustfmt collapse in an audit_sign test follows the shorter name; the assertion is the same. The gateway manifest comment on hmac is corrected: hmac 0.12 reached the lockfile through p256 0.13's rfc6979, and since 0.14 (which uses hmac 0.13) the gateway's own line is its only edge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKcmLyFYX5vcY81ze4tHQ1 --- Cargo.lock | 287 +++++++++++++----- crates/cloud/Cargo.toml | 4 +- crates/cloud/src/audit_keyryx.rs | 4 +- crates/cloud/src/audit_keyryx/tests.rs | 4 +- crates/cloud/src/audit_sign.rs | 9 +- crates/cloud/src/devices.rs | 2 +- .../cloud/tests/it/audit_manifest_keyryx.rs | 4 +- crates/cloud/tests/it/device_tokens.rs | 2 +- crates/cloud/tests/it/pairing.rs | 2 +- crates/delegation/Cargo.toml | 4 +- crates/delegation/src/testing.rs | 4 +- crates/dpop/Cargo.toml | 2 +- crates/dpop/src/lib.rs | 6 +- crates/gateway/Cargo.toml | 10 +- crates/gateway/src/checkpoints.rs | 2 +- crates/gateway/tests/it/mcp_broker.rs | 4 +- crates/gateway/tests/it/mcp_door.rs | 4 +- 17 files changed, 245 insertions(+), 109 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7f5b2e..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" @@ -1200,7 +1221,7 @@ dependencies = [ "rustc-hash", "serde", "serde_derive", - "sha2", + "sha2 0.10.9", "smallvec", "target-lexicon", "wasmtime-internal-core", @@ -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.59.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" @@ -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]] @@ -5094,12 +5191,12 @@ dependencies = [ [[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.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -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.59.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", @@ -6788,7 +6915,7 @@ dependencies = [ "semver", "serde", "serde_derive", - "sha2", + "sha2 0.10.9", "smallvec", "target-lexicon", "wasm-encoder 0.258.3", @@ -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.59.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/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..6714e14 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,11 +273,8 @@ 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() ); } diff --git a/crates/cloud/src/devices.rs b/crates/cloud/src/devices.rs index 73fcedb..8d75d5f 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) } 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/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/tests/it/mcp_broker.rs b/crates/gateway/tests/it/mcp_broker.rs index a0ee155..c3c9df7 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. 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()), From b7218da7ab482dcf586628bb45eba8cf0c17a5f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 03:22:55 +0000 Subject: [PATCH 5/7] test(crypto): pin the P-256 wire bytes to RFC 6979's published vector The external P-256 values the suite already checked were three keyryx thumbprints (measured from keyryx's Go code) and agent-passport's checkpoint vectors, which it verifies but does not produce. Nothing held a signature this code produces, a SEC1 public key, or a PKCS#8 / SEC1 private key parse to an external value, so a curve library that changed one of those could pass the suite as long as it agreed with itself. Three tests now hold the bytes to RFC 6979 appendix A.2.5 (a P-256 key, its point, its deterministic SHA-256 signatures), with the base64 forms, the RFC 7638 thumbprint and the PKCS#8 / SEC1 encodings written out by Python `cryptography` 50.0.1, never by p256: - gateway::checkpoints: the checkpoint key's JWK x and y, its kid, and the raw r||s over "sample" and "test". - cloud::devices: the RFC signature verifies under the uncompressed and compressed SEC1 key; with its halves swapped, or in DER, it is refused. - cloud::audit_sign: the RFC key loads from PKCS#8 PEM, SEC1 PEM, PKCS#8 DER, SEC1 DER and a raw scalar, and each signs the same empty-chain manifest to the same public key and signature bytes. Each went red against a deliberately broken encoding first (padded standard base64 for x and y; s before r; a DER-only signature parser; a compressed public key and a DER signature in the manifest) and green on the real code. All three also pass unchanged on main with p256 0.13.2, so 0.13 and 0.14 put the same bytes on the wire. Encoding the manifest's public key compressed passed every older test; only the new one caught it. Scenarios: features/the-p256-wire-format-is-unchanged.feature, five, each bound. The suite goes from 2414 to 2417; README.md and PROGRESS.md say so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKcmLyFYX5vcY81ze4tHQ1 --- PROGRESS.md | 2 +- README.md | 2 +- crates/cloud/src/audit_sign.rs | 44 +++++++++++++ crates/cloud/src/devices.rs | 26 ++++++++ crates/gateway/src/checkpoints/tests.rs | 57 +++++++++++++++++ .../the-p256-wire-format-is-unchanged.feature | 63 +++++++++++++++++++ 6 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 features/the-p256-wire-format-is-unchanged.feature diff --git a/PROGRESS.md b/PROGRESS.md index 4322066..4385300 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 **2417 passing** (core 469, dpop 29, delegation 75, gateway 1498, cloud 345, umbrella 1, by `cargo test -p `). 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..9bcb963 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-2417-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/src/audit_sign.rs b/crates/cloud/src/audit_sign.rs index 6714e14..889c55f 100644 --- a/crates/cloud/src/audit_sign.rs +++ b/crates/cloud/src/audit_sign.rs @@ -278,6 +278,50 @@ mod tests { ); } + /// 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 8d75d5f..f9c4d3f 100644 --- a/crates/cloud/src/devices.rs +++ b/crates/cloud/src/devices.rs @@ -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/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/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 From fb39712674dce84114b7ff48f17a8ca307617f37 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 04:02:53 +0000 Subject: [PATCH 6/7] test(crypto): allow-list the RFC 6979 test key for the secret scan `secretscan / tree` flagged the PKCS#8 header of the RFC 6979 appendix A.2.5 P-256 key that the audit-signing-key encoding test loads (crates/cloud/src/audit_sign.rs, fingerprint 3021d90eb9437b2d, the same header fingerprint already allowed for the test keys in crates/cloud/tests/it/oidc.rs and crates/dpop/src/lib.rs). The key is a published test vector and signs nothing. The SEC1 header in the same file was already covered by its existing entry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKcmLyFYX5vcY81ze4tHQ1 --- .secretscan-allow | 1 + 1 file changed, 1 insertion(+) 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 From 348786e83120e3f881fe21a09aa701f2a447ad5e Mon Sep 17 00:00:00 2001 From: Yurii Kostiuk Date: Mon, 26 Oct 2026 02:54:32 +0000 Subject: [PATCH 7/7] ci(teeth): eight shards, the count read from the workflow, and stale PR runs cancelled The teeth harness ran as four shards of 11.6 to 19.3 minutes each on main (run 39356312313), the long pole of every CI run; its cases add up to 54.7 minutes (run 40259164936). Eight shards halve the cases each runner carries. The count was written twice, as the matrix and as `shards=4` in the partition gate, and nothing checked that they agreed (invariant 113 said so). The gate now reads it from ci.yml, where the matrix, TEETH_SHARD and the job name each state it, and refuses when they disagree or the matrix is not 1..N. Three teeth cases plant each fault: a matrix that skips a shard, a divisor left at four, the matrix gone. `concurrency` cancels a pull request's older run when a newer push arrives; a push to main is never cancelled. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 37 +++++++++++------ CLAUDE.md | 33 +++++++++------ scripts/gates-have-teeth.sh | 19 ++++++++- scripts/teeth-shards-cover-every-case.sh | 52 ++++++++++++++++++++---- 4 files changed, 105 insertions(+), 36 deletions(-) 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/CLAUDE.md b/CLAUDE.md index 09a92e5..e4ea2f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7385,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/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"