fix(auth): harden JWKS boundary, reject current-status, fix generation and comment quality

HTTP boundary (finding 1):
- Add validate_jwks_uri(): HTTPS-only, no credentials/fragments, bare IP
  private-address rejection via buzz_core::network::is_private_ip
- HttpJwksFetcher::new() builds a hardened client: no redirects, 10s intrinsic
  deadline; with_client() documents caller invariants
- Stream response body incrementally (bytes_stream + StreamExt), stop at
  MAX_JWKS_RESPONSE_BYTES + 1 before any deserialization
- Reject non-2xx status before reading body
- Add reqwest 'stream' feature to workspace; add futures-util to buzz-auth deps
- Add MAX_JWKS_TIMING_SECONDS = 1 year upper bound on timing fields
- Regression tests: non-HTTPS, loopback/private IP, credentials, fragment,
  oversized timing, duplicate issuer all rejected at construction

CurrentStatus posture (finding 2):
- Rename error variant DuplicateIssuer(String) -> DuplicateIssuer (sanitized)
- Add UnsupportedPosture error variant
- validate_nip_fi_config() rejects any CurrentStatus policy with
  UnsupportedPosture — verifier has no status witness; startup fails closed
- discovery.rs: remove FreshnessClassDiscovery::CurrentStatus variant and
  FederatedIdentityDiscovery::current_status() constructor entirely
- Test asserts rejection both with and without JWKS config

Duplicate issuer detection (finding 3):
- validate_nip_fi_config(): explicit duplicate detection in JWKS config slice
  (collect() was silently overwriting); returns DuplicateIssuer on collision
- ProductionJwksSource::new(): rejects duplicate issuer via HashMap::contains_key
  before insert

Timing bounds and overflow (finding 4):
- MAX_JWKS_TIMING_SECONDS constant bounds both refresh and hard-deadline fields
- i64::try_from() + Duration::try_seconds() eliminates u64->i64 cast panic
- Validated at both ProductionJwksSource::new() and validate_nip_fi_config()
- Test: new_rejects_timing_above_maximum()

Generation monotonicity (finding 5):
- Replace wall-clock millis with SHA-256 content digest per issuer
- Generation counter advances (saturating_add) only when digest changes;
  identical documents preserve the prior generation
- Regressions: generation_stable_for_identical_document(),
  generation_advances_for_changed_document()

Clippy (finding 6):
- manual_async_fn: replaced RPITIT form with native 'async fn' in impl block
- single_match (startup): replaced match { None => .., Some(_) => {} } with
  if let / !contains_key
- unnecessary_get_then_check: replaced .get().is_none() with !contains_key()

Comment quality (all files):
- Remove module-to-PR table from nip_fi/mod.rs
- Remove all 'Phase A', 'PR 1/3', 'PRs 4-5' references from every doc comment
- Remove WHAT comments (field-name paraphrases, narrated steps, section
  banners with no contract content, 'Construct with a default reqwest client')
- Retain WHY: security invariants, exact NIP-FI spec refs, fail-closed choices,
  FI-TRACE/FI-INV stable identifiers

Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-31 11:40:33 -04:00
parent 5d7cf011b2
commit e78bafab69
9 changed files with 636 additions and 341 deletions

1
Cargo.lock generated
View File

@@ -939,6 +939,7 @@ dependencies = [
"base64 0.22.1",
"buzz-core",
"chrono",
"futures-util",
"hex",
"jsonwebtoken",
"nostr 0.44.7",

View File

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

View File

@@ -21,6 +21,7 @@ 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 }

View File

