feat(auth): NIP-FI Phase A PR 3 — production assertion runtime

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<F> 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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-31 11:19:58 -04:00
parent bd851f85e7
commit 5d7cf011b2
11 changed files with 1084 additions and 29 deletions

1
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@@ -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<Item = &IssuerPolicy> {
self.policies.values()
}
}
/// Sort and deduplicate a set-valued list of strings into its canonical form.

View File

@@ -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<u64>,
}
/// 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<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

@@ -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<Utc>,
hard_deadline: DateTime<Utc>,
}
/// 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.
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<Output = Result<String, JwksFetchError>> + 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<Output = Result<String, JwksFetchError>> + 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<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.
///
/// 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.
///
/// ## 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<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.
pub fn new(configs: Vec<IssuerJwksConfig>, fetcher: F) -> Option<Self> {
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<CachedSnapshot> {
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<AssertionKeySet> {
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<F: JwksFetcher> super::verifier::sealed::Sealed for ProductionJwksSource<F> {}
impl<F: JwksFetcher> IssuerKeySource for ProductionJwksSource<F> {
/// 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<AssertionKeySet> {
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<F> std::fmt::Debug for ProductionJwksSource<F> {
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;

View File

@@ -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<String, JwksFetchError>,
call_count: Arc<AtomicUsize>,
}
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<Output = Result<String, JwksFetchError>> + 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<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);
}
#[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);
}

View File

@@ -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};

View File

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

View File

@@ -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, &registry, &[]).is_ok());
}
#[test]
fn deny_protected_mode_accepts_empty_registry() {
let registry = IssuerRegistry::new();
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());
}
#[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, &registry, &jwks).is_ok());
}
// ── Enforce: empty registry ───────────────────────────────────────────────────
#[test]
fn enforce_empty_registry_rejects() {
let registry = IssuerRegistry::new();
let err = validate_nip_fi_config(NipFiMode::Enforce, &registry, &[]).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, &registry, &[]).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, &registry, &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, &registry, &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, &registry, &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, &registry, &[]).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, &registry, &jwks).is_ok());
}

View File

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