mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
85
crates/buzz-auth/src/nip_fi/discovery.rs
Normal file
85
crates/buzz-auth/src/nip_fi/discovery.rs
Normal 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),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
408
crates/buzz-auth/src/nip_fi/jwks/mod.rs
Normal file
408
crates/buzz-auth/src/nip_fi/jwks/mod.rs
Normal 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;
|
||||
206
crates/buzz-auth/src/nip_fi/jwks/tests.rs
Normal file
206
crates/buzz-auth/src/nip_fi/jwks/tests.rs
Normal 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);
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
162
crates/buzz-auth/src/nip_fi/startup/mod.rs
Normal file
162
crates/buzz-auth/src/nip_fi/startup/mod.rs
Normal 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;
|
||||
185
crates/buzz-auth/src/nip_fi/startup/tests.rs
Normal file
185
crates/buzz-auth/src/nip_fi/startup/tests.rs
Normal 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, ®istry, &[]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_protected_mode_accepts_empty_registry() {
|
||||
let registry = IssuerRegistry::new();
|
||||
assert!(validate_nip_fi_config(NipFiMode::DenyProtected, ®istry, &[]).is_ok());
|
||||
}
|
||||
|
||||
// ── Enforce: basic happy path ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn enforce_valid_config_passes() {
|
||||
let issuer = "https://id.example";
|
||||
let mut registry = IssuerRegistry::new();
|
||||
registry.insert(make_offline_policy(issuer));
|
||||
|
||||
let jwks = vec![make_jwks_config(issuer)];
|
||||
assert!(validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforce_multiple_issuers_passes() {
|
||||
let issuers = [
|
||||
"https://a.example",
|
||||
"https://b.example",
|
||||
"https://c.example",
|
||||
];
|
||||
let mut registry = IssuerRegistry::new();
|
||||
for iss in &issuers {
|
||||
registry.insert(make_offline_policy(iss));
|
||||
}
|
||||
let jwks: Vec<_> = issuers.iter().map(|i| make_jwks_config(i)).collect();
|
||||
assert!(validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_ok());
|
||||
}
|
||||
|
||||
// ── Enforce: empty registry ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn enforce_empty_registry_rejects() {
|
||||
let registry = IssuerRegistry::new();
|
||||
let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err();
|
||||
assert_eq!(err, NipFiStartupError::EmptyRegistry);
|
||||
}
|
||||
|
||||
// ── Enforce: missing JWKS config ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn enforce_issuer_without_jwks_rejects() {
|
||||
let issuer = "https://id.example";
|
||||
let mut registry = IssuerRegistry::new();
|
||||
registry.insert(make_offline_policy(issuer));
|
||||
|
||||
let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err();
|
||||
assert_eq!(err, NipFiStartupError::MissingJwksConfig);
|
||||
}
|
||||
|
||||
// ── Enforce: unmatched JWKS config ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn enforce_unmatched_jwks_config_rejects() {
|
||||
let issuer = "https://id.example";
|
||||
let mut registry = IssuerRegistry::new();
|
||||
registry.insert(make_offline_policy(issuer));
|
||||
|
||||
// JWKS config for a different issuer.
|
||||
let jwks = vec![make_jwks_config("https://other.example")];
|
||||
let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err();
|
||||
assert_eq!(err, NipFiStartupError::UnmatchedJwksConfig);
|
||||
}
|
||||
|
||||
// ── Enforce: invalid JWKS timing ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn enforce_refresh_equals_hard_deadline_rejects() {
|
||||
let issuer = "https://id.example";
|
||||
let mut registry = IssuerRegistry::new();
|
||||
registry.insert(make_offline_policy(issuer));
|
||||
|
||||
let jwks = vec![IssuerJwksConfig {
|
||||
issuer: issuer.to_owned(),
|
||||
jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(),
|
||||
refresh_interval_seconds: 3600,
|
||||
key_snapshot_hard_deadline_seconds: 3600,
|
||||
}];
|
||||
let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err();
|
||||
assert_eq!(err, NipFiStartupError::InvalidJwksTiming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforce_zero_refresh_interval_rejects() {
|
||||
let issuer = "https://id.example";
|
||||
let mut registry = IssuerRegistry::new();
|
||||
registry.insert(make_offline_policy(issuer));
|
||||
|
||||
let jwks = vec![IssuerJwksConfig {
|
||||
issuer: issuer.to_owned(),
|
||||
jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(),
|
||||
refresh_interval_seconds: 0,
|
||||
key_snapshot_hard_deadline_seconds: 3600,
|
||||
}];
|
||||
let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err();
|
||||
assert_eq!(err, NipFiStartupError::InvalidJwksTiming);
|
||||
}
|
||||
|
||||
// ── current-status requires JWKS ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn enforce_current_status_without_jwks_rejects() {
|
||||
let issuer = "https://id.example";
|
||||
let mut registry = IssuerRegistry::new();
|
||||
registry.insert(make_status_policy(issuer));
|
||||
|
||||
let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err();
|
||||
// Either CurrentStatusRequiresJwks or MissingJwksConfig is correct here;
|
||||
// the current implementation returns CurrentStatusRequiresJwks.
|
||||
assert!(
|
||||
err == NipFiStartupError::CurrentStatusRequiresJwks
|
||||
|| err == NipFiStartupError::MissingJwksConfig,
|
||||
"expected a JWKS-missing error, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforce_current_status_with_jwks_passes() {
|
||||
let issuer = "https://id.example";
|
||||
let mut registry = IssuerRegistry::new();
|
||||
registry.insert(make_status_policy(issuer));
|
||||
|
||||
let jwks = vec![make_jwks_config(issuer)];
|
||||
assert!(validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_ok());
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user