From 2d0f78c6e1c43762aeb51efa633e9387fc301649 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 16 Oct 2026 14:26:08 -0400 Subject: [PATCH] feat(auth): support explicit mesh JWKS transport (#8382) ## Summary Allow an issuer to opt into mesh HTTP for its JSON Web Key Set (JWKS). Public HTTPS remains the default. This supports deployments whose service mesh authenticates and protects the key endpoint. The sealed source contract selects transport and contributes to policy identity. Mesh mode requires a DNS hostname and checks every resolved address against an explicit allow-list before pinning one address. Both modes disable proxies, reject redirects, cap responses, and enforce a whole-fetch deadline. The relay cannot verify mesh security: operators must enforce mutual TLS, endpoint authorization, and egress restrictions. ### Related issue No duplicate mesh-JWKS issue or PR found. Existing NIP-FI relay enforcement: #7224. No deployment change is included. ### Testing - At `eccb8cb19420abda44cf6e5e62b1361e2843bd1d`: full auth package and auth/relay all-target clippy passed. Full relay package passed: 1,457 library tests, 37 binary tests, and 9 boot tests; 446 tests were ignored. - At prior head `113939838cd81d5e8d0b0f8783c42ffaae44a4d3`: the full relay library suite failed the demo echo test (1,455 passed, 1 failed, 440 ignored). Independent checks reproduced that timeout at base `d61fe2071d2b3d8e56dd931947f16bfe0d6173fe`; the receive-cancellation defect is outside this change. A later pass does not establish that defect is fixed. - Three falsifying mutations at `eccb8cb194` were caught: mixed DNS answers, proxy bypass, and whole-fetch deadline. The full locked auth package passed after restoring the exact source. - At `eccb8cb194`, `cargo build --locked --release -p buzz-relay -p buzz-cli -p buzz-admin` passed with command-local `CARGO_PROFILE_RELEASE_STRIP=none`. This avoids a macOS loader error without a source, lockfile, or project-profile change. - Earlier hooks failed the ACP signal test and Tauri login-shell PATH test. The ACP failure also reproduced on unmodified main `93ffc37c`. Main merged without conflict. The full Tauri package then passed with `RUST_TEST_THREADS=1`: 3,422 library tests and 3 integration tests, 19 ignored. All pre-push hooks passed with that same command-local setting; no hook or test was skipped. - At `eccb8cb194`, isolated `just test` passed all 107 runner steps. The monolithic `just ci` run timed out after 1,120 seconds; all recipes passed across that run and separate `just desktop-tauri-test`, `just web-build`, and `just mobile-test` invocations. No monolithic pass is claimed. - Exact-head release live-local checks passed: loopback HTTP mesh JWKS warm; valid assertion 101; expired/wrong issuer/wrong audience 403; valid signed disconnect 200; wrong command audience 403. Redirect-only JWKS returned assertion 503 without a request to its target. Both omitted/default and explicit public transport rejected HTTP at startup. - A separate NIP-FI-off release CLI smoke test created an open channel, sent an accepted message, read it back, and fetched its thread. The assertion probe closes after upgrade; it does not prove a complete NIP-42 session. Local loopback tests do not prove deployment mesh authentication, endpoint authorization, egress controls, or real `240/4` routing. Generated with Codex ## Human Testing valid assertion / configured community audience: HTTP 101 (expected 101) expired assertion: HTTP 403 (expected 403) wrong issuer: HTTP 403 (expected 403) wrong community audience: HTTP 403 (expected 403) valid command JWT / configured command audience: HTTP 200, body=b'{"disconnected": true}' (expected 200) valid command signature / wrong audience: HTTP 403 (expected 403) --------- Signed-off-by: seiler Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> Co-authored-by: Codex Co-authored-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> --- ARCHITECTURE.md | 12 + Justfile | 4 + crates/buzz-auth/src/nip_fi/config.rs | 74 ++- .../buzz-auth/src/nip_fi/jwks/mesh_tests.rs | 516 ++++++++++++++++++ crates/buzz-auth/src/nip_fi/jwks/mod.rs | 351 +++++++++--- crates/buzz-auth/src/nip_fi/jwks/tests.rs | 33 +- crates/buzz-auth/src/nip_fi/startup/tests.rs | 29 + crates/buzz-auth/tests/mesh_jwks_proxy.rs | 256 +++++++++ crates/buzz-relay/src/nip_fi_config.rs | 150 ++++- docs/nips/NIP-FI.md | 41 +- scripts/run-tests.sh | 6 + 11 files changed, 1366 insertions(+), 106 deletions(-) create mode 100644 crates/buzz-auth/src/nip_fi/jwks/mesh_tests.rs create mode 100644 crates/buzz-auth/tests/mesh_jwks_proxy.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 633db903c0..72afede4ac 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -873,6 +873,18 @@ Every security-sensitive operation uses an explicit, verified pattern. No implic Applied in: `buzz-auth` (JWKS boundary), `buzz-workflow` (CallWebhook action), desktop `link_preview` (SSRF check). +JWKS uses `public_https` by default and applies this public-address check to +every resolved answer before pinning the first permitted address. Explicit +`mesh` mode accepts HTTP only for a DNS hostname (not an IP literal) without +userinfo, query, fragment, or backslash. It checks every DNS answer against the +positive address allow-list in the +[NIP-FI JWKS snapshot contract](docs/nips/NIP-FI.md#jwks-snapshot), rejects +empty or mixed answers, and pins the first checked address. Mesh selection is +an operator assertion, not a transport property the Rust relay verifies. +Operators must enforce mutual TLS peer authentication and integrity +protection, authorize the JWKS endpoint, and restrict egress. Unauthenticated +HTTP permits key substitution and issuer impersonation. + ### Audit Integrity - Hash chain: each entry's SHA-256 covers all fields including `prev_hash` — tampering any entry breaks all subsequent hashes diff --git a/Justfile b/Justfile index 5c5511eca6..e8bdbccf49 100644 --- a/Justfile +++ b/Justfile @@ -401,6 +401,10 @@ test-unit: cargo test -p buzz-audio-client if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib + # Mesh JWKS proxy-bypass regression: infra-free loopback I/O with + # child-process environment isolation. Its integration-test binary + # is not selected by --lib above. Keep the Cargo fallback in step. + cargo nextest run -p buzz-auth --test mesh_jwks_proxy cargo nextest run -p buzz-audit --lib # Cross-pod disconnect payload tests (NIP-FI and community archive # fence); all infra-free. diff --git a/crates/buzz-auth/src/nip_fi/config.rs b/crates/buzz-auth/src/nip_fi/config.rs index f59d88d092..26aad88115 100644 --- a/crates/buzz-auth/src/nip_fi/config.rs +++ b/crates/buzz-auth/src/nip_fi/config.rs @@ -378,10 +378,11 @@ pub struct IssuerPolicy { skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, - /// The authenticated key-source contract: validated JWKS URI, refresh - /// interval, and hard deadline. Included in `derive_assertion_policy_id` - /// so that a change to the endpoint, refresh schedule, or hard-deadline - /// rule changes the policy ID and invalidates all prepared evidence. + /// The authenticated key-source contract: validated JWKS URI, transport, + /// refresh interval, and hard deadline. Included in + /// `derive_assertion_policy_id` so that a change to the endpoint, trusted + /// transport, refresh schedule, or hard-deadline rule changes the policy + /// ID and invalidates all prepared evidence. jwks_source_contract: JwksSourceContract, id: AssertionPolicyId, } @@ -735,12 +736,16 @@ fn derive_assertion_policy_id(policy: &IssuerPolicy) -> AssertionPolicyId { hasher.update(maximum_assertion_age_seconds.to_be_bytes()); hasher.update((*maximum_status_age_seconds).unwrap_or(0).to_be_bytes()); // Authenticated key-source contract (NIP-FI.md, "Policy identity and - // snapshots"): URI selects the authenticated source; interval defines - // bounded refresh; hard deadline defines the accepted time rule. These are - // contract, not mutable state — key rotation (JWKS content change) leaves - // all three unchanged and must not move the ID. + // snapshots"): URI selects the key source; mesh mode delegates transport + // authentication to the deployment; interval defines bounded refresh; and + // hard deadline defines the accepted time rule. The mesh transport tag is + // present only when that mode is selected. hasher.update(b"jwks-source-contract\0"); hash_field(&mut hasher, jwks_source_contract.jwks_uri().as_bytes()); + if jwks_source_contract.uses_mesh_transport() { + hasher.update(b"jwks-transport\0"); + hasher.update(b"mesh\0"); + } hasher.update( jwks_source_contract .refresh_interval_seconds() @@ -767,3 +772,56 @@ fn hash_seq<'a>(hasher: &mut Sha256, items: impl ExactSizeIterator IssuerPolicy { + IssuerPolicy::new( + "https://issuer.example".to_owned(), + vec!["https://relay.example".to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, + 3600, + None, + contract, + ) + .expect("valid policy") + } + + #[test] + fn public_https_policy_id_golden_omits_mesh_transport_tag() { + let contract = JwksSourceContract::new( + "https://issuer.example/.well-known/jwks.json".to_owned(), + 300, + 3600, + ) + .expect("valid public contract"); + + assert_eq!( + hex::encode(policy(contract).id().as_bytes()), + "1f904aaaad1c1db3a22ae50bd2d24cdf7181d5d0b8c5006cc1ae9ac1107f9832", + "public_https mode omits the mesh transport tag" + ); + } + + #[test] + fn mesh_transport_policy_id_has_a_separate_golden() { + let contract = JwksSourceContract::new_mesh( + "http://mesh.example.test/.well-known/jwks.json".to_owned(), + 300, + 3600, + ) + .expect("valid mesh contract"); + + assert_eq!( + hex::encode(policy(contract).id().as_bytes()), + "33e4cf02148ca494c12ffc49cd0bb223e0e007de4084ac5b406f2742bbb91c0a", + "mesh transport is part of policy identity" + ); + } +} diff --git a/crates/buzz-auth/src/nip_fi/jwks/mesh_tests.rs b/crates/buzz-auth/src/nip_fi/jwks/mesh_tests.rs new file mode 100644 index 0000000000..23db17027c --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/jwks/mesh_tests.rs @@ -0,0 +1,516 @@ +//! Regression tests for the explicit mesh JWKS transport. + +use super::*; + +fn minimal_jwks_json(kid: &str) -> String { + format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","use":"sig","alg":"ES256","kid":"{kid}"}}]}}"# + ) +} + +fn mesh_contract(uri: &str) -> JwksSourceContract { + JwksSourceContract::new_mesh(uri.to_owned(), 300, 3600).expect("valid mesh contract") +} + +#[test] +fn mesh_contract_rejects_invalid_uri_and_timing() { + for uri in [ + "https://mesh.example.test/jwks.json", + "http://127.0.0.1/jwks.json", + "http://[::1]/jwks.json", + "http://2130706433/jwks.json", + "http://0177.0.0.1/jwks.json", + "http://0x7f000001/jwks.json", + "http://user:password@mesh.example.test/jwks.json", + "http://@mesh.example.test/jwks.json", + "http://mesh.example.test/jwks.json?token=private", + "http://mesh.example.test/jwks.json?", + "http://mesh.example.test/jwks.json#fragment", + "http://mesh.example.test/jwks.json#", + "http:\\\\mesh.example.test\\jwks.json", + ] { + assert!( + JwksSourceContract::new_mesh(uri.to_owned(), 300, 3600).is_none(), + "mesh contract must reject {uri}" + ); + } + + let uri = "http://mesh.example.test/jwks.json".to_owned(); + for (refresh, deadline) in [ + (0, 3600), + (300, 0), + (3600, 3600), + (300, 299), + (1, MAX_JWKS_TIMING_SECONDS + 1), + ] { + assert!( + JwksSourceContract::new_mesh(uri.clone(), refresh, deadline).is_none(), + "mesh contract must reject timing ({refresh}, {deadline})" + ); + } + + assert!( + JwksSourceContract::new_mesh(uri.clone(), 300, 301).is_some(), + "a deadline just above the refresh interval must be accepted" + ); + + assert!( + JwksSourceContract::new(uri, 300, 3600).is_none(), + "the public constructor must keep rejecting HTTP" + ); +} + +#[test] +fn mesh_contract_canonicalizes_and_transport_participates_in_equality() { + let canonical = mesh_contract("http://mesh.example.test/a/jwks.json"); + let equivalent = mesh_contract("HTTP://MESH.EXAMPLE.TEST:80/a/./jwks.json"); + assert_eq!(canonical.jwks_uri(), "http://mesh.example.test/a/jwks.json"); + assert_eq!(canonical, equivalent); + + let public = JwksSourceContract::new( + "https://mesh.example.test/a/jwks.json".to_owned(), + 300, + 3600, + ) + .expect("valid public contract"); + let mut transport_mismatch = public.clone(); + transport_mismatch.transport = JwksTransport::Mesh; + assert_ne!( + public, transport_mismatch, + "the sealed contract equality must include transport" + ); +} + +#[test] +fn mesh_address_allow_list_covers_range_boundaries_and_embedded_ipv6() { + let cases = [ + ("127.0.0.0", true), + ("127.255.255.255", true), + ("126.255.255.255", false), + ("128.0.0.0", false), + ("10.0.0.0", true), + ("10.255.255.255", true), + ("9.255.255.255", false), + ("11.0.0.0", false), + ("172.16.0.0", true), + ("172.31.255.255", true), + ("172.15.255.255", false), + ("172.32.0.0", false), + ("192.168.0.0", true), + ("192.168.255.255", true), + ("192.167.255.255", false), + ("192.169.0.0", false), + ("100.64.0.0", true), + ("100.127.255.255", true), + ("100.63.255.255", false), + ("100.128.0.0", false), + ("240.0.0.0", true), + ("255.255.255.254", true), + ("239.255.255.255", false), + ("255.255.255.255", false), + ("169.254.0.1", false), + ("0.0.0.0", false), + ("224.0.0.1", false), + ("198.18.0.1", false), + ("192.0.2.1", false), + ("198.51.100.1", false), + ("203.0.113.1", false), + ("192.88.99.1", false), + ("fc00::", true), + ("fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", true), + ("fbff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", false), + ("fe00::", false), + ("::1", true), + ("::", false), + ("fe80::1", false), + ("fec0::1", false), + ("fe00::1", false), + ("ff02::1", false), + ("2001:db8::1", false), + ("2001:2::1", false), + ("2002:a00:1::", false), + ("::a00:1", false), + ("::ffff:10.0.0.1", false), + ("::ffff:0:0:a00:1", false), + ("64:ff9b::10.0.0.1", false), + ("64:ff9b:1::10.0.0.1", false), + ]; + + for (address, expected) in cases { + let ip = address.parse().expect("valid test IP address"); + assert_eq!( + is_mesh_address_allowed(&ip), + expected, + "mesh address policy for {address}" + ); + } +} + +#[tokio::test] +async fn mesh_resolver_validates_ip_literal_fast_path() { + use std::net::IpAddr; + + for (host, expected) in [ + ("8.8.8.8", Err(JwksFetchError::InvalidUri)), + ("2606:4700::1", Err(JwksFetchError::InvalidUri)), + ( + "10.0.0.1", + Ok("10.0.0.1".parse::().expect("valid test IP")), + ), + ] { + assert_eq!( + resolve_for_transport(host, 443, JwksTransport::Mesh).await, + expected, + "mesh resolver result for {host}" + ); + } +} + +#[tokio::test] +async fn transport_resolver_checks_every_answer_and_preserves_empty_result_error() { + use std::net::IpAddr; + + let allowed = "10.0.0.1".parse::().expect("valid test IP"); + let also_allowed = "192.168.0.1".parse::().expect("valid test IP"); + let public = "8.8.8.8".parse::().expect("valid test IP"); + let public_v6 = "2606:4700::1".parse::().expect("valid test IP"); + + assert_eq!( + TEST_DNS_ANSWERS + .scope( + vec![allowed, also_allowed], + resolve_for_transport("mesh.example.test", 443, JwksTransport::Mesh), + ) + .await, + Ok(allowed), + "the first address is pinned only after the complete answer set passes" + ); + + for answers in [ + vec![allowed, public], + vec![public, allowed], + vec![also_allowed, public_v6], + vec![public_v6, also_allowed], + ] { + assert_eq!( + TEST_DNS_ANSWERS + .scope( + answers, + resolve_for_transport("mesh.example.test", 443, JwksTransport::Mesh), + ) + .await, + Err(JwksFetchError::InvalidUri), + "mesh rejects a mixed answer regardless of address ordering" + ); + } + + for answers in [vec![allowed, public], vec![public, allowed]] { + assert_eq!( + TEST_DNS_ANSWERS + .scope( + answers, + resolve_for_transport("public.example.test", 443, JwksTransport::PublicHttps), + ) + .await, + Err(JwksFetchError::InvalidUri), + "public HTTPS retains its strict all-answer check" + ); + } + + assert_eq!( + TEST_DNS_ANSWERS + .scope( + Vec::new(), + resolve_for_transport("mesh.example.test", 443, JwksTransport::Mesh), + ) + .await, + Err(JwksFetchError::NetworkError), + "an empty resolver result remains a network error" + ); +} + +async fn bind_localhost_listener() -> tokio::net::TcpListener { + use std::net::ToSocketAddrs; + + let listener = tokio::task::spawn_blocking(|| { + let address = ("localhost", 0) + .to_socket_addrs()? + .next() + .ok_or_else(|| std::io::Error::other("localhost has no addresses"))?; + let listener = std::net::TcpListener::bind(address)?; + listener.set_nonblocking(true)?; + Ok::<_, std::io::Error>(listener) + }) + .await + .expect("localhost listener task") + .expect("bind first localhost address"); + tokio::net::TcpListener::from_std(listener).expect("register listener with Tokio") +} + +async fn read_http_request(stream: &mut tokio::net::TcpStream) -> String { + use tokio::io::AsyncReadExt as _; + + let mut bytes = Vec::new(); + let mut chunk = [0; 1024]; + while !bytes.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream.read(&mut chunk).await.expect("read request"); + if read == 0 { + break; + } + bytes.extend_from_slice(&chunk[..read]); + } + String::from_utf8(bytes).expect("HTTP request headers are ASCII") +} + +async fn write_http_response( + stream: &mut tokio::net::TcpStream, + status: &str, + headers: &str, + body: &str, +) { + use tokio::io::AsyncWriteExt as _; + + let response = format!( + "HTTP/1.1 {status}\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write response"); +} + +#[tokio::test] +async fn production_source_fetches_mesh_contract_and_raw_fetch_stays_public_only() { + let listener = bind_localhost_listener().await; + let port = listener.local_addr().expect("listener address").port(); + let uri = format!("http://localhost:{port}/.well-known/jwks.json"); + let contract = mesh_contract(&uri); + let issuer = "https://issuer.example"; + let public_issuer = "https://public-issuer.example"; + let public_contract = JwksSourceContract::new( + format!("https://localhost:{port}/public-jwks.json"), + 300, + 3600, + ) + .expect("public HTTPS hostname contract"); + let source = ProductionJwksSource::new( + vec![ + IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: contract.clone(), + }, + IssuerJwksConfig { + issuer: public_issuer.to_owned(), + contract: public_contract, + }, + ], + HttpJwksFetcher::new(), + ) + .expect("valid mixed-transport source"); + let body = minimal_jwks_json("mesh-key"); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept JWKS request"); + let request = read_http_request(&mut stream).await; + write_http_response( + &mut stream, + "200 OK", + "Content-Type: application/json\r\n", + &body, + ) + .await; + (listener, request) + }); + + let snapshot = source + .get_snapshot(issuer) + .await + .expect("production source warms its snapshot through the mesh contract"); + assert_eq!(snapshot.issuer(), issuer); + let (listener, request) = server.await.expect("local JWKS server task"); + assert!( + request.starts_with("GET /.well-known/jwks.json HTTP/1.1\r\n"), + "the fetcher uses GET at the configured path: {request:?}" + ); + + assert_eq!( + HttpJwksFetcher::new().fetch_jwks(&uri).await.unwrap_err(), + JwksFetchError::InvalidUri, + "raw fetches cannot inherit the contract's HTTP permission" + ); + assert!( + JwksSourceContract::new(uri, 300, 3600).is_none(), + "the public constructor cannot create an HTTP contract" + ); + + tokio::select! { + snapshot = source.get_snapshot(public_issuer) => { + assert!(snapshot.is_none(), "public HTTPS must reject localhost DNS"); + } + accepted = listener.accept() => { + panic!("public HTTPS contract attempted to connect to its non-global DNS result: {accepted:?}"); + } + } +} + +#[tokio::test] +async fn production_mesh_fetcher_checks_all_dns_answers_before_connecting() { + use std::net::IpAddr; + + let listener = bind_localhost_listener().await; + let address = listener.local_addr().expect("listener address"); + let uri = format!("http://mesh.example.test:{}/jwks.json", address.port()); + let contract = mesh_contract(&uri); + let body = minimal_jwks_json("mixed-answer-key"); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept fetcher request"); + let request = read_http_request(&mut stream).await; + write_http_response(&mut stream, "200 OK", "", &body).await; + request + }); + + let result = TEST_DNS_ANSWERS + .scope( + vec![ + address.ip(), + IpAddr::V4(std::net::Ipv4Addr::new(8, 8, 8, 8)), + ], + HttpJwksFetcher::new().fetch_for_contract(&contract), + ) + .await; + assert_eq!( + result, + Err(JwksFetchError::InvalidUri), + "the production fetcher rejects the mixed DNS answer before connecting" + ); + + server.abort(); + assert!(server.await.unwrap_err().is_cancelled()); +} + +#[tokio::test] +async fn production_mesh_fetcher_denies_redirects_and_non_success_statuses() { + let listener = bind_localhost_listener().await; + let port = listener.local_addr().expect("listener address").port(); + let uri = format!("http://localhost:{port}/jwks.json"); + let contract = mesh_contract(&uri); + let target_body = minimal_jwks_json("redirect-target"); + let server = tokio::spawn(async move { + let (mut first, _) = listener.accept().await.expect("accept first request"); + let first_request = read_http_request(&mut first).await; + write_http_response( + &mut first, + "302 Found", + &format!("Location: http://localhost:{port}/target\r\n"), + "", + ) + .await; + + // If redirects are followed, this second response makes that behavior + // observable as a successful fetch instead of a network failure. + let (mut second, _) = listener.accept().await.expect("accept redirect target"); + let second_request = read_http_request(&mut second).await; + write_http_response(&mut second, "200 OK", "", &target_body).await; + (first_request, second_request) + }); + + assert_eq!( + HttpJwksFetcher::new() + .fetch_for_contract(&contract) + .await + .unwrap_err(), + JwksFetchError::NetworkError, + "3xx responses are rejected without following Location" + ); + server.abort(); + assert!(server.await.unwrap_err().is_cancelled()); + + let listener = bind_localhost_listener().await; + let port = listener.local_addr().expect("listener address").port(); + let uri = format!("http://localhost:{port}/jwks.json"); + let contract = mesh_contract(&uri); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept non-2xx request"); + let request = read_http_request(&mut stream).await; + write_http_response(&mut stream, "503 Service Unavailable", "", "").await; + request + }); + assert_eq!( + HttpJwksFetcher::new() + .fetch_for_contract(&contract) + .await + .unwrap_err(), + JwksFetchError::NetworkError + ); + let request = server.await.expect("non-2xx server task"); + assert!(request.starts_with("GET /jwks.json HTTP/1.1\r\n")); +} + +#[tokio::test(flavor = "current_thread")] +async fn production_fetch_wrapper_deadline_covers_waiting_for_response() { + use std::time::Duration; + + let listener = bind_localhost_listener().await; + let port = listener.local_addr().expect("listener address").port(); + let uri = format!("http://localhost:{port}/jwks.json"); + let contract = mesh_contract(&uri); + let fetch = + tokio::spawn(async move { HttpJwksFetcher::new().fetch_for_contract(&contract).await }); + + let (mut stream, _) = tokio::time::timeout(Duration::from_secs(2), listener.accept()) + .await + .expect("fetcher should connect before its whole-request deadline") + .expect("accept request"); + let request = tokio::time::timeout(Duration::from_secs(2), read_http_request(&mut stream)) + .await + .expect("fetcher should send request headers before waiting for response"); + assert!(request.starts_with("GET /jwks.json HTTP/1.1\r\n")); + + // Establish real socket readiness before freezing Tokio time. Starting + // paused lets timer auto-advance race the OS accept/read readiness. + tokio::time::pause(); + tokio::time::advance(Duration::from_secs(JWKS_REQUEST_TIMEOUT_SECS)).await; + tokio::time::resume(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), fetch) + .await + .expect("fetch wrapper must finish at its whole-request deadline") + .expect("fetch task"), + Err(JwksFetchError::NetworkError) + ); + // Keep the peer open until the bounded assertion above. Without the + // production deadline the fetch remains stalled instead of seeing EOF. + drop(stream); +} + +#[tokio::test] +async fn production_mesh_fetcher_keeps_response_cap() { + let listener = bind_localhost_listener().await; + let port = listener.local_addr().expect("listener address").port(); + let uri = format!("http://localhost:{port}/jwks.json"); + let contract = mesh_contract(&uri); + let oversized_body = vec![b'x'; MAX_JWKS_RESPONSE_BYTES + 1]; + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept oversized request"); + let request = read_http_request(&mut stream).await; + use tokio::io::AsyncWriteExt as _; + stream + .write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n") + .await + .expect("write response headers"); + stream + .write_all(&oversized_body) + .await + .expect("write oversized streamed body"); + request + }); + assert_eq!( + HttpJwksFetcher::new() + .fetch_for_contract(&contract) + .await + .unwrap_err(), + JwksFetchError::ResponseTooLarge + ); + let request = server.await.expect("oversized response server task"); + assert!(request.starts_with("GET /jwks.json HTTP/1.1\r\n")); +} diff --git a/crates/buzz-auth/src/nip_fi/jwks/mod.rs b/crates/buzz-auth/src/nip_fi/jwks/mod.rs index f94cbef4ee..3969ca358b 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/mod.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/mod.rs @@ -38,6 +38,11 @@ use tokio::sync::Mutex; use tracing::warn; use url::Url; +#[cfg(test)] +tokio::task_local! { + static TEST_DNS_ANSWERS: Vec; +} + /// Maximum HTTP response body for a JWKS endpoint. Streaming stops at this /// limit before any deserialization, preventing OOM from a malicious server. pub const MAX_JWKS_RESPONSE_BYTES: usize = 512 * 1024; // 512 KiB @@ -52,11 +57,10 @@ pub const MAX_JWKS_TIMING_SECONDS: u64 = 365 * 24 * 3600; // 1 year /// so a stalled resolver cannot keep `fetch_jwks` pending indefinitely. pub const JWKS_REQUEST_TIMEOUT_SECS: u64 = 10; -/// Validate that a JWKS URI is safe to fetch: HTTPS scheme, no credentials, -/// no fragment, and the host (if a bare IP) is not private/reserved. -/// Hostname targets are resolved and checked at every fetch in `fetch_jwks` -/// to prevent DNS rebinding — this check catches the most common -/// misconfiguration at construction time. +/// Validate a public HTTPS JWKS URI: no credentials or fragment, and the host +/// (if a bare IP) is not private/reserved. Hostname targets are resolved and +/// checked at every raw fetch to prevent DNS rebinding — this check catches the +/// most common misconfiguration at construction time. pub fn validate_jwks_uri(uri: &str) -> Result<(), JwksFetchError> { let parsed = Url::parse(uri).map_err(|_| JwksFetchError::InvalidUri)?; if parsed.scheme() != "https" { @@ -87,11 +91,13 @@ pub fn validate_jwks_uri(uri: &str) -> Result<(), JwksFetchError> { /// The authenticated key-source contract owned by one [`IssuerPolicy`]. /// -/// Encodes the three deployment-configured fields whose change alters which +/// Encodes the deployment-configured fields whose change alters which /// keys the runtime trusts and how long it trusts them: /// /// - `jwks_uri` — selects the authenticated key source; a different endpoint /// may serve different keys even for the same issuer. +/// - `transport` — selects the public HTTPS boundary or the explicitly +/// configured mesh boundary. /// - `refresh_interval_seconds` — defines bounded refresh behavior; a longer /// interval allows stale keys to persist longer. /// - `key_snapshot_hard_deadline_seconds` — defines the source's accepted @@ -105,8 +111,12 @@ pub fn validate_jwks_uri(uri: &str) -> Result<(), JwksFetchError> { /// copies of these fields would let them drift silently; startup validation /// detects any mismatch that a compatibility path temporarily introduces. /// -/// All three fields are validated at construction — an invalid value is caught -/// at configuration time, not at first token verification. +/// All fields are validated at construction — an invalid value is caught at +/// configuration time, not at first token verification. The mesh constructor +/// is an operator assertion: this process cannot prove that mutual TLS peer +/// authentication and integrity protection are enabled. See the +/// [NIP-FI JWKS snapshot contract](../../../../../docs/nips/NIP-FI.md#jwks-snapshot) +/// for the exact mesh address allow-list and operator requirements. /// /// ## Why these fields are contract, not mutable state /// @@ -114,15 +124,18 @@ pub fn validate_jwks_uri(uri: &str) -> Result<(), JwksFetchError> { /// `assertion_policy_id` covers "authenticated key/status-source contracts" /// and "time rules". Key additions/removals (JWKS rotation) and per-snapshot /// deadlines remain *revalidation dependencies* — they change per-token state -/// without changing the contract. These three fields define what the contract -/// *is*; JWKS content is what the contract currently *says*. +/// without changing the contract. The endpoint, transport, and timing fields +/// define what the contract *is*; JWKS content is what the contract currently +/// *says*. #[derive(Debug, Clone, PartialEq, Eq)] pub struct JwksSourceContract { /// Validated JWKS endpoint URI normalized to its canonical `Url` serialization. - /// `Url::to_string()` lowercases the scheme and host, removes the default - /// HTTPS port, and resolves dot-segments — so equivalent URI spellings hash + /// `Url::to_string()` lowercases the scheme and host, removes the scheme's + /// default port, and resolves dot-segments — so equivalent URI spellings hash /// identically. Validated at construction; only stored after parse succeeds. jwks_uri: String, + /// The network trust boundary for this issuer's configured endpoint. + transport: JwksTransport, /// Positive, ≤ [`MAX_JWKS_TIMING_SECONDS`], strictly less than /// `key_snapshot_hard_deadline_seconds`. refresh_interval_seconds: u64, @@ -131,8 +144,18 @@ pub struct JwksSourceContract { key_snapshot_hard_deadline_seconds: u64, } +/// The transport modes accepted by a sealed JWKS source contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum JwksTransport { + /// Public HTTPS with the strict shared public-address SSRF policy. + PublicHttps, + /// HTTP selected by explicit mesh mode; transport authentication is an + /// operator responsibility. + Mesh, +} + impl JwksSourceContract { - /// Validate and seal the three JWKS source fields. + /// Validate and seal a public HTTPS JWKS source. /// /// Rejects: /// - `jwks_uri` that fails [`validate_jwks_uri`] @@ -145,6 +168,48 @@ impl JwksSourceContract { jwks_uri: String, refresh_interval_seconds: u64, key_snapshot_hard_deadline_seconds: u64, + ) -> Option { + Self::new_with_transport( + jwks_uri, + refresh_interval_seconds, + key_snapshot_hard_deadline_seconds, + JwksTransport::PublicHttps, + ) + } + + /// Validate and seal a mesh HTTP JWKS source. + /// + /// The URI must use HTTP and a DNS hostname rather than an IP literal. It + /// must have no userinfo (including an empty userinfo marker), query, + /// fragment, or backslash. Every DNS answer is checked before the first + /// allowed address is pinned; empty or mixed answers fail closed. See the + /// [NIP-FI JWKS snapshot contract](../../../../../docs/nips/NIP-FI.md#jwks-snapshot) + /// for the exact address allow-list and operator requirements. + /// + /// Selecting this mode is an operator assertion that the deployment + /// authenticates and protects the HTTP transport. The relay cannot verify + /// that assertion. Operators must enforce mutual TLS peer authentication + /// and integrity protection, authorize the endpoint, and restrict egress; + /// unauthenticated HTTP permits JWKS key substitution and issuer + /// impersonation. + pub fn new_mesh( + jwks_uri: String, + refresh_interval_seconds: u64, + key_snapshot_hard_deadline_seconds: u64, + ) -> Option { + Self::new_with_transport( + jwks_uri, + refresh_interval_seconds, + key_snapshot_hard_deadline_seconds, + JwksTransport::Mesh, + ) + } + + fn new_with_transport( + jwks_uri: String, + refresh_interval_seconds: u64, + key_snapshot_hard_deadline_seconds: u64, + transport: JwksTransport, ) -> Option { if refresh_interval_seconds == 0 || key_snapshot_hard_deadline_seconds == 0 @@ -154,24 +219,27 @@ impl JwksSourceContract { { return None; } - // Parse once, reject via validate_jwks_uri's rule-set, then store the + if transport == JwksTransport::Mesh && validate_mesh_jwks_uri(&jwks_uri).is_err() { + return None; + } + // Validate with the selected transport's rule-set, then store the // canonical serialization produced by `Url::to_string()`. The `url` - // crate lowercases scheme and host, removes the default HTTPS port, + // crate lowercases scheme and host, removes the default scheme port, // and resolves dot-segments — guaranteeing that equivalent URI spellings - // (e.g. uppercase host, explicit `:443`, `.///../`) produce an identical - // stored string and therefore an identical `AssertionPolicyId` hash. + // (e.g. uppercase host, explicit default port, dot segments) produce an + // identical stored string and therefore an identical `AssertionPolicyId`. let canonical_uri = match Url::parse(&jwks_uri) { Ok(parsed) => parsed.to_string(), Err(_) => return None, }; - // Re-validate on the canonical form so that any normalisation that - // would introduce a forbidden form (e.g. port stripping that leaves - // a bare-IP host) is caught here rather than silently stored. - if validate_jwks_uri(&canonical_uri).is_err() { + // Re-validate on the canonical form so normalization cannot introduce + // a forbidden form before it is sealed into the contract. + if validate_jwks_uri_for_transport(&canonical_uri, transport).is_err() { return None; } Some(Self { jwks_uri: canonical_uri, + transport, refresh_interval_seconds, key_snapshot_hard_deadline_seconds, }) @@ -182,6 +250,11 @@ impl JwksSourceContract { &self.jwks_uri } + /// Whether this issuer explicitly selects mesh HTTP transport. + pub const fn uses_mesh_transport(&self) -> bool { + matches!(self.transport, JwksTransport::Mesh) + } + /// Seconds between successive JWKS refreshes. pub const fn refresh_interval_seconds(&self) -> u64 { self.refresh_interval_seconds @@ -193,29 +266,93 @@ impl JwksSourceContract { } } -/// Resolve `host:port` to IP addresses and reject if any are private/reserved. +fn validate_jwks_uri_for_transport( + uri: &str, + transport: JwksTransport, +) -> Result<(), JwksFetchError> { + match transport { + JwksTransport::PublicHttps => validate_jwks_uri(uri), + JwksTransport::Mesh => validate_mesh_jwks_uri(uri), + } +} + +fn validate_mesh_jwks_uri(uri: &str) -> Result<(), JwksFetchError> { + let parsed = Url::parse(uri).map_err(|_| JwksFetchError::InvalidUri)?; + if parsed.scheme() != "http" + || uri.trim() != uri + || uri.chars().any(char::is_control) + || uri.contains('\\') + || authority_contains_userinfo(uri) + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(JwksFetchError::InvalidUri); + } + match parsed.host() { + Some(url::Host::Domain(domain)) if !domain.is_empty() => { + // The URL parser canonicalizes alternate numeric IPv4 spellings + // into Host::Ipv4. Keep this parse check as a second guard for any + // domain representation that is also accepted by IpAddr. + if domain.parse::().is_ok() { + return Err(JwksFetchError::InvalidUri); + } + } + // Mesh names are DNS names, never IPv4/IPv6 literals. + _ => return Err(JwksFetchError::InvalidUri), + } + Ok(()) +} + +fn authority_contains_userinfo(uri: &str) -> bool { + let Some((scheme, after_scheme)) = uri.split_once("://") else { + return true; + }; + if !scheme.eq_ignore_ascii_case("http") { + return true; + } + let authority = after_scheme + .split(['/', '?', '#']) + .next() + .unwrap_or_default(); + authority.is_empty() || authority.contains('@') +} + +/// Resolve `host:port` and enforce the selected transport's address policy. /// -/// Returns the first safe address for DNS pinning. Blocks on the OS resolver -/// via `spawn_blocking` to avoid blocking the async runtime. +/// Returns the first permitted address for DNS pinning. Blocks on the OS +/// resolver via `spawn_blocking` to avoid blocking the async runtime. /// /// Uses the `(host, port)` tuple form of `ToSocketAddrs` — not -/// `format!("{host}:{port}")` — so IPv6 literal hosts (returned without -/// brackets by `Url::host_str()`) are handled correctly without socket-address -/// ambiguity. +/// `format!("{host}:{port}")` — so bare IPv6 literal hosts are handled +/// correctly without socket-address ambiguity. /// -/// Rejecting *any* resolved address (not just the first) closes split-horizon -/// DNS attacks: if an attacker can cause one DNS record to resolve to a private -/// address, the entire request is blocked even when other records are public. -pub(crate) async fn resolve_and_check_ssrf( +/// Every resolved address must satisfy the selected transport's policy; one +/// disallowed answer blocks the entire request, even when other answers pass. +async fn resolve_for_transport( host: &str, port: u16, + transport: JwksTransport, ) -> Result { + let addrs = resolve_host_addresses(host, port).await?; + validate_resolved_addresses(&addrs, transport) +} + +async fn resolve_host_addresses( + host: &str, + port: u16, +) -> Result, JwksFetchError> { // Fast path: if the host is already a parsed IP literal, skip the resolver. if let Ok(ip) = host.parse::() { - if is_not_global_unicast(&ip) { - return Err(JwksFetchError::InvalidUri); - } - return Ok(ip); + return Ok(vec![ip]); + } + + // Unit tests inject DNS answers through the same resolver path used by the + // HTTP fetcher, without changing the production resolver or using DNS. + #[cfg(test)] + if let Ok(addrs) = TEST_DNS_ANSWERS.try_with(|answers| answers.clone()) { + return Ok(addrs); } // Hostname path: use the tuple form to avoid IPv6-bracket ambiguity. @@ -230,17 +367,44 @@ pub(crate) async fn resolve_and_check_ssrf( .map_err(|_| JwksFetchError::NetworkError)? .map_err(|_| JwksFetchError::NetworkError)?; + Ok(addrs) +} + +fn validate_resolved_addresses( + addrs: &[std::net::IpAddr], + transport: JwksTransport, +) -> Result { if addrs.is_empty() { return Err(JwksFetchError::NetworkError); } - for ip in &addrs { - if is_not_global_unicast(ip) { - return Err(JwksFetchError::InvalidUri); - } + let any_disallowed = match transport { + JwksTransport::PublicHttps => addrs.iter().any(is_not_global_unicast), + JwksTransport::Mesh => addrs.iter().any(|ip| !is_mesh_address_allowed(ip)), + }; + if any_disallowed { + return Err(JwksFetchError::InvalidUri); } Ok(addrs[0]) } +fn is_mesh_address_allowed(ip: &std::net::IpAddr) -> bool { + match ip { + std::net::IpAddr::V4(addr) => { + let bits = u32::from(*addr); + addr.is_loopback() + || (bits & 0xff00_0000) == 0x0a00_0000 // 10.0.0.0/8 + || (bits & 0xfff0_0000) == 0xac10_0000 // 172.16.0.0/12 + || (bits & 0xffff_0000) == 0xc0a8_0000 // 192.168.0.0/16 + || (bits & 0xffc0_0000) == 0x6440_0000 // 100.64.0.0/10 + || ((bits & 0xf000_0000) == 0xf000_0000 + && *addr != std::net::Ipv4Addr::BROADCAST) + } + std::net::IpAddr::V6(addr) => { + addr.is_loopback() || (addr.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7 + } + } +} + #[derive(Clone)] struct CachedSnapshot { key_set: AssertionKeySet, @@ -328,8 +492,8 @@ pub struct IssuerJwksConfig { /// URLs, or raw response content appear in these variants. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum JwksFetchError { - /// Non-HTTPS scheme, embedded credentials, fragment, bare - /// private/reserved IP host, or DNS resolved to a private/reserved address. + /// The URI or a resolved address violated the selected transport's safety + /// rules. #[error("JWKS URI failed safety validation")] InvalidUri, /// Response body exceeded [`MAX_JWKS_RESPONSE_BYTES`]. @@ -349,11 +513,12 @@ pub enum JwksFetchError { /// Sealed injection seam for JWKS HTTP fetching. Only types inside `buzz_auth` /// may implement it — external types cannot name the private supertrait. /// -/// Implementations MUST: -/// - validate the URI (scheme, credentials, fragment, bare private-IP host) -/// before any I/O; -/// - resolve hostname targets and reject any private/reserved resolved address; +/// Raw `fetch_jwks` implementations MUST: +/// - enforce the public HTTPS URI and public-address rules before any I/O; +/// - resolve hostname targets and reject any address denied by the shared +/// public-address predicate; /// - deny redirects (3xx responses rejected as `NetworkError`); +/// - disable system proxies; /// - enforce a finite per-fetch deadline covering resolution, connect, headers, /// and body streaming — the entire operation must be bounded; /// - enforce [`MAX_JWKS_RESPONSE_BYTES`] via incremental streaming; @@ -364,16 +529,39 @@ pub trait JwksFetcher: super::verifier::sealed::Sealed + Send + Sync + 'static { &'a self, uri: &'a str, ) -> impl std::future::Future> + Send + 'a; + + /// Fetch through the sealed JWKS source contract. + /// + /// The default delegates to raw fetch and therefore uses its public HTTPS + /// policy; it does not infer mesh permission from a URI. The production + /// HTTP fetcher overrides this method to honor the transport sealed into + /// the contract. Raw calls always use public HTTPS. An override MUST select + /// URI and address validation only from that sealed mode, resolve once and + /// pin one checked address, deny redirects and proxies, bound the complete + /// fetch with a finite deadline, stream at most + /// [`MAX_JWKS_RESPONSE_BYTES`], reject non-2xx responses, and never fall + /// back to another transport or network path. The exact mesh address + /// allow-list is defined in the + /// [NIP-FI JWKS snapshot contract](../../../../../docs/nips/NIP-FI.md#jwks-snapshot). + fn fetch_for_contract<'a>( + &'a self, + contract: &'a JwksSourceContract, + ) -> impl std::future::Future> + Send + 'a { + self.fetch_jwks(contract.jwks_uri()) + } } -/// Production [`JwksFetcher`] backed by `reqwest`. Each call to `fetch_jwks` -/// builds a dedicated pinned client — no shared connection state between fetches. +/// Production [`JwksFetcher`] backed by `reqwest`. Each raw or contract-aware +/// fetch builds a dedicated pinned client, with no shared connection state. /// /// Per-fetch boundary enforcement: -/// - hostname DNS is resolved and every address checked against -/// `buzz_core::network::is_not_global_unicast` before the request is sent; -/// - the request is pinned to the validated address to prevent DNS rebinding -/// TOCTOU (the OS resolver is called once per fetch, not once per URL); +/// - public HTTPS retains its strict address deny-list; explicit mesh HTTP +/// checks addresses against the allow-list in the +/// [NIP-FI JWKS snapshot contract](../../../../../docs/nips/NIP-FI.md#jwks-snapshot); +/// - every DNS answer is validated, then the first permitted address is pinned +/// to prevent DNS rebinding TOCTOU (empty or mixed answers fail closed; the +/// OS resolver is called once per fetch); +/// - the client disables proxies and never follows redirects; /// - the complete operation (resolution, connect, headers, body streaming) is /// bounded by [`JWKS_REQUEST_TIMEOUT_SECS`] via `tokio::time::timeout`; /// - 3xx responses are rejected as `NetworkError` — redirects are never followed; @@ -384,7 +572,8 @@ pub struct HttpJwksFetcher; impl HttpJwksFetcher { /// Builds a new fetcher. Security invariants are enforced per-request in - /// `fetch_jwks` — each call constructs a dedicated pinned client. + /// `fetch_jwks` and `fetch_for_contract` — each call constructs a dedicated + /// pinned client. pub fn new() -> Self { Self } @@ -400,8 +589,26 @@ impl super::verifier::sealed::Sealed for HttpJwksFetcher {} impl JwksFetcher for HttpJwksFetcher { async fn fetch_jwks<'a>(&'a self, uri: &'a str) -> Result { + self.fetch_with_transport(uri, JwksTransport::PublicHttps) + .await + } + + fn fetch_for_contract<'a>( + &'a self, + contract: &'a JwksSourceContract, + ) -> impl std::future::Future> + Send + 'a { + self.fetch_with_transport(contract.jwks_uri(), contract.transport) + } +} + +impl HttpJwksFetcher { + async fn fetch_with_transport( + &self, + uri: &str, + transport: JwksTransport, + ) -> Result { with_deadline( - fetch_jwks_inner(uri), + fetch_jwks_inner_with_transport(uri, transport), std::time::Duration::from_secs(JWKS_REQUEST_TIMEOUT_SECS), ) .await @@ -409,8 +616,9 @@ impl JwksFetcher for HttpJwksFetcher { } /// Bound `fut` with a hard `tokio::time::timeout`. Elapsed maps to -/// `NetworkError`. Production passes `fetch_jwks_inner(uri)`; tests pass -/// `std::future::pending()` to verify the seam deterministically. +/// `NetworkError`. Production passes the complete fetch future. A helper test +/// uses `pending()`; a local HTTP test also exercises this wrapper while the +/// actual request waits for a response. async fn with_deadline(fut: F, timeout: std::time::Duration) -> Result where F: std::future::Future>, @@ -424,10 +632,10 @@ where /// /// The host is extracted via the typed `Url::host()` accessor, **not** /// `host_str()`. `host_str()` returns IPv6 literals with brackets (e.g. -/// `[2606:4700::1]`), which breaks `IpAddr::parse`: brackets are not valid, -/// so the fast path in `resolve_and_check_ssrf` would fail and fall through -/// to the DNS path, which may attempt to resolve `[2606:4700::1]` as a -/// hostname instead of an IP literal. +/// `[2606:4700::1]`), which breaks `IpAddr::parse`: brackets are not valid, so +/// the literal fast path in `resolve_host_addresses` would fail and fall +/// through to the DNS path, which may attempt to resolve the bracketed value +/// as a hostname. /// /// The extracted bare host string is also the correct input form for /// `reqwest::ClientBuilder::resolve(host, addr)`, whose key must match the @@ -459,19 +667,21 @@ pub(crate) fn extract_url_host_and_port(uri: &str) -> Result<(String, u16), Jwks Ok((host, port)) } -/// Inner fetch logic. Called only by `HttpJwksFetcher::fetch_jwks` via `with_deadline`. -async fn fetch_jwks_inner(uri: &str) -> Result { - // Full URI validation first — scheme, credentials, fragment, bare - // private-IP host. This enforces the JwksFetcher contract for direct - // callers of HttpJwksFetcher regardless of whether ProductionJwksSource - // pre-validated the URI. - validate_jwks_uri(uri)?; +/// Inner fetch logic. Called only with the mode sealed in a contract, or the +/// strict public mode for raw `fetch_jwks` calls. +async fn fetch_jwks_inner_with_transport( + uri: &str, + transport: JwksTransport, +) -> Result { + // Validate again at the I/O boundary. Raw calls always use public HTTPS; + // only the sealed contract method can select mesh HTTP. + validate_jwks_uri_for_transport(uri, transport)?; let (host, port) = extract_url_host_and_port(uri)?; // Resolve and check every IP before sending. Pins DNS to the validated // address to prevent rebinding TOCTOU between check and connect. - let safe_ip = resolve_and_check_ssrf(&host, port).await?; + let safe_ip = resolve_for_transport(&host, port, transport).await?; // Build a per-request client that: // - denies redirects (a 3xx to an internal host bypasses the URI check); @@ -552,7 +762,7 @@ impl ProductionJwksSource { /// Returns `None` when `configs` is empty or any two configs share the /// same `issuer` (duplicate issuers make trust configuration ambiguous). /// - /// Contract fields (`jwks_uri`, `refresh_interval_seconds`, + /// Contract fields (`jwks_uri`, transport, `refresh_interval_seconds`, and /// `key_snapshot_hard_deadline_seconds`) are pre-validated inside the /// embedded [`JwksSourceContract`] — no re-validation is performed here. pub fn new(configs: Vec, fetcher: F) -> Option { @@ -644,7 +854,7 @@ impl ProductionJwksSource { prev_generation: u64, ) -> Option<(CachedSnapshot, u64)> { let config = self.configs.get(issuer)?; - let body = match self.fetcher.fetch_jwks(config.contract.jwks_uri()).await { + let body = match self.fetcher.fetch_for_contract(&config.contract).await { Ok(b) => b, Err(err) => { warn!(error = %err, "nip-fi jwks fetch failed; will use cached snapshot if live"); @@ -850,6 +1060,9 @@ impl JwksFetcher for ToggleJwksFetcher { #[cfg(test)] mod tests; +#[cfg(test)] +mod mesh_tests; + /// A scripted JWKS fetcher for integration tests outside this crate. /// /// Returns pre-queued responses in FIFO order. When the queue is diff --git a/crates/buzz-auth/src/nip_fi/jwks/tests.rs b/crates/buzz-auth/src/nip_fi/jwks/tests.rs index 0aa092255c..f12a747a3d 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/tests.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/tests.rs @@ -574,13 +574,15 @@ async fn http_fetcher_rejects_private_ip_uri_before_connection() { #[tokio::test] async fn resolve_ssrf_rejects_ipv6_loopback_fast_path() { - let err = super::resolve_and_check_ssrf("::1", 443).await.unwrap_err(); + let err = super::resolve_for_transport("::1", 443, JwksTransport::PublicHttps) + .await + .unwrap_err(); assert_eq!(err, JwksFetchError::InvalidUri); } #[tokio::test] async fn resolve_ssrf_accepts_public_ipv6_fast_path() { - let ip = super::resolve_and_check_ssrf("2606:4700::1", 443) + let ip = super::resolve_for_transport("2606:4700::1", 443, JwksTransport::PublicHttps) .await .unwrap(); assert_eq!(ip, "2606:4700::1".parse::().unwrap()); @@ -1297,17 +1299,17 @@ fn jwks_contract_uri_canonicalization_convergence_and_divergence() { /// **Fix 2 — Public bracketed-IPv6 JWKS URI through the resolved-target and pin-input seam.** /// /// This seam test is network-free: both public `2606:4700::1` and site-local -/// `fec0::1` are IP literals, so `resolve_and_check_ssrf` takes the fast path -/// (`host.parse::()` then `is_not_global_unicast`) without any DNS -/// lookup. +/// `fec0::1` are IP literals, so `resolve_for_transport` with `PublicHttps` +/// takes the fast path (`host.parse::()` then `is_not_global_unicast`) +/// without any DNS lookup. /// /// The seam covers the three stages `fetch_jwks_inner` traverses in order: /// 1. `extract_url_host_and_port` — typed `Url::host()` yields bare /// `"2606:4700::1"`, not the bracketed `"[2606:4700::1]"` that /// `host_str()` returns. -/// 2. `resolve_and_check_ssrf(host, port)` — fast path: `host.parse::()` -/// succeeds only for the bare form, passes `is_not_global_unicast`, and -/// returns the `IpAddr`. +/// 2. `resolve_for_transport(host, port, JwksTransport::PublicHttps)` — the +/// `host.parse::()` fast path succeeds only for the bare form, passes +/// `is_not_global_unicast`, and returns the `IpAddr`. /// 3. Reqwest `.resolve(host, SocketAddr::new(ip, port))` uses the raw `host` /// string as its pin key. The key must equal the URL authority form — /// bare for IPv6, brackets forbidden. @@ -1317,7 +1319,8 @@ fn jwks_contract_uri_canonicalization_convergence_and_divergence() { /// reqwest connector; connector-boundary behavior is a runtime concern. /// /// For `fec0::1`: `extract_url_host_and_port` still extracts the bare address; -/// `resolve_and_check_ssrf` rejects it via `is_not_global_unicast`. +/// `resolve_for_transport` rejects it under the `PublicHttps` policy via +/// `is_not_global_unicast`. /// /// ## Mutation oracle /// Replace `Some(url::Host::Ipv6(addr)) => addr.to_string()` with @@ -1326,7 +1329,7 @@ fn jwks_contract_uri_canonicalization_convergence_and_divergence() { /// - `"[2606:4700::1]".parse::()` fails → SSRF fast path unreachable /// → public acceptance assertion flips red. /// - `is_not_global_unicast` is never called on `fec0::1` (the parse also -/// fails) → `resolve_and_check_ssrf` returns `NetworkError` not `InvalidUri` +/// fails) → `resolve_for_transport` returns `NetworkError` not `InvalidUri` /// → fec0 rejection-kind assertion flips red. /// - The pin-input equality assertion also flips red (bracket mismatch). #[tokio::test] @@ -1345,7 +1348,7 @@ async fn resolved_target_and_pin_key_seam_public_ipv6_and_fec0_rejection() { // ── Stage 2: IpAddr resolution (SSRF fast path) ─────────────────────────── // `host.parse::()` succeeds only for the bare form. This is exactly - // the fast path in `resolve_and_check_ssrf` that bypasses DNS. + // the fast path in `resolve_for_transport` that bypasses DNS. let ip: std::net::IpAddr = host .parse() .expect("bracket-free host must parse as IpAddr; mutation: bracketed form fails here"); @@ -1357,8 +1360,8 @@ async fn resolved_target_and_pin_key_seam_public_ipv6_and_fec0_rejection() { "2606:4700::1 must pass as globally reachable; mutation: SSRF check would reject it" ); - // Confirm resolve_and_check_ssrf accepts the public address (network-free fast path). - let resolved = super::resolve_and_check_ssrf(&host, port) + // Confirm the production resolver accepts the public address under PublicHttps. + let resolved = super::resolve_for_transport(&host, port, JwksTransport::PublicHttps) .await .expect("public IPv6 must be accepted by SSRF check"); assert_eq!( @@ -1404,8 +1407,8 @@ async fn resolved_target_and_pin_key_seam_public_ipv6_and_fec0_rejection() { bypasses this check (parse fails, NetworkError not InvalidUri)" ); - // resolve_and_check_ssrf must return InvalidUri for fec0::1. - let fec0_err = super::resolve_and_check_ssrf(&fec0_host, fec0_port) + // The production resolver must return InvalidUri for fec0::1 under PublicHttps. + let fec0_err = super::resolve_for_transport(&fec0_host, fec0_port, JwksTransport::PublicHttps) .await .unwrap_err(); assert_eq!( diff --git a/crates/buzz-auth/src/nip_fi/startup/tests.rs b/crates/buzz-auth/src/nip_fi/startup/tests.rs index 9275c117c8..a328c8e21e 100644 --- a/crates/buzz-auth/src/nip_fi/startup/tests.rs +++ b/crates/buzz-auth/src/nip_fi/startup/tests.rs @@ -146,6 +146,35 @@ fn enforce_jwks_contract_mismatch_rejects() { ); } +#[test] +fn enforce_policy_and_jwks_source_contract_mismatch_rejects() { + let issuer = "https://id.example"; + let mesh_contract = JwksSourceContract::new_mesh( + "http://mesh.example.test/.well-known/jwks.json".to_owned(), + 300, + 3600, + ) + .expect("valid mesh contract"); + let mesh_policy = IssuerPolicy::new( + issuer.to_owned(), + vec!["https://relay.example/api".to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![JwtAlgorithm::ES256], + 0, + 3600, + None, + mesh_contract, + ) + .expect("valid mesh policy"); + let mut registry = IssuerRegistry::new(); + registry.insert(mesh_policy); + + let error = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[make_jwks_config(issuer)]) + .expect_err("policy and JWKS source contracts must match"); + assert_eq!(error, NipFiStartupError::JwksContractMismatch); +} + /// Rejected regardless of whether a JWKS config is present — the verifier /// has no status witness to satisfy the freshness guarantee. #[test] diff --git a/crates/buzz-auth/tests/mesh_jwks_proxy.rs b/crates/buzz-auth/tests/mesh_jwks_proxy.rs new file mode 100644 index 0000000000..4a1424558f --- /dev/null +++ b/crates/buzz-auth/tests/mesh_jwks_proxy.rs @@ -0,0 +1,256 @@ +use std::io::{self, Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream, ToSocketAddrs}; +use std::process::Output; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use buzz_auth::nip_fi::{HttpJwksFetcher, JwksFetcher, JwksSourceContract}; + +const CHILD_MARKER: &str = "BUZZ_AUTH_JWKS_PROXY_TEST_CHILD"; +const CHILD_TARGET: &str = "BUZZ_AUTH_JWKS_PROXY_TEST_TARGET"; +const JWKS_BODY: &str = r#"{"keys":[{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","use":"sig","alg":"ES256","kid":"proxy-test"}]}"#; +const SHUTDOWN_PATH: &str = "/__mesh_jwks_proxy_test_shutdown__"; + +#[test] +fn mesh_fetch_for_contract_ignores_proxy_environment() { + if std::env::var_os(CHILD_MARKER).is_some() { + run_child_test(); + } else { + run_parent_test(); + } +} + +fn run_parent_test() { + let mut servers = LocalServers::start().expect("start local JWKS and proxy listeners"); + let proxy_url = format!("http://{}", servers.proxy_addr); + let target_url = format!("http://localhost:{}", servers.target_addr.port()); + let executable = std::env::current_exe().expect("current integration-test binary"); + + let mut command = tokio::process::Command::new(executable); + command + .args([ + "--exact", + "mesh_fetch_for_contract_ignores_proxy_environment", + "--nocapture", + ]) + .env(CHILD_MARKER, "1") + .env(CHILD_TARGET, target_url) + .env("HTTP_PROXY", &proxy_url) + .env("http_proxy", &proxy_url) + .env_remove("HTTPS_PROXY") + .env_remove("https_proxy") + .env_remove("ALL_PROXY") + .env_remove("all_proxy") + .env_remove("NO_PROXY") + .env_remove("no_proxy") + .env_remove("REQUEST_METHOD") + .kill_on_drop(true); + + // The child receives a controlled proxy environment at process creation, + // before it creates its current-thread Tokio runtime. Its test process + // cannot alter or race the environment of the other buzz-auth tests. + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build parent runtime"); + let output = runtime + .block_on(async { tokio::time::timeout(Duration::from_secs(20), command.output()).await }); + + let shutdown = servers.shutdown(); + shutdown.expect("stop local proxy and JWKS servers"); + let output = output.expect("proxy regression child must finish within 20 seconds"); + let output = output.expect("run proxy regression child"); + assert_child_succeeded(output); +} + +fn run_child_test() { + let target_url = std::env::var(CHILD_TARGET).expect("parent provides local JWKS URL"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build child current-thread runtime"); + + runtime.block_on(async move { + let default_client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("build default-proxy positive control client"); + let control = tokio::time::timeout( + Duration::from_secs(3), + default_client + .get(format!("{target_url}/proxy-control")) + .send(), + ) + .await + .expect("default client should reach the local proxy") + .expect("local proxy should return its positive-control response"); + assert_eq!(control.status(), reqwest::StatusCode::BAD_GATEWAY); + assert_eq!( + control + .headers() + .get("x-test-proxy") + .and_then(|value| value.to_str().ok()), + Some("intercepted"), + "positive control proves proxy environment variables are active" + ); + + let contract = JwksSourceContract::new_mesh(format!("{target_url}/jwks.json"), 300, 3600) + .expect("valid sealed mesh JWKS contract"); + let body = tokio::time::timeout( + Duration::from_secs(12), + HttpJwksFetcher::new().fetch_for_contract(&contract), + ) + .await + .expect("contract fetch is bounded") + .expect("mesh fetch must bypass the configured proxy and reach loopback"); + assert_eq!(body, JWKS_BODY); + }); +} + +fn assert_child_succeeded(output: Output) { + assert!( + output.status.success(), + "proxy regression child failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +struct LocalServers { + target_addr: SocketAddr, + proxy_addr: SocketAddr, + target_thread: Option>>, + proxy_thread: Option>>, +} + +impl LocalServers { + fn start() -> io::Result { + let target_addr = ("localhost", 0) + .to_socket_addrs()? + .next() + .filter(|address| address.ip().is_loopback()) + .ok_or_else(|| io::Error::other("localhost has no loopback address"))?; + let target_listener = TcpListener::bind(target_addr)?; + let proxy_listener = TcpListener::bind(("127.0.0.1", 0))?; + let target_addr = target_listener.local_addr()?; + let proxy_addr = proxy_listener.local_addr()?; + let target_thread = thread::spawn(move || serve_target(target_listener)); + let proxy_thread = thread::spawn(move || serve_proxy(proxy_listener)); + + Ok(Self { + target_addr, + proxy_addr, + target_thread: Some(target_thread), + proxy_thread: Some(proxy_thread), + }) + } + + fn shutdown(&mut self) -> Result<(), String> { + if self + .target_thread + .as_ref() + .is_some_and(|thread| !thread.is_finished()) + { + send_shutdown(self.target_addr); + } + if self + .proxy_thread + .as_ref() + .is_some_and(|thread| !thread.is_finished()) + { + send_shutdown(self.proxy_addr); + } + + join_server(&mut self.target_thread)?; + join_server(&mut self.proxy_thread) + } +} + +impl Drop for LocalServers { + fn drop(&mut self) { + let _ = self.shutdown(); + } +} + +fn join_server(handle: &mut Option>>) -> Result<(), String> { + let Some(handle) = handle.take() else { + return Ok(()); + }; + handle + .join() + .map_err(|_| "local server thread panicked".to_owned())? + .map_err(|error| error.to_string()) +} + +fn send_shutdown(address: SocketAddr) { + if let Ok(mut stream) = TcpStream::connect_timeout(&address, Duration::from_secs(1)) { + let _ = stream.write_all( + format!("GET {SHUTDOWN_PATH} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + .as_bytes(), + ); + } +} + +fn serve_proxy(listener: TcpListener) -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + set_stream_timeouts(&stream)?; + let request = read_request_headers(&mut stream)?; + if request + .lines() + .next() + .unwrap_or_default() + .contains(SHUTDOWN_PATH) + { + return Ok(()); + } + + stream.write_all( + b"HTTP/1.1 502 Bad Gateway\r\nX-Test-Proxy: intercepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) +} + +fn serve_target(listener: TcpListener) -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + set_stream_timeouts(&stream)?; + let request = read_request_headers(&mut stream)?; + let first_line = request.lines().next().unwrap_or_default(); + if first_line.contains(SHUTDOWN_PATH) { + return Ok(()); + } + + if first_line.starts_with("GET /jwks.json ") { + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + JWKS_BODY.len(), + JWKS_BODY + ); + return stream.write_all(response.as_bytes()); + } + + stream.write_all( + b"HTTP/1.1 418 Unexpected Target Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) +} + +fn set_stream_timeouts(stream: &TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_write_timeout(Some(Duration::from_secs(5))) +} + +fn read_request_headers(stream: &mut TcpStream) -> io::Result { + const MAX_HEADER_BYTES: usize = 16 * 1024; + + let mut request = Vec::new(); + let mut chunk = [0; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream.read(&mut chunk)?; + if read == 0 { + break; + } + request.extend_from_slice(&chunk[..read]); + if request.len() > MAX_HEADER_BYTES { + return Err(io::Error::other("request headers exceed test limit")); + } + } + Ok(String::from_utf8_lossy(&request).into_owned()) +} diff --git a/crates/buzz-relay/src/nip_fi_config.rs b/crates/buzz-relay/src/nip_fi_config.rs index 50a6c56157..519b2214a9 100644 --- a/crates/buzz-relay/src/nip_fi_config.rs +++ b/crates/buzz-relay/src/nip_fi_config.rs @@ -61,6 +61,7 @@ const MAX_CONNECTION_LIFETIME_SECS: u64 = 30 * 24 * 3600; /// "skew_seconds": 30, /// "maximum_assertion_age_seconds": 3600, /// "jwks_uri": "https://login.example.com/.well-known/jwks.json", +/// "jwks_transport": "public_https", /// "jwks_refresh_interval_seconds": 300, /// "jwks_hard_deadline_seconds": 86400, /// "maximum_command_age_seconds": 30, @@ -68,6 +69,23 @@ const MAX_CONNECTION_LIFETIME_SECS: u64 = 30 * 24 * 3600; /// } /// ] /// ``` +/// `jwks_transport` is optional and defaults to `public_https`. The `mesh` +/// form requires an HTTP URI with a DNS hostname (not an IP literal) and no +/// userinfo, query, fragment, or backslash. Every DNS answer is checked +/// against the positive address allow-list in the +/// [NIP-FI JWKS snapshot contract](../../../docs/nips/NIP-FI.md#jwks-snapshot); +/// empty or mixed answers deny, and the first checked address is pinned. +/// Selecting `mesh` is an operator assertion: the deployment must enforce +/// mutual TLS peer authentication and integrity protection, authorize the +/// JWKS endpoint, and restrict egress. The relay cannot verify those +/// properties, and unauthenticated HTTP permits key substitution and issuer +/// impersonation. +/// +/// For example: +/// ```json +/// { "jwks_uri": "http://mesh.example.test/.well-known/jwks.json", +/// "jwks_transport": "mesh" } +/// ``` /// The `require_attested_key` field is not part of this schema; S2 removed it /// from buzz-auth. S3 enforces key pairing structurally for every issuer. #[derive(Debug, serde::Deserialize)] @@ -90,8 +108,13 @@ pub(super) struct IssuerEnvConfig { pub skew_seconds: u64, /// `iat + maximum_assertion_age` residual bound in seconds. pub maximum_assertion_age_seconds: u64, - /// HTTPS endpoint serving the JWK Set for this issuer. + /// JWK Set endpoint for this issuer; HTTPS by default, or HTTP with the + /// explicitly selected mesh transport and its + /// [NIP-FI address allow-list](../../../docs/nips/NIP-FI.md#jwks-snapshot). pub jwks_uri: String, + /// Per-issuer JWKS transport. Unknown values are rejected during parsing. + #[serde(default)] + pub jwks_transport: JwksTransportEnvConfig, /// Seconds between JWKS refreshes. pub jwks_refresh_interval_seconds: u64, /// Hard deadline for accepting a JWKS snapshot in seconds. @@ -122,6 +145,18 @@ pub(super) enum TokenClassEnvConfig { AccessTokenAtJwt, } +/// Transport selected for one issuer's JWKS endpoint. +#[derive(Debug, Clone, Copy, Default, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(super) enum JwksTransportEnvConfig { + /// Existing public HTTPS behavior; used when the field is omitted. + #[default] + PublicHttps, + /// HTTP whose authentication, integrity, endpoint authorization, and + /// egress restrictions are enforced by the deployment. + Mesh, +} + /// One entry in the `BUZZ_NIP_FI_COMMUNITIES` JSON array. /// /// ```json @@ -586,13 +621,20 @@ fn build_issuer(entry: &IssuerEnvConfig) -> Result<(IssuerPolicy, IssuerJwksConf } }; - let jwks_contract = JwksSourceContract::new( - entry.jwks_uri.clone(), - entry.jwks_refresh_interval_seconds, - entry.jwks_hard_deadline_seconds, - ) + let jwks_contract = match entry.jwks_transport { + JwksTransportEnvConfig::PublicHttps => JwksSourceContract::new( + entry.jwks_uri.clone(), + entry.jwks_refresh_interval_seconds, + entry.jwks_hard_deadline_seconds, + ), + JwksTransportEnvConfig::Mesh => JwksSourceContract::new_mesh( + entry.jwks_uri.clone(), + entry.jwks_refresh_interval_seconds, + entry.jwks_hard_deadline_seconds, + ), + } .ok_or_else(|| { - "invalid JWKS source contract (check jwks_uri is HTTPS, \ + "invalid JWKS source contract (check jwks_transport and jwks_uri, \ refresh_interval < hard_deadline, and both are positive)" .to_string() })?; @@ -837,6 +879,100 @@ mod tests { }) } + #[test] + fn jwks_transport_defaults_to_public_and_is_per_issuer() { + let _guard = super::NIP_FI_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _env = EnvGuard::new(NIP_FI_VARS); + + let public = valid_enforce_issuer(); + let mut mesh = valid_enforce_issuer(); + mesh["issuer"] = "https://mesh-issuer.test".into(); + mesh["jwks_uri"] = "http://mesh.example.test/.well-known/jwks.json".into(); + mesh["jwks_transport"] = "mesh".into(); + + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::set_var("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"); + set_issuers(serde_json::json!([public, mesh]).to_string()); + let cfg = NipFiRelayConfig::from_env().expect("mixed transport config is valid"); + + let public_policy = cfg + .registry + .policy_for_issuer("https://issuer.test") + .expect("public issuer is registered"); + let mesh_policy = cfg + .registry + .policy_for_issuer("https://mesh-issuer.test") + .expect("mesh issuer is registered"); + let public_contract = JwksSourceContract::new( + "https://issuer.test/.well-known/jwks.json".to_owned(), + 300, + 3600, + ) + .expect("valid public contract"); + let mesh_contract = JwksSourceContract::new_mesh( + "http://mesh.example.test/.well-known/jwks.json".to_owned(), + 300, + 3600, + ) + .expect("valid mesh contract"); + + assert_eq!(public_policy.jwks_source_contract(), &public_contract); + assert_eq!(mesh_policy.jwks_source_contract(), &mesh_contract); + assert_eq!(public_policy.issuer(), "https://issuer.test"); + assert_eq!(mesh_policy.issuer(), "https://mesh-issuer.test"); + for policy in [public_policy, mesh_policy] { + assert_eq!(policy.command_audiences().len(), 1); + assert_eq!(policy.command_audiences()[0], "https://relay.test"); + assert_eq!(policy.community_audiences().len(), 1); + assert_eq!(policy.community_audiences()[0], "https://relay.test"); + } + assert_eq!(cfg.jwks_configs.len(), 2); + assert_eq!(cfg.jwks_configs[0].contract, public_contract); + assert_eq!(cfg.jwks_configs[1].contract, mesh_contract); + } + + #[test] + fn invalid_jwks_transport_is_rejected_without_echoing_uri() { + let _guard = super::NIP_FI_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _env = EnvGuard::new(NIP_FI_VARS); + + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::set_var("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"); + + let mut unknown = valid_enforce_issuer(); + unknown["jwks_uri"] = "https://sentinel-uri.example.test/jwks".into(); + unknown["jwks_transport"] = "sentinel-unknown-transport".into(); + set_issuers(serde_json::json!([unknown]).to_string()); + let error = NipFiRelayConfig::from_env().expect_err("unknown transport must fail"); + let message = error.to_string(); + assert!(message.contains("BUZZ_NIP_FI_ISSUERS")); + assert!(!message.contains("sentinel-uri.example.test")); + assert!(!message.contains("sentinel-unknown-transport")); + + let mut implicit_http = valid_enforce_issuer(); + implicit_http["jwks_uri"] = "http://sentinel-uri.example.test/jwks".into(); + set_issuers(serde_json::json!([implicit_http]).to_string()); + let error = NipFiRelayConfig::from_env() + .expect_err("HTTP without an explicit mesh transport must fail"); + let message = error.to_string(); + assert!(message.contains("invalid JWKS source contract")); + assert!(!message.contains("sentinel-uri.example.test")); + + let mut wrong_scheme = valid_enforce_issuer(); + wrong_scheme["jwks_uri"] = "https://sentinel-uri.example.test/jwks".into(); + wrong_scheme["jwks_transport"] = "mesh".into(); + set_issuers(serde_json::json!([wrong_scheme]).to_string()); + let error = + NipFiRelayConfig::from_env().expect_err("mesh transport must reject HTTPS scheme"); + let message = error.to_string(); + assert!(message.contains("invalid JWKS source contract")); + assert!(!message.contains("sentinel-uri.example.test")); + } + #[test] fn enforce_without_assertion_age_fails_closed() { let _guard = super::NIP_FI_ENV_LOCK diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 5329404b75..3d71a7c436 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -170,6 +170,9 @@ TransportContractId = H(canonical transport contract) association, audience, token class, allowed algorithms, key-source contract, identity/key/claim mapping, time and size rules, and compiled verifier behavior. JWKS key rotation changes the snapshot, not the policy ID. +The key-source contract includes the JWKS transport. A mesh transport tag is +encoded only when `jwks_transport` is `mesh`; `public_https` is represented by +the absence of that tag. `TransportContractId` covers the client-attached field, parsing, attachment, and no-fallback semantics. @@ -228,9 +231,28 @@ regardless of issuer policy. Each issuer policy configures: -- `jwks_uri`: HTTPS URI selecting the authenticated key source. SSRF-protected - at both URI validation and DNS-resolution time; no credentials, fragments, - or private-IP endpoints accepted. +- `jwks_uri`: selects the authenticated key source. With the default + `public_https` transport, it MUST use HTTPS and contain no credentials or + fragment. DNS address checks and pinning are specified under + [Security considerations](#security-considerations). +- `jwks_transport`: optional per-issuer transport, defaulting to + `public_https`. The explicit value `mesh` permits an HTTP URI only when it + uses a DNS hostname rather than an IP literal and has no userinfo, query, + fragment, or backslash. The only accepted IPv4 ranges are RFC 1918 (`10/8`, + `172.16/12`, and `192.168/16`), CGNAT (`100.64/10`), reserved `240/4` except + `255.255.255.255`, and loopback `127/8`. The only accepted IPv6 ranges or + addresses are ULA (`fc00::/7`) and `::1`. Every other IPv4 or IPv6 class is + rejected. IPv4-mapped, IPv4-compatible, NAT64, 6to4, and SIIT-translated + IPv6 addresses are also rejected; embedded IPv4 does not inherit eligibility. + Every DNS answer is checked and the first allowed address is pinned. Empty or + mixed answers deny the fetch. Loopback is accepted for isolated local use; + in production it can reach services on the relay host, so operators MUST + constrain endpoint selection and egress accordingly. Mesh transport does not + authenticate itself: selecting it is an operator assertion that the + deployment enforces mutual TLS peer authentication and integrity protection, + endpoint authorization, and egress restrictions. The relay cannot verify + those properties. Unauthenticated HTTP permits JWKS key substitution and + issuer impersonation. There is no public-network fallback. - `refresh_interval_seconds`: positive, ≤ 1 year, strictly less than `key_snapshot_hard_deadline_seconds`. - `key_snapshot_hard_deadline_seconds`: the outer time bound after which no @@ -974,10 +996,15 @@ gap by requiring assertion verification on every protected HTTP request. The per-request re-verification model means there is no cached admission window; a deny-set entry takes effect on the very next request. -**SSRF.** The JWKS fetcher MUST enforce SSRF protection: HTTPS-only URI -validation, DNS resolution with IP deny-list enforcement, address pinning to -prevent DNS rebinding TOCTOU, and redirect denial. The IP deny-list covers -the complete IANA Special-Purpose address registry. +**JWKS SSRF.** `public_https` MUST validate an HTTPS URI, reject every +resolved address on its IP deny-list, pin the checked address to prevent DNS +rebinding, and deny redirects. The IP deny-list covers the complete IANA +Special-Purpose address registry. The `mesh` address allow-list and operator +security boundary are defined in the [JWKS snapshot contract](#jwks-snapshot). +Both modes MUST resolve once, validate every answer before pinning the first +permitted address, disable proxies, deny redirects, enforce a 10-second +whole-fetch deadline, cap the streamed response at 512 KiB, reject non-2xx +responses, and provide no fallback network path. **Issuer compromise.** A compromised assertion issuer can impersonate any identity but cannot prove possession of the assertion-named Nostr key. The NIP-42 diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 1a51c4e637..f6aab81f24 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -87,6 +87,12 @@ run_unit_tests() { run_test_step "buzz-auth unit tests" \ cargo test -p buzz-auth --lib -- --nocapture + # Mesh JWKS proxy-bypass regression: infra-free loopback I/O with + # child-process environment isolation. --lib does not select its binary. + # Keep this fallback in step with `just test-unit`. + run_test_step "buzz-auth mesh JWKS proxy regression" \ + cargo test -p buzz-auth --test mesh_jwks_proxy -- --nocapture + # Cross-pod disconnect payload tests (NIP-FI and community archive fence; # infra-free). Mirrors `just test-unit`. run_test_step "buzz-pubsub conn_control tests" \