From 5d7cf011b2ac20f7026db488e2cf5226ee11fccd Mon Sep 17 00:00:00 2001 From: Duncan Date: Mon, 31 Aug 2026 11:19:58 -0400 Subject: [PATCH] =?UTF-8?q?feat(auth):=20NIP-FI=20Phase=20A=20PR=203=20?= =?UTF-8?q?=E2=80=94=20production=20assertion=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the JWKS discovery/caching layer, startup validation gate, and NIP-11 discovery output that complete the NIP-FI assertion runtime. The verifier (PRs 1–2) already defined the sealed IssuerKeySource trait and AssertionKeySet constructor as placeholders for this PR. This PR fills that contract with a production implementation: - jwks: ProductionJwksSource implements IssuerKeySource via an injectable JwksFetcher trait (sealed; HttpJwksFetcher for production). Bounded periodic refresh; coalesced in-flight; try_read/try_lock for async-safe synchronous key_set() path. Never serves an expired snapshot; fails closed on fetch/parse error. [FI-TRACE-JWKS-REMOVE] - startup: validate_nip_fi_config() rejects incomplete or unsafe configurations before the relay accepts protected traffic: empty registry, unmatched JWKS configs, invalid timing bounds, and current-status issuers missing a JWKS source. Off/DenyProtected modes accept without validation. [FI-INV-14, FI-INV-15] - discovery: FederatedIdentityDiscovery serializes the NIP-11 federated_identity object. Never exposes enrollment mode, issuer URLs, audiences, or deployment-local identifiers. [FI-TRACE-DISCOVERY-PRIVATE] - config: IssuerRegistry gains all_policies() iterator. - verifier: sealed module promoted to pub(crate) for jwks access; AssertionKeySet::new #[allow(dead_code)] removed (now has real caller). Security checklist: - Issuer binding sealed at constructor: no relabelling possible - Hard deadline enforced on every snapshot access - MAX_JWKS_RESPONSE_BYTES checked before parse - Key count bounded by MAX_JWKS_KEYS - try_read/try_lock: fails closed rather than panicking or blocking - No key material, issuer URLs, or token bytes in errors or Debug Tests: 23 new unit tests (12 JWKS, 11 startup); all green. Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com> Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- Cargo.lock | 1 + crates/buzz-auth/Cargo.toml | 1 + crates/buzz-auth/src/lib.rs | 8 +- crates/buzz-auth/src/nip_fi/config.rs | 5 + crates/buzz-auth/src/nip_fi/discovery.rs | 85 ++++ crates/buzz-auth/src/nip_fi/jwks/mod.rs | 408 +++++++++++++++++++ crates/buzz-auth/src/nip_fi/jwks/tests.rs | 206 ++++++++++ crates/buzz-auth/src/nip_fi/mod.rs | 43 +- crates/buzz-auth/src/nip_fi/startup/mod.rs | 162 ++++++++ crates/buzz-auth/src/nip_fi/startup/tests.rs | 185 +++++++++ crates/buzz-auth/src/nip_fi/verifier.rs | 9 +- 11 files changed, 1084 insertions(+), 29 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/Cargo.lock b/Cargo.lock index 9544a63b89..cc28cbf626 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -943,6 +943,7 @@ dependencies = [ "jsonwebtoken", "nostr 0.44.7", "rand 0.10.1", + "reqwest 0.13.4", "serde", "serde_json", "sha2 0.11.0", diff --git a/crates/buzz-auth/Cargo.toml b/crates/buzz-auth/Cargo.toml index e4ac539a98..13dbdd8856 100644 --- a/crates/buzz-auth/Cargo.toml +++ b/crates/buzz-auth/Cargo.toml @@ -21,6 +21,7 @@ base64 = { workspace = true } chrono = { workspace = true } jsonwebtoken = { workspace = true } nostr = { 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..65366ddef8 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -46,9 +46,11 @@ 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, + validate_nip_fi_config, AssertionKeySet, AssertionPolicyId, CanonicalCapabilities, + ClientSubjectPosture, ConfidentialAssertion, DenialClass, FederatedAssertionVerifier, + FederatedIdentity, FederatedIdentityDiscovery, FreshnessClass, HttpJwksFetcher, + IssuerJwksConfig, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry, + JwksFetchError, JwksFetcher, NipFiMode, NipFiStartupError, ProductionJwksSource, RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass, TransportContractId, VerifiedAssertion, VerifierError, CLIENT_ATTACHED_HEADER, NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, diff --git a/crates/buzz-auth/src/nip_fi/config.rs b/crates/buzz-auth/src/nip_fi/config.rs index 638b5f5363..83866df247 100644 --- a/crates/buzz-auth/src/nip_fi/config.rs +++ b/crates/buzz-auth/src/nip_fi/config.rs @@ -560,6 +560,11 @@ impl IssuerRegistry { pub fn is_empty(&self) -> bool { self.policies.is_empty() } + + /// Iterate over all registered policies. + pub fn all_policies(&self) -> impl Iterator { + self.policies.values() + } } /// Sort and deduplicate a set-valued list of strings into its canonical form. 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..359844342d --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/discovery.rs @@ -0,0 +1,85 @@ +//! NIP-11 federated-identity discovery output (NIP-FI Phase A, PR 3). +//! +//! [`FederatedIdentityDiscovery`] serializes to the `federated_identity` +//! object NIP-FI.md "Discovery" requires in NIP-11 relay information. +//! +//! ## 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 inside `federated_identity`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssertionFreshnessDiscovery { + /// `"offline-jwt"` or `"current-status"`. + pub class: FreshnessClassDiscovery, + /// `null` for `offline-jwt`; a tested positive integer for `current-status`. + pub maximum_residual_upstream_revocation_seconds: Option, +} + +/// The freshness class as a stable wire string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum FreshnessClassDiscovery { + /// Validates the JWT and JWKS snapshot only. + OfflineJwt, + /// Additionally requires a current-status witness. + CurrentStatus, +} + +/// The `federated_identity` NIP-11 discovery object. +/// +/// Placed under `limitation.federated_identity = true` and the top-level +/// `federated_identity` key in the NIP-11 relay information document. +/// Fields never expose enrollment mode, issuer, audience, or private state. +/// [FI-TRACE-DISCOVERY-PRIVATE] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FederatedIdentityDiscovery { + /// Always `"client-attached"` for core. + pub core: String, + /// The assertion freshness contract claimed by this deployment. + pub assertion_freshness: AssertionFreshnessDiscovery, +} + +impl FederatedIdentityDiscovery { + /// Construct an offline-jwt discovery object. This is the minimal core + /// claim that carries no residual revocation bound. + pub fn offline_jwt() -> Self { + Self { + core: "client-attached".to_owned(), + assertion_freshness: AssertionFreshnessDiscovery { + class: FreshnessClassDiscovery::OfflineJwt, + maximum_residual_upstream_revocation_seconds: None, + }, + } + } + + /// Construct a current-status discovery object with a tested positive + /// revocation bound (in seconds). The caller is responsible for ensuring + /// `revocation_bound_seconds` has been empirically verified. + /// + /// Returns `None` when `revocation_bound_seconds` is zero. + pub fn current_status(revocation_bound_seconds: u64) -> Option { + if revocation_bound_seconds == 0 { + return None; + } + Some(Self { + core: "client-attached".to_owned(), + assertion_freshness: AssertionFreshnessDiscovery { + class: FreshnessClassDiscovery::CurrentStatus, + maximum_residual_upstream_revocation_seconds: Some(revocation_bound_seconds), + }, + }) + } +} 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..dd30e1dfb4 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/jwks/mod.rs @@ -0,0 +1,408 @@ +//! JWKS discovery, snapshot caching, and the production [`IssuerKeySource`] +//! implementation (NIP-FI Phase A, PR 3). +//! +//! ## 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.** The HTTP response is capped at +//! [`MAX_JWKS_RESPONSE_BYTES`] before 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 chrono::{DateTime, Duration, Utc}; +use jsonwebtoken::jwk::JwkSet; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; +use tracing::warn; + +/// Maximum HTTP response size for a JWKS endpoint, in bytes. Bounded before +/// parsing to prevent a large or malicious response from consuming unbounded +/// memory during deserialization. +pub const MAX_JWKS_RESPONSE_BYTES: usize = 512 * 1024; // 512 KiB + +/// A JWKS snapshot with its fetch time and configured hard deadline. +#[derive(Clone)] +struct CachedSnapshot { + key_set: AssertionKeySet, + fetched_at: DateTime, + hard_deadline: DateTime, +} + +/// Per-issuer runtime state: the current snapshot and in-flight flag. +struct IssuerState { + snapshot: Option, + /// True while a refresh task owns the fetch. Prevents concurrent fetches. + refresh_in_flight: bool, +} + +impl IssuerState { + fn new() -> Self { + Self { + snapshot: None, + refresh_in_flight: false, + } + } +} + +/// Configuration for one issuer's JWKS endpoint. +#[derive(Debug, Clone)] +pub struct IssuerJwksConfig { + /// The exact `iss` value this config authenticates. Must match the + /// configured [`IssuerPolicy`][super::config::IssuerPolicy] exactly. + pub issuer: String, + /// The HTTPS JWKS endpoint URI. + pub jwks_uri: String, + /// How long a cached snapshot remains fresh before re-fetching is + /// triggered, in seconds. Must be positive and less than + /// `key_snapshot_hard_deadline_seconds`. + pub refresh_interval_seconds: u64, + /// Hard upper bound from fetch time on how long a snapshot may be served. + /// A snapshot whose deadline has passed is never returned, even on error. + /// Folds into every `AssertionKeySet` hard deadline and therefore into + /// every `VerifiedAssertion.revalidation_dependencies`. + pub key_snapshot_hard_deadline_seconds: u64, +} + +/// Why 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 { + /// The HTTP response exceeded [`MAX_JWKS_RESPONSE_BYTES`]. + #[error("JWKS response exceeded size limit")] + ResponseTooLarge, + /// The HTTP request failed (network, TLS, timeout). + #[error("JWKS HTTP request failed")] + NetworkError, + /// The response body was not parseable as a JWK Set. + #[error("JWKS response was not parseable")] + ParseError, + /// The parsed key set was empty or exceeded the key-count bound. + #[error("JWKS key set bounds violation")] + KeyCountBoundsViolation, +} + +/// Async HTTP fetch of a JWKS endpoint. +/// +/// This is a sealed injection seam: only types inside `buzz_auth` may +/// implement it (the private supertrait `sealed` prevents external impls). +/// The production implementation uses `reqwest`; the test implementation +/// returns hard-coded bodies without network calls. +/// +/// Implementations MUST enforce [`MAX_JWKS_RESPONSE_BYTES`]. +pub trait JwksFetcher: super::verifier::sealed::Sealed + Send + Sync + 'static { + /// Fetch the JWK Set from the given URI, returning the raw JSON body. + fn fetch_jwks<'a>( + &'a self, + uri: &'a str, + ) -> impl std::future::Future> + Send + 'a; +} + +/// Production [`JwksFetcher`] backed by `reqwest`. +/// +/// Enforces [`MAX_JWKS_RESPONSE_BYTES`] before reading the full body. +#[derive(Clone)] +pub struct HttpJwksFetcher { + client: reqwest::Client, +} + +impl HttpJwksFetcher { + /// Construct with a default `reqwest` client. + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + } + } + + /// Construct with an explicit `reqwest::Client` (e.g., with custom TLS + /// certificates or timeout configuration). + pub fn with_client(client: reqwest::Client) -> Self { + Self { client } + } +} + +impl Default for HttpJwksFetcher { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Debug for HttpJwksFetcher { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("HttpJwksFetcher") + } +} + +// Sealed so only in-crate types implement `JwksFetcher`. +impl super::verifier::sealed::Sealed for HttpJwksFetcher {} + +impl JwksFetcher for HttpJwksFetcher { + fn fetch_jwks<'a>( + &'a self, + uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + async move { + let response = self + .client + .get(uri) + .send() + .await + .map_err(|_| JwksFetchError::NetworkError)?; + + // Reject based on Content-Length before reading body. + if let Some(content_length) = response.content_length() { + if content_length as usize > MAX_JWKS_RESPONSE_BYTES { + return Err(JwksFetchError::ResponseTooLarge); + } + } + + let bytes = response + .bytes() + .await + .map_err(|_| JwksFetchError::NetworkError)?; + + if bytes.len() > MAX_JWKS_RESPONSE_BYTES { + return Err(JwksFetchError::ResponseTooLarge); + } + + String::from_utf8(bytes.to_vec()).map_err(|_| JwksFetchError::ParseError) + } + } +} + +/// Parse a raw JWKS JSON body into a bounded, validated [`JwkSet`]. +/// +/// Rejects parse errors and key-count bound violations before any per-key +/// lookup or allocation. +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) +} + +/// The production [`IssuerKeySource`]: a multi-issuer JWKS cache that performs +/// bounded periodic refresh and never serves snapshots past their hard deadline. +/// +/// One `ProductionJwksSource` is constructed at startup after +/// [`super::startup::validate_nip_fi_config`] passes. The `Arc>` +/// internal structure lets it be shared across async tasks cheaply. +/// +/// ## 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, + /// Keyed by exact issuer string. + states: Arc>>>, + fetcher: Arc, +} + +impl ProductionJwksSource { + /// Construct a new source from validated issuer JWKS configs. + /// + /// Returns `None` when `configs` is empty (startup validation rejects this + /// before the source is ever built) or when any config has invalid timing + /// bounds. + 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 { + // Hard deadline must be strictly greater than refresh interval so + // a snapshot is always fresh for at least one cycle before expiry. + if c.refresh_interval_seconds == 0 + || c.key_snapshot_hard_deadline_seconds == 0 + || c.key_snapshot_hard_deadline_seconds <= c.refresh_interval_seconds + { + 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), + }) + } + + /// Fetch and seal a fresh snapshot for one issuer, without updating the + /// cache. Returns `None` when the fetch or parse fails (already logged). + async fn fetch_fresh(&self, issuer: &str) -> Option { + let config = self.configs.get(issuer)?; + let body = match self.fetcher.fetch_jwks(&config.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 now = Utc::now(); + let hard_deadline = + now + Duration::seconds(config.key_snapshot_hard_deadline_seconds as i64); + + // Generation: milliseconds since epoch, floored to 1 to satisfy the + // non-zero invariant. Monotone unless the system clock goes backwards. + let generation = u64::try_from(now.timestamp_millis()).unwrap_or(1).max(1); + + let key_set = AssertionKeySet::new(issuer.to_owned(), generation, jwks, hard_deadline)?; + + Some(CachedSnapshot { + key_set, + fetched_at: now, + hard_deadline, + }) + } + + /// Return the current snapshot for `issuer`, refreshing if stale. + /// + /// Returns `None` when no live snapshot is available and the fetch fails. + /// + /// ## Refresh logic + /// + /// - If the cached snapshot is past its hard deadline, it is cleared. + /// - If there is no snapshot, or the snapshot is past its refresh + /// interval, a refresh runs inline (holding the issuer's mutex). + /// - Concurrent calls share the inline refresh via the per-issuer mutex. + 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 = Utc::now(); + let config = self.configs.get(issuer)?; + + // Evict expired snapshot. + 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.refresh_interval_seconds + } + }; + + if !needs_refresh { + return state.snapshot.as_ref().map(|c| c.key_set.clone()); + } + + if state.refresh_in_flight { + // Another task is already refreshing; return the current snapshot + // (may be None if no snapshot is available yet). + return state.snapshot.as_ref().map(|c| c.key_set.clone()); + } + + state.refresh_in_flight = true; + // Drop mutex and read lock while doing async I/O so other issuers + // are not blocked. + drop(state); + drop(states); + + let fresh = self.fetch_fresh(issuer).await; + + // Re-acquire to commit the result and clear the in-flight flag. + let states = self.states.read().await; + if let Some(state_mutex) = states.get(issuer) { + let mut st = state_mutex.lock().await; + st.refresh_in_flight = false; + if let Some(ref cached) = fresh { + st.snapshot = Some(cached.clone()); + } + let now2 = Utc::now(); + return st + .snapshot + .as_ref() + .filter(|c| now2 < c.hard_deadline) + .map(|c| c.key_set.clone()); + } + + None + } +} + +// Sealed so only in-crate types implement `IssuerKeySource`. +impl super::verifier::sealed::Sealed for ProductionJwksSource {} + +impl IssuerKeySource for ProductionJwksSource { + /// Synchronous read of the currently cached snapshot. + /// + /// The verifier calls this per-request after the runtime has ensured the + /// cache is warm via [`get_snapshot`][Self::get_snapshot]. Returns `None` + /// if no snapshot is available or the snapshot is past its hard deadline. + /// + /// Uses `try_read`/`try_lock` so it is safe to call from any context — + /// including inside an async runtime. If the lock is momentarily held + /// (in-flight refresh), fails closed by returning `None` 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 = Utc::now(); + 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..2d4246bd82 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/jwks/tests.rs @@ -0,0 +1,206 @@ +//! Unit tests for the NIP-FI JWKS source (Phase A, PR 3). +//! +//! These tests drive [`ProductionJwksSource`] through a fake [`JwksFetcher`] +//! to avoid live network calls. + +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +// ── Fake fetcher ────────────────────────────────────────────────────────────── + +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 } + } +} + +/// Build a minimal valid ES256 JWK Set JSON with one key. +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(), + jwks_uri: format!("https://{issuer}/.well-known/jwks.json"), + refresh_interval_seconds: 300, + key_snapshot_hard_deadline_seconds: 3600, + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[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 snapshot = source.get_snapshot(issuer).await; + assert!(snapshot.is_some(), "snapshot should be present on success"); + let ks = snapshot.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(); + + let snapshot = source.get_snapshot("https://other.example").await; + assert!(snapshot.is_none(), "unknown issuer must return 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(); + + let snapshot = source.get_snapshot(issuer).await; + assert!(snapshot.is_none(), "no cache + network error = 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 empty_jwks = r#"{"keys":[]}"#; + let err = parse_and_bound_jwks(empty_jwks).unwrap_err(); + assert_eq!(err, JwksFetchError::KeyCountBoundsViolation); +} + +#[tokio::test] +async fn parse_and_bound_rejects_oversized_key_set() { + // Build MAX_JWKS_KEYS + 1 keys. + 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(",")); + let err = parse_and_bound_jwks(&body).unwrap_err(); + assert_eq!(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()); +} + +#[tokio::test] +async fn new_rejects_refresh_ge_hard_deadline() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let bad_config = IssuerJwksConfig { + issuer: "https://id.example".to_owned(), + jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 3600, // equal to hard deadline + key_snapshot_hard_deadline_seconds: 3600, + }; + assert!(ProductionJwksSource::new(vec![bad_config], fetcher).is_none()); +} + +#[tokio::test] +async fn new_rejects_zero_refresh_interval() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let bad_config = IssuerJwksConfig { + issuer: "https://id.example".to_owned(), + jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 0, + key_snapshot_hard_deadline_seconds: 3600, + }; + assert!(ProductionJwksSource::new(vec![bad_config], fetcher).is_none()); +} + +/// Issuer binding: the sealed `key_set()` synchronous path must return +/// `None` before any snapshot is warmed via `get_snapshot`. +#[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(), + "cache is cold before get_snapshot" + ); +} + +/// After a successful `get_snapshot`, the synchronous `key_set()` path must +/// return the same issuer's snapshot without re-fetching. +#[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); +} diff --git a/crates/buzz-auth/src/nip_fi/mod.rs b/crates/buzz-auth/src/nip_fi/mod.rs index f7d1243a05..0641481362 100644 --- a/crates/buzz-auth/src/nip_fi/mod.rs +++ b/crates/buzz-auth/src/nip_fi/mod.rs @@ -1,24 +1,21 @@ -//! NIP-FI federated-identity authorization — canonical assertion verifier and -//! contracts (Phase A, PR 1). +//! NIP-FI federated-identity authorization — assertion verifier, JWKS runtime, +//! startup validation, and discovery (Phase A, PRs 1–3). //! -//! This module is the closed, provider-neutral contract layer at the root of -//! the NIP-FI dependency graph. It defines: +//! ## Module layout //! -//! - 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`). +//! | Module | Introduced | Responsibility | +//! |--------|-----------|----------------| +//! | [`assertion`] | PR 1 | Sealed [`VerifiedAssertion`] result and its fields | +//! | [`config`] | PR 1 | Multi-issuer policy, contract IDs, size/time bounds | +//! | [`denial`] | PR 1 | Privacy-preserving four-class denial wire contract | +//! | [`verifier`] | PR 1 | Single canonical [`FederatedAssertionVerifier`] | +//! | [`jwks`] | PR 3 | JWKS fetch, cache, and [`ProductionJwksSource`] | +//! | [`startup`] | PR 3 | Startup validation gate ([`validate_nip_fi_config`]) | +//! | [`discovery`] | PR 3 | NIP-11 [`FederatedIdentityDiscovery`] object | //! -//! 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. +//! Identity is issuer-qualified `(iss, sub)` throughout. No database schema, +//! binding resolution, or request/proof binding is defined here — those belong +//! to PRs 4–5. /// The exact client-attached header field ([NIP-FI.md](../../../docs/nips/NIP-FI.md), /// "Client-attached transport"). `Authorization` remains reserved for NIP-98. @@ -27,6 +24,9 @@ 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 +39,11 @@ 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, 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..770fbe6cd0 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/startup/mod.rs @@ -0,0 +1,162 @@ +//! Startup validation for the NIP-FI assertion runtime (Phase A, PR 3). +//! +//! [`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`][crate::nip_fi::NipFiMode::Enforce] +//! mode (`FI-INV-14`, `FI-INV-15`). +//! +//! ## What it checks +//! +//! | Check | Why | +//! |-------|-----| +//! | Registry non-empty | An enforce-mode deployment with no issuer policy admits nothing and the gap is undetectable at request time | +//! | Each issuer non-empty `iss` and `aud` | `IssuerPolicy` validates these, but startup re-asserts the invariant at the registry level | +//! | No duplicate `iss` | A duplicate would silently pick one policy; enforce uniqueness | +//! | `current-status` requires `maximum_status_age_seconds` | Already enforced in `IssuerPolicy::new`; startup confirms no offline-mode policy sneaked through with a status-age | +//! | Offline-only deployments: `FreshnessClass::OfflineJwt` is safe | No residual bound claim (per NIP-FI.md:259-266) | +//! | JWKS config present for every issuer in enforce mode | Every issuer needs a reachable key source | +//! | JWKS config issuer match | The JWKS config `issuer` must equal the policy `issuer` | +//! | `refresh_interval` < `hard_deadline` | Prevents an always-stale cache | + +use super::config::{FreshnessClass, IssuerRegistry}; +use super::jwks::IssuerJwksConfig; + +/// Operating mode for the NIP-FI assertion runtime. +/// +/// The 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, + /// Emergency mode: all protected routes deny before any verifier is + /// configured. Used during startup if a previous enforce-mode deployment + /// was misconfigured and must fail closed while the operator repairs + /// configuration. [FI-INV-14] + DenyProtected, +} + +/// Reasons [`validate_nip_fi_config`] rejects a configuration. +/// +/// 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 { + /// Enforce mode requires at least one issuer policy; the registry is empty. + #[error("NIP-FI enforce mode requires at least one issuer policy")] + EmptyRegistry, + + /// Two or more issuer policies share the same `iss` value, which would + /// make issuer selection ambiguous. + #[error("NIP-FI issuer registry contains duplicate issuer: {0}")] + DuplicateIssuer(String), + + /// Enforce mode requires a JWKS config for every registered issuer, but + /// the given issuer has no JWKS configuration. + #[error("NIP-FI issuer has no JWKS configuration: (issuer redacted)")] + MissingJwksConfig, + + /// A JWKS config's `issuer` field does not match any registered issuer + /// policy. Mismatched configs are rejected to prevent silent key-source + /// confusion. + #[error("NIP-FI JWKS config issuer does not match any registered policy")] + UnmatchedJwksConfig, + + /// A JWKS config's `refresh_interval_seconds` is zero or is greater than + /// or equal to `key_snapshot_hard_deadline_seconds`. + #[error("NIP-FI JWKS config has invalid timing bounds: refresh >= hard deadline")] + InvalidJwksTiming, + + /// A `current-status` issuer policy is present but the JWKS URI is + /// absent; current-status requires a reachable JWKS to validate assertion + /// signatures. + #[error("NIP-FI current-status issuer requires a JWKS configuration")] + CurrentStatusRequiresJwks, +} + +/// Validate the complete NIP-FI runtime configuration before the relay +/// accepts any protected traffic. +/// +/// `registry` is the set of issuer policies. `jwks_configs` is the set of +/// JWKS endpoint configurations (one per issuer in enforce mode). +/// `mode` is the intended operating mode. +/// +/// Returns `Ok(())` when the configuration is valid and complete for `mode`. +/// Returns `Err(NipFiStartupError)` when any invariant is violated; the relay +/// MUST refuse to start or must fall back to [`NipFiMode::DenyProtected`]. +pub fn validate_nip_fi_config( + mode: NipFiMode, + registry: &IssuerRegistry, + jwks_configs: &[IssuerJwksConfig], +) -> Result<(), NipFiStartupError> { + match mode { + NipFiMode::Off | NipFiMode::DenyProtected => { + // Off and emergency-denial modes impose no assertion config + // requirements — they admit nothing. + return Ok(()); + } + NipFiMode::Enforce => {} + } + + // Enforce mode: validate the registry and JWKS configs. + + if registry.is_empty() { + return Err(NipFiStartupError::EmptyRegistry); + } + + // Check for duplicate issuers (IssuerRegistry keyed by exact iss, so this + // is already enforced there, but we assert it explicitly for startup). + { + let mut seen = std::collections::HashSet::new(); + for policy in registry.all_policies() { + if !seen.insert(policy.issuer()) { + return Err(NipFiStartupError::DuplicateIssuer( + policy.issuer().to_owned(), + )); + } + } + } + + // Build a map from issuer → JWKS config for O(1) lookup. + let jwks_map: std::collections::HashMap<&str, &IssuerJwksConfig> = jwks_configs + .iter() + .map(|c| (c.issuer.as_str(), c)) + .collect(); + + // Verify every JWKS config references a known issuer. + for config in jwks_configs { + if registry.policy_for_issuer(&config.issuer).is_none() { + return Err(NipFiStartupError::UnmatchedJwksConfig); + } + // Validate timing bounds. + if config.refresh_interval_seconds == 0 + || config.key_snapshot_hard_deadline_seconds == 0 + || config.key_snapshot_hard_deadline_seconds <= config.refresh_interval_seconds + { + return Err(NipFiStartupError::InvalidJwksTiming); + } + } + + // Every issuer policy must have a JWKS config in enforce mode. + for policy in registry.all_policies() { + match jwks_map.get(policy.issuer()) { + None => { + if policy.freshness() == FreshnessClass::CurrentStatus { + return Err(NipFiStartupError::CurrentStatusRequiresJwks); + } + return Err(NipFiStartupError::MissingJwksConfig); + } + Some(_) => {} + } + } + + 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..14924d5c9c --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/startup/tests.rs @@ -0,0 +1,185 @@ +//! Unit tests for NIP-FI startup validation (Phase A, PR 3). + +use super::*; +use crate::nip_fi::config::{FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass}; +use crate::nip_fi::jwks::IssuerJwksConfig; +use jsonwebtoken::Algorithm as JwtAlgorithm; + +/// Build a minimal valid offline-jwt `IssuerPolicy`. +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, + ) + .unwrap() +} + +/// Build a minimal valid current-status `IssuerPolicy`. +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), + ) + .unwrap() +} + +fn make_jwks_config(issuer: &str) -> IssuerJwksConfig { + IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: format!("https://{issuer}/.well-known/jwks.json"), + refresh_interval_seconds: 300, + key_snapshot_hard_deadline_seconds: 3600, + } +} + +// ── Off / DenyProtected accept anything ─────────────────────────────────────── + +#[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()); +} + +// ── Enforce: basic happy path ───────────────────────────────────────────────── + +#[test] +fn enforce_valid_config_passes() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let jwks = vec![make_jwks_config(issuer)]; + assert!(validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).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()); +} + +// ── Enforce: empty registry ─────────────────────────────────────────────────── + +#[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); +} + +// ── Enforce: missing JWKS config ───────────────────────────────────────────── + +#[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); +} + +// ── Enforce: unmatched JWKS config ─────────────────────────────────────────── + +#[test] +fn enforce_unmatched_jwks_config_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + // JWKS config for a different issuer. + let jwks = vec![make_jwks_config("https://other.example")]; + let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(); + assert_eq!(err, NipFiStartupError::UnmatchedJwksConfig); +} + +// ── Enforce: invalid JWKS timing ───────────────────────────────────────────── + +#[test] +fn enforce_refresh_equals_hard_deadline_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let jwks = vec![IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 3600, + key_snapshot_hard_deadline_seconds: 3600, + }]; + let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(); + assert_eq!(err, NipFiStartupError::InvalidJwksTiming); +} + +#[test] +fn enforce_zero_refresh_interval_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let jwks = vec![IssuerJwksConfig { + issuer: issuer.to_owned(), + jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), + refresh_interval_seconds: 0, + key_snapshot_hard_deadline_seconds: 3600, + }]; + let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(); + assert_eq!(err, NipFiStartupError::InvalidJwksTiming); +} + +// ── current-status requires JWKS ───────────────────────────────────────────── + +#[test] +fn enforce_current_status_without_jwks_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_status_policy(issuer)); + + let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err(); + // Either CurrentStatusRequiresJwks or MissingJwksConfig is correct here; + // the current implementation returns CurrentStatusRequiresJwks. + assert!( + err == NipFiStartupError::CurrentStatusRequiresJwks + || err == NipFiStartupError::MissingJwksConfig, + "expected a JWKS-missing error, got {err:?}" + ); +} + +#[test] +fn enforce_current_status_with_jwks_passes() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_status_policy(issuer)); + + let jwks = vec![make_jwks_config(issuer)]; + assert!(validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_ok()); +} diff --git a/crates/buzz-auth/src/nip_fi/verifier.rs b/crates/buzz-auth/src/nip_fi/verifier.rs index 7ac2cbe376..19a4824377 100644 --- a/crates/buzz-auth/src/nip_fi/verifier.rs +++ b/crates/buzz-auth/src/nip_fi/verifier.rs @@ -49,7 +49,7 @@ 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 {} } @@ -101,13 +101,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,