diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 138d192fa12..9905e97b767 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -361,7 +361,7 @@ pub const ALL_KINDS: &[u32] // 80 entries (KIND_AUTH excluded — never stored) |----------|---------| | `filters_match(filters, event)` | OR across filters, AND within each filter. Includes NIP-01 prefix matching on event IDs. | | `verify_event(event)` | Schnorr signature + SHA-256 ID check. CPU-bound — callers use `spawn_blocking`. | -| `is_private_ip(ip)` | SSRF protection: IPv4 unspecified/loopback/private/link-local/CGNAT/benchmarking/broadcast + IPv6 loopback/ULA/link-local/multicast/documentation + IPv4-mapped IPv6. | +| `is_not_global_unicast(ip)` | SSRF protection: enumerated-deny policy — blocks a specific set of non-public address classes and accepts everything else (including addresses not covered by an explicit deny rule, e.g. `fe00::1`). Blocked IPv4 classes: loopback, private (RFC 1918), link-local, CGNAT (RFC 6598), benchmarking (RFC 2544), IETF Protocol Assignments (192.0.0.0/24, exceptions: 192.0.0.9 PCP anycast, 192.0.0.10 TURN anycast), documentation (RFC 5737: 192.0.2/24, 198.51.100/24, 203.0.113/24), deprecated 6to4 relay anycast (192.88.99.0/24, RFC 7526), multicast (RFC 5771, 224/4), reserved/class-E (240/4). Blocked IPv6 classes: loopback, unspecified, ULA (fc00::/7), link-local (fe80::/10), deprecated site-local (fec0::/10, RFC 3879), multicast (ff00::/8), IETF Protocol Assignments envelope (2001::/23, global exceptions: 2001:1::1–::3 anycast, 2001:3::/32 AMT, 2001:4:112::/48 AS112-v6, 2001:20::/28 ORCHIDv2, 2001:30::/28 DETs), documentation (2001:db8::/32, 3fff::/20), 6to4 (2002::/16), Discard-Only (100::/64), Dummy prefix (100:0:0:1::/64), SRv6 SIDs (5f00::/16), NAT64 local-use (64:ff9b:1::/48). IPv4 embedded in mapped, compatible, NAT64 well-known (64:ff9b::/96), and SIIT IPv4-translated (::ffff:0:0:0/96) forms checked recursively. Compat alias: `is_private_ip`. | **Does NOT:** store events, make network calls, spawn tasks, or depend on any async runtime. @@ -746,12 +746,10 @@ Every security-sensitive operation uses an explicit, verified pattern. No implic ### SSRF Protection -`is_private_ip()` in `buzz-core` covers: -- IPv4: unspecified (0.0.0.0/8), loopback (127.0.0.0/8), private (10/8, 172.16/12, 192.168/16), link-local (169.254/16), CGNAT (100.64/10), benchmarking (198.18/15), broadcast (255.255.255.255) -- IPv6: loopback (::1), ULA (fc00::/7), link-local (fe80::/10), multicast (ff00::/8), documentation (2001:db8::/32) -- IPv4-mapped IPv6 (::ffff:0:0/96) — recursively checks the embedded IPv4 address +`is_not_global_unicast(ip)` (compat alias `is_private_ip`) in `buzz-core` is an enumerated-deny policy: it blocks a specific set of non-public address classes and accepts everything else, including addresses not covered by an explicit deny rule (e.g. `fe00::1`). Blocked IPv4 classes: loopback (127.0.0.0/8), private RFC 1918 (10/8, 172.16/12, 192.168/16), link-local (169.254/16), unspecified (0/8), broadcast, CGNAT/RFC 6598 (100.64/10), benchmarking/RFC 2544 (198.18/15), IETF Protocol Assignments (192.0.0.0/24, globally reachable exceptions: 192.0.0.9 PCP anycast RFC 7723 and 192.0.0.10 TURN anycast RFC 8155), documentation/RFC 5737 (192.0.2/24, 198.51.100/24, 203.0.113/24), deprecated 6to4 relay anycast (192.88.99.0/24, RFC 7526, global=None/blank → conservative deny), multicast/RFC 5771 (224/4), and reserved class-E (240/4). Blocked IPv6 classes: loopback (::1), unspecified (::), ULA (fc00::/7), link-local (fe80::/10), deprecated site-local (fec0::/10, RFC 3879), multicast (ff00::/8), IETF Protocol Assignments envelope (2001::/23, global exceptions: 2001:1::1–::3 PCP/TURN/DNS-SD anycast, 2001:3::/32 AMT RFC 7450, 2001:4:112::/48 AS112-v6 RFC 7535, 2001:20::/28 ORCHIDv2 RFC 7343, 2001:30::/28 DETs RFC 9374), documentation (2001:db8::/32 RFC 3849, 3fff::/20 RFC 9637), 6to4 (2002::/16, RFC 3056), Discard-Only (100::/64, RFC 6666), Dummy IPv6 Prefix (100:0:0:1::/64, RFC 9780), SRv6 SIDs (5f00::/16, RFC 9252), and NAT64 local-use (64:ff9b:1::/48, RFC 8215). IPv4 embedded in IPv4-mapped, IPv4-compatible, and NAT64 well-known (64:ff9b::/96, RFC 6052) forms is checked recursively against the IPv4 table; SIIT IPv4-translated (::ffff:0:0:0/96) follows the same path. -Applied in: `buzz-workflow` (CallWebhook action), `buzz-core` (shared utility). +Applied in: `buzz-auth` (JWKS boundary), `buzz-workflow` (CallWebhook action), +desktop `link_preview` (SSRF check). ### Audit Integrity diff --git a/Cargo.lock b/Cargo.lock index 9544a63b899..552a12ca155 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -939,10 +939,12 @@ dependencies = [ "base64 0.22.1", "buzz-core", "chrono", + "futures-util", "hex", "jsonwebtoken", "nostr 0.44.7", "rand 0.10.1", + "reqwest 0.13.4", "serde", "serde_json", "sha2 0.11.0", diff --git a/Cargo.toml b/Cargo.toml index d6ee839f1b0..0af365f52fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,7 +104,7 @@ chrono = { version = "0.4", features = ["serde"] } jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs"] } # HTTP client (webhook delivery) -reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false } +reqwest = { version = "0.13", features = ["json", "rustls", "stream"], default-features = false } # Cryptography sha2 = "0.11" diff --git a/crates/buzz-auth/Cargo.toml b/crates/buzz-auth/Cargo.toml index e4ac539a988..6cbe491e2c8 100644 --- a/crates/buzz-auth/Cargo.toml +++ b/crates/buzz-auth/Cargo.toml @@ -14,6 +14,7 @@ dev = [] [dev-dependencies] # `use_pem` enables EncodingKey::from_ec_pem for minting ES256 test assertions. jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs", "use_pem"] } +tokio = { workspace = true, features = ["test-util"] } [dependencies] buzz-core = { workspace = true } @@ -21,6 +22,8 @@ base64 = { workspace = true } chrono = { workspace = true } jsonwebtoken = { workspace = true } nostr = { workspace = true } +futures-util = { workspace = true } +reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index e6473cdd54b..c21872351f1 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -46,12 +46,14 @@ pub use rate_limit::{ pub use scope::{parse_scopes, Scope}; pub use nip_fi::{ - AssertionKeySet, AssertionPolicyId, CanonicalCapabilities, ClientSubjectPosture, - ConfidentialAssertion, DenialClass, FederatedAssertionVerifier, FederatedIdentity, - FreshnessClass, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry, - RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass, TransportContractId, - VerifiedAssertion, VerifierError, CLIENT_ATTACHED_HEADER, NOSTR_PUBKEY_CLAIM, - OAUTH_CLIENT_ID_CLAIM, + validate_nip_fi_config, AssertionKeySet, AssertionPolicyId, CanonicalCapabilities, + ClientSubjectPosture, ConfidentialAssertion, DenialClass, FederatedAssertionVerifier, + FederatedIdentity, FederatedIdentityDiscovery, FreshnessClass, HttpJwksFetcher, + IssuerJwksConfig, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry, + JwksFetchError, JwksFetcher, JwksSourceContract, NipFiMode, NipFiStartupError, + ProductionJwksSource, RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass, + TransportContractId, VerifiedAssertion, VerifierError, CLIENT_ATTACHED_HEADER, + NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, }; #[cfg(any(test, feature = "test-utils"))] diff --git a/crates/buzz-auth/src/nip_fi/config.rs b/crates/buzz-auth/src/nip_fi/config.rs index 638b5f5363b..37628669a12 100644 --- a/crates/buzz-auth/src/nip_fi/config.rs +++ b/crates/buzz-auth/src/nip_fi/config.rs @@ -27,6 +27,8 @@ use sha2::{Digest, Sha256}; use std::collections::BTreeMap; use std::fmt; +use super::jwks::JwksSourceContract; + /// Maximum accepted length of an `iss` or `aud` string. const MAX_URI_LEN: usize = 2_048; /// Maximum accepted length of a claim name. @@ -349,6 +351,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. + jwks_source_contract: JwksSourceContract, id: AssertionPolicyId, } @@ -382,6 +389,10 @@ pub enum IssuerPolicyError { /// so subject classification could not be total and mutually exclusive. #[error("subject class contract is not exclusive")] NonExclusiveSubjectClass, + /// The [`JwksSourceContract`] was not valid — invalid URI, zero or + /// out-of-range timing, or `refresh_interval >= hard_deadline`. + #[error("invalid JWKS source contract")] + InvalidJwksSourceContract, } impl IssuerPolicy { @@ -397,6 +408,7 @@ impl IssuerPolicy { skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, + jwks_source_contract: JwksSourceContract, ) -> Result { // Identity-bearing strings are validated for bounds but never mutated: // exact `iss`/`aud`/`sub` bytes select policies and form the identity @@ -459,6 +471,7 @@ impl IssuerPolicy { skew_seconds, maximum_assertion_age_seconds, maximum_status_age_seconds, + &jwks_source_contract, ); Ok(Self { @@ -471,6 +484,7 @@ impl IssuerPolicy { skew_seconds, maximum_assertion_age_seconds, maximum_status_age_seconds, + jwks_source_contract, id, }) } @@ -524,6 +538,11 @@ impl IssuerPolicy { pub const fn id(&self) -> AssertionPolicyId { self.id } + + /// The authenticated key-source contract for this policy's JWKS endpoint. + pub fn jwks_source_contract(&self) -> &JwksSourceContract { + &self.jwks_source_contract + } } /// A closed set of issuer policies keyed by exact `iss`. Selection preserves @@ -560,6 +579,12 @@ impl IssuerRegistry { pub fn is_empty(&self) -> bool { self.policies.is_empty() } + + /// Iteration order is deliberately unspecified; callers must not depend on + /// registration order. + pub fn all_policies(&self) -> impl Iterator { + self.policies.values() + } } /// Sort and deduplicate a set-valued list of strings into its canonical form. @@ -625,6 +650,7 @@ fn derive_assertion_policy_id( skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, + jwks_source_contract: &JwksSourceContract, ) -> AssertionPolicyId { let mut hasher = Sha256::new(); hasher.update(b"buzz:nip-fi:assertion-policy:v1\0"); @@ -680,6 +706,23 @@ fn derive_assertion_policy_id( hasher.update(skew_seconds.to_be_bytes()); 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. + hasher.update(b"jwks-source-contract\0"); + hash_field(&mut hasher, jwks_source_contract.jwks_uri().as_bytes()); + hasher.update( + jwks_source_contract + .refresh_interval_seconds() + .to_be_bytes(), + ); + hasher.update( + jwks_source_contract + .key_snapshot_hard_deadline_seconds() + .to_be_bytes(), + ); AssertionPolicyId(hasher.finalize().into()) } diff --git a/crates/buzz-auth/src/nip_fi/discovery.rs b/crates/buzz-auth/src/nip_fi/discovery.rs new file mode 100644 index 00000000000..8d1b1500b12 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/discovery.rs @@ -0,0 +1,66 @@ +//! NIP-11 federated-identity discovery output. +//! +//! [`FederatedIdentityDiscovery`] serializes to the `federated_identity` +//! object required by the NIP-FI.md "Discovery" section of the NIP-11 relay +//! information document. +//! +//! ## Privacy invariants +//! +//! The discovery object MUST NOT contain: enrollment mode, TOFU posture, +//! issuer URLs, audiences, claim names, tenant IDs, or deployment-local +//! identifiers. For a fixed set of claimed profiles the complete output is +//! byte-identical across every enrollment policy and lifecycle state. +//! [FI-TRACE-DISCOVERY-PRIVATE] +//! +//! ## Offline-jwt residual bound +//! +//! `maximum_residual_upstream_revocation_seconds` is `null` for `offline-jwt` +//! deployments. An offline-jwt deployment MUST NOT advertise a finite value +//! here (NIP-FI.md:259-266). + +use serde::{Deserialize, Serialize}; + +/// The `assertion_freshness` sub-object in the `federated_identity` discovery +/// document. Describes the claimed freshness posture without exposing any +/// issuer or deployment-private state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssertionFreshnessDiscovery { + /// The wire string identifying the freshness class. + pub class: FreshnessClassDiscovery, + /// `null` for `offline-jwt`; advertising a finite bound here requires a + /// live status witness that is not yet implemented. + pub maximum_residual_upstream_revocation_seconds: Option, +} + +/// The freshness class as a stable NIP-FI wire string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum FreshnessClassDiscovery { + /// No revocation bound is claimed; JWKS snapshot validation only. + OfflineJwt, +} + +/// The `federated_identity` NIP-11 discovery object. Fields never expose +/// enrollment mode, issuer, audience, or private state. +/// [FI-TRACE-DISCOVERY-PRIVATE] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FederatedIdentityDiscovery { + /// Fixed value `"client-attached"` for the core NIP-FI transport mode. + pub core: String, + /// The freshness contract claimed by this deployment. + pub assertion_freshness: AssertionFreshnessDiscovery, +} + +impl FederatedIdentityDiscovery { + /// The only supported posture: claims no residual revocation bound, which + /// is the honest description of JWKS-only assertion verification. + pub fn offline_jwt() -> Self { + Self { + core: "client-attached".to_owned(), + assertion_freshness: AssertionFreshnessDiscovery { + class: FreshnessClassDiscovery::OfflineJwt, + maximum_residual_upstream_revocation_seconds: None, + }, + } + } +} diff --git a/crates/buzz-auth/src/nip_fi/jwks/mod.rs b/crates/buzz-auth/src/nip_fi/jwks/mod.rs new file mode 100644 index 00000000000..618ee6b0696 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/jwks/mod.rs @@ -0,0 +1,738 @@ +//! JWKS discovery, snapshot caching, and the production [`IssuerKeySource`] +//! implementation for federated-assertion verification. +//! +//! ## Design invariants +//! +//! - **Issuer binding is sealed.** [`ProductionJwksSource`] builds each +//! [`AssertionKeySet`] using the crate-private constructor and stores it +//! keyed by the exact `iss` it authenticates. A caller cannot relabel one +//! issuer's JWKS as another's — the cross-issuer bypass is closed at both +//! the request seam (the verifier re-checks `iss`) and here. +//! +//! - **No stale-key fallback.** On fetch error the source returns the current +//! snapshot if it is within its hard deadline, or `None`. It never serves +//! an expired snapshot. [FI-TRACE-JWKS-REMOVE] +//! +//! - **Bounded resource acquisition.** HTTP response streaming stops at +//! [`MAX_JWKS_RESPONSE_BYTES`] + 1 byte before any allocation for parsing. +//! Key count is bounded by [`super::config::MAX_JWKS_KEYS`] inside +//! [`AssertionKeySet::new`]. +//! +//! - **Coalesced refresh.** A single in-flight refresh per issuer prevents +//! thundering-herd. Concurrent callers observe the snapshot just after the +//! racing refresh commits. +//! +//! - **No secrets or key material in errors or logs.** [`JwksFetchError`] +//! carries only non-sensitive diagnostic codes. + +use super::config::MAX_JWKS_KEYS; +use super::verifier::{AssertionKeySet, IssuerKeySource}; +use buzz_core::network::is_not_global_unicast; +use chrono::{DateTime, Duration, Utc}; +use futures_util::StreamExt as _; +use jsonwebtoken::jwk::JwkSet; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; +use tracing::warn; +use url::Url; + +/// 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 + +/// Hard upper bound on JWKS timing fields. Values above this are rejected at +/// config construction to prevent `u64`→`i64` conversion overflow and Chrono +/// range panics when computing snapshot deadlines. +pub const MAX_JWKS_TIMING_SECONDS: u64 = 365 * 24 * 3600; // 1 year + +/// Hard deadline for the complete JWKS fetch: hostname resolution, connect, +/// headers, and body streaming combined. Applied via `tokio::time::timeout` +/// 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. +pub fn validate_jwks_uri(uri: &str) -> Result<(), JwksFetchError> { + let parsed = Url::parse(uri).map_err(|_| JwksFetchError::InvalidUri)?; + if parsed.scheme() != "https" { + return Err(JwksFetchError::InvalidUri); + } + // Credentials in the URI are never legitimate for a public JWKS endpoint + // and would be forwarded to the server, leaking material in logs. + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(JwksFetchError::InvalidUri); + } + // Fragments are client-side only; their presence indicates a misconfigured URI. + if parsed.fragment().is_some() { + return Err(JwksFetchError::InvalidUri); + } + // Reject bare private/reserved IP targets at construction time. + if let Some(url::Host::Ipv4(addr)) = parsed.host() { + if is_not_global_unicast(&std::net::IpAddr::V4(addr)) { + return Err(JwksFetchError::InvalidUri); + } + } + if let Some(url::Host::Ipv6(addr)) = parsed.host() { + if is_not_global_unicast(&std::net::IpAddr::V6(addr)) { + return Err(JwksFetchError::InvalidUri); + } + } + Ok(()) +} + +/// The authenticated key-source contract owned by one [`IssuerPolicy`]. +/// +/// Encodes the three 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. +/// - `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 +/// time rule; the per-snapshot absolute deadline that flows into every +/// sealed [`VerifiedAssertion`][crate::nip_fi::VerifiedAssertion]'s +/// revalidation dependencies derives from this. +/// +/// This type is the single source of truth for these fields. `IssuerJwksConfig` +/// is built from it (pairing it with the bare issuer string) rather than +/// independently restating the same values. Having both types carry independent +/// 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. +/// +/// ## Why these fields are contract, not mutable state +/// +/// Per the settled NIP-FI spec ("Policy identity and snapshots"): +/// `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*. +#[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 + /// identically. Validated at construction; only stored after parse succeeds. + jwks_uri: String, + /// Positive, ≤ [`MAX_JWKS_TIMING_SECONDS`], strictly less than + /// `key_snapshot_hard_deadline_seconds`. + refresh_interval_seconds: u64, + /// Positive, ≤ [`MAX_JWKS_TIMING_SECONDS`], strictly greater than + /// `refresh_interval_seconds`. + key_snapshot_hard_deadline_seconds: u64, +} + +impl JwksSourceContract { + /// Validate and seal the three JWKS source fields. + /// + /// Rejects: + /// - `jwks_uri` that fails [`validate_jwks_uri`] + /// - zero `refresh_interval_seconds` or `key_snapshot_hard_deadline_seconds` + /// - `refresh_interval_seconds >= key_snapshot_hard_deadline_seconds` (the + /// hard deadline must be strictly greater so a snapshot is fresh for at + /// least one refresh cycle) + /// - either timing field exceeding [`MAX_JWKS_TIMING_SECONDS`] + pub fn new( + jwks_uri: String, + refresh_interval_seconds: u64, + key_snapshot_hard_deadline_seconds: u64, + ) -> Option { + if refresh_interval_seconds == 0 + || key_snapshot_hard_deadline_seconds == 0 + || key_snapshot_hard_deadline_seconds <= refresh_interval_seconds + || refresh_interval_seconds > MAX_JWKS_TIMING_SECONDS + || key_snapshot_hard_deadline_seconds > MAX_JWKS_TIMING_SECONDS + { + return None; + } + // Parse once, reject via validate_jwks_uri'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, + // 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. + 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() { + return None; + } + Some(Self { + jwks_uri: canonical_uri, + refresh_interval_seconds, + key_snapshot_hard_deadline_seconds, + }) + } + + /// The validated JWKS endpoint URI. + pub fn jwks_uri(&self) -> &str { + &self.jwks_uri + } + + /// Seconds between successive JWKS refreshes. + pub const fn refresh_interval_seconds(&self) -> u64 { + self.refresh_interval_seconds + } + + /// Hard upper bound (from fetch time) on how long a snapshot may be served. + pub const fn key_snapshot_hard_deadline_seconds(&self) -> u64 { + self.key_snapshot_hard_deadline_seconds + } +} + +/// Resolve `host:port` to IP addresses and reject if any are private/reserved. +/// +/// Returns the first safe 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. +/// +/// 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( + host: &str, + port: u16, +) -> Result { + // 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); + } + + // Hostname path: use the tuple form to avoid IPv6-bracket ambiguity. + let host_owned = host.to_owned(); + let addrs: Vec = tokio::task::spawn_blocking(move || { + use std::net::ToSocketAddrs; + (host_owned.as_str(), port) + .to_socket_addrs() + .map(|iter| iter.map(|sa| sa.ip()).collect::>()) + }) + .await + .map_err(|_| JwksFetchError::NetworkError)? + .map_err(|_| JwksFetchError::NetworkError)?; + + if addrs.is_empty() { + return Err(JwksFetchError::NetworkError); + } + for ip in &addrs { + if is_not_global_unicast(ip) { + return Err(JwksFetchError::InvalidUri); + } + } + Ok(addrs[0]) +} + +#[derive(Clone)] +struct CachedSnapshot { + key_set: AssertionKeySet, + fetched_at: DateTime, + hard_deadline: DateTime, + /// SHA-256 of the raw JWKS bytes. Suppresses generation advances when the + /// document is unchanged between refreshes. [FI-TRACE-JWKS-ADD/REMOVE] + content_digest: [u8; 32], +} + +struct IssuerState { + snapshot: Option, + /// Advances only when `content_digest` changes; never wraps (saturating). + generation_counter: u64, + /// Owned permit for in-flight refresh. Held across the complete fetch + + /// state commit; dropped automatically if the caller future is cancelled. + /// `try_lock_owned()` succeeds iff no refresh is in progress. + refresh_permit: Arc>, +} + +impl IssuerState { + fn new() -> Self { + Self { + snapshot: None, + generation_counter: 0, + refresh_permit: Arc::new(tokio::sync::Mutex::new(())), + } + } +} + +/// Per-issuer JWKS endpoint configuration. Pairs the exact `iss` value with +/// the policy-owned [`JwksSourceContract`] that was already validated at +/// [`IssuerPolicy`][super::config::IssuerPolicy] construction. +/// +/// `IssuerJwksConfig` is the single combination of issuer string and contract +/// that `ProductionJwksSource` operates on. Because the contract fields are +/// sealed inside [`JwksSourceContract`] and validated there, this type carries +/// no independent copies of those values — startup validation enforces that the +/// contract embedded here matches the one carried by the corresponding policy. +#[derive(Debug, Clone)] +pub struct IssuerJwksConfig { + /// The exact `iss` value this config authenticates. Must match the + /// corresponding [`IssuerPolicy`][super::config::IssuerPolicy] exactly. + pub issuer: String, + /// The validated key-source contract owned by the matching policy. Carries + /// the JWKS URI, refresh interval, and hard deadline — validated at + /// [`JwksSourceContract::new`], not re-validated here. + pub contract: JwksSourceContract, +} + +/// Reason a JWKS fetch or parse operation failed. No key material, issuer +/// 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. + #[error("JWKS URI failed safety validation")] + InvalidUri, + /// Response body exceeded [`MAX_JWKS_RESPONSE_BYTES`]. + #[error("JWKS response exceeded size limit")] + ResponseTooLarge, + /// Network failure, TLS error, request timeout, or non-2xx status. + #[error("JWKS HTTP request failed")] + NetworkError, + /// Response body was not parseable as a JWK Set. + #[error("JWKS response was not parseable")] + ParseError, + /// Parsed key set was empty or exceeded [`super::config::MAX_JWKS_KEYS`]. + #[error("JWKS key set bounds violation")] + KeyCountBoundsViolation, +} + +/// 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; +/// - deny redirects (3xx responses rejected as `NetworkError`); +/// - 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; +/// - reject non-2xx responses. +pub trait JwksFetcher: super::verifier::sealed::Sealed + Send + Sync + 'static { + /// Fetch and return the raw JSON body from the given JWKS URI. + fn fetch_jwks<'a>( + &'a self, + uri: &'a str, + ) -> impl std::future::Future> + Send + 'a; +} + +/// Production [`JwksFetcher`] backed by `reqwest`. Each call to `fetch_jwks` +/// builds a dedicated pinned client — no shared connection state between fetches. +/// +/// 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); +/// - 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; +/// - the body is streamed incrementally and stopped at +/// [`MAX_JWKS_RESPONSE_BYTES`] + 1. +#[derive(Clone, Debug)] +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. + pub fn new() -> Self { + Self + } +} + +impl Default for HttpJwksFetcher { + fn default() -> Self { + Self::new() + } +} + +impl super::verifier::sealed::Sealed for HttpJwksFetcher {} + +impl JwksFetcher for HttpJwksFetcher { + async fn fetch_jwks<'a>(&'a self, uri: &'a str) -> Result { + with_deadline( + fetch_jwks_inner(uri), + std::time::Duration::from_secs(JWKS_REQUEST_TIMEOUT_SECS), + ) + .await + } +} + +/// 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. +async fn with_deadline(fut: F, timeout: std::time::Duration) -> Result +where + F: std::future::Future>, +{ + tokio::time::timeout(timeout, fut) + .await + .map_err(|_| JwksFetchError::NetworkError)? +} + +/// Extract the bare host string and port from a validated JWKS URI. +/// +/// 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. +/// +/// The extracted bare host string is also the correct input form for +/// `reqwest::ClientBuilder::resolve(host, addr)`, whose key must match the +/// URL authority form (bare, without brackets for IPv6). Whether the +/// connector-level pin behaves as expected under mutation is a runtime +/// boundary concern; this function's contract is that it produces the bare +/// form required as input. +/// +/// This function is `pub(crate)` so tests can assert the extracted host string +/// directly and confirm the mutation (restoring `host_str()`) turns the +/// equivalence oracle red without making a live network request. +/// +/// ## Mutation oracle +/// Restoring `Some(url::Host::Ipv6(addr)) => format!("[{}]", addr)` (the +/// `host_str()` form) causes the IPv6 host extraction test to fail: the +/// returned string carries brackets, `IpAddr::parse` rejects it, and the +/// extracted host no longer matches the bare URL authority form. +pub(crate) fn extract_url_host_and_port(uri: &str) -> Result<(String, u16), JwksFetchError> { + let parsed = Url::parse(uri).map_err(|_| JwksFetchError::InvalidUri)?; + let host = match parsed.host() { + Some(url::Host::Ipv4(addr)) => addr.to_string(), + // MUST use the typed accessor — `host_str()` returns `[2606:4700::1]` + // (with brackets) for IPv6 literals, which breaks IpAddr::parse. + Some(url::Host::Ipv6(addr)) => addr.to_string(), + Some(url::Host::Domain(d)) => d.to_owned(), + None => return Err(JwksFetchError::InvalidUri), + }; + let port = parsed.port_or_known_default().unwrap_or(443); + 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)?; + + 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?; + + // Build a per-request client that: + // - denies redirects (a 3xx to an internal host bypasses the URI check); + // - has no system proxy (proxy would resolve the original hostname itself); + // - pins this request to the validated IP. + let pinned_client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .resolve(&host, std::net::SocketAddr::new(safe_ip, port)) + .build() + .map_err(|_| JwksFetchError::NetworkError)?; + + let response = pinned_client + .get(uri) + .send() + .await + .map_err(|_| JwksFetchError::NetworkError)?; + + // Reject non-2xx. A 3xx here means our no-redirect policy was somehow + // bypassed — treat as a network error. + if !response.status().is_success() { + return Err(JwksFetchError::NetworkError); + } + + // Early-exit on Content-Length before streaming. A lying or absent + // Content-Length is caught by the incremental counter below. + if let Some(content_length) = response.content_length() { + if content_length as usize > MAX_JWKS_RESPONSE_BYTES { + return Err(JwksFetchError::ResponseTooLarge); + } + } + + // Stream incrementally; stop at MAX_JWKS_RESPONSE_BYTES + 1 so we + // never buffer more than the limit before rejecting. + let mut body = Vec::with_capacity(MAX_JWKS_RESPONSE_BYTES.min(64 * 1024)); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| JwksFetchError::NetworkError)?; + if body.len().saturating_add(chunk.len()) > MAX_JWKS_RESPONSE_BYTES { + return Err(JwksFetchError::ResponseTooLarge); + } + body.extend_from_slice(&chunk); + } + + String::from_utf8(body).map_err(|_| JwksFetchError::ParseError) +} + +fn parse_and_bound_jwks(body: &str) -> Result { + let key_set: JwkSet = serde_json::from_str(body).map_err(|_| JwksFetchError::ParseError)?; + if key_set.keys.is_empty() || key_set.keys.len() > MAX_JWKS_KEYS { + return Err(JwksFetchError::KeyCountBoundsViolation); + } + Ok(key_set) +} + +/// Multi-issuer JWKS cache that performs bounded periodic refresh and never +/// serves snapshots past their hard deadline. +/// +/// Must be constructed at startup after +/// [`super::startup::validate_nip_fi_config`] passes. Shared across async +/// tasks via the inner `Arc>`. +/// +/// ## Security +/// +/// - Each issuer's JWKS is stored under its exact `iss` — no relabelling. +/// - Expired snapshots are purged on access; no stale-key fallback. +/// - Errors are logged with a stable code; no key material appears in logs. +pub struct ProductionJwksSource { + configs: HashMap, + states: Arc>>>, + fetcher: Arc, + /// Clock used for `hard_deadline` computation and expiry checks. Always + /// `Arc::new(Utc::now)` in production; tests supply a controlled clock. + now_fn: Arc DateTime + Send + Sync>, +} + +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`, + /// `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 { + if configs.is_empty() { + return None; + } + let mut config_map = HashMap::with_capacity(configs.len()); + let mut state_map = HashMap::with_capacity(configs.len()); + for c in configs { + if config_map.contains_key(&c.issuer) { + return None; + } + let issuer = c.issuer.clone(); + state_map.insert(issuer.clone(), Mutex::new(IssuerState::new())); + config_map.insert(issuer, c); + } + Some(Self { + configs: config_map, + states: Arc::new(RwLock::new(state_map)), + fetcher: Arc::new(fetcher), + now_fn: Arc::new(Utc::now), + }) + } + + /// **Test-only.** Construct with an injectable clock so tests can advance + /// `now` past snapshot hard deadlines without wall-clock sleep. + #[cfg(test)] + pub(crate) fn new_with_clock( + configs: Vec, + fetcher: F, + now_fn: Arc DateTime + Send + Sync>, + ) -> Option { + if configs.is_empty() { + return None; + } + let mut config_map = HashMap::with_capacity(configs.len()); + let mut state_map = HashMap::with_capacity(configs.len()); + for c in configs { + if config_map.contains_key(&c.issuer) { + return None; + } + let issuer = c.issuer.clone(); + state_map.insert(issuer.clone(), Mutex::new(IssuerState::new())); + config_map.insert(issuer, c); + } + Some(Self { + configs: config_map, + states: Arc::new(RwLock::new(state_map)), + fetcher: Arc::new(fetcher), + now_fn, + }) + } + + async fn fetch_fresh( + &self, + issuer: &str, + prev_digest: Option<[u8; 32]>, + prev_generation: u64, + ) -> Option<(CachedSnapshot, u64)> { + let config = self.configs.get(issuer)?; + let body = match self.fetcher.fetch_jwks(config.contract.jwks_uri()).await { + Ok(b) => b, + Err(err) => { + warn!(error = %err, "nip-fi jwks fetch failed; will use cached snapshot if live"); + return None; + } + }; + + let jwks = match parse_and_bound_jwks(&body) { + Ok(k) => k, + Err(err) => { + warn!(error = %err, "nip-fi jwks parse failed; will use cached snapshot if live"); + return None; + } + }; + + let content_digest: [u8; 32] = Sha256::digest(body.as_bytes()).into(); + + // Advance only when the document changed so key-rotation events are + // visible [FI-TRACE-JWKS-ADD/REMOVE] while identical refetches are + // stable. Saturating add prevents wrap on the (unreachable) u64 ceiling. + let generation = if Some(content_digest) == prev_digest { + prev_generation + } else { + prev_generation.saturating_add(1).max(1) + }; + + let now = (self.now_fn)(); + // MAX_JWKS_TIMING_SECONDS ≤ ~31.5M < i64::MAX, so this conversion is + // always safe for values that passed the bounds check in JwksSourceContract::new(). + let deadline_secs = i64::try_from(config.contract.key_snapshot_hard_deadline_seconds()) + .unwrap_or(i64::MAX / 2); + let hard_deadline = now + + Duration::try_seconds(deadline_secs) + .unwrap_or_else(|| Duration::seconds(i64::MAX / 2)); + + let key_set = AssertionKeySet::new(issuer.to_owned(), generation, jwks, hard_deadline)?; + + Some(( + CachedSnapshot { + key_set, + fetched_at: now, + hard_deadline, + content_digest, + }, + generation, + )) + } + + /// Returns the cached snapshot for `issuer`, refreshing inline if stale. + /// Returns `None` when no live snapshot is available and the fetch fails. + /// + /// Coalesces concurrent callers: a second call while a refresh is in + /// flight returns the current snapshot immediately rather than starting a + /// second fetch. The refresh permit is an RAII guard — if this future is + /// cancelled while DNS, HTTP, or streaming is pending, the guard drops and + /// the permit is released, so the next caller can start a new fetch. + pub async fn get_snapshot(&self, issuer: &str) -> Option { + let states = self.states.read().await; + let state_mutex = states.get(issuer)?; + let mut state = state_mutex.lock().await; + + let now = (self.now_fn)(); + let config = self.configs.get(issuer)?; + + if let Some(ref cached) = state.snapshot { + if now >= cached.hard_deadline { + state.snapshot = None; + } + } + + let needs_refresh = match state.snapshot { + None => true, + Some(ref cached) => { + let age_secs = (now - cached.fetched_at).num_seconds().max(0) as u64; + age_secs >= config.contract.refresh_interval_seconds() + } + }; + + if !needs_refresh { + return state.snapshot.as_ref().map(|c| c.key_set.clone()); + } + + // Try to acquire the per-issuer refresh permit. Failure means another + // caller is already fetching; return the current snapshot rather than + // starting a second fetch. + let permit = match Arc::clone(&state.refresh_permit).try_lock_owned() { + Ok(g) => g, + Err(_) => return state.snapshot.as_ref().map(|c| c.key_set.clone()), + }; + + let prev_digest = state.snapshot.as_ref().map(|c| c.content_digest); + let prev_generation = state.generation_counter; + drop(state); + drop(states); + + let fresh = self.fetch_fresh(issuer, prev_digest, prev_generation).await; + + // Re-acquire state to commit and release the permit atomically. + let states = self.states.read().await; + if let Some(state_mutex) = states.get(issuer) { + let mut st = state_mutex.lock().await; + if let Some((ref cached, new_generation)) = fresh { + st.generation_counter = new_generation; + st.snapshot = Some(cached.clone()); + } + // Drop the permit only after the state commit is visible. + drop(permit); + let now2 = (self.now_fn)(); + return st + .snapshot + .as_ref() + .filter(|c| now2 < c.hard_deadline) + .map(|c| c.key_set.clone()); + } + + drop(permit); + None + } +} + +impl super::verifier::sealed::Sealed for ProductionJwksSource {} + +impl IssuerKeySource for ProductionJwksSource { + /// Called per-request by the verifier after the cache has been warmed via + /// [`get_snapshot`][Self::get_snapshot]. + /// + /// Uses `try_read`/`try_lock` — safe to call from any async context. + /// Fails closed (returns `None`) when the lock is momentarily held by an + /// in-flight refresh, rather than blocking or panicking. [FI-INV-14] + fn key_set(&self, issuer: &str) -> Option { + let states = self.states.try_read().ok()?; + let state_mutex = states.get(issuer)?; + let state = state_mutex.try_lock().ok()?; + let now = (self.now_fn)(); + state + .snapshot + .as_ref() + .filter(|c| now < c.hard_deadline) + .map(|c| c.key_set.clone()) + } +} + +impl std::fmt::Debug for ProductionJwksSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // No issuer URIs or key material in debug output. + write!( + f, + "ProductionJwksSource([REDACTED; {} issuers])", + self.configs.len() + ) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-auth/src/nip_fi/jwks/tests.rs b/crates/buzz-auth/src/nip_fi/jwks/tests.rs new file mode 100644 index 00000000000..6d9f21a1502 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/jwks/tests.rs @@ -0,0 +1,1619 @@ +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +struct FakeJwksFetcher { + body: Result, + call_count: Arc, +} + +impl super::super::verifier::sealed::Sealed for FakeJwksFetcher {} + +impl JwksFetcher for FakeJwksFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self.body.clone(); + self.call_count.fetch_add(1, Ordering::SeqCst); + async move { result } + } +} + +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 make_config(issuer: &str) -> IssuerJwksConfig { + IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + format!("https://{issuer}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid test contract"), + } +} + +fn make_config_with_uri(issuer: &str, jwks_uri: &str) -> Option { + JwksSourceContract::new(jwks_uri.to_owned(), 300, 3600).map(|contract| IssuerJwksConfig { + issuer: issuer.to_owned(), + contract, + }) +} + +#[tokio::test] +async fn get_snapshot_returns_sealed_key_set_on_success() { + let issuer = "https://id.example"; + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + let ks = source.get_snapshot(issuer).await.unwrap(); + assert_eq!(ks.issuer(), issuer); +} + +#[tokio::test] +async fn get_snapshot_returns_none_for_unknown_issuer() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let source = + ProductionJwksSource::new(vec![make_config("https://id.example")], fetcher).unwrap(); + + assert!(source.get_snapshot("https://other.example").await.is_none()); +} + +#[tokio::test] +async fn get_snapshot_returns_none_on_network_error_with_no_cache() { + let fetcher = FakeJwksFetcher { + body: Err(JwksFetchError::NetworkError), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + assert!(source.get_snapshot(issuer).await.is_none()); +} + +#[tokio::test] +async fn get_snapshot_returns_none_on_oversized_response() { + let fetcher = FakeJwksFetcher { + body: Err(JwksFetchError::ResponseTooLarge), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + assert!(source.get_snapshot(issuer).await.is_none()); +} + +#[tokio::test] +async fn get_snapshot_returns_none_on_parse_error() { + let fetcher = FakeJwksFetcher { + body: Err(JwksFetchError::ParseError), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + assert!(source.get_snapshot(issuer).await.is_none()); +} + +#[tokio::test] +async fn parse_and_bound_rejects_empty_key_set() { + let err = parse_and_bound_jwks(r#"{"keys":[]}"#).unwrap_err(); + assert_eq!(err, JwksFetchError::KeyCountBoundsViolation); +} + +#[tokio::test] +async fn parse_and_bound_rejects_oversized_key_set() { + let keys: Vec = (0..=MAX_JWKS_KEYS) + .map(|i| format!( + r#"{{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","kid":"k{i}"}}"# + )) + .collect(); + let body = format!(r#"{{"keys":[{}]}}"#, keys.join(",")); + assert_eq!( + parse_and_bound_jwks(&body).unwrap_err(), + JwksFetchError::KeyCountBoundsViolation + ); +} + +#[tokio::test] +async fn new_rejects_empty_configs() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + assert!(ProductionJwksSource::new(vec![], fetcher).is_none()); +} + +/// Timing validation is now performed by `JwksSourceContract::new`. These +/// tests verify the contract constructor rejects bad timing, since an invalid +/// contract prevents building an `IssuerJwksConfig` entirely. +#[test] +fn contract_rejects_refresh_ge_hard_deadline() { + assert!(JwksSourceContract::new( + "https://id.example/.well-known/jwks.json".to_owned(), + 3600, + 3600, + ) + .is_none()); +} + +#[test] +fn contract_rejects_zero_refresh_interval() { + assert!(JwksSourceContract::new( + "https://id.example/.well-known/jwks.json".to_owned(), + 0, + 3600, + ) + .is_none()); +} + +#[test] +fn contract_rejects_timing_above_maximum() { + assert!(JwksSourceContract::new( + "https://id.example/.well-known/jwks.json".to_owned(), + MAX_JWKS_TIMING_SECONDS + 1, + MAX_JWKS_TIMING_SECONDS + 2, + ) + .is_none()); +} + +#[tokio::test] +async fn new_rejects_duplicate_issuer() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let config_a = make_config(issuer); + let config_b = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + "https://id.example/.well-known/jwks-alt.json".to_owned(), + 600, + 7200, + ) + .unwrap(), + }; + assert!(ProductionJwksSource::new(vec![config_a, config_b], fetcher).is_none()); +} + +/// URI validation is now performed by `JwksSourceContract::new`; an invalid +/// URI makes the contract `None` and prevents an `IssuerJwksConfig` from being +/// built at all. The tests below verify that `JwksSourceContract::new` rejects +/// the same invalid URIs that `ProductionJwksSource::new` previously checked. +#[test] +fn contract_rejects_non_https_jwks_uri() { + assert!(make_config_with_uri( + "https://id.example", + "http://id.example/.well-known/jwks.json" + ) + .is_none()); +} + +#[test] +fn contract_rejects_loopback_jwks_uri() { + assert!(make_config_with_uri( + "https://id.example", + "https://127.0.0.1/.well-known/jwks.json" + ) + .is_none()); +} + +#[test] +fn contract_rejects_private_ip_jwks_uri() { + assert!(make_config_with_uri( + "https://id.example", + "https://10.0.0.1/.well-known/jwks.json" + ) + .is_none()); +} + +#[test] +fn contract_rejects_jwks_uri_with_credentials() { + assert!(make_config_with_uri( + "https://id.example", + "https://user:pass@id.example/.well-known/jwks.json" + ) + .is_none()); +} + +#[test] +fn contract_rejects_jwks_uri_with_fragment() { + assert!(make_config_with_uri( + "https://id.example", + "https://id.example/.well-known/jwks.json#keys" + ) + .is_none()); +} + +/// `key_set()` fails closed (returns `None`) before any snapshot is warmed via +/// `get_snapshot` — the synchronous path never fetches. +#[tokio::test] +async fn sync_key_set_returns_none_before_warmup() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + assert!(source.key_set(issuer).is_none()); +} + +#[tokio::test] +async fn sync_key_set_returns_snapshot_after_warmup() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + source.get_snapshot(issuer).await.unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + let ks = source.key_set(issuer).unwrap(); + assert_eq!(ks.issuer(), issuer); +} + +/// Identical document fetched twice must not advance the generation counter +/// — stable generation for unchanged JWKS prevents spurious revalidation. +#[tokio::test] +async fn generation_stable_for_identical_document() { + let issuer = "https://id.example"; + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + format!("https://{issuer}/.well-known/jwks.json"), + 1, + 3600, + ) + .unwrap(), + }; + let source = ProductionJwksSource::new(vec![config], fetcher).unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + source.get_snapshot(issuer).await.unwrap(); + let gen1 = source.key_set(issuer).unwrap().generation(); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + source.get_snapshot(issuer).await.unwrap(); + let gen2 = source.key_set(issuer).unwrap().generation(); + + assert_eq!(gen1, gen2); +} + +/// Changed document must advance the generation so key-rotation events are +/// visible [FI-TRACE-JWKS-ADD/REMOVE]. +#[tokio::test] +async fn generation_advances_for_changed_document() { + let issuer = "https://id.example"; + + let bodies = Arc::new(std::sync::Mutex::new(vec![ + Ok::(minimal_jwks_json("k2")), + Ok(minimal_jwks_json("k1")), + ])); + + struct MultiBodyFetcher { + bodies: Arc>>>, + } + impl super::super::verifier::sealed::Sealed for MultiBodyFetcher {} + impl JwksFetcher for MultiBodyFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self + .bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } + } + + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + format!("https://{issuer}/.well-known/jwks.json"), + 1, + 3600, + ) + .unwrap(), + }; + let source = ProductionJwksSource::new(vec![config], MultiBodyFetcher { bodies }).unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + source.get_snapshot(issuer).await.unwrap(); + let gen1 = source.key_set(issuer).unwrap().generation(); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + source.get_snapshot(issuer).await.unwrap(); + let gen2 = source.key_set(issuer).unwrap().generation(); + + assert!(gen2 > gen1, "gen1={gen1}, gen2={gen2}"); +} + +#[test] +fn validate_uri_accepts_valid_https() { + assert!(validate_jwks_uri("https://id.example/.well-known/jwks.json").is_ok()); +} + +#[test] +fn validate_uri_accepts_public_ipv6() { + assert!(validate_jwks_uri("https://[2606:4700::1]/.well-known/jwks.json").is_ok()); +} + +#[test] +fn validate_uri_rejects_http() { + assert_eq!( + validate_jwks_uri("http://id.example/.well-known/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_loopback_ip() { + assert_eq!( + validate_jwks_uri("https://127.0.0.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_private_ip() { + assert_eq!( + validate_jwks_uri("https://192.168.1.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_link_local_ip() { + assert_eq!( + validate_jwks_uri("https://169.254.169.254/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_documentation_ip_test_net_1() { + // 192.0.2.0/24 — RFC 5737 TEST-NET-1, never globally routed. + assert_eq!( + validate_jwks_uri("https://192.0.2.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_documentation_ip_test_net_2() { + // 198.51.100.0/24 — RFC 5737 TEST-NET-2. + assert_eq!( + validate_jwks_uri("https://198.51.100.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_documentation_ip_test_net_3() { + // 203.0.113.0/24 — RFC 5737 TEST-NET-3. + assert_eq!( + validate_jwks_uri("https://203.0.113.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_multicast_ip() { + // 224.0.0.1 — all-hosts multicast group (224.0.0.0/4). + assert_eq!( + validate_jwks_uri("https://224.0.0.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_reserved_class_e_ip() { + // 240.0.0.1 — reserved class E (240.0.0.0/4). + assert_eq!( + validate_jwks_uri("https://240.0.0.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_ietf_protocol_assignments_ipv4() { + // 192.0.0.0/24 — IETF Protocol Assignments (non-global by default). + // 192.0.0.1 is a representative interior address. + assert_eq!( + validate_jwks_uri("https://192.0.0.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_accepts_ietf_protocol_assignments_pcp_turn_anycast() { + // 192.0.0.9 (PCP anycast, RFC 7723) and 192.0.0.10 (TURN anycast, RFC 8155) + // are the only globally-reachable exceptions inside 192.0.0.0/24. + assert!(validate_jwks_uri("https://192.0.0.9/jwks.json").is_ok()); + assert!(validate_jwks_uri("https://192.0.0.10/jwks.json").is_ok()); +} + +#[test] +fn validate_uri_rejects_deprecated_6to4_anycast_ipv4() { + // 192.88.99.0/24 — deprecated 6to4 relay anycast (RFC 7526). + // Registry global field is None/blank; conservative posture: block. + assert_eq!( + validate_jwks_uri("https://192.88.99.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_ietf_protocol_assignments_v6_interior() { + // 2001:2::1 — interior of 2001::/23 IETF Protocol Assignments (non-global). + assert_eq!( + validate_jwks_uri("https://[2001:2::1]/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_accepts_ietf_protocol_assignments_v6_global_exception() { + // 2001:1::1 (PCP anycast, RFC 7723) — globally reachable exception inside 2001::/23. + assert!(validate_jwks_uri("https://[2001:1::1]/jwks.json").is_ok()); +} + +#[test] +fn validate_uri_rejects_discard_only_v6() { + // 100::1 — 100::/64 Discard-Only address space (RFC 6666). + assert_eq!( + validate_jwks_uri("https://[100::1]/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_documentation_v6_3fff() { + // 3fff::1 — 3fff::/20 Documentation space (RFC 9637). + assert_eq!( + validate_jwks_uri("https://[3fff::1]/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_srv6_sids_v6() { + // 5f00::1 — 5f00::/16 SRv6 SID space (RFC 9252). + assert_eq!( + validate_jwks_uri("https://[5f00::1]/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_credentials() { + assert_eq!( + validate_jwks_uri("https://user:pass@id.example/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_fragment() { + assert_eq!( + validate_jwks_uri("https://id.example/jwks.json#section").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_unparseable() { + assert_eq!( + validate_jwks_uri("not a url").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[tokio::test] +async fn http_fetcher_rejects_http_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("http://id.example/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn http_fetcher_rejects_credentials_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://user:pass@id.example/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn http_fetcher_rejects_fragment_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://id.example/.well-known/jwks.json#section") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn http_fetcher_rejects_private_ip_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://10.0.0.1/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn resolve_ssrf_rejects_ipv6_loopback_fast_path() { + let err = super::resolve_and_check_ssrf("::1", 443).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) + .await + .unwrap(); + assert_eq!(ip, "2606:4700::1".parse::().unwrap()); +} + +/// The public fetcher rejects an IPv6 loopback JWKS URI before any network +/// connection is attempted. `fetch_jwks_inner` calls `validate_jwks_uri` as +/// its first step; `validate_jwks_uri` parses the URI, extracts the host via +/// `Url::host()`, and rejects any address matched by the shared enumerated +/// deny policy as +/// `InvalidUri`. `::1` (loopback) never reaches the extraction or +/// resolved-target enforcement stages. Bracket-free extraction and +/// resolved-target value-flow evidence is covered by the dedicated +/// `resolved_target_and_pin_key_seam_public_ipv6_and_fec0_rejection` test; +/// connector-boundary behavior is a separate runtime concern. +#[tokio::test] +async fn http_fetcher_rejects_ipv6_loopback_uri_as_invalid() { + // https://[::1]/... is rejected by validate_jwks_uri (SSRF: loopback) + // before extraction or resolved-target enforcement runs. + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://[::1]/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!( + err, + JwksFetchError::InvalidUri, + "IPv6 loopback URI must be rejected as InvalidUri, not NetworkError" + ); +} + +/// Rejected private IPv6 site-local URI at the pre-connection SSRF boundary. +/// fec0::/10 (deprecated site-local, RFC 3879) must deny as InvalidUri. +#[tokio::test] +async fn http_fetcher_rejects_ipv6_site_local_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://[fec0::1]/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +/// `with_deadline` fires before the outer guard: removing `tokio::time::timeout` +/// inside `with_deadline` leaves the pending future unresolved and the outer guard fires. +#[tokio::test(start_paused = true)] +async fn with_deadline_fires_before_outer_guard() { + let inner = super::with_deadline( + std::future::pending::>(), + std::time::Duration::ZERO, + ); + let result = tokio::time::timeout(std::time::Duration::from_secs(1), inner).await; + assert_eq!( + result.expect("outer guard fired — with_deadline timeout seam missing"), + Err(JwksFetchError::NetworkError), + ); +} + +// A fetcher whose per-call behaviour is scripted by an explicit sequence of steps. +// Each call pops the next step: signals `entered` on entry, then blocks until +// its release channel resolves. +struct FetchStep { + entered: tokio::sync::oneshot::Sender<()>, + release: tokio::sync::oneshot::Receiver, +} + +struct ScriptedFetcher { + steps: std::sync::Mutex>, + call_count: Arc, +} + +impl super::super::verifier::sealed::Sealed for ScriptedFetcher {} + +impl JwksFetcher for ScriptedFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + self.call_count.fetch_add(1, Ordering::SeqCst); + let step = self.steps.lock().unwrap().pop_front(); + async move { + match step { + Some(FetchStep { entered, release }) => { + let _ = entered.send(()); + release.await.map_err(|_| JwksFetchError::NetworkError) + } + None => Err(JwksFetchError::NetworkError), + } + } + } +} + +fn script(steps: impl IntoIterator) -> ScriptedFetcher { + ScriptedFetcher { + steps: std::sync::Mutex::new(steps.into_iter().collect()), + call_count: Arc::new(AtomicUsize::new(0)), + } +} + +fn pending_step() -> ( + FetchStep, + tokio::sync::oneshot::Receiver<()>, + tokio::sync::oneshot::Sender, +) { + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::(); + // release_tx is returned to the caller; the fetch future is genuinely + // pending until the caller drops or sends it — not resolved immediately. + ( + FetchStep { + entered: entered_tx, + release: release_rx, + }, + entered_rx, + release_tx, + ) +} + +fn ready_step(body: String) -> (FetchStep, tokio::sync::oneshot::Receiver<()>) { + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::(); + let _ = release_tx.send(body); + ( + FetchStep { + entered: entered_tx, + release: release_rx, + }, + entered_rx, + ) +} + +fn blocking_step() -> ( + FetchStep, + tokio::sync::oneshot::Receiver<()>, + tokio::sync::oneshot::Sender, +) { + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::(); + ( + FetchStep { + entered: entered_tx, + release: release_rx, + }, + entered_rx, + release_tx, + ) +} + +/// A second concurrent `get_snapshot` while the first fetch is in progress must +/// not start a second fetch — the RAII permit coalesces callers. +#[tokio::test] +async fn concurrent_refresh_coalesces_without_second_fetch() { + let (step, entered_rx, release_tx) = blocking_step(); + let fetcher = script([step]); + let call_count = Arc::clone(&fetcher.call_count); + + let issuer = "https://id.example"; + let source = Arc::new(ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap()); + + let source2 = Arc::clone(&source); + let issuer_owned = issuer.to_owned(); + let first = tokio::spawn(async move { source2.get_snapshot(&issuer_owned).await }); + + entered_rx.await.unwrap(); // first fetch holds the permit + + let second_result = source.get_snapshot(issuer).await; + let count_after_second = call_count.load(Ordering::SeqCst); + + let _ = release_tx.send(minimal_jwks_json("k1")); + let first_result = first.await.unwrap(); + + assert!(first_result.is_some()); + assert!(second_result.is_none()); + assert_eq!(count_after_second, 1); +} + +/// Aborting the first caller releases the RAII permit; the next call on the same +/// source fetches and succeeds. A manual boolean cleared only on success would +/// leave the permit poisoned. +#[tokio::test] +async fn aborted_first_caller_releases_permit_for_next_caller() { + let (step1, entered_rx_1, _release_tx_1) = pending_step(); + let (step2, _entered_rx_2) = ready_step(minimal_jwks_json("k2")); + + let fetcher = script([step1, step2]); + let call_count = Arc::clone(&fetcher.call_count); + + let issuer = "https://id.example"; + let source = Arc::new(ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap()); + + { + let source2 = Arc::clone(&source); + let issuer_owned = issuer.to_owned(); + let first = tokio::spawn(async move { source2.get_snapshot(&issuer_owned).await }); + entered_rx_1.await.unwrap(); + first.abort(); + let _ = first.await; + // _release_tx_1 drops here: the fetch future was blocked on an open + // receiver when abort fired — not resolved via an error path. + } + + let result = source.get_snapshot(issuer).await; + assert!(result.is_some()); + assert_eq!(call_count.load(Ordering::SeqCst), 2); +} + +/// An expired snapshot must never be served — both `get_snapshot` and the +/// synchronous `key_set` path return `None` after the hard deadline passes. +#[tokio::test] +async fn expired_snapshot_never_served_after_hard_deadline() { + let issuer = "https://id.example"; + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + "https://id.example/.well-known/jwks.json".to_owned(), + 1, + 2, + ) + .unwrap(), + }; + let bodies = Arc::new(std::sync::Mutex::new(vec![ + Err::(JwksFetchError::NetworkError), + Ok(minimal_jwks_json("k1")), + ])); + struct FailAfterFirstFetcher { + bodies: Arc>>>, + } + impl super::super::verifier::sealed::Sealed for FailAfterFirstFetcher {} + impl JwksFetcher for FailAfterFirstFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self + .bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } + } + let source = ProductionJwksSource::new(vec![config], FailAfterFirstFetcher { bodies }).unwrap(); + + assert!( + source.get_snapshot(issuer).await.is_some(), + "initial fetch must succeed" + ); + + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + assert!( + source.get_snapshot(issuer).await.is_none(), + "expired snapshot must not be served after hard deadline" + ); + + use crate::nip_fi::verifier::IssuerKeySource; + assert!( + source.key_set(issuer).is_none(), + "key_set must not serve an expired snapshot" + ); +} + +/// Two issuers are fully isolated: distinct key material, independent generation +/// counters, no cross-issuer forgery. Three distinct P-256 keypairs (A1, A2, +/// B1) driven through `ProductionJwksSource` into `FederatedAssertionVerifier`. +#[tokio::test] +async fn two_issuer_keys_and_generations_are_isolated() { + use crate::nip_fi::{ + FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass, + }; + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use serde_json::json; + + // Three genuinely distinct P-256 keypairs (PKCS#8 PEM + public JWK coords). + const PKCS8_A1: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + const X_A1: &str = "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI"; + const Y_A1: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; + + const PKCS8_A2: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMKMRn6EQMn67Z6tu\n\ + DbUTZWzrQpbRRTL3SJSMSd+EDG2hRANCAATGgMYxftLlZ11AIANHcr0b13pWkaLy\n\ + lkOeBZRG0bBMoUesLN7EdVYhtzcrCeNJh031QuO+UDWcwOmShbeR43x6\n\ + -----END PRIVATE KEY-----\n"; + const X_A2: &str = "xoDGMX7S5WddQCADR3K9G9d6VpGi8pZDngWURtGwTKE"; + const Y_A2: &str = "R6ws3sR1ViG3NysJ40mHTfVC475QNZzA6ZKFt5HjfHo"; + + const PKCS8_B1: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgKcmDf3+zDWyC96/X\n\ + Gv8aYK552uF5aE6nXKzxAfl4fSWhRANCAATf0ccbp1c4mMd6WvSuliv5ZAS8iIWL\n\ + Ne2tqOfFa0hRpa41DANab1/EuDGi7PtIo8xSYwkaoib1MAJlfLvRMjQA\n\ + -----END PRIVATE KEY-----\n"; + const X_B1: &str = "39HHG6dXOJjHelr0rpYr-WQEvIiFizXtrajnxWtIUaU"; + const Y_B1: &str = "rjUMA1pvX8S4MaLs-0ijzFJjCRqiJvUwAmV8u9EyNAA"; + + const KID_A1: &str = "a-key-1"; + const KID_A2: &str = "a-key-2"; + const KID_B1: &str = "b-key-1"; + + let issuer_a = "https://a.example"; + let issuer_b = "https://b.example"; + let audience = "https://relay.example"; + + fn jwks_str(kid: &str, x: &str, y: &str) -> String { + format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","use":"sig","alg":"ES256","kid":"{kid}","x":"{x}","y":"{y}"}}]}}"# + ) + } + + fn sign(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { + let now = chrono::Utc::now().timestamp(); + let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "iat": now, "exp": now + 600}); + let mut hdr = Header::new(Algorithm::ES256); + hdr.kid = Some(kid.to_owned()); + hdr.typ = Some("nip-fi+jwt".to_owned()); + let key = EncodingKey::from_ec_pem(pkcs8_pem.as_bytes()).expect("valid EC PEM"); + jsonwebtoken::encode(&hdr, &claims, &key).expect("sign") + } + + fn policy(issuer: &str, aud: &str) -> IssuerPolicy { + let contract = JwksSourceContract::new( + format!( + "https://{}/jwks.json", + issuer.trim_start_matches("https://") + ), + 1, + 3600, + ) + .expect("valid contract"); + IssuerPolicy::new( + issuer.to_owned(), + vec![aud.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + false, + 60, + 3600, + None, + contract, + ) + .expect("valid policy") + } + + fn configs(issuer_a: &str, issuer_b: &str) -> (IssuerJwksConfig, IssuerJwksConfig) { + ( + IssuerJwksConfig { + issuer: issuer_a.to_owned(), + contract: JwksSourceContract::new( + "https://a.example/.well-known/jwks.json".to_owned(), + 1, + 3600, + ) + .unwrap(), + }, + IssuerJwksConfig { + issuer: issuer_b.to_owned(), + contract: JwksSourceContract::new( + "https://b.example/.well-known/jwks.json".to_owned(), + 1, + 3600, + ) + .unwrap(), + }, + ) + } + + struct TwoFetcher { + a: std::sync::Mutex>, + b: String, + } + impl super::super::verifier::sealed::Sealed for TwoFetcher {} + impl JwksFetcher for TwoFetcher { + fn fetch_jwks<'a>( + &'a self, + uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = if uri.contains("a.example") { + self.a + .lock() + .unwrap() + .pop_front() + .map(Ok) + .unwrap_or(Err(JwksFetchError::NetworkError)) + } else { + Ok(self.b.clone()) + }; + async move { result } + } + } + + let mut registry = IssuerRegistry::new(); + registry.insert(policy(issuer_a, audience)); + registry.insert(policy(issuer_b, audience)); + + // Pre-rotation: source serves A1 and B1. + let (cfg_a, cfg_b) = configs(issuer_a, issuer_b); + let pre = ProductionJwksSource::new( + vec![cfg_a, cfg_b], + TwoFetcher { + a: std::sync::Mutex::new([jwks_str(KID_A1, X_A1, Y_A1)].into()), + b: jwks_str(KID_B1, X_B1, Y_B1), + }, + ) + .unwrap(); + pre.get_snapshot(issuer_a).await.unwrap(); + pre.get_snapshot(issuer_b).await.unwrap(); + + let v_pre = FederatedAssertionVerifier::new(registry.clone(), pre); + v_pre + .verify(&sign(PKCS8_A1, KID_A1, issuer_a, audience)) + .expect("A1 token must verify pre-rotation"); + v_pre + .verify(&sign(PKCS8_B1, KID_B1, issuer_b, audience)) + .expect("B1 token must verify pre-rotation"); + v_pre + .verify(&sign(PKCS8_B1, KID_A1, issuer_a, audience)) + .expect_err("B1 key must not forge issuer A"); + + // Post-rotation: fresh source, A rotates A1→A2, B unchanged. + let (cfg_a2, cfg_b2) = configs(issuer_a, issuer_b); + let post = ProductionJwksSource::new( + vec![cfg_a2, cfg_b2], + TwoFetcher { + a: std::sync::Mutex::new( + [jwks_str(KID_A1, X_A1, Y_A1), jwks_str(KID_A2, X_A2, Y_A2)].into(), + ), + b: jwks_str(KID_B1, X_B1, Y_B1), + }, + ) + .unwrap(); + post.get_snapshot(issuer_a).await.unwrap(); + post.get_snapshot(issuer_b).await.unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + let gen_a_pre = post.key_set(issuer_a).unwrap().generation(); + let gen_b_stable = post.key_set(issuer_b).unwrap().generation(); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + post.get_snapshot(issuer_a).await.unwrap(); + + let gen_a_post = post.key_set(issuer_a).unwrap().generation(); + let gen_b_post = post.key_set(issuer_b).unwrap().generation(); + assert!( + gen_a_post > gen_a_pre, + "A generation must advance after rotation" + ); + assert_eq!( + gen_b_post, gen_b_stable, + "B generation must not advance when only A rotates" + ); + + let v_post = FederatedAssertionVerifier::new(registry, post); + v_post + .verify(&sign(PKCS8_A2, KID_A2, issuer_a, audience)) + .expect("A2 token must verify post-rotation"); + v_post + .verify(&sign(PKCS8_A1, KID_A1, issuer_a, audience)) + .expect_err("old A1 token must fail after A2 rotation"); + v_post + .verify(&sign(PKCS8_B1, KID_A1, issuer_a, audience)) + .expect_err("B1 key must not forge issuer A post-rotation"); + v_post + .verify(&sign(PKCS8_B1, KID_B1, issuer_b, audience)) + .expect("B1 token must still verify post-rotation"); +} + +/// Public-API regression: one long-lived [`FederatedAssertionVerifier`] backed +/// by a shared `Arc` observes key rotation through the +/// same cache it was constructed with — it does NOT need to be rebuilt when +/// keys rotate. +/// +/// Scenario: +/// A1 → initial key set (generation 1) +/// A2 → rotated key set (generation 2, committed after a refresh interval) +/// +/// The verifier is constructed once before A2 is known, then the source is +/// refreshed in-place (simulating a normal JWKS rotation). The same verifier +/// must then reject A1-signed tokens and accept A2-signed tokens, because it +/// reads from the shared cache. +/// +/// Mutation (correctness): change `Arc` to a plain +/// `ProductionJwksSource` (no sharing). The verifier would hold its own +/// copy of the pre-rotation cache and could not observe the refresh. A2 tokens +/// would fail and A1 tokens would pass — the test turns red on both assertions. +#[tokio::test] +async fn shared_arc_source_verifier_observes_rotation() { + use crate::nip_fi::{ + FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass, + }; + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use serde_json::json; + use std::sync::Arc; + + // Two genuinely distinct P-256 keypairs (re-use the constants from the + // two-issuer test). + const PKCS8_A1: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + const X_A1: &str = "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI"; + const Y_A1: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; + + const PKCS8_A2: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMKMRn6EQMn67Z6tu\n\ + DbUTZWzrQpbRRTL3SJSMSd+EDG2hRANCAATGgMYxftLlZ11AIANHcr0b13pWkaLy\n\ + lkOeBZRG0bBMoUesLN7EdVYhtzcrCeNJh031QuO+UDWcwOmShbeR43x6\n\ + -----END PRIVATE KEY-----\n"; + const X_A2: &str = "xoDGMX7S5WddQCADR3K9G9d6VpGi8pZDngWURtGwTKE"; + const Y_A2: &str = "R6ws3sR1ViG3NysJ40mHTfVC475QNZzA6ZKFt5HjfHo"; + + const KID_A1: &str = "arc-key-1"; + const KID_A2: &str = "arc-key-2"; + + let issuer = "https://arc-issuer.example"; + let audience = "https://relay.example"; + + fn jwks_str(kid: &str, x: &str, y: &str) -> String { + format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","use":"sig","alg":"ES256","kid":"{kid}","x":"{x}","y":"{y}"}}]}}"# + ) + } + + fn sign_token(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { + let now = chrono::Utc::now().timestamp(); + let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "iat": now, "exp": now + 600}); + let mut hdr = Header::new(Algorithm::ES256); + hdr.kid = Some(kid.to_owned()); + hdr.typ = Some("nip-fi+jwt".to_owned()); + let key = EncodingKey::from_ec_pem(pkcs8_pem.as_bytes()).expect("valid EC PEM"); + jsonwebtoken::encode(&hdr, &claims, &key).expect("sign") + } + + // Scripted fetcher: first call returns A1 JWKS, second call returns A2 JWKS. + let bodies = Arc::new(std::sync::Mutex::new(vec![ + Ok::(jwks_str(KID_A2, X_A2, Y_A2)), // popped second + Ok(jwks_str(KID_A1, X_A1, Y_A1)), // popped first + ])); + + struct RotatingFetcher { + bodies: Arc>>>, + } + impl super::super::verifier::sealed::Sealed for RotatingFetcher {} + impl JwksFetcher for RotatingFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self + .bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } + } + + let jwks_contract = + JwksSourceContract::new(format!("https://{issuer}/.well-known/jwks.json"), 1, 3600) + .unwrap(); + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: jwks_contract.clone(), + }; + + // Wrap the source in Arc — this is the sharing path under test. + let source = + Arc::new(ProductionJwksSource::new(vec![config], RotatingFetcher { bodies }).unwrap()); + + // Warm the cache with A1 JWKS. + source.get_snapshot(issuer).await.unwrap(); + + // Build the verifier from an Arc clone. This is the one long-lived + // verifier we never rebuild. + let mut registry = IssuerRegistry::new(); + registry.insert( + IssuerPolicy::new( + issuer.to_owned(), + vec![audience.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + false, + 60, + 3600, + None, + jwks_contract, + ) + .unwrap(), + ); + let verifier = FederatedAssertionVerifier::new(registry, Arc::clone(&source)); + + // Pre-rotation: A1 token verifies. + verifier + .verify(&sign_token(PKCS8_A1, KID_A1, issuer, audience)) + .expect("A1 token must verify before rotation"); + + // Advance past the refresh interval so the next get_snapshot triggers a + // re-fetch (which will return A2 JWKS from the scripted fetcher). + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + source.get_snapshot(issuer).await.unwrap(); + + // Post-rotation: the SAME verifier (never rebuilt) must now see A2 keys. + // This proves the verifier reads from the shared Arc cache, not a + // snapshot captured at construction time. + // + // Mutation: if the verifier held a plain `ProductionJwksSource` (cloned + // at construction), it would serve the pre-rotation A1 snapshot forever — + // A2 would fail and A1 would still pass, turning both assertions red. + verifier + .verify(&sign_token(PKCS8_A2, KID_A2, issuer, audience)) + .expect("A2 token must verify through the shared Arc after rotation"); + verifier + .verify(&sign_token(PKCS8_A1, KID_A1, issuer, audience)) + .expect_err("old A1 token must be rejected after rotation (kid no longer in JWKS)"); +} + +/// **Fix 1 — URI canonicalization convergence/divergence oracle.** +/// +/// `JwksSourceContract::new` must store the `Url`-normalized form of the URI, +/// not the caller's raw input bytes. This means: +/// - An uppercase host (`EXAMPLE.COM`) normalizes to lowercase (`example.com`) +/// and produces the same `AssertionPolicyId` as the lowercase form. +/// - An explicit default HTTPS port (`:443`) is removed by `Url` normalization +/// and produces the same ID as the form without the port. +/// - A genuinely different host always produces a distinct ID. +/// +/// Mutation (correctness): changing `JwksSourceContract::new` to store the raw +/// input `jwks_uri` instead of `parsed.to_string()` causes the uppercase-host +/// and explicit-port variant tests to fail — the raw bytes differ, the SHA-256 +/// hash diverges, and `assert_eq!` on the policy IDs turns red. +#[test] +fn jwks_contract_uri_canonicalization_convergence_and_divergence() { + use crate::nip_fi::{config::IssuerPolicy, FreshnessClass, TokenClass}; + use jsonwebtoken::Algorithm; + + fn make_policy(jwks_uri: &str) -> Option { + let contract = JwksSourceContract::new(jwks_uri.to_owned(), 300, 3600)?; + IssuerPolicy::new( + "https://issuer.example".to_owned(), + vec!["https://aud.example".to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + false, + 30, + 600, + None, + contract, + ) + .ok() + .map(|p| p.id()) + } + + let canonical = + make_policy("https://issuer.example/.well-known/jwks.json").expect("canonical form"); + + // Equivalent spellings must converge after `Url` normalization. + let uppercase_host = + make_policy("https://ISSUER.EXAMPLE/.well-known/jwks.json").expect("uppercase host"); + assert_eq!( + canonical, uppercase_host, + "uppercase host must normalize to lowercase and produce identical policy ID; \ + mutation: store raw input bytes → this diverges" + ); + + let explicit_port = + make_policy("https://issuer.example:443/.well-known/jwks.json").expect("explicit port"); + assert_eq!( + canonical, explicit_port, + "explicit default HTTPS port :443 must be stripped by Url normalization; \ + mutation: store raw input bytes → this diverges" + ); + + // A genuinely different host MUST diverge (not accidentally collapse). + let different_host = + make_policy("https://other.example/.well-known/jwks.json").expect("different host"); + assert_ne!( + canonical, different_host, + "different JWKS host must produce distinct policy ID" + ); + + // A different path MUST diverge. + let different_path = + make_policy("https://issuer.example/.well-known/other-jwks.json").expect("different path"); + assert_ne!( + canonical, different_path, + "different JWKS path must produce distinct policy ID" + ); + + // Dot-segment path that resolves to the same resource MUST converge. + // `Url::parse` resolves `./jwks.json` relative paths during parsing, so + // `/.well-known/./jwks.json` normalises to `/.well-known/jwks.json`. + // Mutation: store raw input bytes -> the dot-segment form remains in the + // stored URI, the SHA-256 hash diverges, and `assert_eq!` turns red. + let dot_segment = + make_policy("https://issuer.example/.well-known/./jwks.json").expect("dot-segment path"); + assert_eq!( + canonical, dot_segment, + "dot-segment-equivalent path must normalize and produce identical policy ID; \ + mutation: store raw input bytes -> this diverges" + ); +} + +/// **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. +/// +/// 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`. +/// 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. +/// +/// This test proves that the extracted host string is bare (the correct input +/// form for `reqwest::ClientBuilder::resolve`). It does not exercise the +/// 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`. +/// +/// ## Mutation oracle +/// Replace `Some(url::Host::Ipv6(addr)) => addr.to_string()` with +/// `Some(url::Host::Ipv6(addr)) => format!("[{}]", addr)` in +/// `extract_url_host_and_port`. The bracketed string is returned. +/// - `"[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` +/// → fec0 rejection-kind assertion flips red. +/// - The pin-input equality assertion also flips red (bracket mismatch). +#[tokio::test] +async fn resolved_target_and_pin_key_seam_public_ipv6_and_fec0_rejection() { + use buzz_core::network::is_not_global_unicast; + + // ── Stage 1: extraction ─────────────────────────────────────────────────── + let uri = "https://[2606:4700::1]/.well-known/jwks.json"; + let (host, port) = + super::extract_url_host_and_port(uri).expect("public IPv6 URI must be parseable"); + assert_eq!( + host, "2606:4700::1", + "host must be bare (mutation: bracket → IpAddr::parse fails)" + ); + assert_eq!(port, 443u16, "default HTTPS port"); + + // ── 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. + let ip: std::net::IpAddr = host + .parse() + .expect("bracket-free host must parse as IpAddr; mutation: bracketed form fails here"); + assert!(ip.is_ipv6(), "must be an IPv6 address"); + + // `is_not_global_unicast` must return false for a public address. + assert!( + !is_not_global_unicast(&ip), + "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) + .await + .expect("public IPv6 must be accepted by SSRF check"); + assert_eq!( + resolved, ip, + "resolved address must equal the IpAddr parsed from the bare host" + ); + + // ── Stage 3: pin-key string form ──────────────────────────────────────── + // The host string extracted by `extract_url_host_and_port` is the value + // passed to reqwest's `.resolve(host, ...)`. For a reqwest pin to apply, + // the key passed to `.resolve()` must equal the URL authority form. For + // IPv6 literals the URL authority form is bare (no brackets), so the + // extracted host must also be bare. This assertion verifies that the + // extracted host string is bare — it does not directly exercise the + // reqwest connector, but proves the input to the pin call is correct. + let socket_addr = std::net::SocketAddr::new(resolved, port); + let expected_pin_key = "2606:4700::1"; + assert_eq!( + host, expected_pin_key, + "extracted host must equal the bare URL authority for use as reqwest pin key; \ + mutation: bracketed extraction returns \"[2606:4700::1]\" (differs from authority form)" + ); + // Sanity: confirm the SocketAddr is valid (no panic = key formation succeeded). + let _ = socket_addr; + + // ── fec0::/10 rejection through the same seam ──────────────────────────── + // Stage 1: extraction succeeds (SSRF decision is downstream). + let fec0_uri = "https://[fec0::1]/.well-known/jwks.json"; + let (fec0_host, fec0_port) = + super::extract_url_host_and_port(fec0_uri).expect("extraction succeeds for fec0 URI"); + assert_eq!(fec0_host, "fec0::1", "fec0 host must be bare"); + assert_eq!(fec0_port, 443u16); + + // Stage 2: IpAddr parse succeeds for the bare form. + let fec0_ip: std::net::IpAddr = fec0_host + .parse() + .expect("bracket-free fec0 host parses as IpAddr; mutation: bracketed form fails here"); + + // is_not_global_unicast must block fec0::/10 (deprecated site-local, RFC 3879). + assert!( + is_not_global_unicast(&fec0_ip), + "fec0::1 must be rejected by is_not_global_unicast; mutation: wrong bracket form \ + 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) + .await + .unwrap_err(); + assert_eq!( + fec0_err, + JwksFetchError::InvalidUri, + "fec0::1 must be rejected as InvalidUri, not NetworkError; \ + mutation: bracketed form -> parse fails -> DNS path -> NetworkError (red)" + ); +} + +/// **Fix 3 — Unchanged verifier observes A1→A2 rotation beyond A1's original absolute deadline.** +/// +/// Uses an injectable clock (`new_with_clock`) to advance controlled `now` past +/// A1's immutable hard deadline without wall-clock sleep. A1's deadline is +/// computed at first-fetch time (T0) and never mutated. The clock then advances +/// to T0 + HARD_DEADLINE_SECS + 1, beyond A1's original absolute deadline. +/// `get_snapshot` fires because the snapshot is expired, fetches A2, and the +/// one unchanged verifier (never rebuilt) must reflect the new keys. +/// +/// ## Mutation oracles +/// 1. **Sharing:** Replace `Arc::clone(&source)` passed to the verifier with a +/// fresh `Arc::new(second_source)` built from the same configs but independent, +/// sharing the same controlled clock. Warm the independent source with a +/// separate A1 fetch before advancing the clock. After advancement, +/// `key_set()` on the verifier's independent source filters the expired A1 +/// snapshot (`filter(|c| now < c.hard_deadline)`) and returns no keys — +/// the verifier never re-fetches and never observes A2. The A2-accept +/// assertion flips red reliably, because the verifier never observes A2. +/// The A1-reject assertion stays green: the independent cache is also +/// expired (same advanced clock), so that source also returns no A1 keys — +/// A1 tokens are still rejected, but through expiry of the independent +/// cache rather than through shared-arc rotation. **A2 acceptance is the +/// reliable shared-source oracle here.** +/// +/// Note: the expiry-purge (`state.snapshot = None` in `get_snapshot`) is +/// correctness-critical for concurrent callers: it clears the expired snapshot +/// before permit acquisition, so a caller that loses the permit race and falls +/// back to `state.snapshot` receives `None` rather than an expired snapshot. +/// A1 rejection after the deadline is also enforced independently by the `key_set` +/// read path (`filter(|c| now < c.hard_deadline)`), but the purge is what +/// prevents the fallback path from serving a stale snapshot to concurrent +/// refresh losers, so no separate purge mutation oracle is claimed here. +#[tokio::test] +async fn shared_arc_source_verifier_rejects_expired_a1_accepts_a2() { + use crate::nip_fi::{ + FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass, + }; + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use serde_json::json; + use std::sync::atomic::{AtomicI64, Ordering}; + use std::sync::Arc; + + // Two distinct P-256 keypairs (reuse constants from shared_arc test). + const PKCS8_A1: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\ + \n-----END PRIVATE KEY-----\n"; + const X_A1: &str = "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI"; + const Y_A1: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; + + const PKCS8_A2: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMKMRn6EQMn67Z6tu\ + DbUTZWzrQpbRRTL3SJSMSd+EDG2hRANCAATGgMYxftLlZ11AIANHcr0b13pWkaLy\ + lkOeBZRG0bBMoUesLN7EdVYhtzcrCeNJh031QuO+UDWcwOmShbeR43x6\ + \n-----END PRIVATE KEY-----\n"; + const X_A2: &str = "xoDGMX7S5WddQCADR3K9G9d6VpGi8pZDngWURtGwTKE"; + const Y_A2: &str = "R6ws3sR1ViG3NysJ40mHTfVC475QNZzA6ZKFt5HjfHo"; + + const KID_A1: &str = "exp-key-1"; + const KID_A2: &str = "exp-key-2"; + const HARD_DEADLINE_SECS: u64 = 3600; + + let issuer = "https://exp-issuer.example"; + let audience = "https://exp-relay.example"; + + fn jwks_str(kid: &str, x: &str, y: &str) -> String { + format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","use":"sig","alg":"ES256","kid":"{kid}","x":"{x}","y":"{y}"}}]}}"# + ) + } + + fn sign_token(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { + let wall_now = chrono::Utc::now().timestamp(); + let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "iat": wall_now, "exp": wall_now + 600}); + let mut hdr = Header::new(Algorithm::ES256); + hdr.kid = Some(kid.to_owned()); + hdr.typ = Some("nip-fi+jwt".to_owned()); + let key = EncodingKey::from_ec_pem(pkcs8_pem.as_bytes()).expect("valid EC PEM"); + jsonwebtoken::encode(&hdr, &claims, &key).expect("sign") + } + + // Scripted fetcher: first call -> A1, second call -> A2. + let bodies = Arc::new(std::sync::Mutex::new(vec![ + Ok::(jwks_str(KID_A2, X_A2, Y_A2)), // popped second + Ok(jwks_str(KID_A1, X_A1, Y_A1)), // popped first + ])); + + struct RotatingFetcher { + bodies: Arc>>>, + } + impl super::super::verifier::sealed::Sealed for RotatingFetcher {} + impl JwksFetcher for RotatingFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self + .bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } + } + + let jwks_contract = JwksSourceContract::new( + format!("https://{issuer}/.well-known/jwks.json"), + 1, + HARD_DEADLINE_SECS, + ) + .unwrap(); + + // Controlled clock: atomic epoch-seconds, starts at real T0. + let t0 = chrono::Utc::now().timestamp(); + let clock = Arc::new(AtomicI64::new(t0)); + let clock2 = Arc::clone(&clock); + let now_fn: Arc chrono::DateTime + Send + Sync> = + Arc::new(move || { + chrono::DateTime::from_timestamp(clock2.load(Ordering::SeqCst), 0) + .unwrap_or(chrono::DateTime::UNIX_EPOCH) + }); + + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: jwks_contract.clone(), + }; + // Mutation oracle 1 (sharing): pass a second independent Arc to the verifier, + // separately warmed with A1 before advancing the clock. After advancement, + // A2-accept flips red (verifier never observes A2 keys); A1-reject stays + // green (independent cache also expired, so A1 keys are absent there too). + let source = Arc::new( + ProductionJwksSource::new_with_clock( + vec![config], + RotatingFetcher { bodies }, + Arc::clone(&now_fn), + ) + .unwrap(), + ); + + // Step 1: warm cache with A1 JWKS (first scripted fetch at T0). + let snap_a1 = source.get_snapshot(issuer).await.unwrap(); + let gen_a1 = snap_a1.generation(); + // A1's hard deadline is T0 + HARD_DEADLINE_SECS; never mutated by this test. + let deadline_a1 = snap_a1.hard_deadline(); + + // Step 2: build the ONE long-lived verifier. + let mut registry = IssuerRegistry::new(); + registry.insert( + IssuerPolicy::new( + issuer.to_owned(), + vec![audience.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + false, + 60, + HARD_DEADLINE_SECS, + None, + jwks_contract, + ) + .unwrap(), + ); + let verifier = FederatedAssertionVerifier::new(registry, Arc::clone(&source)); + + // Pre-advancement: A1 verifies. + verifier + .verify(&sign_token(PKCS8_A1, KID_A1, issuer, audience)) + .expect("A1 token must verify before clock advances past its deadline"); + + // Step 3: advance clock past A1's original hard deadline (no sleep). + clock.store(t0 + HARD_DEADLINE_SECS as i64 + 1, Ordering::SeqCst); + + // Step 4: re-fetch through the SAME shared source. + // Expiry purge fires (now > A1 deadline), second scripted response is A2. + let snap_a2 = source.get_snapshot(issuer).await.unwrap(); + let gen_a2 = snap_a2.generation(); + let deadline_a2 = snap_a2.hard_deadline(); + + assert!( + gen_a2 > gen_a1, + "generation must advance: A1={gen_a1} A2={gen_a2}" + ); + // A2's deadline is computed at advanced clock time, so it is later than A1's. + assert!( + deadline_a2 > deadline_a1, + "A2 deadline must be later than A1's original" + ); + + // Step 5: the SAME unchanged verifier reflects A2 keys. + verifier + .verify(&sign_token(PKCS8_A2, KID_A2, issuer, audience)) + .expect( + "A2 token must verify through the unchanged verifier after A1 deadline expired; \ + mutation oracle: use independent Arc -> A2-accept flips red (reliable oracle)", + ); + verifier + .verify(&sign_token(PKCS8_A1, KID_A1, issuer, audience)) + .expect_err("A1 must be rejected after expiry + rotation"); +} diff --git a/crates/buzz-auth/src/nip_fi/mod.rs b/crates/buzz-auth/src/nip_fi/mod.rs index f7d1243a058..ce977090645 100644 --- a/crates/buzz-auth/src/nip_fi/mod.rs +++ b/crates/buzz-auth/src/nip_fi/mod.rs @@ -1,32 +1,19 @@ -//! NIP-FI federated-identity authorization — canonical assertion verifier and -//! contracts (Phase A, PR 1). -//! -//! This module is the closed, provider-neutral contract layer at the root of -//! the NIP-FI dependency graph. It defines: -//! -//! - the multi-issuer assertion-policy [`config`] and the two deterministic -//! semantic contract identities ([`AssertionPolicyId`], -//! [`TransportContractId`]); -//! - the origin-sealed normalized [`VerifiedAssertion`] result (`FI-INV-16`); -//! - the single [`FederatedAssertionVerifier`] (`FI-INV-16` canonical verifier); -//! - the privacy-preserving four-class [`DenialClass`] wire contract -//! (`FI-INV-13`). -//! -//! It has no dependencies on other NIP-FI PRs. It defines no database schema, -//! migration, runtime JWKS fetching, binding resolution, enrollment, or -//! request/proof binding — those belong to later PRs. Identity is issuer- -//! qualified `(iss, sub)` throughout: the `sub` claim is the fixed subject -//! coordinate and `nostr_pubkey` is the fixed key claim, never configurable, -//! so no deployment can seal a mutable attribute as identity. Issuer URL and -//! audience remain deployment configuration. +//! NIP-FI federated-identity authorization — assertion verifier, JWKS runtime, +//! startup validation, and discovery. -/// The exact client-attached header field ([NIP-FI.md](../../../docs/nips/NIP-FI.md), -/// "Client-attached transport"). `Authorization` remains reserved for NIP-98. +/// The client-attached transport header for federated-identity assertions. +/// +/// `Authorization` remains reserved for NIP-98; this separate header avoids +/// conflating authentication schemes at the relay ingress. +/// ([NIP-FI.md](../../../docs/nips/NIP-FI.md), "Client-attached transport") pub const CLIENT_ATTACHED_HEADER: &str = "Nostr-Federated-Identity"; pub mod assertion; pub mod config; pub mod denial; +pub mod discovery; +pub mod jwks; +pub mod startup; pub mod verifier; pub use assertion::{ @@ -39,4 +26,12 @@ pub use config::{ NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, }; pub use denial::DenialClass; +pub use discovery::{ + AssertionFreshnessDiscovery, FederatedIdentityDiscovery, FreshnessClassDiscovery, +}; +pub use jwks::{ + HttpJwksFetcher, IssuerJwksConfig, JwksFetchError, JwksFetcher, JwksSourceContract, + ProductionJwksSource, +}; +pub use startup::{validate_nip_fi_config, NipFiMode, NipFiStartupError}; pub use verifier::{AssertionKeySet, FederatedAssertionVerifier, IssuerKeySource, VerifierError}; diff --git a/crates/buzz-auth/src/nip_fi/startup/mod.rs b/crates/buzz-auth/src/nip_fi/startup/mod.rs new file mode 100644 index 00000000000..410c862ec70 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/startup/mod.rs @@ -0,0 +1,134 @@ +//! Startup validation for the NIP-FI assertion runtime. +//! +//! [`validate_nip_fi_config`] is the production entry point. It rejects any +//! configuration that would make the runtime unsafe, incomplete, or ambiguous +//! before the relay accepts any protected traffic. The relay MUST call this and +//! refuse to start on error in [`Enforce`][NipFiMode::Enforce] mode +//! (`FI-INV-14`, `FI-INV-15`). + +use super::config::{FreshnessClass, IssuerRegistry}; +use super::jwks::IssuerJwksConfig; + +/// Variant names are stable contract values; do not rename without a +/// `VERIFIER_CONTRACT_VERSION` bump. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NipFiMode { + /// NIP-FI is disabled. Protected ingresses are unreachable or absent. + Off, + /// Production enforcement: every protected ingress requires valid + /// federated assertion evidence. The relay MUST call + /// [`validate_nip_fi_config`] before accepting traffic in this mode. + Enforce, + /// All protected routes deny unconditionally. Used when a prior + /// enforce-mode deployment was misconfigured and must fail closed while + /// the operator repairs configuration. [FI-INV-14] + DenyProtected, +} + +/// Every variant corresponds to a concrete, operator-actionable defect. +/// No key material, token bytes, or raw claim values appear. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum NipFiStartupError { + /// Registry has no entries; enforce mode requires at least one issuer. + #[error("NIP-FI enforce mode requires at least one issuer policy")] + EmptyRegistry, + + /// The duplicate `iss` is omitted to avoid leaking configuration into + /// operational logs. + #[error("NIP-FI issuer registry contains a duplicate issuer")] + DuplicateIssuer, + + /// Every registered issuer requires a JWKS endpoint in enforce mode. + #[error("NIP-FI issuer has no JWKS configuration")] + MissingJwksConfig, + + /// Mismatched configs are rejected to prevent silent key-source confusion. + #[error("NIP-FI JWKS config issuer does not match any registered policy")] + UnmatchedJwksConfig, + + /// The `JwksSourceContract` embedded in the `IssuerJwksConfig` does not + /// match the contract in the corresponding `IssuerPolicy`. Both must carry + /// exactly the same contract to keep a single source of truth per issuer. + #[error("NIP-FI JWKS config contract does not match the registered policy contract")] + JwksContractMismatch, + + /// `current-status` requires an authenticated status witness that is not + /// yet implemented. Use `FreshnessClass::OfflineJwt` instead. + #[error( + "NIP-FI current-status freshness is not yet supported; \ + use offline-jwt posture" + )] + UnsupportedPosture, +} + +/// Validates the complete NIP-FI runtime configuration. On error the relay +/// MUST refuse to start or fall back to [`NipFiMode::DenyProtected`]. +pub fn validate_nip_fi_config( + mode: NipFiMode, + registry: &IssuerRegistry, + jwks_configs: &[IssuerJwksConfig], +) -> Result<(), NipFiStartupError> { + if let NipFiMode::Off | NipFiMode::DenyProtected = mode { + return Ok(()); + } + + if registry.is_empty() { + return Err(NipFiStartupError::EmptyRegistry); + } + + // IssuerRegistry overwrites duplicates silently; assert uniqueness here so + // a misconfigured multi-issuer call-site is caught before traffic is served. + { + let mut seen = std::collections::HashSet::new(); + for policy in registry.all_policies() { + if !seen.insert(policy.issuer()) { + return Err(NipFiStartupError::DuplicateIssuer); + } + } + } + + // Reject current-status policies: the status witness is not yet + // implemented. Fail closed rather than advertise a freshness guarantee the + // verifier cannot satisfy. + for policy in registry.all_policies() { + if policy.freshness() == FreshnessClass::CurrentStatus { + return Err(NipFiStartupError::UnsupportedPosture); + } + } + + // Build JWKS map, rejecting duplicates. Two configs for the same issuer + // would make the effective endpoint selection order-dependent. + let mut jwks_map: std::collections::HashMap<&str, &IssuerJwksConfig> = + std::collections::HashMap::with_capacity(jwks_configs.len()); + for config in jwks_configs { + if jwks_map.insert(config.issuer.as_str(), config).is_some() { + return Err(NipFiStartupError::DuplicateIssuer); + } + } + + for config in jwks_configs { + if registry.policy_for_issuer(&config.issuer).is_none() { + return Err(NipFiStartupError::UnmatchedJwksConfig); + } + // Contract fields are pre-validated inside `JwksSourceContract::new` + // at `IssuerPolicy` construction. Enforce that the config carries the + // same contract as the policy — a mismatch would mean two independent + // copies of the URI/timing drifted apart, violating the single-source- + // of-truth invariant. + let policy = registry.policy_for_issuer(&config.issuer).unwrap(); + if &config.contract != policy.jwks_source_contract() { + return Err(NipFiStartupError::JwksContractMismatch); + } + } + + for policy in registry.all_policies() { + if !jwks_map.contains_key(policy.issuer()) { + return Err(NipFiStartupError::MissingJwksConfig); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-auth/src/nip_fi/startup/tests.rs b/crates/buzz-auth/src/nip_fi/startup/tests.rs new file mode 100644 index 00000000000..04b28a7b964 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/startup/tests.rs @@ -0,0 +1,182 @@ +use super::*; +use crate::nip_fi::config::{FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass}; +use crate::nip_fi::jwks::{IssuerJwksConfig, JwksSourceContract}; +use jsonwebtoken::Algorithm as JwtAlgorithm; + +fn test_contract(issuer: &str) -> JwksSourceContract { + // Build a canonical JWKS URI from the issuer URL. The issuer may already + // be a full HTTPS URL (e.g. "https://id.example") or a bare hostname. + let uri = if issuer.starts_with("https://") { + format!("{}/.well-known/jwks.json", issuer.trim_end_matches('/')) + } else { + format!("https://{}/.well-known/jwks.json", issuer) + }; + JwksSourceContract::new(uri, 300, 3600).expect("valid test contract") +} + +fn make_offline_policy(issuer: &str) -> IssuerPolicy { + IssuerPolicy::new( + issuer.to_owned(), + vec![format!("https://relay.example/api")], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![JwtAlgorithm::ES256], + false, + 0, + 3600, + None, + test_contract(issuer), + ) + .unwrap() +} + +fn make_status_policy(issuer: &str) -> IssuerPolicy { + IssuerPolicy::new( + issuer.to_owned(), + vec![format!("https://relay.example/api")], + TokenClass::DedicatedNipFi, + FreshnessClass::CurrentStatus, + vec![JwtAlgorithm::ES256], + false, + 0, + 3600, + Some(60), + test_contract(issuer), + ) + .unwrap() +} + +fn make_jwks_config(issuer: &str) -> IssuerJwksConfig { + IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: test_contract(issuer), + } +} + +#[test] +fn off_mode_accepts_empty_registry() { + let registry = IssuerRegistry::new(); + assert!(validate_nip_fi_config(NipFiMode::Off, ®istry, &[]).is_ok()); +} + +#[test] +fn deny_protected_mode_accepts_empty_registry() { + let registry = IssuerRegistry::new(); + assert!(validate_nip_fi_config(NipFiMode::DenyProtected, ®istry, &[]).is_ok()); +} + +#[test] +fn enforce_valid_config_passes() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + assert!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[make_jwks_config(issuer)]).is_ok() + ); +} + +#[test] +fn enforce_multiple_issuers_passes() { + let issuers = [ + "https://a.example", + "https://b.example", + "https://c.example", + ]; + let mut registry = IssuerRegistry::new(); + for iss in &issuers { + registry.insert(make_offline_policy(iss)); + } + let jwks: Vec<_> = issuers.iter().map(|i| make_jwks_config(i)).collect(); + assert!(validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_ok()); +} + +#[test] +fn enforce_empty_registry_rejects() { + let registry = IssuerRegistry::new(); + let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err(); + assert_eq!(err, NipFiStartupError::EmptyRegistry); +} + +#[test] +fn enforce_issuer_without_jwks_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err(); + assert_eq!(err, NipFiStartupError::MissingJwksConfig); +} + +#[test] +fn enforce_unmatched_jwks_config_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let err = validate_nip_fi_config( + NipFiMode::Enforce, + ®istry, + &[make_jwks_config("https://other.example")], + ) + .unwrap_err(); + assert_eq!(err, NipFiStartupError::UnmatchedJwksConfig); +} + +/// A JWKS config whose contract differs from the policy contract must be +/// rejected — a mismatch means two independent copies of URI/timing have +/// drifted, violating the single-source-of-truth invariant. +#[test] +fn enforce_jwks_contract_mismatch_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + // Config carries a different refresh interval than the policy (300 vs 600). + let mismatched_config = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + format!("{}/.well-known/jwks.json", issuer.trim_end_matches('/')), + 600, // differs from policy contract (300) + 3600, + ) + .unwrap(), + }; + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[mismatched_config]).unwrap_err(), + NipFiStartupError::JwksContractMismatch + ); +} + +/// Rejected regardless of whether a JWKS config is present — the verifier +/// has no status witness to satisfy the freshness guarantee. +#[test] +fn enforce_current_status_policy_always_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_status_policy(issuer)); + + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err(), + NipFiStartupError::UnsupportedPosture + ); + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[make_jwks_config(issuer)]) + .unwrap_err(), + NipFiStartupError::UnsupportedPosture + ); +} + +/// Duplicate JWKS configs for the same issuer must not silently succeed. +#[test] +fn enforce_duplicate_jwks_issuer_in_configs_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let jwks = vec![make_jwks_config(issuer), make_jwks_config(issuer)]; + assert!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_err(), + "duplicate JWKS configs must not pass" + ); +} diff --git a/crates/buzz-auth/src/nip_fi/verifier.rs b/crates/buzz-auth/src/nip_fi/verifier.rs index 7ac2cbe3766..aa3b5796a0f 100644 --- a/crates/buzz-auth/src/nip_fi/verifier.rs +++ b/crates/buzz-auth/src/nip_fi/verifier.rs @@ -49,9 +49,13 @@ use std::fmt; /// the key-source trait. Combined with the crate-private [`AssertionKeySet`] /// constructor, this makes the accepted issuer→JWKS authority impossible to /// synthesize outside the crate's trusted configuration path. -mod sealed { +pub(crate) mod sealed { /// Private marker preventing external implementations of the key source. pub trait Sealed {} + + // Blanket seal for `Arc` so `Arc` satisfies + // the sealed supertrait without requiring callers to implement it. + impl Sealed for std::sync::Arc {} } /// One issuer's key source: a JWKS snapshot bound to the exact `iss` it @@ -64,7 +68,7 @@ mod sealed { /// construction seam: [`verify`] takes no snapshot argument, and this type has /// no public constructor, so an external consumer cannot build a snapshot that /// labels issuer B's JWKS as issuer A. Building a snapshot (and the source that -/// serves it) is the trusted configuration act PR 3's JWKS runtime performs at +/// serves it) is the trusted configuration act the `jwks` runtime performs at /// startup, not a per-request or external input. /// /// The crate-private constructor is a live regression: an external crate that @@ -90,7 +94,7 @@ impl AssertionKeySet { /// generation and a required key-snapshot hard deadline. Rejects a zero /// generation, an empty issuer, an empty or oversized key set /// ([`MAX_JWKS_KEYS`]), or a non-positive deadline. Crate-private: only the - /// trusted in-crate configuration path (PR 3's JWKS runtime) may bind key + /// trusted in-crate configuration path (the `jwks` runtime) may bind key /// material to an issuer. /// /// Bounding the key count here is the pre-lookup control (NIP-FI.md:166-171): @@ -101,13 +105,6 @@ impl AssertionKeySet { /// finite key-snapshot bound into `revalidation_dependencies` /// (NIP-FI.md:240-249). /// - /// Its only current callers are the in-crate `cfg(test)` verifier suite; - /// PR 3's JWKS runtime is the intended non-test consumer. Until it lands the - /// non-test lib build sees no caller, so this narrowly allows `dead_code` - /// for this one constructor rather than deferring it or widening the lint. - /// `expect` would misfire: under `cfg(test)` the lint does not trigger, so - /// the expectation would be unfulfilled and fail `-D warnings`. - #[allow(dead_code)] pub(crate) fn new( issuer: String, generation: u64, @@ -139,6 +136,13 @@ impl AssertionKeySet { pub const fn generation(&self) -> u64 { self.generation } + + /// The snapshot hard deadline. Test-only accessor for deadline-crossing + /// oracles; not compiled into production builds. + #[cfg(test)] + pub(crate) fn hard_deadline(&self) -> chrono::DateTime { + self.hard_deadline + } } impl fmt::Debug for AssertionKeySet { @@ -153,7 +157,7 @@ impl fmt::Debug for AssertionKeySet { /// instead asks this source for the snapshot bound to the token's /// signature-authenticated `iss`. A request-path caller therefore cannot /// relabel one issuer's JWKS as another's — the cross-issuer bypass at the old -/// `verify(token, key_set)` seam. Configuring the source (PR 3's JWKS runtime) +/// `verify(token, key_set)` seam. Configuring the source (the `jwks` runtime) /// is a trusted startup act, not per-request input. /// /// This trait is sealed via a private supertrait, so it cannot be implemented @@ -180,8 +184,27 @@ pub trait IssuerKeySource: sealed::Sealed { fn key_set(&self, issuer: &str) -> Option; } +/// Forwarding implementation so a single `Arc` can be cheaply cloned and +/// shared across multiple [`FederatedAssertionVerifier`] instances while all +/// of them observe every refresh committed to the shared source. +/// +/// This is the canonical sharing path for `ProductionJwksSource`, which is +/// not itself `Clone` (its internal `RwLock`-protected state is not cheaply +/// copyable). Wrap it in `Arc` at startup, then pass `Arc::clone(&source)` to +/// each verifier — all verifiers read from the same underlying cache and see +/// key rotations as soon as `get_snapshot` commits them. +/// +/// The blanket seal (`impl Sealed for Arc`) in the `sealed` +/// module ensures this forwarding impl remains crate-owned: an external crate +/// still cannot implement `IssuerKeySource` for its own type. +impl IssuerKeySource for std::sync::Arc { + fn key_set(&self, issuer: &str) -> Option { + (**self).key_set(issuer) + } +} + /// A fixed issuer→snapshot key source for the in-crate verifier tests, -/// standing in for PR 3's JWKS runtime. It is `cfg(test)`-only — not behind a +/// standing in for the `jwks` runtime. It is `cfg(test)`-only — not behind a /// downstream-selectable Cargo feature — so no dependent crate can enable it to /// reconstruct the authority. An honest source returns only the snapshot bound /// to the exact issuer requested, the invariant the real runtime source @@ -352,7 +375,7 @@ impl FederatedAssertionVerifier { // is `evidence_rejected` (403), and this defers a valid one as // `authorization_unavailable` (503) so a missing witness never // masquerades as rejected evidence, nor invalid input as unavailable - // (NIP-FI.md:459-476). PR 3 adds the witness path additively. + // (NIP-FI.md:459-476). if policy.freshness() == FreshnessClass::CurrentStatus { return Err(VerifierError::StatusWitnessUnavailable); } @@ -726,8 +749,8 @@ fn parse_nostr_pubkey_claim( } } -/// Capture only the claim names the policy reads into a canonical set. For PR 1 -/// the closed set is the `scope` claim, split on ASCII space; unchecked claims +/// Capture only the claim names the policy reads into a canonical set. The +/// closed set is the `scope` claim, split on ASCII space; unchecked claims /// never enter the result. fn capture_capabilities( _policy: &IssuerPolicy, diff --git a/crates/buzz-auth/src/nip_fi/verifier/tests.rs b/crates/buzz-auth/src/nip_fi/verifier/tests.rs index 316681e0afc..990a3310e40 100644 --- a/crates/buzz-auth/src/nip_fi/verifier/tests.rs +++ b/crates/buzz-auth/src/nip_fi/verifier/tests.rs @@ -28,6 +28,17 @@ const TEST_KID: &str = "test-key-1"; const ISSUER: &str = "https://issuer.example"; const AUDIENCE: &str = "https://relay.example"; +/// A canonical JWKS contract for the default test issuer. Used wherever a +/// `JwksSourceContract` is required but JWKS behavior is not under test. +fn test_jwks_contract() -> crate::nip_fi::jwks::JwksSourceContract { + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .expect("valid test contract") +} + // A second, independent P-256 key: issuer B's real signing key, used to prove // that a token signed by B and claiming `iss=A` cannot mint an A identity. const TEST_EC_PKCS8_PEM_B: &str = "-----BEGIN PRIVATE KEY-----\n\ @@ -102,11 +113,18 @@ fn access_token_policy_with(subject_class: SubjectClassContract) -> IssuerPolicy 60, 3600, None, + test_jwks_contract(), ) .expect("valid policy") } fn dedicated_policy(issuer: &str) -> IssuerPolicy { + let contract = crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", issuer.trim_end_matches('/')), + 300, + 3600, + ) + .expect("valid test contract"); IssuerPolicy::new( issuer.to_owned(), vec![AUDIENCE.to_owned()], @@ -117,6 +135,7 @@ fn dedicated_policy(issuer: &str) -> IssuerPolicy { 60, 3600, None, + contract, ) .expect("valid policy") } @@ -132,6 +151,7 @@ fn dedicated_policy_with_audiences(audiences: Vec) -> IssuerPolicy { 60, 3600, None, + test_jwks_contract(), ) .expect("valid policy") } @@ -147,6 +167,7 @@ fn dedicated_policy_with_algorithms(algorithms: Vec) -> IssuerPolicy 60, 3600, None, + test_jwks_contract(), ) .expect("valid policy") } @@ -688,6 +709,7 @@ fn missing_nostr_pubkey_denies_under_attested_key_policy() { 60, 3600, None, + test_jwks_contract(), ) .unwrap(); let verifier = verifier_with(policy); @@ -1087,6 +1109,7 @@ fn current_status_policy() -> IssuerPolicy { 60, 3600, Some(120), // maximum_status_age required for current-status + test_jwks_contract(), ) .expect("valid current-status policy") } @@ -1366,6 +1389,7 @@ fn assertion_policy_id_is_deterministic_and_semantic() { 120, // different skew => different semantics 3600, None, + test_jwks_contract(), ) .unwrap(); assert_ne!(p1.id(), changed.id()); @@ -1391,6 +1415,7 @@ fn offline_policy_rejects_inapplicable_maximum_status_age() { 60, 3600, Some(120), + test_jwks_contract(), ) .unwrap_err(); assert_eq!(err, IssuerPolicyError::InapplicableMaximumStatusAge); @@ -1409,6 +1434,7 @@ fn offline_policy_accepts_absent_maximum_status_age() { 60, 3600, None, + test_jwks_contract(), ) .is_ok()); } @@ -1427,6 +1453,7 @@ fn current_status_policy_still_requires_positive_maximum_status_age() { 60, 3600, None, + test_jwks_contract(), ) .unwrap_err(); assert_eq!(missing, IssuerPolicyError::MissingMaximumStatusAge); @@ -1440,6 +1467,7 @@ fn current_status_policy_still_requires_positive_maximum_status_age() { 60, 3600, Some(0), + test_jwks_contract(), ) .unwrap_err(); assert_eq!(zero, IssuerPolicyError::InvalidTimeBounds); @@ -1533,7 +1561,185 @@ fn assertion_policy_id_is_invariant_under_subject_class_value_permutation_and_du assert_eq!(base.id(), permuted.id()); } -// ---- Canonical scope capture --------------------------------------------- +// ---- JwksSourceContract in AssertionPolicyId ------------------------------ +// +// Per the NIP-FI spec ("Policy identity and snapshots"): `assertion_policy_id` +// covers "authenticated key/status-source contracts" and "time rules". The +// three contract fields are immutable contract identity, not mutable state — +// changing any one of them changes which keys the runtime trusts or how long +// it trusts them, invalidating all prepared evidence against the old contract. +// Key rotation (JWKS content change) leaves all three unchanged and must NOT +// move the ID. + +/// Helper: build a policy with the given `JwksSourceContract`. +fn policy_with_contract(contract: crate::nip_fi::jwks::JwksSourceContract) -> IssuerPolicy { + IssuerPolicy::new( + ISSUER.to_owned(), + vec![AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + false, + 60, + 3600, + None, + contract, + ) + .expect("valid policy") +} + +#[test] +fn assertion_policy_id_moves_when_jwks_uri_changes() { + // The JWKS URI selects the authenticated key source. A different URI may + // serve different keys — the policy ID must change. + // + // Mutation (omit URI from hash): both policies hash identically despite + // different endpoints; this test turns red. + let base = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + let different_uri = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks-alt.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + assert_ne!( + base.id(), + different_uri.id(), + "JWKS URI change must move assertion_policy_id" + ); +} + +#[test] +fn assertion_policy_id_moves_when_refresh_interval_changes() { + // The refresh interval defines bounded refresh behavior. A longer interval + // allows stale keys to persist longer — the policy ID must change. + // + // Mutation (omit refresh_interval from hash): both policies hash + // identically; this test turns red. + let base = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + let different_interval = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 600, // doubled + 3600, + ) + .unwrap(), + ); + assert_ne!( + base.id(), + different_interval.id(), + "refresh_interval_seconds change must move assertion_policy_id" + ); +} + +#[test] +fn assertion_policy_id_moves_when_hard_deadline_changes() { + // The hard deadline defines the source's accepted time rule; every + // per-snapshot deadline the verifier seals into `VerifiedAssertion` + // derives from this. A looser deadline extends the valid window beyond + // what the new policy intends — the policy ID must change. + // + // Mutation (omit key_snapshot_hard_deadline from hash): both policies + // hash identically; this test turns red. + let base = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + let different_deadline = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 7200, // doubled + ) + .unwrap(), + ); + assert_ne!( + base.id(), + different_deadline.id(), + "key_snapshot_hard_deadline_seconds change must move assertion_policy_id" + ); +} + +#[test] +fn assertion_policy_id_is_stable_for_same_jwks_contract() { + // URI canonicalization is deterministic: the same validated URI, interval, + // and deadline always hash to the same policy ID regardless of call order. + let c1 = crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(); + let c2 = crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(); + let p1 = policy_with_contract(c1); + let p2 = policy_with_contract(c2); + assert_eq!( + p1.id(), + p2.id(), + "same JWKS contract must produce identical assertion_policy_id" + ); +} + +#[test] +fn identical_contract_produces_stable_assertion_policy_id() { + // `AssertionPolicyId` is derived from the contract fields only — not from + // JWKS key material. This means JWKS key additions/removals (runtime + // rotation) cannot change the policy ID; only changes to the contract + // itself (JWKS URI, refresh interval, hard deadline) would do so. + // + // This test verifies the structural invariant: two `IssuerPolicy` values + // built from identical contracts produce the same `AssertionPolicyId`, + // regardless of when or how many times the ID is derived. Because key + // material never flows into `derive_assertion_policy_id`, the ID is + // stable for the lifetime of a given contract. + let p1 = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + let p2 = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + // Identical contract → identical ID: key material is not part of the hash. + assert_eq!( + p1.id(), + p2.id(), + "identical contract must produce the same assertion_policy_id (key material is not hashed)" + ); +} #[test] fn scope_capture_is_canonical_under_order_and_duplicates() { diff --git a/crates/buzz-core/src/network.rs b/crates/buzz-core/src/network.rs index fb3718d58c5..fe5b4bb80a6 100644 --- a/crates/buzz-core/src/network.rs +++ b/crates/buzz-core/src/network.rs @@ -19,344 +19,390 @@ fn embedded_ipv4(v6: &std::net::Ipv6Addr, prefix: &[u8; 12]) -> Option bool { +/// Blocked classes are drawn from the IANA IPv4 and IPv6 Special-Purpose +/// Address Space registries (last updated 2025-10-09): ranges whose +/// `Globally Reachable` column is `False`, `None`, or absent, plus multicast +/// space. Within otherwise-denied envelopes, explicitly global entries are +/// carved out as exceptions (e.g., PCP/TURN/DNS-SD anycast inside 2001::/23). +/// IPv4 embedded in IPv4-mapped, IPv4-compatible, and NAT64 well-known +/// (64:ff9b::/96) space is evaluated recursively against the IPv4 table — +/// registry global=True for the IPv6 wrapper does not bypass the +/// embedded-address check. SIIT IPv4-translated (::ffff:0:0:0/96) follows the +/// same recursive path. The local-use NAT64 prefix (64:ff9b:1::/48) is blocked +/// wholesale as a non-global range; its embedded IPv4 payload is not decoded. +/// +/// Used for SSRF protection: rejects outbound targets in known non-public +/// address classes; addresses not covered by an explicit deny rule pass through. +/// Conservative posture: `None`/blank registry entries are treated as non-global. +/// +/// Registries retrieved 2026-08-31; registries last updated 2025-10-09: +/// https://www.iana.org/assignments/iana-ipv4-special-registry/ +/// https://www.iana.org/assignments/iana-ipv6-special-registry/ +/// +/// Compatibility alias: `is_private_ip` (see below). +/// +/// Callers: `buzz-auth` JWKS boundary, `buzz-workflow` webhook SSRF check, +/// desktop `link_preview` SSRF check. +pub fn is_not_global_unicast(ip: &std::net::IpAddr) -> bool { match ip { std::net::IpAddr::V4(v4) => { - let octets = v4.octets(); - v4.is_loopback() - || v4.is_private() - || v4.is_link_local() - || octets[0] == 0 - || v4.is_broadcast() - // Carrier-Grade NAT (RFC 6598) — 100.64.0.0/10 - // Dangerous in cloud environments (AWS, GCP) where CGNAT can route to metadata services. - || (octets[0] == 100 && (octets[1] & 0xC0) == 64) - // Benchmarking (RFC 2544) — 198.18.0.0/15 - || (octets[0] == 198 && (octets[1] & 0xFE) == 18) + let o = v4.octets(); + v4.is_loopback() // 127.0.0.0/8 + || v4.is_private() // 10/8, 172.16/12, 192.168/16 + || v4.is_link_local() // 169.254.0.0/16 + || o[0] == 0 // 0.0.0.0/8 "This network" + || v4.is_broadcast() // 255.255.255.255 + || (o[0] == 100 && (o[1] & 0xC0) == 64) // 100.64.0.0/10 Shared/CGNAT + || (o[0] == 198 && (o[1] & 0xFE) == 18) // 198.18.0.0/15 Benchmarking + || (o[0] & 0xF0) == 0xE0 // 224.0.0.0/4 Multicast + || (o[0] & 0xF0) == 0xF0 // 240.0.0.0/4 Reserved + // 192.0.0.0/24 IETF Protocol Assignments. + // Globally reachable exceptions: 192.0.0.9 (PCP anycast, RFC 7723) + // and 192.0.0.10 (TURN anycast, RFC 8155). + || (o[0] == 192 && o[1] == 0 && o[2] == 0 + && o[3] != 9 && o[3] != 10) + || (o[0] == 192 && o[1] == 0 && o[2] == 2) // 192.0.2.0/24 TEST-NET-1 + // 192.88.99.0/24 deprecated 6to4 relay anycast (RFC 7526). + // Registry global field is None/blank — conservative posture: block. + || (o[0] == 192 && o[1] == 88 && o[2] == 99) + || (o[0] == 198 && o[1] == 51 && o[2] == 100) // 198.51.100.0/24 TEST-NET-2 + || (o[0] == 203 && o[1] == 0 && o[2] == 113) // 203.0.113.0/24 TEST-NET-3 } std::net::IpAddr::V6(v6) => { - // Check IPv4-compatible and mapped addresses against IPv4 rules. + // IPv4-compatible and IPv4-mapped addresses are checked against IPv4 rules. if let Some(v4) = v6.to_ipv4() { - return is_private_ip(&std::net::IpAddr::V4(v4)); + return is_not_global_unicast(&std::net::IpAddr::V4(v4)); } - let segments = v6.segments(); + let s = v6.segments(); - // NAT64 well-known prefix (RFC 6052). Preserve access to public IPv4 - // destinations while rejecting embedded private/reserved addresses. + // NAT64 well-known prefix (RFC 6052): reachability follows the embedded + // IPv4 address (registry global=True, but SSRF policy checks payload). if let Some(v4) = embedded_ipv4(v6, &NAT64_WELL_KNOWN_PREFIX) { - return is_private_ip(&std::net::IpAddr::V4(v4)); + return is_not_global_unicast(&std::net::IpAddr::V4(v4)); } - // Legacy SIIT IPv4-translated addresses can route to the IPv4 value - // in their final four octets but are not recognized by `to_ipv4()`. + // SIIT IPv4-translated addresses (::ffff:0:0:0/96) route to the embedded + // IPv4 value and are not recognised by `to_ipv4()`. if let Some(v4) = embedded_ipv4(v6, &IPV4_TRANSLATED_PREFIX) { - return is_private_ip(&std::net::IpAddr::V4(v4)); + return is_not_global_unicast(&std::net::IpAddr::V4(v4)); + } + + if v6.is_loopback() || v6.is_unspecified() { + return true; } - v6.is_loopback() - || v6.is_unspecified() - || segments[0] & 0xfe00 == 0xfc00 // fc00::/7 ULA - || segments[0] & 0xffc0 == 0xfe80 // fe80::/10 link-local - || segments[0] & 0xff00 == 0xff00 // ff00::/8 multicast - || (segments[0] == 0x0064 - && segments[1] == 0xff9b - && segments[2] == 1) // 64:ff9b:1::/48 local-use NAT64 - || (segments[0] == 0x2001 && segments[1] == 0) // 2001::/32 Teredo - || segments[0] == 0x2002 // 2002::/16 6to4 - // RFC 3849 — documentation range, should never appear in production - || (segments[0] == 0x2001 && segments[1] == 0x0db8) + // 2001::/23 IETF Protocol Assignments envelope (registry global=False). + // All addresses within the /23 are non-global by default, with explicit + // globally-reachable exceptions carved out below. + // + // /23 check: segments[0]==0x2001 and top 7 bits of segments[1] are zero + // (i.e., segments[1] in [0x0000..0x01ff]). + if s[0] == 0x2001 && (s[1] >> 9) == 0 { + // Globally reachable exceptions inside 2001::/23 (registry global=True): + // 2001:1::1 PCP Anycast RFC 7723 + // 2001:1::2 TURN Anycast RFC 8155 + // 2001:1::3 DNS-SD SRP Anycast RFC 9665 + // 2001:3::/32 AMT RFC 7450 + // 2001:4:112::/48 AS112-v6 RFC 7535 + // 2001:20::/28 ORCHIDv2 RFC 7343 (segments[1] in 0x0020..0x002f) + // 2001:30::/28 DETs Prefix RFC 9374 (segments[1] in 0x0030..0x003f) + let is_global_exception = (s[1] == 1 + && s[2] == 0 + && s[3] == 0 + && s[4] == 0 + && s[5] == 0 + && s[6] == 0 + && matches!(s[7], 1..=3)) + || s[1] == 3 // 2001:3::/32 AMT + || (s[1] == 4 && s[2] == 0x0112) // 2001:4:112::/48 AS112-v6 + || (s[1] >> 4) == 0x0002 // 2001:20::/28 ORCHIDv2 + || (s[1] >> 4) == 0x0003; // 2001:30::/28 DETs + + if !is_global_exception { + return true; + } + } + + s[0] & 0xfe00 == 0xfc00 // fc00::/7 ULA + || s[0] & 0xffc0 == 0xfe80 // fe80::/10 link-local + || s[0] & 0xffc0 == 0xfec0 // fec0::/10 deprecated site-local (RFC 3879) + || s[0] & 0xff00 == 0xff00 // ff00::/8 multicast + // 64:ff9b:1::/48 local-use NAT64 (RFC 8215) + || (s[0] == 0x0064 && s[1] == 0xff9b && s[2] == 1) + // 100::/64 Discard-Only (RFC 6666) + || (s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 0) + // 100:0:0:1::/64 Dummy IPv6 Prefix (RFC 9780) + || (s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 1) + // 2001:db8::/32 Documentation (RFC 3849) — outside 2001::/23 + || (s[0] == 0x2001 && s[1] == 0x0db8) + || s[0] == 0x2002 // 2002::/16 6to4 (RFC 3056) + // 3fff::/20 Documentation (RFC 9637) + || (s[0] == 0x3fff && (s[1] >> 12) == 0) + || s[0] == 0x5f00 // 5f00::/16 SRv6 SIDs (RFC 9252) } } } +/// Compatibility alias; prefer [`is_not_global_unicast`]. +#[inline] +pub fn is_private_ip(ip: &std::net::IpAddr) -> bool { + is_not_global_unicast(ip) +} + #[cfg(test)] mod tests { use super::*; use std::net::IpAddr; - #[test] - fn test_loopback_v4() { - assert!(is_private_ip(&"127.0.0.1".parse::().unwrap())); - } - #[test] - fn test_private_10() { - assert!(is_private_ip(&"10.0.0.1".parse::().unwrap())); - } - #[test] - fn test_private_172() { - assert!(is_private_ip(&"172.16.0.1".parse::().unwrap())); - } - #[test] - fn test_private_192() { - assert!(is_private_ip(&"192.168.1.1".parse::().unwrap())); - } - #[test] - fn test_link_local() { - assert!(is_private_ip(&"169.254.1.1".parse::().unwrap())); - } - #[test] - fn test_unspecified() { - assert!(is_private_ip(&"0.0.0.0".parse::().unwrap())); - } - #[test] - fn test_broadcast() { - assert!(is_private_ip(&"255.255.255.255".parse::().unwrap())); + fn blocked(s: &str) -> bool { + is_not_global_unicast(&s.parse::().unwrap()) } + #[test] - fn test_public_v4() { - assert!(!is_private_ip(&"8.8.8.8".parse::().unwrap())); + fn public_v4() { + assert!(!blocked("1.1.1.1")); + assert!(!blocked("8.8.8.8")); } + #[test] - fn test_loopback_v6() { - assert!(is_private_ip(&"::1".parse::().unwrap())); + fn public_v6_cloudflare() { + assert!(!blocked("2606:4700::1")); } + #[test] - fn test_unspecified_v6() { - assert!(is_private_ip(&"::".parse::().unwrap())); + fn loopback_and_unspecified() { + assert!(blocked("127.0.0.1")); + assert!(blocked("0.0.0.0")); + assert!(blocked("::1")); + assert!(blocked("::")); } + #[test] - fn test_ula_v6() { - assert!(is_private_ip(&"fd00::1".parse::().unwrap())); + fn private_rfc1918() { + assert!(blocked("10.0.0.1")); + assert!(blocked("172.16.0.1")); + assert!(blocked("192.168.1.1")); } + #[test] - fn test_link_local_v6() { - assert!(is_private_ip(&"fe80::1".parse::().unwrap())); + fn link_local() { + assert!(blocked("169.254.1.1")); + assert!(blocked("fe80::1")); } + #[test] - fn test_public_v6() { - assert!(!is_private_ip(&"2606:4700::1".parse::().unwrap())); + fn broadcast() { + assert!(blocked("255.255.255.255")); } + #[test] - fn test_documentation_range_v6() { - // 2001:db8::/32 — RFC 3849 documentation range, must be blocked - assert!(is_private_ip(&"2001:db8::1".parse::().unwrap())); - assert!(is_private_ip( - &"2001:db8:ffff::1".parse::().unwrap() - )); + fn cgnat() { + assert!(blocked("100.64.0.1")); + assert!(blocked("100.127.255.254")); + assert!(!blocked("100.63.255.255")); + assert!(!blocked("100.128.0.0")); } + #[test] - fn test_ipv4_mapped_v6_private() { - // ::ffff:10.0.0.1 is an IPv4-mapped IPv6 address pointing to a private IPv4 - assert!(is_private_ip(&"::ffff:10.0.0.1".parse::().unwrap())); + fn benchmarking_v4() { + assert!(blocked("198.18.0.1")); + assert!(blocked("198.19.255.254")); + assert!(!blocked("198.17.255.255")); + assert!(!blocked("198.20.0.0")); } + #[test] - fn test_ipv4_mapped_v6_loopback() { - assert!(is_private_ip( - &"::ffff:127.0.0.1".parse::().unwrap() - )); + fn multicast_and_reserved_v4() { + assert!(blocked("224.0.0.0")); + assert!(blocked("239.255.255.255")); + assert!(blocked("240.0.0.0")); + assert!(blocked("254.255.255.255")); + assert!(!blocked("223.255.255.255")); } + + // Most of 192.0.0.0/24 is non-global; 192.0.0.9 (PCP, RFC 7723) and + // 192.0.0.10 (TURN, RFC 8155) are the only globally-reachable exceptions. #[test] - fn test_ipv4_mapped_v6_public() { - assert!(!is_private_ip(&"::ffff:8.8.8.8".parse::().unwrap())); + fn ietf_protocol_assignments() { + assert!(blocked("192.0.0.0")); + assert!(blocked("192.0.0.1")); + assert!(blocked("192.0.0.170")); // NAT64/DNS64 discovery — non-global + assert!(blocked("192.0.0.255")); + assert!(!blocked("192.0.0.9")); // PCP Anycast (RFC 7723) — global + assert!(!blocked("192.0.0.10")); // TURN Anycast (RFC 8155) — global } + #[test] - fn test_ipv4_compatible_v6_private() { - assert!(is_private_ip(&"::10.0.0.1".parse::().unwrap())); - assert!(is_private_ip(&"::127.0.0.1".parse::().unwrap())); - assert!(is_private_ip( - &"::169.254.169.254".parse::().unwrap() - )); - assert!(!is_private_ip(&"::8.8.8.8".parse::().unwrap())); + fn documentation_v4() { + assert!(blocked("192.0.2.0")); + assert!(blocked("192.0.2.255")); + assert!(blocked("198.51.100.0")); + assert!(blocked("198.51.100.255")); + assert!(blocked("203.0.113.0")); + assert!(blocked("203.0.113.255")); + assert!(!blocked("192.0.1.255")); + assert!(!blocked("192.0.3.0")); + assert!(!blocked("198.51.99.255")); + assert!(!blocked("198.51.101.0")); + assert!(!blocked("203.0.112.255")); + assert!(!blocked("203.0.114.0")); } + + // Registry global field is None/blank; conservative posture: block. #[test] - fn test_nat64_well_known_prefix() { - let first = "64:ff9b::".parse().unwrap(); - let last = "64:ff9b::ffff:ffff".parse().unwrap(); - assert_eq!( - embedded_ipv4(&first, &NAT64_WELL_KNOWN_PREFIX), - Some("0.0.0.0".parse().unwrap()) - ); - assert_eq!( - embedded_ipv4(&last, &NAT64_WELL_KNOWN_PREFIX), - Some("255.255.255.255".parse().unwrap()) - ); - let embedded = "64:ff9b::172.16.1.2".parse().unwrap(); - assert_eq!( - embedded_ipv4(&embedded, &NAT64_WELL_KNOWN_PREFIX), - Some("172.16.1.2".parse().unwrap()) - ); - assert!(is_private_ip( - &"64:ff9b::10.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"64:ff9b::127.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"64:ff9b::169.254.169.254".parse::().unwrap() - )); - assert!(!is_private_ip( - &"64:ff9b::8.8.8.8".parse::().unwrap() - )); - assert!(!is_private_ip( - &"64:ff9a:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"64:ff9b::1:0:0".parse::().unwrap())); + fn deprecated_6to4_anycast_v4() { + assert!(blocked("192.88.99.0")); + assert!(blocked("192.88.99.1")); + assert!(blocked("192.88.99.255")); + assert!(!blocked("192.88.98.255")); + assert!(!blocked("192.88.100.0")); } + #[test] - fn test_ipv4_translated_prefix() { - let first = "0:0:0:0:ffff:0:0:0".parse().unwrap(); - let last = "0:0:0:0:ffff:0:ffff:ffff".parse().unwrap(); - assert_eq!( - embedded_ipv4(&first, &IPV4_TRANSLATED_PREFIX), - Some("0.0.0.0".parse().unwrap()) - ); - assert_eq!( - embedded_ipv4(&last, &IPV4_TRANSLATED_PREFIX), - Some("255.255.255.255".parse().unwrap()) - ); - assert!(is_private_ip( - &"::ffff:0:10.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"::ffff:0:127.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"::ffff:0:169.254.169.254".parse::().unwrap() - )); - assert!(!is_private_ip( - &"::ffff:0:8.8.8.8".parse::().unwrap() - )); - assert!(!is_private_ip( - &"0:0:0:0:fffe:ffff:ffff:ffff".parse::().unwrap() - )); - assert!(!is_private_ip( - &"0:0:0:0:ffff:1:0:0".parse::().unwrap() - )); + fn ula_v6() { + assert!(blocked("fd00::1")); + assert!(blocked("fc00::1")); } + #[test] - fn test_nat64_local_use_prefix_boundaries() { - assert!(is_private_ip(&"64:ff9b:1::".parse::().unwrap())); - assert!(is_private_ip( - &"64:ff9b:1:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip( - &"64:ff9b::ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"64:ff9b:2::".parse::().unwrap())); + fn multicast_v6() { + assert!(blocked("ff02::1")); + assert!(blocked("ff02::2")); + assert!(blocked("ffff::1")); + assert!(!blocked("fe00::1")); } + #[test] - fn test_teredo_prefix_boundaries() { - assert!(is_private_ip(&"2001::".parse::().unwrap())); - assert!(is_private_ip( - &"2001:0:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip( - &"2000:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"2001:1::1".parse::().unwrap())); + fn ietf_protocol_assignments_v6_interior() { + assert!(blocked("2001::")); + assert!(blocked("2001:2::1")); + assert!(blocked("2001:10::1")); + assert!(blocked("2001:db8::1")); // Documentation — outside /23 but blocked separately + assert!(blocked("2001:1ff:ffff::1")); + assert!(!blocked("2001:200::1")); } + #[test] - fn test_6to4_prefix_boundaries() { - assert!(is_private_ip(&"2002::".parse::().unwrap())); - assert!(is_private_ip( - &"2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip( - &"2001:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"2003::1".parse::().unwrap())); + fn ietf_protocol_assignments_v6_global_exceptions() { + // PCP/TURN/DNS-SD anycast /128s — registry global=True + assert!(!blocked("2001:1::1")); // PCP Anycast (RFC 7723) + assert!(!blocked("2001:1::2")); // TURN Anycast (RFC 8155) + assert!(!blocked("2001:1::3")); // DNS-SD SRP Anycast (RFC 9665) + assert!(blocked("2001:1::4")); // not an exception + assert!(blocked("2001:1:1::1")); // not an exception + + // 2001:3::/32 AMT — registry global=True + assert!(!blocked("2001:3::1")); + assert!(!blocked("2001:3:ffff::1")); + assert!(blocked("2001:4::1")); + + // 2001:4:112::/48 AS112-v6 — registry global=True + assert!(!blocked("2001:4:112::1")); + assert!(!blocked("2001:4:112:ffff::1")); + assert!(blocked("2001:4:113::1")); + + // 2001:20::/28 ORCHIDv2 — registry global=True + assert!(!blocked("2001:20::1")); + assert!(!blocked("2001:2f::1")); + assert!(blocked("2001:10::1")); + + // 2001:30::/28 DETs — registry global=True + assert!(!blocked("2001:30::1")); + assert!(!blocked("2001:3f::1")); + assert!(!blocked("2001:3::1")); // AMT exception — distinct check } - // CGNAT (RFC 6598) — 100.64.0.0/10 #[test] - fn test_cgnat_start() { - // 100.64.0.1 — start of CGNAT range - assert!(is_private_ip(&"100.64.0.1".parse::().unwrap())); + fn documentation_v6() { + assert!(blocked("2001:db8::1")); + assert!(blocked("2001:db8:ffff::1")); } + #[test] - fn test_cgnat_end() { - // 100.127.255.254 — end of CGNAT range - assert!(is_private_ip(&"100.127.255.254".parse::().unwrap())); + fn six_to_four_v6() { + assert!(blocked("2002::")); + assert!(blocked("2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff")); + assert!(!blocked("2003::1")); } + #[test] - fn test_cgnat_below_range() { - // 100.63.255.255 — just below CGNAT range (100.0–100.63 is public) - assert!(!is_private_ip(&"100.63.255.255".parse::().unwrap())); + fn discard_only_v6() { + assert!(blocked("100::1")); + assert!(blocked("100::ffff:ffff:ffff:ffff")); + assert!(!blocked("100:0:1::1")); // outside both discard and dummy ranges } + #[test] - fn test_cgnat_above_range() { - // 100.128.0.0 — just above CGNAT range (100.128+ is public) - assert!(!is_private_ip(&"100.128.0.0".parse::().unwrap())); + fn dummy_prefix_v6() { + assert!(blocked("100:0:0:1::")); + assert!(blocked("100:0:0:1:ffff:ffff:ffff:ffff")); + assert!(!blocked("100:0:0:2::1")); } - // Benchmarking (RFC 2544) — 198.18.0.0/15 #[test] - fn test_benchmarking_start() { - assert!(is_private_ip(&"198.18.0.1".parse::().unwrap())); + fn nat64_local_use_v6() { + assert!(blocked("64:ff9b:1::")); + assert!(blocked("64:ff9b:1:ffff:ffff:ffff:ffff:ffff")); + assert!(!blocked("64:ff9b:2::")); } + #[test] - fn test_benchmarking_end() { - assert!(is_private_ip(&"198.19.255.254".parse::().unwrap())); + fn documentation_3fff_v6() { + assert!(blocked("3fff::1")); + assert!(blocked("3fff:0fff::1")); + assert!(!blocked("3fff:1000::1")); + assert!(!blocked("3ffe::1")); } + #[test] - fn test_benchmarking_below_range() { - // 198.17.255.255 — just below benchmarking range - assert!(!is_private_ip(&"198.17.255.255".parse::().unwrap())); + fn srv6_sids_v6() { + assert!(blocked("5f00::1")); + assert!(blocked("5f00:ffff::1")); + assert!(!blocked("5e00::1")); + assert!(!blocked("5fff::1")); // 5fff ≠ 5f00 — outside /16 } + #[test] - fn test_benchmarking_above_range() { - // 198.20.0.0 — just above benchmarking range - assert!(!is_private_ip(&"198.20.0.0".parse::().unwrap())); + fn nat64_well_known_v6() { + assert!(blocked("64:ff9b::10.0.0.1")); // private embedded + assert!(blocked("64:ff9b::127.0.0.1")); // loopback embedded + assert!(blocked("64:ff9b::169.254.169.254")); // link-local embedded + assert!(!blocked("64:ff9b::8.8.8.8")); // public embedded — policy follows payload + assert!(!blocked("64:ff9a:ffff:ffff:ffff:ffff:ffff:ffff")); // different prefix + assert!(!blocked("64:ff9b::1:0:0")); // outside /96 } - // IPv6 multicast — ff00::/8 #[test] - fn test_ipv6_multicast_all_nodes() { - // ff02::1 — all-nodes multicast - assert!(is_private_ip(&"ff02::1".parse::().unwrap())); + fn ipv4_translated_v6() { + assert!(blocked("::ffff:0:10.0.0.1")); + assert!(blocked("::ffff:0:127.0.0.1")); + assert!(!blocked("::ffff:0:8.8.8.8")); + assert!(!blocked("0:0:0:0:fffe:ffff:ffff:ffff")); // outside prefix } + #[test] - fn test_ipv6_multicast_all_routers() { - // ff02::2 — all-routers multicast - assert!(is_private_ip(&"ff02::2".parse::().unwrap())); + fn ipv4_mapped_v6() { + assert!(blocked("::ffff:10.0.0.1")); + assert!(blocked("::ffff:127.0.0.1")); + assert!(!blocked("::ffff:8.8.8.8")); } + #[test] - fn test_ipv6_multicast_high() { - // ffff::1 — still in ff00::/8 - assert!(is_private_ip(&"ffff::1".parse::().unwrap())); + fn ipv4_compatible_v6() { + assert!(blocked("::10.0.0.1")); + assert!(blocked("::127.0.0.1")); + assert!(!blocked("::8.8.8.8")); } + #[test] - fn test_ipv6_not_multicast() { - // fe00:: — just below ff00::/8 (not multicast, not link-local, not ULA) - assert!(!is_private_ip(&"fe00::1".parse::().unwrap())); + fn deprecated_site_local_fec0() { + // fec0::/10 — deprecated IPv6 site-local (RFC 3879); blocked as non-global. + assert!(blocked("fec0::1")); + assert!(blocked("feff::1")); // fec0::/10 boundary } }