@@ -1,7 +1,8 @@
//! NIP-11 federated-identity discovery output (NIP-FI Phase A, PR 3).
//! NIP-11 federated-identity discovery output.
//!
//! [`FederatedIdentityDiscovery`] serializes to the `federated_identity`
//! object NIP-FI.md "Discovery" requires in NIP-11 relay information.
//! object required by the NIP-FI.md "Discovery" section of the NIP-11 relay
//! information document.
//!
//! ## Privacy invariants
//!
@@ -19,42 +20,40 @@
use serde::{Deserialize, Serialize};
/// The `assertion_freshness` sub-object inside `federated_identity`.
/// 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 {
/// `"offline-jwt"` or `"current-status"`.
/// The wire string identifying the freshness class.
pub class: FreshnessClassDiscovery,
/// `null` for `offline-jwt`; a tested positive integer for `current-status`.
/// `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<u64>,
}
/// The freshness class as a stable wire string.
/// 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 {
/// Validates the JWT and JWKS snapshot only.
/// No revocation bound is claimed; JWKS snapshot validation 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.
/// 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 {
/// Always `"client-attached"` for core.
/// Fixed value `"client-attached"` for the core NIP-FI transport mode.
pub core: String,
/// The assertion freshness contract claimed by this deployment.
/// The 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.
/// 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(),
@@ -64,22 +63,4 @@ impl FederatedIdentityDiscovery {
},
}
}
/// 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<Self> {
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),
},
})
}
}

View File

