diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index 65366ddef8..c21872351f 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -50,10 +50,10 @@ pub use nip_fi::{ 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, + JwksFetchError, JwksFetcher, JwksSourceContract, NipFiMode, NipFiStartupError, + ProductionJwksSource, RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass, + TransportContractId, VerifiedAssertion, VerifierError, CLIENT_ATTACHED_HEADER, + NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, }; #[cfg(any(test, feature = "test-utils"))] diff --git a/crates/buzz-auth/src/nip_fi/config.rs b/crates/buzz-auth/src/nip_fi/config.rs index 5c264b00ee..37628669a1 100644 --- a/crates/buzz-auth/src/nip_fi/config.rs +++ b/crates/buzz-auth/src/nip_fi/config.rs @@ -27,6 +27,8 @@ use sha2::{Digest, Sha256}; use std::collections::BTreeMap; use std::fmt; +use super::jwks::JwksSourceContract; + /// Maximum accepted length of an `iss` or `aud` string. const MAX_URI_LEN: usize = 2_048; /// Maximum accepted length of a claim name. @@ -349,6 +351,11 @@ pub struct IssuerPolicy { skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, + /// The authenticated key-source contract: validated JWKS URI, refresh + /// interval, and hard deadline. Included in `derive_assertion_policy_id` + /// so that a change to the endpoint, refresh schedule, or hard-deadline + /// rule changes the policy ID and invalidates all prepared evidence. + jwks_source_contract: JwksSourceContract, id: AssertionPolicyId, } @@ -382,6 +389,10 @@ pub enum IssuerPolicyError { /// so subject classification could not be total and mutually exclusive. #[error("subject class contract is not exclusive")] NonExclusiveSubjectClass, + /// The [`JwksSourceContract`] was not valid — invalid URI, zero or + /// out-of-range timing, or `refresh_interval >= hard_deadline`. + #[error("invalid JWKS source contract")] + InvalidJwksSourceContract, } impl IssuerPolicy { @@ -397,6 +408,7 @@ impl IssuerPolicy { skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, + jwks_source_contract: JwksSourceContract, ) -> Result { // Identity-bearing strings are validated for bounds but never mutated: // exact `iss`/`aud`/`sub` bytes select policies and form the identity @@ -459,6 +471,7 @@ impl IssuerPolicy { skew_seconds, maximum_assertion_age_seconds, maximum_status_age_seconds, + &jwks_source_contract, ); Ok(Self { @@ -471,6 +484,7 @@ impl IssuerPolicy { skew_seconds, maximum_assertion_age_seconds, maximum_status_age_seconds, + jwks_source_contract, id, }) } @@ -524,6 +538,11 @@ impl IssuerPolicy { pub const fn id(&self) -> AssertionPolicyId { self.id } + + /// The authenticated key-source contract for this policy's JWKS endpoint. + pub fn jwks_source_contract(&self) -> &JwksSourceContract { + &self.jwks_source_contract + } } /// A closed set of issuer policies keyed by exact `iss`. Selection preserves @@ -631,6 +650,7 @@ fn derive_assertion_policy_id( skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, + jwks_source_contract: &JwksSourceContract, ) -> AssertionPolicyId { let mut hasher = Sha256::new(); hasher.update(b"buzz:nip-fi:assertion-policy:v1\0"); @@ -686,6 +706,23 @@ fn derive_assertion_policy_id( hasher.update(skew_seconds.to_be_bytes()); hasher.update(maximum_assertion_age_seconds.to_be_bytes()); hasher.update(maximum_status_age_seconds.unwrap_or(0).to_be_bytes()); + // Authenticated key-source contract (NIP-FI.md, "Policy identity and + // snapshots"): URI selects the authenticated source; interval defines + // bounded refresh; hard deadline defines the accepted time rule. These are + // contract, not mutable state — key rotation (JWKS content change) leaves + // all three unchanged and must not move the ID. + hasher.update(b"jwks-source-contract\0"); + hash_field(&mut hasher, jwks_source_contract.jwks_uri().as_bytes()); + hasher.update( + jwks_source_contract + .refresh_interval_seconds() + .to_be_bytes(), + ); + hasher.update( + jwks_source_contract + .key_snapshot_hard_deadline_seconds() + .to_be_bytes(), + ); AssertionPolicyId(hasher.finalize().into()) } diff --git a/crates/buzz-auth/src/nip_fi/jwks/mod.rs b/crates/buzz-auth/src/nip_fi/jwks/mod.rs index c6db1bc6f3..8a00b8e86b 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/mod.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/mod.rs @@ -85,6 +85,100 @@ pub fn validate_jwks_uri(uri: &str) -> Result<(), JwksFetchError> { Ok(()) } +/// The authenticated key-source contract owned by one [`IssuerPolicy`]. +/// +/// Encodes the three deployment-configured fields whose change alters which +/// keys the runtime trusts and how long it trusts them: +/// +/// - `jwks_uri` — selects the authenticated key source; a different endpoint +/// may serve different keys even for the same issuer. +/// - `refresh_interval_seconds` — defines bounded refresh behavior; a longer +/// interval allows stale keys to persist longer. +/// - `key_snapshot_hard_deadline_seconds` — defines the source's accepted +/// time rule; the per-snapshot absolute deadline that flows into every +/// sealed [`VerifiedAssertion`][crate::nip_fi::VerifiedAssertion]'s +/// revalidation dependencies derives from this. +/// +/// This type is the single source of truth for these fields. `IssuerJwksConfig` +/// is built from it (pairing it with the bare issuer string) rather than +/// independently restating the same values. Having both types carry independent +/// copies of these fields would let them drift silently; startup validation +/// detects any mismatch that a compatibility path temporarily introduces. +/// +/// All three fields are validated at construction — an invalid value is caught +/// at configuration time, not at first token verification. +/// +/// ## Why these fields are contract, not mutable state +/// +/// Per the settled NIP-FI spec ("Policy identity and snapshots"): +/// `assertion_policy_id` covers "authenticated key/status-source contracts" +/// and "time rules". Key additions/removals (JWKS rotation) and per-snapshot +/// deadlines remain *revalidation dependencies* — they change per-token state +/// without changing the contract. These three fields define what the contract +/// *is*; JWKS content is what the contract currently *says*. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JwksSourceContract { + /// Validated JWKS endpoint URI (HTTPS, no credentials/fragment, no bare + /// private/reserved-IP host). Stored exactly as validated — canonical form + /// used verbatim in the hash. + jwks_uri: String, + /// Positive, ≤ [`MAX_JWKS_TIMING_SECONDS`], strictly less than + /// `key_snapshot_hard_deadline_seconds`. + refresh_interval_seconds: u64, + /// Positive, ≤ [`MAX_JWKS_TIMING_SECONDS`], strictly greater than + /// `refresh_interval_seconds`. + key_snapshot_hard_deadline_seconds: u64, +} + +impl JwksSourceContract { + /// Validate and seal the three JWKS source fields. + /// + /// Rejects: + /// - `jwks_uri` that fails [`validate_jwks_uri`] + /// - zero `refresh_interval_seconds` or `key_snapshot_hard_deadline_seconds` + /// - `refresh_interval_seconds >= key_snapshot_hard_deadline_seconds` (the + /// hard deadline must be strictly greater so a snapshot is fresh for at + /// least one refresh cycle) + /// - either timing field exceeding [`MAX_JWKS_TIMING_SECONDS`] + pub fn new( + jwks_uri: String, + refresh_interval_seconds: u64, + key_snapshot_hard_deadline_seconds: u64, + ) -> Option { + if refresh_interval_seconds == 0 + || key_snapshot_hard_deadline_seconds == 0 + || key_snapshot_hard_deadline_seconds <= refresh_interval_seconds + || refresh_interval_seconds > MAX_JWKS_TIMING_SECONDS + || key_snapshot_hard_deadline_seconds > MAX_JWKS_TIMING_SECONDS + { + return None; + } + if validate_jwks_uri(&jwks_uri).is_err() { + return None; + } + Some(Self { + jwks_uri, + refresh_interval_seconds, + key_snapshot_hard_deadline_seconds, + }) + } + + /// The validated JWKS endpoint URI. + pub fn jwks_uri(&self) -> &str { + &self.jwks_uri + } + + /// Seconds between successive JWKS refreshes. + pub const fn refresh_interval_seconds(&self) -> u64 { + self.refresh_interval_seconds + } + + /// Hard upper bound (from fetch time) on how long a snapshot may be served. + pub const fn key_snapshot_hard_deadline_seconds(&self) -> u64 { + self.key_snapshot_hard_deadline_seconds + } +} + /// Resolve `host:port` to IP addresses and reject if any are private/reserved. /// /// Returns the first safe address for DNS pinning. Blocks on the OS resolver @@ -163,26 +257,24 @@ impl IssuerState { } } -/// Per-issuer JWKS endpoint configuration. All fields are validated by -/// [`validate_jwks_uri`] and timing bounds at [`ProductionJwksSource::new`]. +/// Per-issuer JWKS endpoint configuration. Pairs the exact `iss` value with +/// the policy-owned [`JwksSourceContract`] that was already validated at +/// [`IssuerPolicy`][super::config::IssuerPolicy] construction. +/// +/// `IssuerJwksConfig` is the single combination of issuer string and contract +/// that `ProductionJwksSource` operates on. Because the contract fields are +/// sealed inside [`JwksSourceContract`] and validated there, this type carries +/// no independent copies of those values — startup validation enforces that the +/// contract embedded here matches the one carried by the corresponding policy. #[derive(Debug, Clone)] pub struct IssuerJwksConfig { /// The exact `iss` value this config authenticates. Must match the /// corresponding [`IssuerPolicy`][super::config::IssuerPolicy] exactly. pub issuer: String, - /// Must pass [`validate_jwks_uri`]: HTTPS, no credentials/fragment, no - /// bare private-IP host. - pub jwks_uri: String, - /// Seconds until a cached snapshot is considered stale and re-fetching is - /// triggered. Must be positive, strictly less than - /// `key_snapshot_hard_deadline_seconds`, and ≤ [`MAX_JWKS_TIMING_SECONDS`]. - pub refresh_interval_seconds: u64, - /// Hard upper bound from fetch time on how long a snapshot may be served. - /// Expired snapshots are never returned, even on fetch error — no stale - /// fallback. Folds into every `AssertionKeySet` hard deadline and therefore - /// into every `VerifiedAssertion.revalidation_dependencies`. - /// Must be ≤ [`MAX_JWKS_TIMING_SECONDS`]. - pub key_snapshot_hard_deadline_seconds: u64, + /// The validated key-source contract owned by the matching policy. Carries + /// the JWKS URI, refresh interval, and hard deadline — validated at + /// [`JwksSourceContract::new`], not re-validated here. + pub contract: JwksSourceContract, } /// Reason a JWKS fetch or parse operation failed. No key material, issuer @@ -379,9 +471,12 @@ pub struct ProductionJwksSource { } impl ProductionJwksSource { - /// Returns `None` when `configs` is empty, any config has invalid timing - /// bounds or fails URI validation, or any two configs share the same - /// `issuer` (duplicate issuers make trust configuration ambiguous). + /// Returns `None` when `configs` is empty or any two configs share the + /// same `issuer` (duplicate issuers make trust configuration ambiguous). + /// + /// Contract fields (`jwks_uri`, `refresh_interval_seconds`, + /// `key_snapshot_hard_deadline_seconds`) are pre-validated inside the + /// embedded [`JwksSourceContract`] — no re-validation is performed here. pub fn new(configs: Vec, fetcher: F) -> Option { if configs.is_empty() { return None; @@ -389,19 +484,6 @@ impl ProductionJwksSource { 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 - || c.refresh_interval_seconds > MAX_JWKS_TIMING_SECONDS - || c.key_snapshot_hard_deadline_seconds > MAX_JWKS_TIMING_SECONDS - { - return None; - } - if validate_jwks_uri(&c.jwks_uri).is_err() { - return None; - } if config_map.contains_key(&c.issuer) { return None; } @@ -423,7 +505,7 @@ impl ProductionJwksSource { prev_generation: u64, ) -> Option<(CachedSnapshot, u64)> { let config = self.configs.get(issuer)?; - let body = match self.fetcher.fetch_jwks(&config.jwks_uri).await { + let body = match self.fetcher.fetch_jwks(config.contract.jwks_uri()).await { Ok(b) => b, Err(err) => { warn!(error = %err, "nip-fi jwks fetch failed; will use cached snapshot if live"); @@ -452,9 +534,9 @@ impl ProductionJwksSource { let now = Utc::now(); // MAX_JWKS_TIMING_SECONDS ≤ ~31.5M < i64::MAX, so this conversion is - // always safe for values that passed the bounds check in new(). - let deadline_secs = - i64::try_from(config.key_snapshot_hard_deadline_seconds).unwrap_or(i64::MAX / 2); + // always safe for values that passed the bounds check in JwksSourceContract::new(). + let deadline_secs = i64::try_from(config.contract.key_snapshot_hard_deadline_seconds()) + .unwrap_or(i64::MAX / 2); let hard_deadline = now + Duration::try_seconds(deadline_secs) .unwrap_or_else(|| Duration::seconds(i64::MAX / 2)); @@ -498,7 +580,7 @@ impl ProductionJwksSource { None => true, Some(ref cached) => { let age_secs = (now - cached.fetched_at).num_seconds().max(0) as u64; - age_secs >= config.refresh_interval_seconds + age_secs >= config.contract.refresh_interval_seconds() } }; diff --git a/crates/buzz-auth/src/nip_fi/jwks/tests.rs b/crates/buzz-auth/src/nip_fi/jwks/tests.rs index 8988733a71..9a39707ed2 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/tests.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/tests.rs @@ -29,19 +29,20 @@ fn minimal_jwks_json(kid: &str) -> String { 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, + contract: JwksSourceContract::new( + format!("https://{issuer}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid test contract"), } } -fn make_config_with_uri(issuer: &str, jwks_uri: &str) -> IssuerJwksConfig { - IssuerJwksConfig { +fn make_config_with_uri(issuer: &str, jwks_uri: &str) -> Option { + JwksSourceContract::new(jwks_uri.to_owned(), 300, 3600).map(|contract| IssuerJwksConfig { issuer: issuer.to_owned(), - jwks_uri: jwks_uri.to_owned(), - refresh_interval_seconds: 300, - key_snapshot_hard_deadline_seconds: 3600, - } + contract, + }) } #[tokio::test] @@ -134,49 +135,37 @@ async fn new_rejects_empty_configs() { 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, - key_snapshot_hard_deadline_seconds: 3600, - }; - assert!(ProductionJwksSource::new(vec![bad_config], fetcher).is_none()); +/// Timing validation is now performed by `JwksSourceContract::new`. These +/// tests verify the contract constructor rejects bad timing, since an invalid +/// contract prevents building an `IssuerJwksConfig` entirely. +#[test] +fn contract_rejects_refresh_ge_hard_deadline() { + assert!(JwksSourceContract::new( + "https://id.example/.well-known/jwks.json".to_owned(), + 3600, + 3600, + ) + .is_none()); } -#[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()); +#[test] +fn contract_rejects_zero_refresh_interval() { + assert!(JwksSourceContract::new( + "https://id.example/.well-known/jwks.json".to_owned(), + 0, + 3600, + ) + .is_none()); } -#[tokio::test] -async fn new_rejects_timing_above_maximum() { - let fetcher = FakeJwksFetcher { - body: Ok(minimal_jwks_json("k1")), - call_count: Arc::new(AtomicUsize::new(0)), - }; - let bad_config = IssuerJwksConfig { - issuer: "https://id.example".to_owned(), - jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), - refresh_interval_seconds: MAX_JWKS_TIMING_SECONDS + 1, - key_snapshot_hard_deadline_seconds: MAX_JWKS_TIMING_SECONDS + 2, - }; - assert!(ProductionJwksSource::new(vec![bad_config], fetcher).is_none()); +#[test] +fn contract_rejects_timing_above_maximum() { + assert!(JwksSourceContract::new( + "https://id.example/.well-known/jwks.json".to_owned(), + MAX_JWKS_TIMING_SECONDS + 1, + MAX_JWKS_TIMING_SECONDS + 2, + ) + .is_none()); } #[tokio::test] @@ -186,97 +175,64 @@ async fn new_rejects_duplicate_issuer() { call_count: Arc::new(AtomicUsize::new(0)), }; let issuer = "https://id.example"; - let config_a = IssuerJwksConfig { - issuer: issuer.to_owned(), - jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), - refresh_interval_seconds: 300, - key_snapshot_hard_deadline_seconds: 3600, - }; + let config_a = make_config(issuer); let config_b = IssuerJwksConfig { issuer: issuer.to_owned(), - jwks_uri: "https://id.example/.well-known/jwks-alt.json".to_owned(), - refresh_interval_seconds: 600, - key_snapshot_hard_deadline_seconds: 7200, + contract: JwksSourceContract::new( + "https://id.example/.well-known/jwks-alt.json".to_owned(), + 600, + 7200, + ) + .unwrap(), }; assert!(ProductionJwksSource::new(vec![config_a, config_b], fetcher).is_none()); } -#[tokio::test] -async fn new_rejects_non_https_jwks_uri() { - let fetcher = FakeJwksFetcher { - body: Ok(minimal_jwks_json("k1")), - call_count: Arc::new(AtomicUsize::new(0)), - }; - assert!(ProductionJwksSource::new( - vec![make_config_with_uri( - "https://id.example", - "http://id.example/.well-known/jwks.json" - )], - fetcher +/// URI validation is now performed by `JwksSourceContract::new`; an invalid +/// URI makes the contract `None` and prevents an `IssuerJwksConfig` from being +/// built at all. The tests below verify that `JwksSourceContract::new` rejects +/// the same invalid URIs that `ProductionJwksSource::new` previously checked. +#[test] +fn contract_rejects_non_https_jwks_uri() { + assert!(make_config_with_uri( + "https://id.example", + "http://id.example/.well-known/jwks.json" ) .is_none()); } -#[tokio::test] -async fn new_rejects_loopback_jwks_uri() { - let fetcher = FakeJwksFetcher { - body: Ok(minimal_jwks_json("k1")), - call_count: Arc::new(AtomicUsize::new(0)), - }; - assert!(ProductionJwksSource::new( - vec![make_config_with_uri( - "https://id.example", - "https://127.0.0.1/.well-known/jwks.json" - )], - fetcher +#[test] +fn contract_rejects_loopback_jwks_uri() { + assert!(make_config_with_uri( + "https://id.example", + "https://127.0.0.1/.well-known/jwks.json" ) .is_none()); } -#[tokio::test] -async fn new_rejects_private_ip_jwks_uri() { - let fetcher = FakeJwksFetcher { - body: Ok(minimal_jwks_json("k1")), - call_count: Arc::new(AtomicUsize::new(0)), - }; - assert!(ProductionJwksSource::new( - vec![make_config_with_uri( - "https://id.example", - "https://10.0.0.1/.well-known/jwks.json" - )], - fetcher +#[test] +fn contract_rejects_private_ip_jwks_uri() { + assert!(make_config_with_uri( + "https://id.example", + "https://10.0.0.1/.well-known/jwks.json" ) .is_none()); } -#[tokio::test] -async fn new_rejects_jwks_uri_with_credentials() { - let fetcher = FakeJwksFetcher { - body: Ok(minimal_jwks_json("k1")), - call_count: Arc::new(AtomicUsize::new(0)), - }; - assert!(ProductionJwksSource::new( - vec![make_config_with_uri( - "https://id.example", - "https://user:pass@id.example/.well-known/jwks.json" - )], - fetcher +#[test] +fn contract_rejects_jwks_uri_with_credentials() { + assert!(make_config_with_uri( + "https://id.example", + "https://user:pass@id.example/.well-known/jwks.json" ) .is_none()); } -#[tokio::test] -async fn new_rejects_jwks_uri_with_fragment() { - let fetcher = FakeJwksFetcher { - body: Ok(minimal_jwks_json("k1")), - call_count: Arc::new(AtomicUsize::new(0)), - }; - assert!(ProductionJwksSource::new( - vec![make_config_with_uri( - "https://id.example", - "https://id.example/.well-known/jwks.json#keys" - )], - fetcher +#[test] +fn contract_rejects_jwks_uri_with_fragment() { + assert!(make_config_with_uri( + "https://id.example", + "https://id.example/.well-known/jwks.json#keys" ) .is_none()); } @@ -323,9 +279,12 @@ async fn generation_stable_for_identical_document() { }; let config = IssuerJwksConfig { issuer: issuer.to_owned(), - jwks_uri: format!("https://{issuer}/.well-known/jwks.json"), - refresh_interval_seconds: 1, - key_snapshot_hard_deadline_seconds: 3600, + contract: JwksSourceContract::new( + format!("https://{issuer}/.well-known/jwks.json"), + 1, + 3600, + ) + .unwrap(), }; let source = ProductionJwksSource::new(vec![config], fetcher).unwrap(); @@ -372,9 +331,12 @@ async fn generation_advances_for_changed_document() { let config = IssuerJwksConfig { issuer: issuer.to_owned(), - jwks_uri: format!("https://{issuer}/.well-known/jwks.json"), - refresh_interval_seconds: 1, - key_snapshot_hard_deadline_seconds: 3600, + contract: JwksSourceContract::new( + format!("https://{issuer}/.well-known/jwks.json"), + 1, + 3600, + ) + .unwrap(), }; let source = ProductionJwksSource::new(vec![config], MultiBodyFetcher { bodies }).unwrap(); @@ -728,9 +690,12 @@ async fn expired_snapshot_never_served_after_hard_deadline() { let issuer = "https://id.example"; let config = IssuerJwksConfig { issuer: issuer.to_owned(), - jwks_uri: "https://id.example/.well-known/jwks.json".to_owned(), - refresh_interval_seconds: 1, - key_snapshot_hard_deadline_seconds: 2, + contract: JwksSourceContract::new( + "https://id.example/.well-known/jwks.json".to_owned(), + 1, + 2, + ) + .unwrap(), }; let bodies = Arc::new(std::sync::Mutex::new(vec![ Err::(JwksFetchError::NetworkError), @@ -837,6 +802,15 @@ async fn two_issuer_keys_and_generations_are_isolated() { } fn policy(issuer: &str, aud: &str) -> IssuerPolicy { + let contract = JwksSourceContract::new( + format!( + "https://{}/jwks.json", + issuer.trim_start_matches("https://") + ), + 1, + 3600, + ) + .expect("valid contract"); IssuerPolicy::new( issuer.to_owned(), vec![aud.to_owned()], @@ -847,6 +821,7 @@ async fn two_issuer_keys_and_generations_are_isolated() { 60, 3600, None, + contract, ) .expect("valid policy") } @@ -855,15 +830,21 @@ async fn two_issuer_keys_and_generations_are_isolated() { ( IssuerJwksConfig { issuer: issuer_a.to_owned(), - jwks_uri: "https://a.example/.well-known/jwks.json".to_owned(), - refresh_interval_seconds: 1, - key_snapshot_hard_deadline_seconds: 3600, + contract: JwksSourceContract::new( + "https://a.example/.well-known/jwks.json".to_owned(), + 1, + 3600, + ) + .unwrap(), }, IssuerJwksConfig { issuer: issuer_b.to_owned(), - jwks_uri: "https://b.example/.well-known/jwks.json".to_owned(), - refresh_interval_seconds: 1, - key_snapshot_hard_deadline_seconds: 3600, + contract: JwksSourceContract::new( + "https://b.example/.well-known/jwks.json".to_owned(), + 1, + 3600, + ) + .unwrap(), }, ) } @@ -1061,11 +1042,12 @@ async fn shared_arc_source_verifier_observes_rotation() { } } + let jwks_contract = + JwksSourceContract::new(format!("https://{issuer}/.well-known/jwks.json"), 1, 3600) + .unwrap(); let config = IssuerJwksConfig { issuer: issuer.to_owned(), - jwks_uri: format!("https://{issuer}/.well-known/jwks.json"), - refresh_interval_seconds: 1, - key_snapshot_hard_deadline_seconds: 3600, + contract: jwks_contract.clone(), }; // Wrap the source in Arc — this is the sharing path under test. @@ -1089,6 +1071,7 @@ async fn shared_arc_source_verifier_observes_rotation() { 60, 3600, None, + jwks_contract, ) .unwrap(), ); diff --git a/crates/buzz-auth/src/nip_fi/mod.rs b/crates/buzz-auth/src/nip_fi/mod.rs index 2f649f95a6..ce97709064 100644 --- a/crates/buzz-auth/src/nip_fi/mod.rs +++ b/crates/buzz-auth/src/nip_fi/mod.rs @@ -30,7 +30,8 @@ pub use discovery::{ AssertionFreshnessDiscovery, FederatedIdentityDiscovery, FreshnessClassDiscovery, }; pub use jwks::{ - HttpJwksFetcher, IssuerJwksConfig, JwksFetchError, JwksFetcher, ProductionJwksSource, + HttpJwksFetcher, IssuerJwksConfig, JwksFetchError, JwksFetcher, JwksSourceContract, + ProductionJwksSource, }; pub use startup::{validate_nip_fi_config, NipFiMode, NipFiStartupError}; pub use verifier::{AssertionKeySet, FederatedAssertionVerifier, IssuerKeySource, VerifierError}; diff --git a/crates/buzz-auth/src/nip_fi/startup/mod.rs b/crates/buzz-auth/src/nip_fi/startup/mod.rs index 15af19eb00..410c862ec7 100644 --- a/crates/buzz-auth/src/nip_fi/startup/mod.rs +++ b/crates/buzz-auth/src/nip_fi/startup/mod.rs @@ -7,7 +7,7 @@ //! (`FI-INV-14`, `FI-INV-15`). use super::config::{FreshnessClass, IssuerRegistry}; -use super::jwks::{validate_jwks_uri, IssuerJwksConfig, MAX_JWKS_TIMING_SECONDS}; +use super::jwks::IssuerJwksConfig; /// Variant names are stable contract values; do not rename without a /// `VERIFIER_CONTRACT_VERSION` bump. @@ -46,15 +46,11 @@ pub enum NipFiStartupError { #[error("NIP-FI JWKS config issuer does not match any registered policy")] UnmatchedJwksConfig, - /// `refresh_interval_seconds` is zero, exceeds [`MAX_JWKS_TIMING_SECONDS`], - /// or is ≥ `key_snapshot_hard_deadline_seconds`. - #[error("NIP-FI JWKS config has invalid timing bounds")] - InvalidJwksTiming, - - /// Non-HTTPS scheme, embedded credentials, fragment, or bare - /// private/reserved IP host. See [`validate_jwks_uri`]. - #[error("NIP-FI JWKS URI failed safety validation")] - InvalidJwksUri, + /// The `JwksSourceContract` embedded in the `IssuerJwksConfig` does not + /// match the contract in the corresponding `IssuerPolicy`. Both must carry + /// exactly the same contract to keep a single source of truth per issuer. + #[error("NIP-FI JWKS config contract does not match the registered policy contract")] + JwksContractMismatch, /// `current-status` requires an authenticated status witness that is not /// yet implemented. Use `FreshnessClass::OfflineJwt` instead. @@ -114,16 +110,14 @@ pub fn validate_nip_fi_config( if registry.policy_for_issuer(&config.issuer).is_none() { return Err(NipFiStartupError::UnmatchedJwksConfig); } - if config.refresh_interval_seconds == 0 - || config.key_snapshot_hard_deadline_seconds == 0 - || config.key_snapshot_hard_deadline_seconds <= config.refresh_interval_seconds - || config.refresh_interval_seconds > MAX_JWKS_TIMING_SECONDS - || config.key_snapshot_hard_deadline_seconds > MAX_JWKS_TIMING_SECONDS - { - return Err(NipFiStartupError::InvalidJwksTiming); - } - if validate_jwks_uri(&config.jwks_uri).is_err() { - return Err(NipFiStartupError::InvalidJwksUri); + // Contract fields are pre-validated inside `JwksSourceContract::new` + // at `IssuerPolicy` construction. Enforce that the config carries the + // same contract as the policy — a mismatch would mean two independent + // copies of the URI/timing drifted apart, violating the single-source- + // of-truth invariant. + let policy = registry.policy_for_issuer(&config.issuer).unwrap(); + if &config.contract != policy.jwks_source_contract() { + return Err(NipFiStartupError::JwksContractMismatch); } } diff --git a/crates/buzz-auth/src/nip_fi/startup/tests.rs b/crates/buzz-auth/src/nip_fi/startup/tests.rs index 12455f98b0..04b28a7b96 100644 --- a/crates/buzz-auth/src/nip_fi/startup/tests.rs +++ b/crates/buzz-auth/src/nip_fi/startup/tests.rs @@ -1,8 +1,19 @@ use super::*; use crate::nip_fi::config::{FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass}; -use crate::nip_fi::jwks::IssuerJwksConfig; +use crate::nip_fi::jwks::{IssuerJwksConfig, JwksSourceContract}; use jsonwebtoken::Algorithm as JwtAlgorithm; +fn test_contract(issuer: &str) -> JwksSourceContract { + // Build a canonical JWKS URI from the issuer URL. The issuer may already + // be a full HTTPS URL (e.g. "https://id.example") or a bare hostname. + let uri = if issuer.starts_with("https://") { + format!("{}/.well-known/jwks.json", issuer.trim_end_matches('/')) + } else { + format!("https://{}/.well-known/jwks.json", issuer) + }; + JwksSourceContract::new(uri, 300, 3600).expect("valid test contract") +} + fn make_offline_policy(issuer: &str) -> IssuerPolicy { IssuerPolicy::new( issuer.to_owned(), @@ -14,6 +25,7 @@ fn make_offline_policy(issuer: &str) -> IssuerPolicy { 0, 3600, None, + test_contract(issuer), ) .unwrap() } @@ -29,6 +41,7 @@ fn make_status_policy(issuer: &str) -> IssuerPolicy { 0, 3600, Some(60), + test_contract(issuer), ) .unwrap() } @@ -36,9 +49,7 @@ fn make_status_policy(issuer: &str) -> IssuerPolicy { 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, + contract: test_contract(issuer), } } @@ -112,39 +123,28 @@ fn enforce_unmatched_jwks_config_rejects() { assert_eq!(err, NipFiStartupError::UnmatchedJwksConfig); } +/// A JWKS config whose contract differs from the policy contract must be +/// rejected — a mismatch means two independent copies of URI/timing have +/// drifted, violating the single-source-of-truth invariant. #[test] -fn enforce_refresh_equals_hard_deadline_rejects() { +fn enforce_jwks_contract_mismatch_rejects() { let issuer = "https://id.example"; let mut registry = IssuerRegistry::new(); registry.insert(make_offline_policy(issuer)); - let jwks = vec![IssuerJwksConfig { + // Config carries a different refresh interval than the policy (300 vs 600). + let mismatched_config = 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, - }]; + contract: JwksSourceContract::new( + format!("{}/.well-known/jwks.json", issuer.trim_end_matches('/')), + 600, // differs from policy contract (300) + 3600, + ) + .unwrap(), + }; assert_eq!( - validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_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, - }]; - assert_eq!( - validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(), - NipFiStartupError::InvalidJwksTiming + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[mismatched_config]).unwrap_err(), + NipFiStartupError::JwksContractMismatch ); } @@ -180,39 +180,3 @@ fn enforce_duplicate_jwks_issuer_in_configs_rejects() { "duplicate JWKS configs must not pass" ); } - -#[test] -fn enforce_non_https_jwks_uri_rejects() { - let issuer = "https://id.example"; - let mut registry = IssuerRegistry::new(); - registry.insert(make_offline_policy(issuer)); - - let jwks = vec![IssuerJwksConfig { - issuer: issuer.to_owned(), - jwks_uri: "http://id.example/.well-known/jwks.json".to_owned(), - refresh_interval_seconds: 300, - key_snapshot_hard_deadline_seconds: 3600, - }]; - assert_eq!( - validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(), - NipFiStartupError::InvalidJwksUri - ); -} - -#[test] -fn enforce_loopback_jwks_uri_rejects() { - let issuer = "https://id.example"; - let mut registry = IssuerRegistry::new(); - registry.insert(make_offline_policy(issuer)); - - let jwks = vec![IssuerJwksConfig { - issuer: issuer.to_owned(), - jwks_uri: "https://127.0.0.1/.well-known/jwks.json".to_owned(), - refresh_interval_seconds: 300, - key_snapshot_hard_deadline_seconds: 3600, - }]; - assert_eq!( - validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).unwrap_err(), - NipFiStartupError::InvalidJwksUri - ); -} diff --git a/crates/buzz-auth/src/nip_fi/verifier/tests.rs b/crates/buzz-auth/src/nip_fi/verifier/tests.rs index 316681e0af..c3a26cdbc8 100644 --- a/crates/buzz-auth/src/nip_fi/verifier/tests.rs +++ b/crates/buzz-auth/src/nip_fi/verifier/tests.rs @@ -28,6 +28,17 @@ const TEST_KID: &str = "test-key-1"; const ISSUER: &str = "https://issuer.example"; const AUDIENCE: &str = "https://relay.example"; +/// A canonical JWKS contract for the default test issuer. Used wherever a +/// `JwksSourceContract` is required but JWKS behavior is not under test. +fn test_jwks_contract() -> crate::nip_fi::jwks::JwksSourceContract { + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .expect("valid test contract") +} + // A second, independent P-256 key: issuer B's real signing key, used to prove // that a token signed by B and claiming `iss=A` cannot mint an A identity. const TEST_EC_PKCS8_PEM_B: &str = "-----BEGIN PRIVATE KEY-----\n\ @@ -102,11 +113,18 @@ fn access_token_policy_with(subject_class: SubjectClassContract) -> IssuerPolicy 60, 3600, None, + test_jwks_contract(), ) .expect("valid policy") } fn dedicated_policy(issuer: &str) -> IssuerPolicy { + let contract = crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", issuer.trim_end_matches('/')), + 300, + 3600, + ) + .expect("valid test contract"); IssuerPolicy::new( issuer.to_owned(), vec![AUDIENCE.to_owned()], @@ -117,6 +135,7 @@ fn dedicated_policy(issuer: &str) -> IssuerPolicy { 60, 3600, None, + contract, ) .expect("valid policy") } @@ -132,6 +151,7 @@ fn dedicated_policy_with_audiences(audiences: Vec) -> IssuerPolicy { 60, 3600, None, + test_jwks_contract(), ) .expect("valid policy") } @@ -147,6 +167,7 @@ fn dedicated_policy_with_algorithms(algorithms: Vec) -> IssuerPolicy 60, 3600, None, + test_jwks_contract(), ) .expect("valid policy") } @@ -688,6 +709,7 @@ fn missing_nostr_pubkey_denies_under_attested_key_policy() { 60, 3600, None, + test_jwks_contract(), ) .unwrap(); let verifier = verifier_with(policy); @@ -1087,6 +1109,7 @@ fn current_status_policy() -> IssuerPolicy { 60, 3600, Some(120), // maximum_status_age required for current-status + test_jwks_contract(), ) .expect("valid current-status policy") } @@ -1366,6 +1389,7 @@ fn assertion_policy_id_is_deterministic_and_semantic() { 120, // different skew => different semantics 3600, None, + test_jwks_contract(), ) .unwrap(); assert_ne!(p1.id(), changed.id()); @@ -1391,6 +1415,7 @@ fn offline_policy_rejects_inapplicable_maximum_status_age() { 60, 3600, Some(120), + test_jwks_contract(), ) .unwrap_err(); assert_eq!(err, IssuerPolicyError::InapplicableMaximumStatusAge); @@ -1409,6 +1434,7 @@ fn offline_policy_accepts_absent_maximum_status_age() { 60, 3600, None, + test_jwks_contract(), ) .is_ok()); } @@ -1427,6 +1453,7 @@ fn current_status_policy_still_requires_positive_maximum_status_age() { 60, 3600, None, + test_jwks_contract(), ) .unwrap_err(); assert_eq!(missing, IssuerPolicyError::MissingMaximumStatusAge); @@ -1440,6 +1467,7 @@ fn current_status_policy_still_requires_positive_maximum_status_age() { 60, 3600, Some(0), + test_jwks_contract(), ) .unwrap_err(); assert_eq!(zero, IssuerPolicyError::InvalidTimeBounds); @@ -1533,7 +1561,188 @@ fn assertion_policy_id_is_invariant_under_subject_class_value_permutation_and_du assert_eq!(base.id(), permuted.id()); } -// ---- Canonical scope capture --------------------------------------------- +// ---- JwksSourceContract in AssertionPolicyId ------------------------------ +// +// Per the NIP-FI spec ("Policy identity and snapshots"): `assertion_policy_id` +// covers "authenticated key/status-source contracts" and "time rules". The +// three contract fields are immutable contract identity, not mutable state — +// changing any one of them changes which keys the runtime trusts or how long +// it trusts them, invalidating all prepared evidence against the old contract. +// Key rotation (JWKS content change) leaves all three unchanged and must NOT +// move the ID. + +/// Helper: build a policy with the given `JwksSourceContract`. +fn policy_with_contract(contract: crate::nip_fi::jwks::JwksSourceContract) -> IssuerPolicy { + IssuerPolicy::new( + ISSUER.to_owned(), + vec![AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + false, + 60, + 3600, + None, + contract, + ) + .expect("valid policy") +} + +#[test] +fn assertion_policy_id_moves_when_jwks_uri_changes() { + // The JWKS URI selects the authenticated key source. A different URI may + // serve different keys — the policy ID must change. + // + // Mutation (omit URI from hash): both policies hash identically despite + // different endpoints; this test turns red. + let base = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + let different_uri = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks-alt.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + assert_ne!( + base.id(), + different_uri.id(), + "JWKS URI change must move assertion_policy_id" + ); +} + +#[test] +fn assertion_policy_id_moves_when_refresh_interval_changes() { + // The refresh interval defines bounded refresh behavior. A longer interval + // allows stale keys to persist longer — the policy ID must change. + // + // Mutation (omit refresh_interval from hash): both policies hash + // identically; this test turns red. + let base = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + let different_interval = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 600, // doubled + 3600, + ) + .unwrap(), + ); + assert_ne!( + base.id(), + different_interval.id(), + "refresh_interval_seconds change must move assertion_policy_id" + ); +} + +#[test] +fn assertion_policy_id_moves_when_hard_deadline_changes() { + // The hard deadline defines the source's accepted time rule; every + // per-snapshot deadline the verifier seals into `VerifiedAssertion` + // derives from this. A looser deadline extends the valid window beyond + // what the new policy intends — the policy ID must change. + // + // Mutation (omit key_snapshot_hard_deadline from hash): both policies + // hash identically; this test turns red. + let base = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + let different_deadline = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 7200, // doubled + ) + .unwrap(), + ); + assert_ne!( + base.id(), + different_deadline.id(), + "key_snapshot_hard_deadline_seconds change must move assertion_policy_id" + ); +} + +#[test] +fn assertion_policy_id_is_stable_for_same_jwks_contract() { + // URI canonicalization is deterministic: the same validated URI, interval, + // and deadline always hash to the same policy ID regardless of call order. + let c1 = crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(); + let c2 = crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(); + let p1 = policy_with_contract(c1); + let p2 = policy_with_contract(c2); + assert_eq!( + p1.id(), + p2.id(), + "same JWKS contract must produce identical assertion_policy_id" + ); +} + +#[test] +fn key_rotation_does_not_change_assertion_policy_id() { + // Key additions/removals (JWKS rotation) change per-token state via the + // generation counter and `AssertionKeySet` content, but must NOT change + // the policy's `AssertionPolicyId`. The ID is built from the contract + // fields only — not from key material. + // + // This test proves the invariant at the `IssuerPolicy` level: constructing + // two policies with the same contract and different issuers (simulating + // a rotated JWKS) must produce the same ID if and only if all contract + // fields are identical. Because `IssuerPolicy` is a sealed type and JWKS + // key material never flows into `derive_assertion_policy_id`, we verify + // the invariant by constructing the same policy twice and confirming the + // ID is stable across calls. + let p1 = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + let p2 = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + // Identical contract, identical policy — the ID is the same even if JWKS + // content would differ at runtime (keys are not part of the hash input). + assert_eq!( + p1.id(), + p2.id(), + "key rotation must not change assertion_policy_id (key material is not hashed)" + ); +} #[test] fn scope_capture_is_canonical_under_order_and_duplicates() {