From 18e7f9351ca3fdfe16c420499bbfde9d7041651c Mon Sep 17 00:00:00 2001 From: Yurii Kostiuk Date: Mon, 19 Oct 2026 20:38:38 +0100 Subject: [PATCH 1/6] test(delegation): a proof names one of the configured origins (red first) Scaffold and tests for CLAUDE.md invariant 138. The delegation door, the agent-token door and the MCP broker's proof door take a list of public origins; a proof is accepted when its htu is one of them at the route's path. The product code keeps today's behaviour in this commit: the single origin is the only one used, and the list stubs (origin::normalize_origin, origin::expected_url, origin::parse_origin_list, chainproof::origins_from_values) return what the single-origin code returned. The tests name the behaviour the fix must reach and fail on the assertions, not on compilation. Red on this commit (run by CI, not locally): a proof addressed to a second configured origin is refused; the first origin spelled with its default port is refused; a fragment in the htu is admitted; a list with a trailing comma, a path or user information is accepted at startup. Co-Authored-By: Claude Haiku 5.5 --- components.json | 3 + crates/dpop/src/lib.rs | 2 + crates/dpop/src/origin.rs | 261 ++++++++++++++++++ crates/gateway/src/agenttoken.rs | 13 +- crates/gateway/src/chainproof.rs | 83 +++++- crates/gateway/src/mcpdoor.rs | 12 +- crates/gateway/src/proxy.rs | 4 +- .../it/agent_token_is_the_door_credential.rs | 2 +- .../tests/it/delegation_htu_matches_wire.rs | 2 +- crates/gateway/tests/it/delegation_origins.rs | 198 +++++++++++++ crates/gateway/tests/it/main.rs | 1 + crates/gateway/tests/it/mcp_agent_token.rs | 2 +- crates/gateway/tests/it/mcp_broker.rs | 2 +- crates/gateway/tests/it/mcp_door.rs | 128 +++++++++ .../tests/it/protected_resource_metadata.rs | 2 +- .../gateway/tests/it/static_key_allow_list.rs | 2 +- crates/gateway/tests/it/wardryx.rs | 4 +- ...of-names-one-of-the-public-origins.feature | 73 +++++ 18 files changed, 773 insertions(+), 21 deletions(-) create mode 100644 crates/dpop/src/origin.rs create mode 100644 crates/gateway/tests/it/delegation_origins.rs create mode 100644 features/a-proof-names-one-of-the-public-origins.feature diff --git a/components.json b/components.json index 3321ceb3..8fb18d38 100644 --- a/components.json +++ b/components.json @@ -139,6 +139,9 @@ "TOKENFUSE_DELEGATION_JWKS_URL": { "required": false }, + "TOKENFUSE_DELEGATION_ORIGINS": { + "required": false + }, "TOKENFUSE_DELEGATION_REVOCATIONS": { "required": false }, diff --git a/crates/dpop/src/lib.rs b/crates/dpop/src/lib.rs index cb1cecf4..e62a5aef 100644 --- a/crates/dpop/src/lib.rs +++ b/crates/dpop/src/lib.rs @@ -46,6 +46,8 @@ //! to the caller: `delegation` does it from a token's `cnf.jkt`, `mcpdoor` from //! a CIMD client metadata document. +pub mod origin; + use base64::Engine as _; use jsonwebtoken::jwk::{AlgorithmParameters, Jwk}; use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation}; diff --git a/crates/dpop/src/origin.rs b/crates/dpop/src/origin.rs new file mode 100644 index 00000000..9540936f --- /dev/null +++ b/crates/dpop/src/origin.rs @@ -0,0 +1,261 @@ +//! The public origins a DPoP proof's `htu` may name (CLAUDE.md invariant 138). +//! +//! A gateway is reached at one address by its clients and at another by the +//! containers beside it, so one configured origin cannot describe every caller. +//! The operator lists each origin this process is reached at, and a proof is +//! accepted for a route when its `htu`, with the query removed, is one of them +//! plus that route's path. The `Host` header is never consulted: the caller +//! writes it, and a check against it would agree with whatever the caller liked. +//! +//! Origins are compared in the form wardryx's agent-token door uses for its own +//! list (`WARDRYX_AGENT_TOKEN_DOOR_ORIGINS`): scheme and host in lower case, the +//! default port written out, and nothing after the port but an optional `/`. + +/// Why an origin was refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OriginRefusal { + /// Not `http` or `https`, no host, user information, a path, query or + /// fragment after the port, a port that is not 1 to 65535, or a host that + /// is not a host. + NotAnOrigin, +} + +/// Normalise one configured origin written `scheme://host[:port]`. +/// +/// STUB for the red-first commit: returns the trimmed input unchanged, which is +/// what the code did before the list existed. +pub fn normalize_origin(raw: &str) -> Result { + Ok(raw.trim().to_string()) +} + +/// The normalised origin and the path of an absolute `htu`, query removed. +/// +/// STUB for the red-first commit: always `None`. +pub fn origin_and_path(_htu: &str) -> Option<(String, String)> { + None +} + +/// The `htu` claim of a compact JWS payload, read WITHOUT checking the signature. +/// +/// STUB for the red-first commit: always `None`. +pub fn unverified_htu(_proof: &str) -> Option { + None +} + +/// The URL a proof must name for a request on `path`. +/// +/// STUB for the red-first commit: the first configured origin plus the path, +/// which is what the single-origin code computed. +pub fn expected_url(origins: &[String], _proof: Option<&str>, path: &str) -> String { + format!( + "{}{}", + origins.first().map(String::as_str).unwrap_or_default(), + path + ) +} + +/// Parse a comma-separated list of origins, normalising each entry. +/// +/// STUB for the red-first commit: the whole value as one entry, trailing slash +/// removed, which is what `TOKENFUSE_MCP_PROOF_URL` alone meant. +pub fn parse_origin_list(list: &str) -> Result, String> { + let list = list.trim(); + Ok(if list.is_empty() { + Vec::new() + } else { + vec![list.trim_end_matches('/').to_string()] + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine as _; + + /// A compact JWS with an unchecked signature, carrying `claims`. Only the + /// payload is read by `unverified_htu`, so the header and signature are + /// placeholders here. + fn unsigned(claims: serde_json::Value) -> String { + let enc = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); + format!( + "{}.{}.{}", + enc(br#"{"alg":"ES256","typ":"dpop+jwt"}"#), + enc(claims.to_string().as_bytes()), + enc(b"sig") + ) + } + + #[test] + fn an_origin_is_normalised_the_way_wardryx_normalises_its_door_origins() { + assert_eq!( + normalize_origin("https://tokenfuse.acme.example").unwrap(), + "https://tokenfuse.acme.example:443" + ); + assert_eq!( + normalize_origin("HTTPS://TokenFuse.Acme.Example:443/").unwrap(), + "https://tokenfuse.acme.example:443" + ); + assert_eq!( + normalize_origin("http://tokenfuse-gateway").unwrap(), + "http://tokenfuse-gateway:80" + ); + assert_eq!( + normalize_origin("http://127.0.0.1:4100").unwrap(), + "http://127.0.0.1:4100" + ); + assert_eq!( + normalize_origin("http://[::1]:4100").unwrap(), + "http://[::1]:4100" + ); + assert_eq!( + normalize_origin("https://[::1]").unwrap(), + "https://[::1]:443" + ); + } + + #[test] + fn an_origin_with_anything_after_the_port_or_the_wrong_scheme_is_refused() { + for bad in [ + "", + "tokenfuse.acme.example", + "ftp://tokenfuse.acme.example", + "https://", + "https://user:pass@tokenfuse.acme.example", + "https://tokenfuse.acme.example/prefix", + "https://tokenfuse.acme.example//", + "https://tokenfuse.acme.example?x=1", + "https://tokenfuse.acme.example#frag", + "https://tokenfuse.acme.example:0", + "https://tokenfuse.acme.example:99999", + "https://tokenfuse.acme.example:", + "https://tokenfuse.acme.example:44a", + "https://a:b:c", + "https://2001:db8::1", + ] { + assert_eq!( + normalize_origin(bad), + Err(OriginRefusal::NotAnOrigin), + "{bad:?} must be refused" + ); + } + } + + #[test] + fn a_proof_names_an_origin_and_a_path_and_the_query_is_part_of_neither() { + assert_eq!( + origin_and_path("https://Tokenfuse.acme.example/v1/messages?cache=1"), + Some(( + "https://tokenfuse.acme.example:443".to_string(), + "/v1/messages".to_string() + )) + ); + assert_eq!( + origin_and_path("https://tokenfuse.acme.example"), + Some(( + "https://tokenfuse.acme.example:443".to_string(), + "/".to_string() + )) + ); + for bad in [ + "https://user@tokenfuse.acme.example/v1/messages", + "https://tokenfuse.acme.example/v1/messages#frag", + "wss://tokenfuse.acme.example/v1/messages", + "/v1/messages", + "", + ] { + assert_eq!(origin_and_path(bad), None, "{bad:?} must name no origin"); + } + } + + #[test] + fn the_list_is_normalised_an_empty_entry_is_refused_and_an_empty_list_is_empty() { + assert_eq!(parse_origin_list("").unwrap(), Vec::::new()); + assert_eq!(parse_origin_list(" ").unwrap(), Vec::::new()); + assert_eq!( + parse_origin_list("https://a.example, https://b.example:8443").unwrap(), + vec![ + "https://a.example:443".to_string(), + "https://b.example:8443".to_string() + ] + ); + assert_eq!( + parse_origin_list("https://a.example,https://A.example:443") + .unwrap() + .len(), + 1, + "two spellings of one origin are one entry" + ); + assert_eq!( + parse_origin_list("https://a.example,").unwrap_err(), + "", + "a trailing comma is an empty entry: refused, not skipped" + ); + assert_eq!( + parse_origin_list("https://a.example,https://b.example/x").unwrap_err(), + "https://b.example/x" + ); + } + + #[test] + fn a_proof_is_checked_against_the_origin_it_names_at_this_path_and_no_other() { + let origins = vec![ + "https://tokenfuse.acme.example:443".to_string(), + "http://tokenfuse-gateway:4100".to_string(), + ]; + let at = |htu: &str| { + unsigned(serde_json::json!({"htm": "POST", "htu": htu, "iat": 1, "jti": "j"})) + }; + + // A configured origin at the route's path: the htu the proof carries is + // the one it is checked against. + let named = at("http://tokenfuse-gateway:4100/v1/messages"); + assert_eq!( + expected_url(&origins, Some(&named), "/v1/messages"), + "http://tokenfuse-gateway:4100/v1/messages" + ); + // The same origin spelled another way is the same origin. + let spelt = at("HTTPS://Tokenfuse.acme.example/v1/messages"); + assert_eq!( + expected_url(&origins, Some(&spelt), "/v1/messages"), + "HTTPS://Tokenfuse.acme.example/v1/messages" + ); + + // Anything else gets the first configured origin plus the route's path, + // which the verifier refuses, because the proof named something else. + for htu in [ + "https://evil.example/v1/messages", + "http://tokenfuse-gateway:4100/v1/chat/completions", + "http://tokenfuse-gateway:4100/prefix/v1/messages", + "https://user@tokenfuse.acme.example/v1/messages", + "https://tokenfuse.acme.example/v1/messages#frag", + ] { + let proof = at(htu); + assert_eq!( + expected_url(&origins, Some(&proof), "/v1/messages"), + "https://tokenfuse.acme.example:443/v1/messages", + "{htu} must not be the url the proof is checked against" + ); + } + assert_eq!( + expected_url(&origins, None, "/v1/messages"), + "https://tokenfuse.acme.example:443/v1/messages" + ); + assert_eq!( + expected_url(&origins, Some("not-a-jws"), "/v1/messages"), + "https://tokenfuse.acme.example:443/v1/messages" + ); + } + + #[test] + fn the_htu_is_read_without_checking_the_signature_and_a_missing_one_is_none() { + assert_eq!( + unverified_htu(&unsigned(serde_json::json!({"htu": "https://a.example/x"}))), + Some("https://a.example/x".to_string()) + ); + assert_eq!( + unverified_htu(&unsigned(serde_json::json!({"jti": "j"}))), + None + ); + assert_eq!(unverified_htu("not-a-jws"), None); + } +} diff --git a/crates/gateway/src/agenttoken.rs b/crates/gateway/src/agenttoken.rs index c74fc197..c21915e4 100644 --- a/crates/gateway/src/agenttoken.rs +++ b/crates/gateway/src/agenttoken.rs @@ -343,7 +343,16 @@ pub fn admit( .and_then(|v| v.to_str().ok()); // The origin is the deployment's, the path the route's: never a `Host` // header, which the caller writes (invariant 30's `htu` rule). - let url = format!("{}{}", proving.origin.trim_end_matches('/'), path); + let url = format!( + "{}{}", + proving + .origins + .first() + .map(String::as_str) + .unwrap_or_default() + .trim_end_matches('/'), + path + ); let agent = match tokenfuse_delegation::verify_agent_token( &proving.cfg, token, @@ -594,7 +603,7 @@ mod tests { c.audience = audience.to_string(); Proving { cfg: c, - origin: "https://tokenfuse.acme.example".to_string(), + origins: vec!["https://tokenfuse.acme.example".to_string()], } } diff --git a/crates/gateway/src/chainproof.rs b/crates/gateway/src/chainproof.rs index 0d485631..f91ab9fd 100644 --- a/crates/gateway/src/chainproof.rs +++ b/crates/gateway/src/chainproof.rs @@ -108,7 +108,7 @@ pub struct Proving { /// fact the code knows and the origin is a fact only the deployment knows. /// A gateway behind a proxy cannot read its own external origin off the /// request, and letting it try is how `htu` ends up agreeing with anything. - pub origin: String, + pub origins: Vec, } pub type ChainProof = Option>; @@ -183,7 +183,16 @@ pub fn resolve( return Chain::Claimed(declared.to_vec()); }; - let url = format!("{}{}", proving.origin.trim_end_matches('/'), url); + let url = format!( + "{}{}", + proving + .origins + .first() + .map(String::as_str) + .unwrap_or_default() + .trim_end_matches('/'), + url + ); let verified = match verify_delegation(&proving.cfg, token, proof, method, &url, now, revoked) { Ok(v) => v, // Every refusal is the same answer on the wire, for the reason @@ -458,15 +467,32 @@ pub async fn from_env(client: &reqwest::Client) -> ChainProof { ); std::process::exit(2); } + let origins = match origins_from_values( + &origin, + &std::env::var("TOKENFUSE_DELEGATION_ORIGINS").unwrap_or_default(), + ) { + Ok(origins) => origins, + Err(msg) => { + eprintln!("tokenfuse: {msg}"); + std::process::exit(2); + } + }; Some(Arc::new(Proving { cfg: DelegationConfig { audience: std::env::var("TOKENFUSE_DELEGATION_AUDIENCE").unwrap_or_default(), ..base }, - origin, + origins, })) } +/// The origins a `Proving` is configured with, from the single origin and the list. +/// +/// STUB for the red-first commit: the single origin only, unnormalised. +pub(crate) fn origins_from_values(single: &str, _list: &str) -> Result, String> { + Ok(vec![single.to_string()]) +} + #[cfg(test)] mod tests { use super::*; @@ -593,7 +619,7 @@ mod tests { let issuer_configured = cfg.issuer.clone(); let proving: ChainProof = Some(Arc::new(Proving { cfg, - origin: origin.to_string(), + origins: vec![origin.to_string()], })); let tok = token( &issuer, @@ -675,7 +701,7 @@ mod tests { let url = format!("{origin}/v1/messages"); let proving: ChainProof = Some(Arc::new(Proving { cfg: cfg(&issuer), - origin: origin.to_string(), + origins: vec![origin.to_string()], })); let on: crate::agenttoken::Tokens = Some(Arc::new(crate::agenttoken::AgentTokens::new( crate::agenttoken::Mode::Accept, @@ -902,3 +928,50 @@ mod tests { assert_eq!(cfg.jwks.key_count(), 1); } } + +#[cfg(test)] +mod origin_list_tests { + use super::*; + + /// The single origin and the list are read as one set, normalised, and a bad + /// entry names the variable it came from (CLAUDE.md invariant 138). + #[test] + fn the_single_origin_and_the_list_are_one_set_and_a_bad_entry_names_its_variable() { + assert_eq!( + origins_from_values("https://tokenfuse.acme.example", "").unwrap(), + vec!["https://tokenfuse.acme.example:443".to_string()] + ); + assert_eq!( + origins_from_values( + "", + "http://tokenfuse-gateway:4100,https://tokenfuse.acme.example" + ) + .unwrap(), + vec![ + "http://tokenfuse-gateway:4100".to_string(), + "https://tokenfuse.acme.example:443".to_string() + ] + ); + assert_eq!( + origins_from_values( + "https://tokenfuse.acme.example", + "https://tokenfuse.acme.example:443" + ) + .unwrap() + .len(), + 1, + "the same origin named twice is one origin" + ); + assert!(origins_from_values("", "") + .unwrap_err() + .contains("TOKENFUSE_DELEGATION_URL")); + assert!( + origins_from_values("https://tokenfuse.acme.example/prefix", "") + .unwrap_err() + .contains("TOKENFUSE_DELEGATION_URL") + ); + assert!(origins_from_values("", "https://a.example,") + .unwrap_err() + .contains("TOKENFUSE_DELEGATION_ORIGINS")); + } +} diff --git a/crates/gateway/src/mcpdoor.rs b/crates/gateway/src/mcpdoor.rs index ba452f90..36dd4415 100644 --- a/crates/gateway/src/mcpdoor.rs +++ b/crates/gateway/src/mcpdoor.rs @@ -248,7 +248,7 @@ pub struct ClientRegistry { /// with any trailing slash removed. Server-side and operator-supplied on /// purpose: reconstructing it from a `Host` header would let the caller make /// `htu` agree with anything it liked, which turns the check into decoration. - origin: String, + origins: Vec, replay: ReplayCache, } @@ -256,7 +256,7 @@ impl Default for ClientRegistry { fn default() -> Self { ClientRegistry { by_jkt: HashMap::new(), - origin: String::new(), + origins: Vec::new(), replay: ReplayCache::new(REPLAY_CAP), } } @@ -368,7 +368,7 @@ impl ClientRegistry { Ok(ClientRegistry { by_jkt, - origin: origin.to_string(), + origins: vec![origin.to_string()], replay: ReplayCache::new(REPLAY_CAP), }) } @@ -399,7 +399,11 @@ impl ClientRegistry { /// anything, which is what makes refusing at the cap a self-inflicted wound /// rather than something a stranger can do to a deployment. pub fn authenticate(&self, proof: &str, path: &str, now: i64) -> Result<&str, DoorRefusal> { - let expected = format!("{}{}", self.origin, path); + let expected = format!( + "{}{}", + self.origins.first().map(String::as_str).unwrap_or_default(), + path + ); // POST because that is the only method this door ever sees: `mcpbroker::app` // routes `/` and `/mcp` with `post` and nothing else. let verified = diff --git a/crates/gateway/src/proxy.rs b/crates/gateway/src/proxy.rs index 974ce4e2..889a24ff 100644 --- a/crates/gateway/src/proxy.rs +++ b/crates/gateway/src/proxy.rs @@ -6409,7 +6409,7 @@ pub(crate) mod tests { let st = firewall_state(mode, resp_body).with_chain_proof( Some(std::sync::Arc::new(crate::chainproof::Proving { cfg: cfg(&issuer), - origin: PROVING_ORIGIN.to_string(), + origins: vec![PROVING_ORIGIN.to_string()], })), None, ); @@ -11132,7 +11132,7 @@ pub(crate) mod tests { .with_chain_proof( Some(Arc::new(crate::chainproof::Proving { cfg: cfg(&issuer), - origin: PROVING_ORIGIN.to_string(), + origins: vec![PROVING_ORIGIN.to_string()], })), None, ); diff --git a/crates/gateway/tests/it/agent_token_is_the_door_credential.rs b/crates/gateway/tests/it/agent_token_is_the_door_credential.rs index 51da6427..22dcc049 100644 --- a/crates/gateway/tests/it/agent_token_is_the_door_credential.rs +++ b/crates/gateway/tests/it/agent_token_is_the_door_credential.rs @@ -78,7 +78,7 @@ fn gate(wire: Wire, mode: Option, keys: Option<&str>) -> Gate { .with_chain_proof( Some(Arc::new(Proving { cfg: cfg(&issuer), - origin: ORIGIN.to_string(), + origins: vec![ORIGIN.to_string()], })), None, ) diff --git a/crates/gateway/tests/it/delegation_htu_matches_wire.rs b/crates/gateway/tests/it/delegation_htu_matches_wire.rs index 9e66f7d8..7956c75d 100644 --- a/crates/gateway/tests/it/delegation_htu_matches_wire.rs +++ b/crates/gateway/tests/it/delegation_htu_matches_wire.rs @@ -47,7 +47,7 @@ fn proving_state(wire: Wire) -> (tokenfuse_gateway::state::AppState, Key, Key) { let st = common::app_with_wire(wire).with_chain_proof( Some(Arc::new(Proving { cfg: cfg(&issuer), - origin: ORIGIN.to_string(), + origins: vec![ORIGIN.to_string()], })), None, ); diff --git a/crates/gateway/tests/it/delegation_origins.rs b/crates/gateway/tests/it/delegation_origins.rs new file mode 100644 index 00000000..e883f209 --- /dev/null +++ b/crates/gateway/tests/it/delegation_origins.rs @@ -0,0 +1,198 @@ +//! The delegation door takes a LIST of public origins (CLAUDE.md invariant 138). +//! +//! A gateway is reached at a public name by its clients and at another name by the +//! containers beside it. A proof is accepted when its `htu` names one configured +//! origin at the route's own path. The `Host` header is never consulted. +//! +//! Driven through the real router, on the OpenAI door and on the agent-token door. + +use crate::common; + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tokenfuse_delegation::testing::{agent_token, cfg, proof_at, proof_for, token, Key}; +use tokenfuse_gateway::agenttoken::{AgentTokens, Mode}; +use tokenfuse_gateway::chainproof::Proving; +use tokenfuse_gateway::mcpdoor::PROOF_HEADER; +use tokenfuse_gateway::state::AppState; +use tokenfuse_gateway::wire::Wire; +use tower::ServiceExt; + +/// The public name clients call the gateway at. +const ORIGIN: &str = "https://tokenfuse.acme.example"; +/// The name the containers beside the gateway call it at. +const INTERNAL: &str = "http://tokenfuse-gateway:4100"; + +fn body() -> &'static str { + r#"{"model":"test-model","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}"# +} + +fn now() -> i64 { + tokenfuse_gateway::sink::now_millis() / 1000 +} + +/// A gateway on the OpenAI door whose delegation door is configured with `origins`. +fn gateway(origins: &[&str]) -> (AppState, Key, Key) { + let (issuer, holder) = (Key::new(), Key::new()); + let proving = Proving { + cfg: cfg(&issuer), + origins: origins.iter().map(|o| o.to_string()).collect(), + }; + let st = common::app_with_wire(Wire::OpenAi).with_chain_proof(Some(Arc::new(proving)), None); + (st, issuer, holder) +} + +/// POST one chat completion with `headers`, answering with its status. +async fn post(st: AppState, headers: Vec<(&'static str, String)>) -> StatusCode { + let mut b = Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("content-type", "application/json") + .header("x-fuse-run-id", "r-origins-1"); + for (k, v) in headers { + b = b.header(k, v); + } + let req = b.body(Body::from(body())).unwrap(); + tokenfuse_gateway::app(st) + .oneshot(req) + .await + .unwrap() + .status() +} + +/// A delegation token from `issuer` for `holder`, and a proof naming `htu`. +fn delegated(issuer: &Key, holder: &Key, htu: &str, jti: &str) -> Vec<(&'static str, String)> { + let at = now(); + let tok = token(issuer, holder, at, serde_json::json!({"exp": at + 300})); + let pf = proof_at(holder, at, "POST", htu, jti); + vec![("authorization", format!("DPoP {tok}")), (PROOF_HEADER, pf)] +} + +/// THE FEATURE. A proof addressed to the second configured origin is accepted on the +/// OpenAI door. Red before the fix: the door verified the proof against one origin. +#[tokio::test] +async fn a_proof_addressed_to_a_second_configured_origin_is_accepted_on_the_openai_door() { + let (st, issuer, holder) = gateway(&[ORIGIN, INTERNAL]); + let h = delegated( + &issuer, + &holder, + &format!("{INTERNAL}/v1/chat/completions"), + "origins-internal-1", + ); + assert_eq!( + post(st, h).await, + StatusCode::OK, + "a proof addressed to a configured origin was refused" + ); +} + +/// The first origin spelled with its default port names the same origin. Red before +/// the fix: the proof's htu and the configured origin were compared as bare strings. +#[tokio::test] +async fn the_first_origin_spelled_with_its_default_port_is_the_same_origin() { + let (st, issuer, holder) = gateway(&[ORIGIN]); + let h = delegated( + &issuer, + &holder, + "https://tokenfuse.acme.example:443/v1/chat/completions", + "origins-default-port-1", + ); + assert_eq!(post(st, h).await, StatusCode::OK); +} + +/// A Host header naming a configured origin admits no htu that is not one. +#[tokio::test] +async fn a_host_header_naming_a_configured_origin_does_not_admit_an_htu_that_is_not_one() { + let (st, issuer, holder) = gateway(&[ORIGIN, INTERNAL]); + let mut h = delegated( + &issuer, + &holder, + "https://evil.example/v1/chat/completions", + "origins-host-1", + ); + h.push(("host", "tokenfuse-gateway:4100".to_string())); + assert_eq!(post(st, h).await, StatusCode::UNAUTHORIZED); +} + +/// An origin nobody listed is refused. +#[tokio::test] +async fn a_proof_naming_an_unlisted_origin_is_refused() { + let (st, issuer, holder) = gateway(&[ORIGIN, INTERNAL]); + let h = delegated( + &issuer, + &holder, + "https://other.example/v1/chat/completions", + "origins-unlisted-1", + ); + assert_eq!(post(st, h).await, StatusCode::UNAUTHORIZED); +} + +/// User information in the htu is refused. +#[tokio::test] +async fn a_proof_with_user_information_in_its_htu_is_refused() { + let (st, issuer, holder) = gateway(&[ORIGIN]); + let h = delegated( + &issuer, + &holder, + "https://user@tokenfuse.acme.example/v1/chat/completions", + "origins-userinfo-1", + ); + assert_eq!(post(st, h).await, StatusCode::UNAUTHORIZED); +} + +/// A fragment is refused. Red before the fix: the verifier trimmed it and admitted. +#[tokio::test] +async fn a_proof_with_a_fragment_in_its_htu_is_refused() { + let (st, issuer, holder) = gateway(&[ORIGIN]); + let h = delegated( + &issuer, + &holder, + &format!("{ORIGIN}/v1/chat/completions#frag"), + "origins-fragment-1", + ); + assert_eq!(post(st, h).await, StatusCode::UNAUTHORIZED); +} + +/// A configured origin with a path in front of the route is refused. +#[tokio::test] +async fn a_configured_origin_at_a_path_in_front_of_the_route_is_refused() { + let (st, issuer, holder) = gateway(&[ORIGIN, INTERNAL]); + let h = delegated( + &issuer, + &holder, + &format!("{INTERNAL}/prefix/v1/chat/completions"), + "origins-prefix-1", + ); + assert_eq!(post(st, h).await, StatusCode::UNAUTHORIZED); +} + +/// The agent-token door takes a proof addressed to the second configured origin. +#[tokio::test] +async fn an_agent_token_proof_addressed_to_a_second_configured_origin_is_accepted() { + let (issuer, holder) = (Key::new(), Key::new()); + let proving = Proving { + cfg: cfg(&issuer), + origins: vec![ORIGIN.to_string(), INTERNAL.to_string()], + }; + let st = common::app_with_wire(Wire::OpenAi) + .with_chain_proof(Some(Arc::new(proving)), None) + .with_agent_tokens(Some(Arc::new(AgentTokens::new(Mode::Accept)))); + let at = now(); + let tok = agent_token(&issuer, &holder, at, serde_json::json!({})); + let pf = proof_for( + &holder, + at, + "POST", + &format!("{INTERNAL}/v1/chat/completions"), + "origins-agent-1", + &tok, + ); + let h = vec![("authorization", format!("DPoP {tok}")), (PROOF_HEADER, pf)]; + assert_eq!( + post(st, h).await, + StatusCode::OK, + "an agent proof addressed to a configured origin was refused" + ); +} diff --git a/crates/gateway/tests/it/main.rs b/crates/gateway/tests/it/main.rs index 9aa40195..afb78a45 100644 --- a/crates/gateway/tests/it/main.rs +++ b/crates/gateway/tests/it/main.rs @@ -73,6 +73,7 @@ mod send_failure_retention; mod shadow_tool_pruning; mod streamed_tool_calls_are_judged; +mod delegation_origins; mod spend_reports_survive_a_stop; mod static_key_allow_list; mod static_key_allow_list_startup; diff --git a/crates/gateway/tests/it/mcp_agent_token.rs b/crates/gateway/tests/it/mcp_agent_token.rs index b7dcfc1a..758d72ce 100644 --- a/crates/gateway/tests/it/mcp_agent_token.rs +++ b/crates/gateway/tests/it/mcp_agent_token.rs @@ -94,7 +94,7 @@ impl Broker { let state = Arc::new(BrokerState { chain_proof: Some(Arc::new(Proving { cfg: cfg(&self.issuer), - origin: BROKER_ORIGIN.to_string(), + origins: vec![BROKER_ORIGIN.to_string()], })), revocations: None, identity_strict: tokenfuse_gateway::identitymap::StrictMode::Off, diff --git a/crates/gateway/tests/it/mcp_broker.rs b/crates/gateway/tests/it/mcp_broker.rs index 0c05332a..a0ee155a 100644 --- a/crates/gateway/tests/it/mcp_broker.rs +++ b/crates/gateway/tests/it/mcp_broker.rs @@ -2050,7 +2050,7 @@ fn broker_proving_recording( Arc::get_mut(&mut st).unwrap().chain_proof = Some(Arc::new(tokenfuse_gateway::chainproof::Proving { cfg: cfg(&issuer), - origin, + origins: vec![origin], })); (app(st), issuer, holder) } diff --git a/crates/gateway/tests/it/mcp_door.rs b/crates/gateway/tests/it/mcp_door.rs index a784aad2..0bb21f03 100644 --- a/crates/gateway/tests/it/mcp_door.rs +++ b/crates/gateway/tests/it/mcp_door.rs @@ -446,3 +446,131 @@ fn the_documents_are_read_from_a_file_when_the_spec_is_a_path() { fn a_path_that_cannot_be_read_refuses_rather_than_reading_as_off() { assert!(ClientRegistry::from_spec("/no/such/file/clients.json", ORIGIN).is_err()); } + +// --- a LIST of public origins (CLAUDE.md invariant 138) -------------------- + +/// A second address this broker is reached at: the one its own container network +/// calls, which is not the public name the first origin is. +const SECOND_ORIGIN: &str = "http://tokenfuse-gateway:4100"; + +/// A registry for `holder` whose origins are `origins`, read as the comma-separated +/// list `TOKENFUSE_MCP_PROOF_URL` and `TOKENFUSE_MCP_PROOF_ORIGINS` are joined into. +fn registry_at(holder: &Key, origins: &str) -> ClientRegistry { + let docs = serde_json::json!([document(CLIENT, holder)]).to_string(); + ClientRegistry::from_spec(&docs, origins).expect("a usable client spec and origin list") +} + +/// A proof of possession by `holder` naming `htu`, made at `NOW`. +fn proof_naming(holder: &Key, htu: &str, jti: &str) -> String { + holder.sign( + serde_json::json!({"typ": "dpop+jwt", "alg": "ES256", "jwk": holder.jwk()}), + serde_json::json!({"htm": "POST", "htu": htu, "iat": NOW, "jti": jti}), + ) +} + +/// A proof addressed to a configured origin other than the first opens the door, with +/// the identity of the client that published its key. Red before the fix: the broker +/// compared every proof against one origin, and the list was one string. +#[test] +fn a_proof_addressed_to_a_second_configured_origin_opens_the_door() { + let (holder, k) = (Key::new(), keys("")); + let c = registry_at(&holder, &format!("{ORIGIN},{SECOND_ORIGIN}")); + let proof = proof_naming(&holder, &format!("{SECOND_ORIGIN}{PATH}"), "list-second-1"); + match admit(door(&k, &c, false), None, Some(&proof), PATH, NOW) { + Admission::Proof(client_id) => assert_eq!(client_id, CLIENT), + other => panic!("a proof for a configured origin must open the door: {other:?}"), + } +} + +/// The first origin still works beside a list (red today for the same reason as above). +#[test] +fn a_proof_addressed_to_the_first_origin_still_opens_the_door_with_a_list() { + let (holder, k) = (Key::new(), keys("")); + let c = registry_at(&holder, &format!("{ORIGIN},{SECOND_ORIGIN}")); + let proof = proof_naming(&holder, &format!("{ORIGIN}{PATH}"), "list-first-1"); + assert!(matches!( + admit(door(&k, &c, false), None, Some(&proof), PATH, NOW), + Admission::Proof(_) + )); +} + +/// An origin spelled with its default port is the same origin. +#[test] +fn the_first_origin_spelled_with_its_default_port_is_the_same_origin() { + let (holder, k) = (Key::new(), keys("")); + let c = registry_at(&holder, ORIGIN); + let proof = proof_naming(&holder, "https://mcp.acme.example:443/mcp", "port-1"); + assert!(matches!( + admit(door(&k, &c, false), None, Some(&proof), PATH, NOW), + Admission::Proof(_) + )); +} + +/// An origin nobody listed is refused, whatever path it names. +#[test] +fn a_proof_naming_an_unlisted_origin_is_refused() { + let (holder, k) = (Key::new(), keys("")); + let c = registry_at(&holder, &format!("{ORIGIN},{SECOND_ORIGIN}")); + let proof = proof_naming(&holder, "https://evil.example/mcp", "unlisted-1"); + assert_eq!( + admit(door(&k, &c, false), None, Some(&proof), PATH, NOW), + Admission::Refused(DoorRefusal::BadProof) + ); +} + +/// User information in the htu is refused, even when the host after it is listed. +#[test] +fn a_proof_with_user_information_in_its_htu_is_refused() { + let (holder, k) = (Key::new(), keys("")); + let c = registry_at(&holder, ORIGIN); + let proof = proof_naming(&holder, "https://user@mcp.acme.example/mcp", "userinfo-1"); + assert_eq!( + admit(door(&k, &c, false), None, Some(&proof), PATH, NOW), + Admission::Refused(DoorRefusal::BadProof) + ); +} + +/// A fragment is never part of the URI a request was made to. Red before the fix: the +/// verifier trimmed everything after the `#` and admitted the proof. +#[test] +fn a_proof_with_a_fragment_in_its_htu_is_refused() { + let (holder, k) = (Key::new(), keys("")); + let c = registry_at(&holder, ORIGIN); + let proof = proof_naming(&holder, &format!("{ORIGIN}{PATH}#frag"), "fragment-1"); + assert_eq!( + admit(door(&k, &c, false), None, Some(&proof), PATH, NOW), + Admission::Refused(DoorRefusal::BadProof) + ); +} + +/// A configured origin at a path other than the route's is refused. +#[test] +fn a_configured_origin_at_another_path_is_refused() { + let (holder, k) = (Key::new(), keys("")); + let c = registry_at(&holder, &format!("{ORIGIN},{SECOND_ORIGIN}")); + let proof = proof_naming(&holder, &format!("{SECOND_ORIGIN}{PATH}/extra"), "path-1"); + assert_eq!( + admit(door(&k, &c, false), None, Some(&proof), PATH, NOW), + Admission::Refused(DoorRefusal::BadProof) + ); +} + +/// A list with one entry that is not an origin refuses to start, naming nothing but +/// the list: a trailing comma is an empty entry, not a shorter list. +#[test] +fn a_list_with_a_bad_entry_refuses_to_start_at_the_mcp_door() { + let holder = Key::new(); + let docs = serde_json::json!([document(CLIENT, &holder)]).to_string(); + for bad in [ + format!("{ORIGIN},"), + format!("{ORIGIN}/mcp"), + "https://user@mcp.acme.example".to_string(), + "http://tokenfuse-gateway:99999".to_string(), + "mcp.acme.example".to_string(), + ] { + assert!( + ClientRegistry::from_spec(&docs, &bad).is_err(), + "{bad:?} must refuse the whole list" + ); + } +} diff --git a/crates/gateway/tests/it/protected_resource_metadata.rs b/crates/gateway/tests/it/protected_resource_metadata.rs index 91d4fa59..dc5cbef6 100644 --- a/crates/gateway/tests/it/protected_resource_metadata.rs +++ b/crates/gateway/tests/it/protected_resource_metadata.rs @@ -65,7 +65,7 @@ impl Door { let state = Arc::new(BrokerState { chain_proof: Some(Arc::new(Proving { cfg: delegation, - origin: BROKER_ORIGIN.to_string(), + origins: vec![BROKER_ORIGIN.to_string()], })), revocations: None, identity_strict: tokenfuse_gateway::identitymap::StrictMode::Off, diff --git a/crates/gateway/tests/it/static_key_allow_list.rs b/crates/gateway/tests/it/static_key_allow_list.rs index 3d7c804a..443dba9c 100644 --- a/crates/gateway/tests/it/static_key_allow_list.rs +++ b/crates/gateway/tests/it/static_key_allow_list.rs @@ -66,7 +66,7 @@ fn gate(mode: Mode, listed: &[&str]) -> Gate { .with_chain_proof( Some(Arc::new(Proving { cfg: cfg(&issuer), - origin: ORIGIN.to_string(), + origins: vec![ORIGIN.to_string()], })), None, ) diff --git a/crates/gateway/tests/it/wardryx.rs b/crates/gateway/tests/it/wardryx.rs index 72b1c2ed..286cea0c 100644 --- a/crates/gateway/tests/it/wardryx.rs +++ b/crates/gateway/tests/it/wardryx.rs @@ -947,7 +947,7 @@ async fn a_proven_chain_reaches_the_pdp_from_the_token() { cfg: cfg(&issuer), // The origin the fixture's own URL names, so the two agree by // construction rather than by a string retyped in two places. - origin: URL.trim_end_matches("/v1/messages").to_string(), + origins: vec![URL.trim_end_matches("/v1/messages").to_string()], })); let now = tokenfuse_gateway::sink::now_millis() / 1000; @@ -1182,7 +1182,7 @@ async fn a_proven_delegation_is_attested_as_dpop_key_whatever_the_header_says() let holder = Key::new(); st.chain_proof = Some(Arc::new(tokenfuse_gateway::chainproof::Proving { cfg: cfg(&issuer), - origin: URL.trim_end_matches("/v1/messages").to_string(), + origins: vec![URL.trim_end_matches("/v1/messages").to_string()], })); let now = tokenfuse_gateway::sink::now_millis() / 1000; let tok = token( diff --git a/features/a-proof-names-one-of-the-public-origins.feature b/features/a-proof-names-one-of-the-public-origins.feature new file mode 100644 index 00000000..e5cff160 --- /dev/null +++ b/features/a-proof-names-one-of-the-public-origins.feature @@ -0,0 +1,73 @@ +Feature: A delegation proof names one of the public origins the operator configured + + A gateway is reached at a public name by its clients and at another name by the + containers beside it. The operator names each origin this process is reached at, + and a proof is accepted when the address it names is one of them at the route's + own path. The address the caller says it reached is never read from a Host header. + + # @test:a_proof_addressed_to_a_second_configured_origin_is_accepted_on_the_openai_door + Scenario: A proof addressed to the second configured origin is accepted + Given the operator configured the public origin and the internal origin of the gateway + When an agent presents a delegation proof addressed to the internal origin for a chat completion + Then the call is answered 200 + + # @test:the_first_origin_spelled_with_its_default_port_is_the_same_origin + Scenario: The same origin spelled with its default port is the same origin + Given the operator configured the public origin of the gateway + When an agent presents a delegation proof addressed to that origin with its default port written out + Then the call is answered 200 + + # @test:a_host_header_naming_a_configured_origin_does_not_admit_an_htu_that_is_not_one + Scenario: A Host header naming a configured origin admits nothing the proof does not name + Given the operator configured the public origin and the internal origin of the gateway + When an agent presents a proof addressed to an unlisted origin and a Host header naming a listed one + Then the call is answered 401 + + # @test:a_proof_naming_an_unlisted_origin_is_refused + Scenario: A proof addressed to an origin nobody listed is refused + Given the operator configured the public origin and the internal origin of the gateway + When an agent presents a proof addressed to an origin the operator did not list + Then the call is answered 401 + + # @test:a_proof_with_user_information_in_its_htu_is_refused + Scenario: A proof whose address carries user information is refused + Given the operator configured the public origin of the gateway + When an agent presents a proof whose address carries user information before the host + Then the call is answered 401 + + # @test:a_proof_with_a_fragment_in_its_htu_is_refused + Scenario: A proof whose address carries a fragment is refused + Given the operator configured the public origin of the gateway + When an agent presents a proof whose address ends in a fragment + Then the call is answered 401 + + # @test:a_configured_origin_at_a_path_in_front_of_the_route_is_refused + Scenario: A configured origin with a path in front of the route is refused + Given the operator configured the public origin and the internal origin of the gateway + When an agent presents a proof addressed to the internal origin with a path before the route + Then the call is answered 401 + + # @test:an_agent_token_proof_addressed_to_a_second_configured_origin_is_accepted + Scenario: An agent token's proof addressed to the second configured origin is accepted + Given the operator configured the public origin and the internal origin of the gateway + And the agent token door is on in accept mode + When an agent presents its own token with a proof addressed to the internal origin + Then the call is answered 200 + + # @test:a_proof_addressed_to_a_second_configured_origin_opens_the_door + Scenario: A proof addressed to a second configured origin opens the MCP broker's door + Given the operator configured the public origin and the internal origin of the broker + When a client presents a proof of possession addressed to the internal origin + Then the broker admits the client + + # @test:a_list_with_a_bad_entry_refuses_to_start_at_the_mcp_door + Scenario: A list with an entry that is not an origin refuses to start the broker + Given the operator's origin list has a trailing comma, a path, user information or a bad port in it + When the broker reads the list + Then it refuses to start + + # @test:the_single_origin_and_the_list_are_one_set_and_a_bad_entry_names_its_variable + Scenario: The single origin and the list are read as one set + Given the operator set the single origin and a list that repeats it + When the gateway reads its delegation origins + Then the origin is named once, and an empty entry names the list's variable From e955c94df3b85371d6cd0b3efbd9ad48c0ed9e4a Mon Sep 17 00:00:00 2001 From: Yurii Kostiuk Date: Mon, 19 Oct 2026 20:41:52 +0100 Subject: [PATCH 2/6] feat(delegation): the proof doors take a list of public origins A gateway reached at more than one address (a public name and an internal one) now accepts a delegation or broker proof addressed to any configured origin at the route's path (CLAUDE.md invariant 141). - TOKENFUSE_DELEGATION_ORIGINS and TOKENFUSE_MCP_PROOF_ORIGINS take a comma-separated list, read beside the single-origin variables. Each entry is normalised as wardryx's normalizeOrigin does and must be scheme://host[:port] with nothing after the port; a bad entry or an empty list refuses to start, naming its variable. - dpop::origin::expected_url chooses the configured origin the proof names, reading the htu before the signature. The verifier then checks the signature over that same claim, so nothing can change it after the choice. Never a Host header. - dpop::verify_proof refuses a fragment in the htu (RFC 9449 section 4.3). This changes every door, not only the new list. - The delegation door, the agent-token door and the MCP broker's proof door use the same rule. The Proving struct carries origins: Vec. Release notes under docs/releases/UNRELEASED.md, invariant 141 in CLAUDE.md, the README paragraph and the docs/25 table row. Co-Authored-By: Claude Haiku 5.5 --- CLAUDE.md | 2 + README.md | 2 + components.json | 3 + crates/dpop/src/lib.rs | 5 + crates/dpop/src/origin.rs | 173 +++++++++++++++--- crates/gateway/src/agenttoken.rs | 11 +- crates/gateway/src/chainproof.rs | 67 ++++--- crates/gateway/src/main.rs | 13 +- crates/gateway/src/mcpdoor.rs | 29 +-- crates/gateway/tests/it/delegation_origins.rs | 2 +- crates/gateway/tests/it/mcp_door.rs | 2 +- docs/25-the-delegation-door.md | 1 + docs/releases/UNRELEASED.md | 17 ++ 13 files changed, 253 insertions(+), 74 deletions(-) create mode 100644 docs/releases/UNRELEASED.md diff --git a/CLAUDE.md b/CLAUDE.md index 3799b1a5..62026c5a 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, `@claude` 2026-10-19, under delegated authority, open to reversal: 4 KiB, matching `on_behalf_of`'s header cap; the member is omitted rather than sent as null, so a PDP that predates the field sees the body it always saw. (test: `agenttoken::tests::a_token_for_the_policy_plane_is_only_an_agent_token`, `a_token_for_the_policy_plane_is_bounded_at_4_kib`, `a_proof_for_the_policy_plane_is_the_dpop_header_within_the_cap`; `wardryx::tests::the_wire_body_carries_the_agent_token_only_when_the_call_had_one`; `tests/it/static_key_allow_list.rs`: `the_policy_plane_is_sent_the_agent_token_when_a_token_admitted_the_call`, `a_static_key_call_is_never_sent_an_agent_token`, `an_oversized_agent_token_is_admitted_but_not_forwarded`; `tests/it/mcp_agent_token.rs`: `the_broker_sends_the_agent_token_to_the_pdp`, `the_broker_sends_no_agent_token_for_a_static_key_call`. Scenarios: `features/the-policy-plane-is-given-the-agent-token.feature`.) +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`.)* + ## 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/README.md b/README.md index 2f36df16..dc05285f 100644 --- a/README.md +++ b/README.md @@ -603,6 +603,8 @@ Since 2026-08-06, a non-loopback bind with nothing configured on the broker's do There are two ways to put something on that door, and the second is newer and stronger. `TOKENFUSE_MCP_KEYS` is a shared secret in a header: whoever captures it holds it. `TOKENFUSE_MCP_CLIENT_IDS` instead names client metadata documents in the shape [CIMD](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/) defines, each published by a client at its own https `client_id` URL and naming that client's public keys; a caller then proves possession of one of those keys per request with an [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449) DPoP proof, single-use. Nothing an operator holds is worth stealing, and a captured header is worth nothing after a minute. Both doors can be configured at once while clients move across, and `TOKENFUSE_MCP_REQUIRE_PROOF=1` is how that ends: [docs/24](docs/24-mcp-proof-door.md). +The proof door takes one public origin in `TOKENFUSE_MCP_PROOF_URL`, or a comma-separated list in `TOKENFUSE_MCP_PROOF_ORIGINS` when the broker is reached at more than one address, for example a public name and an internal one. The delegation door takes the same pair, `TOKENFUSE_DELEGATION_URL` and `TOKENFUSE_DELEGATION_ORIGINS`. Each entry is an origin written `scheme://host[:port]` with nothing after the port, and a proof is accepted when its `htu` is one of them at the route's path (CLAUDE.md invariant 141). The `Host` header is never read, and an entry that is not an origin stops the process at startup. + A third, experimental since 2026-09-24: an access token that [vouchryx](https://github.com/TAIPANBOX/vouchryx) issued through Cross App Access, where an enterprise identity provider's assertion about a person (an ID-JAG, `draft-ietf-oauth-identity-assertion-authz-grant-04`) is redeemed for a five-minute token scoped to this broker, presented as `Authorization: Bearer`. Set `TOKENFUSE_MCP_ACCEPT_XAA=on` and `TOKENFUSE_MCP_RESOURCE` (this broker's own resource URL, https, which the token's audience must equal exactly), with `TOKENFUSE_DELEGATION_ISSUER` and `TOKENFUSE_DELEGATION_JWKS` naming vouchryx and `TOKENFUSE_DELEGATION_URL` the broker's public origin (the delegation door in the same process requires it). The broker then publishes [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) metadata at `/.well-known/oauth-protected-resource`, every refusal points at it in `WWW-Authenticate`, a call is attributed to the agent the token names with the person as the root of its chain, and a token for another resource, a token bound to a key, a delegation token presented as bearer, or a token naming a revoked person or agent is refused. With XAA on, a caller presenting no credential is refused even when no other door is configured. The grant is an IETF draft, so both settings are experimental in `compat/1.0.json`; CLAUDE.md invariant 62 holds the rules. --- diff --git a/components.json b/components.json index 8fb18d38..26b1db9c 100644 --- a/components.json +++ b/components.json @@ -223,6 +223,9 @@ "TOKENFUSE_MCP_LOCK": { "required": false }, + "TOKENFUSE_MCP_PROOF_ORIGINS": { + "required": false + }, "TOKENFUSE_MCP_PROOF_URL": { "required": false }, diff --git a/crates/dpop/src/lib.rs b/crates/dpop/src/lib.rs index e62a5aef..b1eb8011 100644 --- a/crates/dpop/src/lib.rs +++ b/crates/dpop/src/lib.rs @@ -284,6 +284,11 @@ pub fn verify_proof( .get("htu") .and_then(|v| v.as_str()) .unwrap_or_default(); + // RFC 9449 section 4.3: `htu` carries no fragment. Trimming one away would admit a proof + // for a URI this request was never made to. + if htu.contains('#') { + return Err(ProofRefusal::WrongRequest); + } if trim_query(htu) != trim_query(url) { return Err(ProofRefusal::WrongRequest); } diff --git a/crates/dpop/src/origin.rs b/crates/dpop/src/origin.rs index 9540936f..817b4da3 100644 --- a/crates/dpop/src/origin.rs +++ b/crates/dpop/src/origin.rs @@ -1,4 +1,4 @@ -//! The public origins a DPoP proof's `htu` may name (CLAUDE.md invariant 138). +//! The public origins a DPoP proof's `htu` may name (CLAUDE.md invariant 141). //! //! A gateway is reached at one address by its clients and at another by the //! containers beside it, so one configured origin cannot describe every caller. @@ -11,6 +11,8 @@ //! list (`WARDRYX_AGENT_TOKEN_DOOR_ORIGINS`): scheme and host in lower case, the //! default port written out, and nothing after the port but an optional `/`. +use base64::Engine as _; + /// Why an origin was refused. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OriginRefusal { @@ -20,51 +22,170 @@ pub enum OriginRefusal { NotAnOrigin, } -/// Normalise one configured origin written `scheme://host[:port]`. +/// Normalise one configured origin written `scheme://host[:port]`, with nothing after the +/// port but an optional `/`. /// -/// STUB for the red-first commit: returns the trimmed input unchanged, which is -/// what the code did before the list existed. +/// The result is `scheme://host:port`, with the scheme and host in lower case and the +/// default port (443 for https, 80 for http) written out, so two spellings of one origin +/// compare equal. This is wardryx's `normalizeOrigin` (`internal/agenttoken/verifier.go`): +/// the same inputs are accepted and refused. A port of zero is refused too, since nothing +/// is served on it. pub fn normalize_origin(raw: &str) -> Result { - Ok(raw.trim().to_string()) + let raw = raw.trim(); + let (scheme, rest) = raw.split_once("://").ok_or(OriginRefusal::NotAnOrigin)?; + let (authority, tail) = match rest.find(['/', '?', '#']) { + Some(i) => rest.split_at(i), + None => (rest, ""), + }; + if !(tail.is_empty() || tail == "/") || authority.contains('@') { + return Err(OriginRefusal::NotAnOrigin); + } + let scheme = scheme.to_ascii_lowercase(); + let default_port = match scheme.as_str() { + "https" => 443, + "http" => 80, + _ => return Err(OriginRefusal::NotAnOrigin), + }; + let (host, port) = split_host_port(authority)?; + let host = if host.contains(':') { + format!("[{host}]") + } else { + host + }; + Ok(format!( + "{scheme}://{host}:{}", + port.unwrap_or(default_port) + )) } -/// The normalised origin and the path of an absolute `htu`, query removed. +/// The host (lower case, brackets removed) and the explicit port of an authority. +fn split_host_port(authority: &str) -> Result<(String, Option), OriginRefusal> { + let (host, port) = if let Some(inner) = authority.strip_prefix('[') { + let end = inner.find(']').ok_or(OriginRefusal::NotAnOrigin)?; + let host = &inner[..end]; + let after = &inner[end + 1..]; + let port = match after { + "" => None, + _ => Some(after.strip_prefix(':').ok_or(OriginRefusal::NotAnOrigin)?), + }; + if host.is_empty() + || !host + .chars() + .all(|c| c.is_ascii_hexdigit() || c == ':' || c == '.') + { + return Err(OriginRefusal::NotAnOrigin); + } + (host, port) + } else { + let (host, port) = match authority.split_once(':') { + Some((h, p)) => (h, Some(p)), + None => (authority, None), + }; + if host.is_empty() + || !host + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_')) + { + return Err(OriginRefusal::NotAnOrigin); + } + (host, port) + }; + let port = match port { + None => None, + Some(p) => { + if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) { + return Err(OriginRefusal::NotAnOrigin); + } + match p.parse::() { + Ok(n) if n != 0 => Some(n), + _ => return Err(OriginRefusal::NotAnOrigin), + } + } + }; + Ok((host.to_ascii_lowercase(), port)) +} + +/// The normalised origin and the path of an absolute `htu`, its query removed. /// -/// STUB for the red-first commit: always `None`. -pub fn origin_and_path(_htu: &str) -> Option<(String, String)> { - None +/// `None` for a fragment, user information, a scheme other than http or https, or no host. +/// RFC 9449 section 4.3: an `htu` names a URI without its query and fragment, and a proof +/// whose `htu` names one of those is not for this request. +pub fn origin_and_path(htu: &str) -> Option<(String, String)> { + if htu.contains('#') { + return None; + } + let bare = htu.split('?').next().unwrap_or_default(); + let (scheme, rest) = bare.split_once("://")?; + let cut = rest.find('/').unwrap_or(rest.len()); + let (authority, path) = rest.split_at(cut); + let origin = normalize_origin(&format!("{scheme}://{authority}")).ok()?; + let path = if path.is_empty() { "/" } else { path }; + Some((origin, path.to_string())) } /// The `htu` claim of a compact JWS payload, read WITHOUT checking the signature. /// -/// STUB for the red-first commit: always `None`. -pub fn unverified_htu(_proof: &str) -> Option { - None +/// Used only to choose which configured origin a proof is verified against. The verifier +/// then checks the signature over the same claim, so a claim altered after the choice fails +/// there. +pub fn unverified_htu(proof: &str) -> Option { + let payload = proof.split('.').nth(1)?; + let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .ok()?; + let claims: serde_json::Value = serde_json::from_slice(&raw).ok()?; + claims.get("htu")?.as_str().map(str::to_string) } -/// The URL a proof must name for a request on `path`. +/// The URL a proof must name for a request on `path`, given the origins configured. +/// +/// When the proof's `htu`, read unverified, names one of `origins` (each normalised, as +/// configured or not) at exactly `path`, the `htu` as the proof wrote it is returned. The +/// verifier then checks the signature over that claim and compares it with itself, so the +/// signature is the only thing that decides. Otherwise the first configured origin plus +/// `path` is returned, which the verifier refuses, because the proof named something else. +/// Never a `Host` header. /// -/// STUB for the red-first commit: the first configured origin plus the path, -/// which is what the single-origin code computed. -pub fn expected_url(origins: &[String], _proof: Option<&str>, path: &str) -> String { - format!( +/// `origins` is never empty where this runs: both doors refuse to start without one. +pub fn expected_url(origins: &[String], proof: Option<&str>, path: &str) -> String { + let fallback = format!( "{}{}", origins.first().map(String::as_str).unwrap_or_default(), path - ) + ); + let Some(htu) = proof.and_then(unverified_htu) else { + return fallback; + }; + match origin_and_path(&htu) { + Some((origin, htu_path)) + if htu_path == path + && origins + .iter() + .any(|o| normalize_origin(o).is_ok_and(|n| n == origin)) => + { + htu + } + _ => fallback, + } } /// Parse a comma-separated list of origins, normalising each entry. /// -/// STUB for the red-first commit: the whole value as one entry, trailing slash -/// removed, which is what `TOKENFUSE_MCP_PROOF_URL` alone meant. +/// A blank list is an empty list. An empty entry is refused, not skipped: a trailing comma +/// is a typo, and a typo in an allow-list must not read as a shorter one. The error is the +/// entry that was refused, as written. pub fn parse_origin_list(list: &str) -> Result, String> { - let list = list.trim(); - Ok(if list.is_empty() { - Vec::new() - } else { - vec![list.trim_end_matches('/').to_string()] - }) + let mut origins: Vec = Vec::new(); + if list.trim().is_empty() { + return Ok(origins); + } + for entry in list.split(',') { + let origin = normalize_origin(entry).map_err(|_| entry.trim().to_string())?; + if !origins.contains(&origin) { + origins.push(origin); + } + } + Ok(origins) } #[cfg(test)] diff --git a/crates/gateway/src/agenttoken.rs b/crates/gateway/src/agenttoken.rs index c21915e4..927d7f4d 100644 --- a/crates/gateway/src/agenttoken.rs +++ b/crates/gateway/src/agenttoken.rs @@ -343,16 +343,7 @@ pub fn admit( .and_then(|v| v.to_str().ok()); // The origin is the deployment's, the path the route's: never a `Host` // header, which the caller writes (invariant 30's `htu` rule). - let url = format!( - "{}{}", - proving - .origins - .first() - .map(String::as_str) - .unwrap_or_default() - .trim_end_matches('/'), - path - ); + let url = proving.expected_url(proof, path); let agent = match tokenfuse_delegation::verify_agent_token( &proving.cfg, token, diff --git a/crates/gateway/src/chainproof.rs b/crates/gateway/src/chainproof.rs index f91ab9fd..7323b49c 100644 --- a/crates/gateway/src/chainproof.rs +++ b/crates/gateway/src/chainproof.rs @@ -111,6 +111,14 @@ pub struct Proving { pub origins: Vec, } +impl Proving { + /// The URL a proof on this request must name: one configured origin plus `path`, see + /// [`tokenfuse_dpop::origin::expected_url`]. Never a `Host` header. + pub fn expected_url(&self, proof: Option<&str>, path: &str) -> String { + tokenfuse_dpop::origin::expected_url(&self.origins, proof, path) + } +} + pub type ChainProof = Option>; /// What one request's credentials resolved to. @@ -183,16 +191,7 @@ pub fn resolve( return Chain::Claimed(declared.to_vec()); }; - let url = format!( - "{}{}", - proving - .origins - .first() - .map(String::as_str) - .unwrap_or_default() - .trim_end_matches('/'), - url - ); + let url = proving.expected_url(proof, url); let verified = match verify_delegation(&proving.cfg, token, proof, method, &url, now, revoked) { Ok(v) => v, // Every refusal is the same answer on the wire, for the reason @@ -459,14 +458,6 @@ pub async fn from_env(client: &reqwest::Client) -> ChainProof { }, }; let origin = std::env::var("TOKENFUSE_DELEGATION_URL").unwrap_or_default(); - if origin.trim().is_empty() { - eprintln!( - "tokenfuse: TOKENFUSE_DELEGATION_URL must name the absolute origin this \ - gateway is reached at, so a proof's `htu` has something to be compared \ - against. Without it the binding to one request is decoration." - ); - std::process::exit(2); - } let origins = match origins_from_values( &origin, &std::env::var("TOKENFUSE_DELEGATION_ORIGINS").unwrap_or_default(), @@ -486,11 +477,39 @@ pub async fn from_env(client: &reqwest::Client) -> ChainProof { })) } -/// The origins a `Proving` is configured with, from the single origin and the list. -/// -/// STUB for the red-first commit: the single origin only, unnormalised. -pub(crate) fn origins_from_values(single: &str, _list: &str) -> Result, String> { - Ok(vec![single.to_string()]) +/// The origins a `Proving` is configured with: the single origin and the list, each +/// normalised, the union kept in order with duplicates dropped. A value that is not an origin +/// names the variable it came from, and no origin at all is refused by name. +pub(crate) fn origins_from_values(single: &str, list: &str) -> Result, String> { + let mut origins: Vec = Vec::new(); + if !single.trim().is_empty() { + let one = tokenfuse_dpop::origin::normalize_origin(single) + .map_err(|_| origin_refusal("TOKENFUSE_DELEGATION_URL", single))?; + origins.push(one); + } + let from_list = tokenfuse_dpop::origin::parse_origin_list(list) + .map_err(|entry| origin_refusal("TOKENFUSE_DELEGATION_ORIGINS", &entry))?; + for origin in from_list { + if !origins.contains(&origin) { + origins.push(origin); + } + } + if origins.is_empty() { + return Err( + "TOKENFUSE_DELEGATION_URL or TOKENFUSE_DELEGATION_ORIGINS must name the absolute \ + origin this gateway is reached at, so a proof's `htu` has something to be compared \ + against. Without one the binding to one request is decoration." + .to_string(), + ); + } + Ok(origins) +} + +fn origin_refusal(var: &str, raw: &str) -> String { + format!( + "{var}: {raw:?} is not an origin: write scheme://host[:port] with nothing after the port, \ + for example https://tokenfuse.acme.example" + ) } #[cfg(test)] @@ -934,7 +953,7 @@ mod origin_list_tests { use super::*; /// The single origin and the list are read as one set, normalised, and a bad - /// entry names the variable it came from (CLAUDE.md invariant 138). + /// entry names the variable it came from (CLAUDE.md invariant 141). #[test] fn the_single_origin_and_the_list_are_one_set_and_a_bad_entry_names_its_variable() { assert_eq!( diff --git a/crates/gateway/src/main.rs b/crates/gateway/src/main.rs index 415c9961..2ce4253f 100644 --- a/crates/gateway/src/main.rs +++ b/crates/gateway/src/main.rs @@ -523,9 +523,20 @@ async fn mcp_broker() { // reached: reading a typo as "not configured" leaves the door in whatever // state the OTHER variable happens to be in, at the moment an operator // believed they had just tightened it. + // The single origin and the list are one set (CLAUDE.md invariant 141): joined here, and + // every entry validated by `from_spec`. + let proof_origins = [ + std::env::var("TOKENFUSE_MCP_PROOF_URL").unwrap_or_default(), + std::env::var("TOKENFUSE_MCP_PROOF_ORIGINS").unwrap_or_default(), + ] + .into_iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect::>() + .join(","); let clients = match tokenfuse_gateway::mcpdoor::ClientRegistry::from_spec( &std::env::var("TOKENFUSE_MCP_CLIENT_IDS").unwrap_or_default(), - &std::env::var("TOKENFUSE_MCP_PROOF_URL").unwrap_or_default(), + &proof_origins, ) { Ok(clients) => clients, Err(e) => { diff --git a/crates/gateway/src/mcpdoor.rs b/crates/gateway/src/mcpdoor.rs index 36dd4415..49ee88e8 100644 --- a/crates/gateway/src/mcpdoor.rs +++ b/crates/gateway/src/mcpdoor.rs @@ -278,12 +278,17 @@ impl ClientRegistry { if trimmed.is_empty() { return Ok(Self::default()); } - let origin = origin.trim().trim_end_matches('/'); - if origin.is_empty() { + let origins = tokenfuse_dpop::origin::parse_origin_list(origin).map_err(|entry| { + ClientSpecError(format!( + "{entry:?} is not an origin: write scheme://host[:port] with nothing after the port, \ + in TOKENFUSE_MCP_PROOF_URL or TOKENFUSE_MCP_PROOF_ORIGINS" + )) + })?; + if origins.is_empty() { return Err(ClientSpecError( - "no TOKENFUSE_MCP_PROOF_URL is set, so a proof could not be checked against the \ - address clients actually call. Set it to the origin they address this broker at, \ - for example https://mcp.acme.example" + "no TOKENFUSE_MCP_PROOF_URL or TOKENFUSE_MCP_PROOF_ORIGINS is set, so a proof could \ + not be checked against the address clients actually call. Set one to the origin \ + they address this broker at, for example https://mcp.acme.example" .into(), )); } @@ -368,7 +373,7 @@ impl ClientRegistry { Ok(ClientRegistry { by_jkt, - origins: vec![origin.to_string()], + origins, replay: ReplayCache::new(REPLAY_CAP), }) } @@ -390,6 +395,12 @@ impl ClientRegistry { self.by_jkt.is_empty() } + /// The URL a proof must name for `path`: one configured origin plus the route, see + /// [`tokenfuse_dpop::origin::expected_url`]. Never a `Host` header. + fn expected_url(&self, proof: &str, path: &str) -> String { + tokenfuse_dpop::origin::expected_url(&self.origins, Some(proof), path) + } + /// The `client_id` this proof belongs to, or why it does not. /// /// The order is deliberate and is the difference between a replay cache and @@ -399,11 +410,7 @@ impl ClientRegistry { /// anything, which is what makes refusing at the cap a self-inflicted wound /// rather than something a stranger can do to a deployment. pub fn authenticate(&self, proof: &str, path: &str, now: i64) -> Result<&str, DoorRefusal> { - let expected = format!( - "{}{}", - self.origins.first().map(String::as_str).unwrap_or_default(), - path - ); + let expected = self.expected_url(proof, path); // POST because that is the only method this door ever sees: `mcpbroker::app` // routes `/` and `/mcp` with `post` and nothing else. let verified = diff --git a/crates/gateway/tests/it/delegation_origins.rs b/crates/gateway/tests/it/delegation_origins.rs index e883f209..ddf0e9ba 100644 --- a/crates/gateway/tests/it/delegation_origins.rs +++ b/crates/gateway/tests/it/delegation_origins.rs @@ -1,4 +1,4 @@ -//! The delegation door takes a LIST of public origins (CLAUDE.md invariant 138). +//! The delegation door takes a LIST of public origins (CLAUDE.md invariant 141). //! //! A gateway is reached at a public name by its clients and at another name by the //! containers beside it. A proof is accepted when its `htu` names one configured diff --git a/crates/gateway/tests/it/mcp_door.rs b/crates/gateway/tests/it/mcp_door.rs index 0bb21f03..724a3a39 100644 --- a/crates/gateway/tests/it/mcp_door.rs +++ b/crates/gateway/tests/it/mcp_door.rs @@ -447,7 +447,7 @@ fn a_path_that_cannot_be_read_refuses_rather_than_reading_as_off() { assert!(ClientRegistry::from_spec("/no/such/file/clients.json", ORIGIN).is_err()); } -// --- a LIST of public origins (CLAUDE.md invariant 138) -------------------- +// --- a LIST of public origins (CLAUDE.md invariant 141) -------------------- /// A second address this broker is reached at: the one its own container network /// calls, which is not the public name the first origin is. diff --git a/docs/25-the-delegation-door.md b/docs/25-the-delegation-door.md index 15820a58..c1f110e7 100644 --- a/docs/25-the-delegation-door.md +++ b/docs/25-the-delegation-door.md @@ -104,6 +104,7 @@ the door was on. That is the failure `firewall::from_env` and | `TOKENFUSE_DELEGATION_JWKS_INTERVAL_MS` | Poll interval for the URL form. Default 60000. | | `TOKENFUSE_DELEGATION_AUDIENCE` | The `aud` this deployment answers to. Empty accepts any, which is a real single-tenant choice and is distinct from unset. | | `TOKENFUSE_DELEGATION_URL` | The absolute origin this process is reached at, so a proof's `htu` has something to compare against. | +| `TOKENFUSE_DELEGATION_ORIGINS` | A comma-separated list of absolute origins, each normalised as wardryx does, added to the one above (CLAUDE.md invariant 141). | | `TOKENFUSE_DELEGATION_REVOCATIONS` | Absolute http(s) URL of the revocation list. Unset means no polling and no refusals. | | `TOKENFUSE_DELEGATION_REVOCATIONS_INTERVAL_MS` | Poll interval. Default 12000, a fifth of the maximum age, so four consecutive failures are needed before a miss stops being answered from the list. | | `TOKENFUSE_DELEGATION_REVOCATIONS_MAX_AGE_SECS` | How old a held list may be and still answer a miss. Default 60. Zero or negative means only a hit counts, which is a real policy and is not corrected. | diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md new file mode 100644 index 00000000..d2abb359 --- /dev/null +++ b/docs/releases/UNRELEASED.md @@ -0,0 +1,17 @@ +# Unreleased + +## Changed + +- The delegation door, the agent-token door and the MCP broker's proof door take a list of public origins. `TOKENFUSE_DELEGATION_ORIGINS` and `TOKENFUSE_MCP_PROOF_ORIGINS` are read beside the existing single-origin variables, and a proof is accepted when its `htu` names any of them at the route's path (CLAUDE.md invariant 141). +- A proof whose `htu` carries a fragment is refused at every door. RFC 9449 section 4.3 says an `htu` names a URI without one; the verifier used to trim it away. +- An origin is compared in the form wardryx's agent-token door uses: scheme and host in lower case, the default port written out. `https://host/path` and `https://HOST:443/path` name one origin. + +## Upgrade + +- A deployment that names one origin and sends proofs for it keeps working unchanged. +- A list with an entry that is not an origin, a trailing comma included, now refuses to start. Before, the broker read the whole value as one origin and refused every proof. + +## Not proven + +- The list is read at startup, so a change to it needs a restart. +- The rkyv advisory entry in `.cargo/audit.toml` is stale after the `rust_decimal` 1.43.0 lockfile bump on this branch and is left for a follow-up, because removing it changes teeth cases that CI has not run against the new config. From a8bf9857c253c8f5eea301c8b6f868467dbf32ba Mon Sep 17 00:00:00 2001 From: Yurii Kostiuk Date: Mon, 19 Oct 2026 20:42:01 +0100 Subject: [PATCH 3/6] chore(deps): rust_decimal 1.43.0 takes rkyv out of the cluster lockfile RUSTSEC-2026-0235 (rkyv 0.7.46, patched only in 0.8.17) reached crates/cluster/Cargo.lock through openraft, byte-unit and rust_decimal 1.42.1, which requires rkyv ^0.7.46 behind an optional feature. rust_decimal 1.43.0 drops that dependency, and byte-unit's requirement (^1.15) admits it. The bump is lockfile-only: `cargo update -p rust_decimal --precise 1.43.0` in crates/cluster removes rkyv, rkyv_derive and the crates only rkyv used, also adds rand 0.9.5 with rand_chacha and rand_core 0.9, which rust_decimal 1.43.0 names as an optional dependency in the manifests. The root Cargo.lock never contained rkyv. The ignore entry in .cargo/audit.toml now matches nothing. Its comment says so, and its removal is left for a follow-up, because the teeth cases that edit the entry must be re-pointed first and CI has not run them against the new config. Co-Authored-By: Claude Haiku 5.5 --- .cargo/audit.toml | 21 ++-- CLAUDE.md | 4 +- crates/cluster/Cargo.lock | 217 ++++++-------------------------------- 3 files changed, 42 insertions(+), 200 deletions(-) diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 8f094794..bb6ce4c1 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -9,20 +9,13 @@ [advisories] ignore = [ - # RUSTSEC-2026-0235, rkyv 0.7.46: out-of-bounds reads on archives containing - # Rc/Arc. Fixed in rkyv 0.8.17, which nothing here can move to. + # RUSTSEC-2026-0235, rkyv 0.7.46: out-of-bounds reads on archives containing Rc/Arc. + # Fixed in rkyv 0.8.17. The 0.7 series is no longer supported upstream and gets no fix. # - # The path is tokenfuse-gateway --features cluster -> tokenfuse-cluster -> - # openraft 0.9.24 -> byte-unit 5.2.5 -> rust_decimal 1.42.1 -> rkyv. In - # rust_decimal, rkyv is behind an OPTIONAL feature (`rkyv = ["dep:rkyv"]`) - # that nothing in this graph enables, so cargo records it in Cargo.lock and - # never compiles it. cargo-audit reads the lockfile, not the build, which is - # the right default and the reason this entry exists. - # - # rust_decimal 1.42.1 is the latest published version, so there is no upgrade - # that removes the lockfile entry. What can change is the feature: anything - # that turns rust_decimal's `rkyv` feature on makes this vulnerability real - # and this entry wrong. That is exactly what scripts/audit-ignores.sh checks, - # by asserting rkyv is absent from the built dependency graph. + # Since the rust_decimal 1.43.0 bump in crates/cluster/Cargo.lock (2026-10-19) this entry + # matches nothing: rust_decimal 1.43.0 no longer depends on rkyv, so rkyv is out of that + # lockfile (the root lockfile never had it). The entry stays only until the follow-up that + # removes it, which also re-points the teeth cases that edit it. scripts/audit.sh still + # asserts that rkyv is absent from the build graph. "RUSTSEC-2026-0235", ] diff --git a/CLAUDE.md b/CLAUDE.md index 62026c5a..6f917dc5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -515,9 +515,7 @@ build)`, `cloud apns (feature build)`. the sentence stops being true without saying so and the entry then protects nothing while looking like a decision. - Today's single entry is rkyv (RUSTSEC-2026-0235), which reaches the - lockfile through openraft, byte-unit and rust_decimal, sits behind an - optional rust_decimal feature nothing enables, and is therefore never built. + Today's single entry is rkyv (RUSTSEC-2026-0235). It no longer reaches any lockfile: rust_decimal 1.43.0 dropped its rkyv dependency, so `cargo update -p rust_decimal --precise 1.43.0` in crates/cluster removed rkyv from that lockfile (`@measured` 2026-10-19), and the entry is stale until its follow-up removes it. That is a checkable fact rather than a judgement, so `scripts/audit.sh` asserts it: the crate must be absent from the build graph of both manifests under `--all-features --target all`, and an ignore with no recorded crate is diff --git a/crates/cluster/Cargo.lock b/crates/cluster/Cargo.lock index 587da4c7..33ff43f4 100644 --- a/crates/cluster/Cargo.lock +++ b/crates/cluster/Cargo.lock @@ -2,17 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -237,18 +226,6 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -[[package]] -name = "bitvec" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "borsh" version = "1.7.0" @@ -291,28 +268,6 @@ dependencies = [ "utf8-width", ] -[[package]] -name = "bytecheck" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", -] - -[[package]] -name = "bytecheck_derive" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "bytes" version = "1.12.0" @@ -540,12 +495,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futures" version = "0.3.32" @@ -692,15 +641,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -953,7 +893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", + "hashbrown", ] [[package]] @@ -1231,26 +1171,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "ptr_meta" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "quinn" version = "0.11.11" @@ -1328,12 +1248,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "rand" version = "0.8.6" @@ -1341,10 +1255,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.3" @@ -1366,6 +1290,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1375,6 +1309,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" @@ -1449,15 +1392,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "rend" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] - [[package]] name = "reqwest" version = "0.12.28" @@ -1510,47 +1444,18 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rkyv" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "rust_decimal" -version = "1.42.1" +version = "1.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" dependencies = [ "arrayvec", "borsh", "bytes", "num-traits", "rand 0.8.6", - "rkyv", + "rand 0.9.5", "serde", "serde_json", "wasm-bindgen", @@ -1633,12 +1538,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - [[package]] name = "semver" version = "1.0.28" @@ -1736,12 +1635,6 @@ dependencies = [ "libc", ] -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "slab" version = "0.4.12" @@ -1782,17 +1675,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.118" @@ -1835,12 +1717,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "thiserror" version = "1.0.69" @@ -2206,16 +2082,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "1.23.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "validit" version = "0.2.5" @@ -2231,12 +2097,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "want" version = "0.3.1" @@ -2581,15 +2441,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "yasna" version = "0.5.2" From 2f994ab87d2fce811791a12979ad21f4a7ea1ab7 Mon Sep 17 00:00:00 2001 From: Yurii Kostiuk Date: Mon, 19 Oct 2026 20:42:58 +0100 Subject: [PATCH 4/6] test(delegation): give the MCP door's origin tests names of their own Four test names were used both in mcp_door and in delegation_origins. Renamed the mcp_door ones with the door's name; no assertion changed. Co-Authored-By: Claude Haiku 5.5 --- crates/gateway/tests/it/mcp_door.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/gateway/tests/it/mcp_door.rs b/crates/gateway/tests/it/mcp_door.rs index 724a3a39..df410593 100644 --- a/crates/gateway/tests/it/mcp_door.rs +++ b/crates/gateway/tests/it/mcp_door.rs @@ -496,7 +496,7 @@ fn a_proof_addressed_to_the_first_origin_still_opens_the_door_with_a_list() { /// An origin spelled with its default port is the same origin. #[test] -fn the_first_origin_spelled_with_its_default_port_is_the_same_origin() { +fn the_mcp_door_takes_the_first_origin_spelled_with_its_default_port() { let (holder, k) = (Key::new(), keys("")); let c = registry_at(&holder, ORIGIN); let proof = proof_naming(&holder, "https://mcp.acme.example:443/mcp", "port-1"); @@ -508,7 +508,7 @@ fn the_first_origin_spelled_with_its_default_port_is_the_same_origin() { /// An origin nobody listed is refused, whatever path it names. #[test] -fn a_proof_naming_an_unlisted_origin_is_refused() { +fn the_mcp_door_refuses_a_proof_naming_an_unlisted_origin() { let (holder, k) = (Key::new(), keys("")); let c = registry_at(&holder, &format!("{ORIGIN},{SECOND_ORIGIN}")); let proof = proof_naming(&holder, "https://evil.example/mcp", "unlisted-1"); @@ -520,7 +520,7 @@ fn a_proof_naming_an_unlisted_origin_is_refused() { /// User information in the htu is refused, even when the host after it is listed. #[test] -fn a_proof_with_user_information_in_its_htu_is_refused() { +fn the_mcp_door_refuses_a_proof_with_user_information_in_its_htu() { let (holder, k) = (Key::new(), keys("")); let c = registry_at(&holder, ORIGIN); let proof = proof_naming(&holder, "https://user@mcp.acme.example/mcp", "userinfo-1"); @@ -533,7 +533,7 @@ fn a_proof_with_user_information_in_its_htu_is_refused() { /// A fragment is never part of the URI a request was made to. Red before the fix: the /// verifier trimmed everything after the `#` and admitted the proof. #[test] -fn a_proof_with_a_fragment_in_its_htu_is_refused() { +fn the_mcp_door_refuses_a_proof_with_a_fragment_in_its_htu() { let (holder, k) = (Key::new(), keys("")); let c = registry_at(&holder, ORIGIN); let proof = proof_naming(&holder, &format!("{ORIGIN}{PATH}#frag"), "fragment-1"); From 093e2f76ceffb048ca3f414b21f172792ad9d420 Mon Sep 17 00:00:00 2001 From: Yurii Kostiuk Date: Mon, 19 Oct 2026 21:39:29 +0100 Subject: [PATCH 5/6] fix(dpop): drop the test module's unused base64 import, and count the new tests Clippy with -D warnings refused the test module's `use base64::Engine as _`, which the module-level import already brings in through `use super::*`, so the lib test target did not compile and the stated-numbers baseline in the teeth shards read red on a clean tree (UNJUDGEABLE, CI run 39315890343). The badge and PROGRESS.md now count the 23 tests this branch adds: 6 in dpop::origin, 1 in gateway::chainproof, 16 in tests/it. Co-Authored-By: Claude Opus 5.5 --- PROGRESS.md | 2 +- README.md | 2 +- crates/dpop/src/origin.rs | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 3574c0a4..4322066b 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 **2391 passing** (core 469, dpop 23, delegation 75, gateway 1480, cloud 343, umbrella 1, by `cargo test -p `). @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 **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 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 dc05285f..cdf1a006 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.19.0-brightgreen) -![tests](https://img.shields.io/badge/tests-2391-brightgreen) +![tests](https://img.shields.io/badge/tests-2414-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/dpop/src/origin.rs b/crates/dpop/src/origin.rs index 817b4da3..13d67ed9 100644 --- a/crates/dpop/src/origin.rs +++ b/crates/dpop/src/origin.rs @@ -191,7 +191,6 @@ pub fn parse_origin_list(list: &str) -> Result, String> { #[cfg(test)] mod tests { use super::*; - use base64::Engine as _; /// A compact JWS with an unchecked signature, carrying `claims`. Only the /// payload is read by `unverified_htu`, so the header and signature are From 55b90bd19e3cd7ee61e4c19a6d901f43938690b9 Mon Sep 17 00:00:00 2001 From: Yurii Kostiuk Date: Mon, 19 Oct 2026 22:01:49 +0100 Subject: [PATCH 6/6] docs(release): a delegation URL with a path now refuses to start, say so under Upgrade Co-Authored-By: Claude Opus 5.5 --- docs/releases/UNRELEASED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md index d2abb359..b4e1f6bc 100644 --- a/docs/releases/UNRELEASED.md +++ b/docs/releases/UNRELEASED.md @@ -10,6 +10,7 @@ - A deployment that names one origin and sends proofs for it keeps working unchanged. - A list with an entry that is not an origin, a trailing comma included, now refuses to start. Before, the broker read the whole value as one origin and refused every proof. +- `TOKENFUSE_DELEGATION_URL` and `TOKENFUSE_MCP_PROOF_URL` must now be an origin, `scheme://host[:port]` with nothing after the port but an optional `/`. A value with a path, for example a gateway published under `https://host/tokenfuse`, used to be joined to the route's path and now refuses to start, naming the variable. The stack launchers set an origin without a path and are not affected. ## Not proven