@@ -1,5 +1,5 @@
//! JWKS discovery, snapshot caching, and the production [`IssuerKeySource`]
//! implementation (NIP-FI Phase A, PR 3).
//! implementation for federated-assertion verification.
//!
//! ## Design invariants
//!
@@ -13,9 +13,10 @@
//! 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`].
//! - **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
@@ -26,30 +27,79 @@
use super::config::MAX_JWKS_KEYS;
use super::verifier::{AssertionKeySet, IssuerKeySource};
use buzz_core::network::is_private_ip;
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 size for a JWKS endpoint, in bytes. Bounded before
/// parsing to prevent a large or malicious response from consuming unbounded
/// memory during deserialization.
/// 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
/// A JWKS snapshot with its fetch time and configured hard deadline.
/// 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
/// Per-request deadline for the complete JWKS fetch (connect + headers + body).
/// This constant documents the timeout set on the default `HttpJwksFetcher::new()`
/// client; it cannot be removed via `with_client`.
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. Hostnames
/// are not resolved here — runtime SSRF for hostname targets is limited by
/// redirect denial and the intrinsic request deadline.
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. Hostname
// targets are additionally constrained at runtime by redirect denial.
if let Some(url::Host::Ipv4(addr)) = parsed.host() {
if is_private_ip(&std::net::IpAddr::V4(addr)) {
return Err(JwksFetchError::InvalidUri);
}
}
if let Some(url::Host::Ipv6(addr)) = parsed.host() {
if is_private_ip(&std::net::IpAddr::V6(addr)) {
return Err(JwksFetchError::InvalidUri);
}
}
Ok(())
}
#[derive(Clone)]
struct CachedSnapshot {
key_set: AssertionKeySet,
fetched_at: DateTime<Utc>,
hard_deadline: DateTime<Utc>,
/// 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],
}
/// Per-issuer runtime state: the current snapshot and in-flight flag.
struct IssuerState {
snapshot: Option<CachedSnapshot>,
/// True while a refresh task owns the fetch. Prevents concurrent fetches.
/// Advances only when `content_digest` changes; never wraps (saturating).
generation_counter: u64,
/// True while a refresh task owns the fetch lock. Prevents thundering-herd.
refresh_in_flight: bool,
}
@@ -57,82 +107,96 @@ impl IssuerState {
fn new() -> Self {
Self {
snapshot: None,
generation_counter: 0,
refresh_in_flight: false,
}
}
}
/// Configuration for one issuer's JWKS endpoint.
/// Per-issuer JWKS endpoint configuration. All fields are validated by
/// [`validate_jwks_uri`] and timing bounds at [`ProductionJwksSource::new`].
#[derive(Debug, Clone)]
pub struct IssuerJwksConfig {
/// The exact `iss` value this config authenticates. Must match the
/// configured [`IssuerPolicy`][super::config::IssuerPolicy] exactly.
/// corresponding [`IssuerPolicy`][super::config::IssuerPolicy] exactly.
pub issuer: String,
/// The HTTPS JWKS endpoint URI.
/// Must pass [`validate_jwks_uri`]: HTTPS, no credentials/fragment, no
/// bare private-IP host.
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`.
/// Seconds until a cached snapshot is considered stale and re-fetching is
/// triggered. Must be positive, strictly less than
/// `key_snapshot_hard_deadline_seconds`, and ≤ [`MAX_JWKS_TIMING_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`.
/// Expired snapshots are never returned, even on fetch error — no stale
/// fallback. Folds into every `AssertionKeySet` hard deadline and therefore
/// into every `VerifiedAssertion.revalidation_dependencies`.
/// Must be ≤ [`MAX_JWKS_TIMING_SECONDS`].
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.
/// 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 {
/// The HTTP response exceeded [`MAX_JWKS_RESPONSE_BYTES`].
/// Non-HTTPS scheme, embedded credentials, fragment, or bare
/// private/reserved IP host.
#[error("JWKS URI failed safety validation")]
InvalidUri,
/// Response body exceeded [`MAX_JWKS_RESPONSE_BYTES`].
#[error("JWKS response exceeded size limit")]
ResponseTooLarge,
/// The HTTP request failed (network, TLS, timeout).
/// Network failure, TLS error, request timeout, or non-2xx status.
#[error("JWKS HTTP request failed")]
NetworkError,
/// The response body was not parseable as a JWK Set.
/// 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.
/// Parsed key set was empty or exceeded [`super::config::MAX_JWKS_KEYS`].
#[error("JWKS key set bounds violation")]
KeyCountBoundsViolation,
}
/// Async HTTP fetch of a JWKS endpoint.
/// Sealed injection seam for JWKS HTTP fetching. Only types inside `buzz_auth`
/// may implement it — external types cannot name the private supertrait.
///
/// 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`].
/// Implementations MUST enforce [`MAX_JWKS_RESPONSE_BYTES`] and MUST reject
/// non-2xx responses.
pub trait JwksFetcher: super::verifier::sealed::Sealed + Send + Sync + 'static {
/// Fetch the JWK Set from the given URI, returning the raw JSON body.
/// 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<Output = Result<String, JwksFetchError>> + Send + 'a;
}
/// Production [`JwksFetcher`] backed by `reqwest`.
/// Production [`JwksFetcher`] backed by `reqwest`. The default client enforces:
/// - no redirects (`Policy::none()`) — a redirect to an internal host would
/// bypass the URI safety check performed at startup;
/// - a finite per-request deadline ([`JWKS_REQUEST_TIMEOUT_SECS`]).
///
/// Enforces [`MAX_JWKS_RESPONSE_BYTES`] before reading the full body.
/// `with_client` accepts a caller-supplied client; the caller must preserve
/// the no-redirect and finite-timeout invariants. The JWKS URI safety check
/// is still enforced by [`ProductionJwksSource::new`] regardless.
#[derive(Clone)]
pub struct HttpJwksFetcher {
client: reqwest::Client,
}
impl HttpJwksFetcher {
/// Construct with a default `reqwest` client.
/// Builds a hardened client: no redirects (`Policy::none()`), finite
/// request deadline ([`JWKS_REQUEST_TIMEOUT_SECS`]).
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
}
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(JWKS_REQUEST_TIMEOUT_SECS))
.build()
.expect("HttpJwksFetcher default client build failed");
Self { client }
}
/// Construct with an explicit `reqwest::Client` (e.g., with custom TLS
/// certificates or timeout configuration).
/// The caller is responsible for preserving the no-redirect and
/// finite-timeout invariants documented on this type.
pub fn with_client(client: reqwest::Client) -> Self {
Self { client }
}
@@ -150,63 +214,62 @@ impl std::fmt::Debug for 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<Output = Result<String, JwksFetchError>> + Send + 'a {
async move {
let response = self
.client
.get(uri)
.send()
.await
.map_err(|_| JwksFetchError::NetworkError)?;
async fn fetch_jwks<'a>(&'a self, uri: &'a str) -> Result<String, JwksFetchError> {
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);
}
}
// Non-2xx rejected before reading the body. A 3xx here means the
// client followed a redirect (default client disallows this); 4xx/5xx
// means the endpoint is not serving JWKS.
if !response.status().is_success() {
return Err(JwksFetchError::NetworkError);
}
let bytes = response
.bytes()
.await
.map_err(|_| JwksFetchError::NetworkError)?;
if bytes.len() > MAX_JWKS_RESPONSE_BYTES {
// 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);
}
String::from_utf8(bytes.to_vec()).map_err(|_| JwksFetchError::ParseError)
}
// 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)
}
}
/// 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<JwkSet, JwksFetchError> {
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.
/// 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<RwLock<…>>`
/// internal structure lets it be shared across async tasks cheaply.
/// Must be constructed at startup after
/// [`super::startup::validate_nip_fi_config`] passes. Shared across async
/// tasks via the inner `Arc<RwLock<…>>`.
///
/// ## Security
///
@@ -215,17 +278,14 @@ fn parse_and_bound_jwks(body: &str) -> Result<JwkSet, JwksFetchError> {
/// - Errors are logged with a stable code; no key material appears in logs.
pub struct ProductionJwksSource<F = HttpJwksFetcher> {
configs: HashMap<String, IssuerJwksConfig>,
/// Keyed by exact issuer string.
states: Arc<RwLock<HashMap<String, Mutex<IssuerState>>>>,
fetcher: Arc<F>,
}
impl<F: JwksFetcher> ProductionJwksSource<F> {
/// 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.
/// Returns `None` when `configs` is empty, any config has invalid timing
/// bounds or fails URI validation, or any two configs share the same
/// `issuer` (duplicate issuers make trust configuration ambiguous).
pub fn new(configs: Vec<IssuerJwksConfig>, fetcher: F) -> Option<Self> {
if configs.is_empty() {
return None;
@@ -238,9 +298,17 @@ impl<F: JwksFetcher> ProductionJwksSource<F> {
if c.refresh_interval_seconds == 0
|| c.key_snapshot_hard_deadline_seconds == 0
|| c.key_snapshot_hard_deadline_seconds <= c.refresh_interval_seconds
|| c.refresh_interval_seconds > MAX_JWKS_TIMING_SECONDS
|| c.key_snapshot_hard_deadline_seconds > MAX_JWKS_TIMING_SECONDS
{
return None;
}
if validate_jwks_uri(&c.jwks_uri).is_err() {
return None;
}
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);
@@ -252,17 +320,17 @@ impl<F: JwksFetcher> ProductionJwksSource<F> {
})
}
/// 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<CachedSnapshot> {
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.jwks_uri).await {
Ok(b) => b,
Err(err) => {
warn!(
error = %err,
"nip-fi jwks fetch failed; will use cached snapshot if live"
);
warn!(error = %err, "nip-fi jwks fetch failed; will use cached snapshot if live");
return None;
}
};
@@ -270,41 +338,50 @@ impl<F: JwksFetcher> ProductionJwksSource<F> {
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"
);
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);
let content_digest: [u8; 32] = Sha256::digest(body.as_bytes()).into();
// 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);
// 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 = Utc::now();
// MAX_JWKS_TIMING_SECONDS ≤ ~31.5M < i64::MAX, so this conversion is
// always safe for values that passed the bounds check in new().
let deadline_secs =
i64::try_from(config.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,
})
Some((
CachedSnapshot {
key_set,
fetched_at: now,
hard_deadline,
content_digest,
},
generation,
))
}
/// Return the current snapshot for `issuer`, refreshing if stale.
///
/// Returns the cached snapshot for `issuer`, refreshing inline 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.
/// If a refresh is already in flight for this issuer, returns the current
/// snapshot rather than blocking — coalesces concurrent callers. Drops
/// both locks before the async fetch so other issuers are not blocked.
pub async fn get_snapshot(&self, issuer: &str) -> Option<AssertionKeySet> {
let states = self.states.read().await;
let state_mutex = states.get(issuer)?;
@@ -313,7 +390,6 @@ impl<F: JwksFetcher> ProductionJwksSource<F> {
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;
@@ -333,25 +409,23 @@ impl<F: JwksFetcher> ProductionJwksSource<F> {
}
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.
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).await;
let fresh = self.fetch_fresh(issuer, prev_digest, prev_generation).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 {
if let Some((ref cached, new_generation)) = fresh {
st.generation_counter = new_generation;
st.snapshot = Some(cached.clone());
}
let now2 = Utc::now();
@@ -366,20 +440,15 @@ impl<F: JwksFetcher> ProductionJwksSource<F> {
}
}
// Sealed so only in-crate types implement `IssuerKeySource`.
impl<F: JwksFetcher> super::verifier::sealed::Sealed for ProductionJwksSource<F> {}
impl<F: JwksFetcher> IssuerKeySource for ProductionJwksSource<F> {
/// Synchronous read of the currently cached snapshot.
/// Called per-request by the verifier after the cache has been warmed via
/// [`get_snapshot`][Self::get_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]
/// 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<AssertionKeySet> {
let states = self.states.try_read().ok()?;
let state_mutex = states.get(issuer)?;

View File

@@ -1,14 +1,7 @@
//! 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<String, JwksFetchError>,
call_count: Arc<AtomicUsize>,
@@ -27,7 +20,6 @@ impl JwksFetcher for FakeJwksFetcher {
}
}
/// 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}"}}]}}"#
@@ -43,7 +35,14 @@ fn make_config(issuer: &str) -> IssuerJwksConfig {
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
fn make_config_with_uri(issuer: &str, jwks_uri: &str) -> IssuerJwksConfig {
IssuerJwksConfig {
issuer: issuer.to_owned(),
jwks_uri: jwks_uri.to_owned(),
refresh_interval_seconds: 300,
key_snapshot_hard_deadline_seconds: 3600,
}
}
#[tokio::test]
async fn get_snapshot_returns_sealed_key_set_on_success() {
@@ -54,9 +53,7 @@ async fn get_snapshot_returns_sealed_key_set_on_success() {
};
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();
let ks = source.get_snapshot(issuer).await.unwrap();
assert_eq!(ks.issuer(), issuer);
}
@@ -69,8 +66,7 @@ async fn get_snapshot_returns_none_for_unknown_issuer() {
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");
assert!(source.get_snapshot("https://other.example").await.is_none());
}
#[tokio::test]
@@ -82,8 +78,7 @@ async fn get_snapshot_returns_none_on_network_error_with_no_cache() {
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");
assert!(source.get_snapshot(issuer).await.is_none());
}
#[tokio::test]
@@ -112,22 +107,22 @@ async fn get_snapshot_returns_none_on_parse_error() {
#[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();
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() {
// Build MAX_JWKS_KEYS + 1 keys.
let keys: Vec<String> = (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);
assert_eq!(
parse_and_bound_jwks(&body).unwrap_err(),
JwksFetchError::KeyCountBoundsViolation
);
}
#[tokio::test]
@@ -148,7 +143,7 @@ async fn new_rejects_refresh_ge_hard_deadline() {
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
refresh_interval_seconds: 3600,
key_snapshot_hard_deadline_seconds: 3600,
};
assert!(ProductionJwksSource::new(vec![bad_config], fetcher).is_none());
@@ -169,8 +164,125 @@ async fn new_rejects_zero_refresh_interval() {
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 new_rejects_timing_above_maximum() {
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: MAX_JWKS_TIMING_SECONDS + 1,
key_snapshot_hard_deadline_seconds: MAX_JWKS_TIMING_SECONDS + 2,
};
assert!(ProductionJwksSource::new(vec![bad_config], fetcher).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 = IssuerJwksConfig {
issuer: issuer.to_owned(),
jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(),
refresh_interval_seconds: 300,
key_snapshot_hard_deadline_seconds: 3600,
};
let config_b = IssuerJwksConfig {
issuer: issuer.to_owned(),
jwks_uri: "https://id.example/.well-known/jwks-alt.json".to_owned(),
refresh_interval_seconds: 600,
key_snapshot_hard_deadline_seconds: 7200,
};
assert!(ProductionJwksSource::new(vec![config_a, config_b], fetcher).is_none());
}
#[tokio::test]
async fn new_rejects_non_https_jwks_uri() {
let fetcher = FakeJwksFetcher {
body: Ok(minimal_jwks_json("k1")),
call_count: Arc::new(AtomicUsize::new(0)),
};
assert!(ProductionJwksSource::new(
vec![make_config_with_uri(
"https://id.example",
"http://id.example/.well-known/jwks.json"
)],
fetcher
)
.is_none());
}
#[tokio::test]
async fn new_rejects_loopback_jwks_uri() {
let fetcher = FakeJwksFetcher {
body: Ok(minimal_jwks_json("k1")),
call_count: Arc::new(AtomicUsize::new(0)),
};
assert!(ProductionJwksSource::new(
vec![make_config_with_uri(
"https://id.example",
"https://127.0.0.1/.well-known/jwks.json"
)],
fetcher
)
.is_none());
}
#[tokio::test]
async fn new_rejects_private_ip_jwks_uri() {
let fetcher = FakeJwksFetcher {
body: Ok(minimal_jwks_json("k1")),
call_count: Arc::new(AtomicUsize::new(0)),
};
assert!(ProductionJwksSource::new(
vec![make_config_with_uri(
"https://id.example",
"https://10.0.0.1/.well-known/jwks.json"
)],
fetcher
)
.is_none());
}
#[tokio::test]
async fn new_rejects_jwks_uri_with_credentials() {
let fetcher = FakeJwksFetcher {
body: Ok(minimal_jwks_json("k1")),
call_count: Arc::new(AtomicUsize::new(0)),
};
assert!(ProductionJwksSource::new(
vec![make_config_with_uri(
"https://id.example",
"https://user:pass@id.example/.well-known/jwks.json"
)],
fetcher
)
.is_none());
}
#[tokio::test]
async fn new_rejects_jwks_uri_with_fragment() {
let fetcher = FakeJwksFetcher {
body: Ok(minimal_jwks_json("k1")),
call_count: Arc::new(AtomicUsize::new(0)),
};
assert!(ProductionJwksSource::new(
vec![make_config_with_uri(
"https://id.example",
"https://id.example/.well-known/jwks.json#keys"
)],
fetcher
)
.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 {
@@ -181,14 +293,9 @@ async fn sync_key_set_returns_none_before_warmup() {
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"
);
assert!(source.key_set(issuer).is_none());
}
/// 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 {
@@ -204,3 +311,141 @@ async fn sync_key_set_returns_snapshot_after_warmup() {
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(),
jwks_uri: format!("https://{issuer}/.well-known/jwks.json"),
refresh_interval_seconds: 1,
key_snapshot_hard_deadline_seconds: 3600,
};
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::<String, JwksFetchError>(minimal_jwks_json("k2")),
Ok(minimal_jwks_json("k1")),
]));
struct MultiBodyFetcher {
bodies: Arc<std::sync::Mutex<Vec<Result<String, JwksFetchError>>>>,
}
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<Output = Result<String, JwksFetchError>> + Send + 'a {
let result = self
.bodies
.lock()
.unwrap()
.pop()
.unwrap_or(Err(JwksFetchError::NetworkError));
async move { result }
}
}
let config = IssuerJwksConfig {
issuer: issuer.to_owned(),
jwks_uri: format!("https://{issuer}/.well-known/jwks.json"),
refresh_interval_seconds: 1,
key_snapshot_hard_deadline_seconds: 3600,
};
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_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_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
);
}

