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" \