From 70895b355fcea9f99894b426c020052b715bd368 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 1 Sep 2026 13:00:40 -0400 Subject: [PATCH] feat(buzz-auth): add production NIP-FI federated assertion runtime (#7109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Depends on #6994 (merged). Stack: PR 2 #6994 (merged) → this PR (#7109) → PR 4 #7148 → PR 5 ## What Production assertion runtime for NIP-FI Phase A: JWKS caching layer, SSRF-hardened HTTP fetcher, startup validation gate, NIP-11 discovery serialization, federated assertion verifier with sealed key-source authority, and supporting invariant tests. ## Changes ### `crates/buzz-auth/src/nip_fi/jwks/` `ProductionJwksSource` implements the sealed `IssuerKeySource` trait: - `HttpJwksFetcher`: reqwest-backed fetch with SSRF protection — URI validation (HTTPS, no credentials, no fragment, no host matched by the shared enumerated deny policy), per-fetch DNS resolution rejecting any resolved address matched by the shared enumerated deny policy, address pinning to prevent DNS rebinding TOCTOU, redirect denial, incremental body streaming capped at 512 KiB before any parse - IPv6 host extraction via typed `Url::host()` accessor (strips brackets before SSRF check and provides the correct bare input form for reqwest `resolve()` pinning; the bracketed form from `host_str()` fails `IpAddr::parse` and does not match the URL authority key) - Complete-operation deadline via `tokio::time::timeout` covering DNS resolution through body streaming - Bounded periodic refresh with configurable interval and hard snapshot deadline - Cancellation-safe RAII refresh permit: dropped on future cancellation so the next caller can re-fetch - Content-digest-gated generation counter: identical re-fetches preserve generation; key rotations advance it - Injectable clock (`now_fn: Arc DateTime + Send + Sync>`): production uses `Arc::new(Utc::now)`; all four deadline creation and expiry checks use `(self.now_fn)()`, enabling controlled-time testing without wall-clock sleep `JwksSourceContract`: a closed value type that is the single source of truth for the three deployment fields whose change alters which keys the runtime trusts and how long it trusts them: - `jwks_uri` — selects the authenticated key source; validated at construction (HTTPS, no credentials/fragment, no bare private-IP host); stored as the `Url`-normalized form so that equivalent spellings (uppercase host, explicit default port `:443`, dot-segment paths like `/.well-known/./jwks.json`) converge to the same `AssertionPolicyId` - `refresh_interval_seconds` — defines bounded refresh behavior; positive, ≤ 1 year, strictly < `key_snapshot_hard_deadline_seconds` - `key_snapshot_hard_deadline_seconds` — defines the source's accepted time rule; every `VerifiedAssertion.revalidation_dependencies` deadline derives from this `JwksSourceContract` is a required `IssuerPolicy` input and is included in `derive_assertion_policy_id` after a domain separator. `IssuerJwksConfig` embeds the contract instead of independently restating these fields — startup validation rejects any contract mismatch (`NipFiStartupError::JwksContractMismatch`). ### `crates/buzz-auth/src/nip_fi/verifier.rs` - `FederatedAssertionVerifier`: provider-neutral verifier over a closed multi-issuer registry and sealed `IssuerKeySource` - `Arc: IssuerKeySource` forwarding impl (blanket seal for `Arc` in the sealed module) — one `Arc` can be shared across multiple verifiers; all observe JWKS refreshes through the shared cache without rebuilding the verifier - `AssertionKeySet`: crate-private constructor seals issuer binding — no external crate can relabel issuer B's JWKS as issuer A - Sealed `IssuerKeySource` trait closes the authority-construction seam at both ends ### `crates/buzz-core/src/network.rs` Renamed `is_private_ip` to `is_not_global_unicast` (compat alias retained) and restored the complete IANA deny/exception table from this branch's own history (`272dacadb`). The predicate is an enumerated deny/explicit exception policy: addresses covered by a named deny rule are rejected; addresses not covered by any explicit deny rule (e.g. `fe00::1`) pass through. Deny rules are derived from the IANA Special-Purpose Address Space registries (last updated 2025-10-09), with globally-reachable exceptions carved out explicitly (e.g. PCP/TURN anycast inside 2001::/23). Blocked IPv4 classes: loopback (127/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 → conservative deny), multicast/RFC 5771 (224/4), 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, globally reachable 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), NAT64 local-use (64:ff9b:1::/48, RFC 8215). IPv4 embedded in mapped, compatible, NAT64 well-known (64:ff9b::/96), and SIIT IPv4-translated (::ffff:0:0:0/96) forms is checked recursively. All three callers (JWKS boundary, webhook SSRF, link-preview SSRF) inherit the complete predicate through the inline `is_private_ip` compatibility alias. ### Invariant coverage **Canonical URI convergence.** `jwks_contract_uri_canonicalization_convergence_and_divergence` asserts that uppercase host, explicit `:443`, and dot-segment path (`/.well-known/./jwks.json`) each produce the same `AssertionPolicyId` as the canonical form; a genuinely different host or path diverges. Mutation: storing raw input bytes instead of `parsed.to_string()` turns the three convergence assertions red. **Resolved-target and pin-input seam.** `resolved_target_and_pin_key_seam_public_ipv6_and_fec0_rejection` carries a public `2606:4700::1` URI through all three stages of `fetch_jwks_inner`: `extract_url_host_and_port` yields the bare host (no brackets), `resolve_and_check_ssrf` takes the IP-literal fast path and returns the accepted `IpAddr`, and the extracted host string equals the URL authority form (verifying the correct bare input to reqwest's `resolve()` pin call). `fec0::1` traverses the same extraction and SSRF stages and is rejected as `InvalidUri`. Network-free: both addresses are IP literals with no DNS lookup. Mutation: restoring `host_str()` brackets the address, `IpAddr::parse` fails, the SSRF fast path is unreachable, and all three assertions flip red. **Controlled original-deadline rotation.** `shared_arc_source_verifier_rejects_expired_a1_accepts_a2` uses an `AtomicI64`-backed injectable clock to advance past A1's original absolute deadline without wall-clock sleep. A1's deadline is computed at T0 and never mutated. The clock advances to T0 + HARD_DEADLINE_SECS + 1; `get_snapshot` fires a re-fetch and installs A2. One unchanged `FederatedAssertionVerifier` then rejects A1-signed tokens (deadline enforced by the `key_set` read path) and accepts A2-signed tokens, proves A2's generation is strictly greater, and confirms A2's deadline is later than A1's original. Mutation oracle: replace the shared `Arc` with an independently constructed source built from the same configs and sharing the same controlled clock, warmed with a separate A1 fetch before advancement. Post-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. A1-reject stays green (the independent cache is also expired, so no A1 keys are served), but A2-accept flips red, because the verifier never observes A2. A2 acceptance is the reliable shared-source oracle. **Complete SSRF classifier boundary.** JWKS-boundary tests cover every newly restored class through `validate_jwks_uri` (URI-validation path): `192.0.0.1` (IETF Protocol Assignments interior), `192.0.0.9`/`.10` (PCP/TURN anycast global exceptions), `192.88.99.1` (deprecated 6to4 anycast), `2001:2::1` (2001::/23 interior), `2001:1::1` (2001::/23 global exception), `100::1` (Discard-Only), `3fff::1` (documentation), and `5f00::1` (SRv6 SIDs). URI validation and resolved-target enforcement share the same `is_not_global_unicast` predicate, so these URI-path tests exercise the complete classifier table. All pass mutation: removing any deny branch makes the rejection assertion red; removing any exception branch makes the acceptance assertion red. The resolved-target enforcement path is covered separately by `resolved_target_and_pin_key_seam_public_ipv6_and_fec0_rejection` for `::1` (loopback), public `2606:4700::1`, and `fec0::1`. --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com> --- ARCHITECTURE.md | 10 +- Cargo.lock | 2 + Cargo.toml | 2 +- crates/buzz-auth/Cargo.toml | 3 + crates/buzz-auth/src/lib.rs | 14 +- crates/buzz-auth/src/nip_fi/config.rs | 43 + crates/buzz-auth/src/nip_fi/discovery.rs | 66 + crates/buzz-auth/src/nip_fi/jwks/mod.rs | 738 ++++++++ crates/buzz-auth/src/nip_fi/jwks/tests.rs | 1619 +++++++++++++++++ crates/buzz-auth/src/nip_fi/mod.rs | 41 +- crates/buzz-auth/src/nip_fi/startup/mod.rs | 134 ++ crates/buzz-auth/src/nip_fi/startup/tests.rs | 182 ++ crates/buzz-auth/src/nip_fi/verifier.rs | 53 +- crates/buzz-auth/src/nip_fi/verifier/tests.rs | 208 ++- crates/buzz-core/src/network.rs | 646 ++++--- 15 files changed, 3409 insertions(+), 352 deletions(-) create mode 100644 crates/buzz-auth/src/nip_fi/discovery.rs create mode 100644 crates/buzz-auth/src/nip_fi/jwks/mod.rs create mode 100644 crates/buzz-auth/src/nip_fi/jwks/tests.rs create mode 100644 crates/buzz-auth/src/nip_fi/startup/mod.rs create mode 100644 crates/buzz-auth/src/nip_fi/startup/tests.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 138d192fa1..9905e97b76 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 9544a63b89..552a12ca15 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 d6ee839f1b..0af365f52f 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 e4ac539a98..6cbe491e2c 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 e6473cdd54..c21872351f 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 638b5f5363..37628669a1 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 0000000000..8d1b1500b1 --- /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 0000000000..618ee6b069 --- /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 0000000000..6d9f21a150 --- /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 f7d1243a05..ce97709064 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 0000000000..410c862ec7 --- /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 0000000000..04b28a7b96 --- /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 7ac2cbe376..aa3b5796a0 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 316681e0af..990a3310e4 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 fb3718d58c..fe5b4bb80a 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)); } - 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) + if v6.is_loopback() || v6.is_unspecified() { + return true; + } + + // 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())); - } - #[test] - fn test_public_v4() { - assert!(!is_private_ip(&"8.8.8.8".parse::().unwrap())); - } - #[test] - fn test_loopback_v6() { - assert!(is_private_ip(&"::1".parse::().unwrap())); - } - #[test] - fn test_unspecified_v6() { - assert!(is_private_ip(&"::".parse::().unwrap())); - } - #[test] - fn test_ula_v6() { - assert!(is_private_ip(&"fd00::1".parse::().unwrap())); - } - #[test] - fn test_link_local_v6() { - assert!(is_private_ip(&"fe80::1".parse::().unwrap())); - } - #[test] - fn test_public_v6() { - assert!(!is_private_ip(&"2606:4700::1".parse::().unwrap())); - } - #[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() - )); - } - #[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())); - } - #[test] - fn test_ipv4_mapped_v6_loopback() { - assert!(is_private_ip( - &"::ffff:127.0.0.1".parse::().unwrap() - )); - } - #[test] - fn test_ipv4_mapped_v6_public() { - assert!(!is_private_ip(&"::ffff:8.8.8.8".parse::().unwrap())); - } - #[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())); - } - #[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())); - } - #[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() - )); - } - #[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())); - } - #[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())); - } - #[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 blocked(s: &str) -> bool { + is_not_global_unicast(&s.parse::().unwrap()) } - // 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())); - } - #[test] - fn test_cgnat_end() { - // 100.127.255.254 — end of CGNAT range - assert!(is_private_ip(&"100.127.255.254".parse::().unwrap())); - } - #[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())); - } - #[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 public_v4() { + assert!(!blocked("1.1.1.1")); + assert!(!blocked("8.8.8.8")); } - // Benchmarking (RFC 2544) — 198.18.0.0/15 #[test] - fn test_benchmarking_start() { - assert!(is_private_ip(&"198.18.0.1".parse::().unwrap())); - } - #[test] - fn test_benchmarking_end() { - assert!(is_private_ip(&"198.19.255.254".parse::().unwrap())); - } - #[test] - fn test_benchmarking_below_range() { - // 198.17.255.255 — just below benchmarking range - assert!(!is_private_ip(&"198.17.255.255".parse::().unwrap())); - } - #[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 public_v6_cloudflare() { + assert!(!blocked("2606:4700::1")); } - // 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 loopback_and_unspecified() { + assert!(blocked("127.0.0.1")); + assert!(blocked("0.0.0.0")); + assert!(blocked("::1")); + assert!(blocked("::")); } + #[test] - fn test_ipv6_multicast_all_routers() { - // ff02::2 — all-routers multicast - assert!(is_private_ip(&"ff02::2".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_ipv6_multicast_high() { - // ffff::1 — still in ff00::/8 - assert!(is_private_ip(&"ffff::1".parse::().unwrap())); + fn link_local() { + assert!(blocked("169.254.1.1")); + assert!(blocked("fe80::1")); } + #[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 broadcast() { + assert!(blocked("255.255.255.255")); + } + + #[test] + 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 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 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 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 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 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 ula_v6() { + assert!(blocked("fd00::1")); + assert!(blocked("fc00::1")); + } + + #[test] + fn multicast_v6() { + assert!(blocked("ff02::1")); + assert!(blocked("ff02::2")); + assert!(blocked("ffff::1")); + assert!(!blocked("fe00::1")); + } + + #[test] + 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 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 + } + + #[test] + fn documentation_v6() { + assert!(blocked("2001:db8::1")); + assert!(blocked("2001:db8:ffff::1")); + } + + #[test] + fn six_to_four_v6() { + assert!(blocked("2002::")); + assert!(blocked("2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff")); + assert!(!blocked("2003::1")); + } + + #[test] + 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 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")); + } + + #[test] + 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 documentation_3fff_v6() { + assert!(blocked("3fff::1")); + assert!(blocked("3fff:0fff::1")); + assert!(!blocked("3fff:1000::1")); + assert!(!blocked("3ffe::1")); + } + + #[test] + 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 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 + } + + #[test] + 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 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 ipv4_compatible_v6() { + assert!(blocked("::10.0.0.1")); + assert!(blocked("::127.0.0.1")); + assert!(!blocked("::8.8.8.8")); + } + + #[test] + 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 } }