View File

@@ -1,24 +1,11 @@
//! NIP-FI federated-identity authorization — assertion verifier, JWKS runtime,
//! startup validation, and discovery (Phase A, PRs 1–3).
//!
//! ## Module layout
//!
//! | 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 |
//!
//! 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.
//! 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;

View File

@@ -1,30 +1,15 @@
//! Startup validation for the NIP-FI assertion runtime (Phase A, PR 3).
//! 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`][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 |
//! 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;
use super::jwks::{validate_jwks_uri, IssuerJwksConfig, MAX_JWKS_TIMING_SECONDS};
/// Operating mode for the NIP-FI assertion runtime.
///
/// The variant names are stable contract values; do not rename without a
/// Variant names are stable contract values; do not rename without a
/// `VERIFIER_CONTRACT_VERSION` bump.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NipFiMode {
@@ -34,124 +19,117 @@ pub enum NipFiMode {
/// 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]
/// 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,
}
/// 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.
/// Registry has no entries; enforce mode requires at least one issuer.
#[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),
/// The duplicate `iss` is omitted to avoid leaking configuration into
/// operational logs.
#[error("NIP-FI issuer registry contains a duplicate issuer")]
DuplicateIssuer,
/// 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)")]
/// Every registered issuer requires a JWKS endpoint in enforce mode.
#[error("NIP-FI issuer has no JWKS configuration")]
MissingJwksConfig,
/// A JWKS config's `issuer` field does not match any registered issuer
/// policy. Mismatched configs are rejected to prevent silent key-source
/// confusion.
/// 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")]
/// `refresh_interval_seconds` is zero, exceeds [`MAX_JWKS_TIMING_SECONDS`],
/// or is ≥ `key_snapshot_hard_deadline_seconds`.
#[error("NIP-FI JWKS config has invalid timing bounds")]
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,
/// Non-HTTPS scheme, embedded credentials, fragment, or bare
/// private/reserved IP host. See [`validate_jwks_uri`].
#[error("NIP-FI JWKS URI failed safety validation")]
InvalidJwksUri,
/// `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,
}
/// 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`].
/// 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> {
match mode {
NipFiMode::Off | NipFiMode::DenyProtected => {
// Off and emergency-denial modes impose no assertion config
// requirements — they admit nothing.
return Ok(());
}
NipFiMode::Enforce => {}
if let NipFiMode::Off | NipFiMode::DenyProtected = mode {
return Ok(());
}
// 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).
// 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(
policy.issuer().to_owned(),
));
return Err(NipFiStartupError::DuplicateIssuer);
}
}
}
// 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();
// 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);
}
}
// 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
|| config.refresh_interval_seconds > MAX_JWKS_TIMING_SECONDS
|| config.key_snapshot_hard_deadline_seconds > MAX_JWKS_TIMING_SECONDS
{
return Err(NipFiStartupError::InvalidJwksTiming);
}
if validate_jwks_uri(&config.jwks_uri).is_err() {
return Err(NipFiStartupError::InvalidJwksUri);
}
}
// 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(_) => {}
if !jwks_map.contains_key(policy.issuer()) {
return Err(NipFiStartupError::MissingJwksConfig);
}
}

