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 3799b1a5..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 @@ -1921,6 +1919,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/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 2f36df16..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) @@ -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 3321ceb3..26b1db9c 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 }, @@ -220,6 +223,9 @@ "TOKENFUSE_MCP_LOCK": { "required": false }, + "TOKENFUSE_MCP_PROOF_ORIGINS": { + "required": false + }, "TOKENFUSE_MCP_PROOF_URL": { "required": false }, 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" diff --git a/crates/dpop/src/lib.rs b/crates/dpop/src/lib.rs index cb1cecf4..b1eb8011 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}; @@ -282,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 new file mode 100644 index 00000000..13d67ed9 --- /dev/null +++ b/crates/dpop/src/origin.rs @@ -0,0 +1,381 @@ +//! 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. +//! 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 `/`. + +use base64::Engine as _; + +/// 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]`, with nothing after the +/// port but an optional `/`. +/// +/// 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 { + 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 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. +/// +/// `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. +/// +/// 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`, 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. +/// +/// `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. +/// +/// 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 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)] +mod tests { + use super::*; + + /// 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..927d7f4d 100644 --- a/crates/gateway/src/agenttoken.rs +++ b/crates/gateway/src/agenttoken.rs @@ -343,7 +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.origin.trim_end_matches('/'), path); + let url = proving.expected_url(proof, path); let agent = match tokenfuse_delegation::verify_agent_token( &proving.cfg, token, @@ -594,7 +594,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..7323b49c 100644 --- a/crates/gateway/src/chainproof.rs +++ b/crates/gateway/src/chainproof.rs @@ -108,7 +108,15 @@ 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, +} + +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>; @@ -183,7 +191,7 @@ pub fn resolve( return Chain::Claimed(declared.to_vec()); }; - let url = format!("{}{}", proving.origin.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 @@ -450,23 +458,60 @@ 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(), + ) { + 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: 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)] mod tests { use super::*; @@ -593,7 +638,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 +720,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 +947,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 141). + #[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/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 ba452f90..49ee88e8 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), } } @@ -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, - origin: 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,7 +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.origin, 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/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..ddf0e9ba --- /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 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 +//! 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..df410593 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 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. +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_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"); + 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 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"); + 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 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"); + 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 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"); + 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/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..b4e1f6bc --- /dev/null +++ b/docs/releases/UNRELEASED.md @@ -0,0 +1,18 @@ +# 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. +- `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 + +- 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. 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