View File

@@ -1,11 +1,8 @@
//! 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(),
@@ -21,7 +18,6 @@ fn make_offline_policy(issuer: &str) -> IssuerPolicy {
.unwrap()
}
/// Build a minimal valid current-status `IssuerPolicy`.
fn make_status_policy(issuer: &str) -> IssuerPolicy {
IssuerPolicy::new(
issuer.to_owned(),
@@ -46,8 +42,6 @@ fn make_jwks_config(issuer: &str) -> IssuerJwksConfig {
}
}
// ── Off / DenyProtected accept anything ───────────────────────────────────────
#[test]
fn off_mode_accepts_empty_registry() {
let registry = IssuerRegistry::new();
@@ -60,16 +54,15 @@ fn deny_protected_mode_accepts_empty_registry() {
assert!(validate_nip_fi_config(NipFiMode::DenyProtected, &registry, &[]).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, &registry, &jwks).is_ok());
assert!(
validate_nip_fi_config(NipFiMode::Enforce, &registry, &[make_jwks_config(issuer)]).is_ok()
);
}
#[test]
@@ -87,8 +80,6 @@ fn enforce_multiple_issuers_passes() {
assert!(validate_nip_fi_config(NipFiMode::Enforce, &registry, &jwks).is_ok());
}
// ── Enforce: empty registry ───────────────────────────────────────────────────
#[test]
fn enforce_empty_registry_rejects() {
let registry = IssuerRegistry::new();
@@ -96,8 +87,6 @@ fn enforce_empty_registry_rejects() {
assert_eq!(err, NipFiStartupError::EmptyRegistry);
}
// ── Enforce: missing JWKS config ─────────────────────────────────────────────
#[test]
fn enforce_issuer_without_jwks_rejects() {
let issuer = "https://id.example";
@@ -108,22 +97,21 @@ fn enforce_issuer_without_jwks_rejects() {
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, &registry, &jwks).unwrap_err();
let err = validate_nip_fi_config(
NipFiMode::Enforce,
&registry,
&[make_jwks_config("https://other.example")],
)
.unwrap_err();
assert_eq!(err, NipFiStartupError::UnmatchedJwksConfig);
}
// ── Enforce: invalid JWKS timing ─────────────────────────────────────────────
#[test]
fn enforce_refresh_equals_hard_deadline_rejects() {
let issuer = "https://id.example";
@@ -136,8 +124,10 @@ fn enforce_refresh_equals_hard_deadline_rejects() {
refresh_interval_seconds: 3600,
key_snapshot_hard_deadline_seconds: 3600,
}];
let err = validate_nip_fi_config(NipFiMode::Enforce, &registry, &jwks).unwrap_err();
assert_eq!(err, NipFiStartupError::InvalidJwksTiming);
assert_eq!(
validate_nip_fi_config(NipFiMode::Enforce, &registry, &jwks).unwrap_err(),
NipFiStartupError::InvalidJwksTiming
);
}
#[test]
@@ -152,34 +142,77 @@ fn enforce_zero_refresh_interval_rejects() {
refresh_interval_seconds: 0,
key_snapshot_hard_deadline_seconds: 3600,
}];
let err = validate_nip_fi_config(NipFiMode::Enforce, &registry, &jwks).unwrap_err();
assert_eq!(err, NipFiStartupError::InvalidJwksTiming);
assert_eq!(
validate_nip_fi_config(NipFiMode::Enforce, &registry, &jwks).unwrap_err(),
NipFiStartupError::InvalidJwksTiming
);
}
// ── current-status requires JWKS ─────────────────────────────────────────────
/// 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_without_jwks_rejects() {
fn enforce_current_status_policy_always_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, &registry, &[]).unwrap_err();
// Either CurrentStatusRequiresJwks or MissingJwksConfig is correct here;
// the current implementation returns CurrentStatusRequiresJwks.
assert_eq!(
validate_nip_fi_config(NipFiMode::Enforce, &registry, &[]).unwrap_err(),
NipFiStartupError::UnsupportedPosture
);
assert_eq!(
validate_nip_fi_config(NipFiMode::Enforce, &registry, &[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!(
err == NipFiStartupError::CurrentStatusRequiresJwks
|| err == NipFiStartupError::MissingJwksConfig,
"expected a JWKS-missing error, got {err:?}"
validate_nip_fi_config(NipFiMode::Enforce, &registry, &jwks).is_err(),
"duplicate JWKS configs must not pass"
);
}
#[test]
fn enforce_current_status_with_jwks_passes() {
fn enforce_non_https_jwks_uri_rejects() {
let issuer = "https://id.example";
let mut registry = IssuerRegistry::new();
registry.insert(make_status_policy(issuer));
registry.insert(make_offline_policy(issuer));
let jwks = vec![make_jwks_config(issuer)];
assert!(validate_nip_fi_config(NipFiMode::Enforce, &registry, &jwks).is_ok());
let jwks = vec![IssuerJwksConfig {
issuer: issuer.to_owned(),
jwks_uri: "http://id.example/.well-known/jwks.json".to_owned(),
refresh_interval_seconds: 300,
key_snapshot_hard_deadline_seconds: 3600,
}];
assert_eq!(
validate_nip_fi_config(NipFiMode::Enforce, &registry, &jwks).unwrap_err(),
NipFiStartupError::InvalidJwksUri
);
}
#[test]
fn enforce_loopback_jwks_uri_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://127.0.0.1/.well-known/jwks.json".to_owned(),
refresh_interval_seconds: 300,
key_snapshot_hard_deadline_seconds: 3600,
}];
assert_eq!(
validate_nip_fi_config(NipFiMode::Enforce, &registry, &jwks).unwrap_err(),
NipFiStartupError::InvalidJwksUri
);
}