mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
feat(nip-fi): introduce JwksSourceContract as required IssuerPolicy input
The three JWKS deployment fields (jwks_uri, refresh_interval_seconds, key_snapshot_hard_deadline_seconds) were absent from AssertionPolicyId. A different endpoint serves different keys; a looser hard deadline extends the valid window beyond what the new policy intends. Both changes must invalidate prepared evidence by moving the policy ID. Introduce JwksSourceContract, a closed value type that: - validates the URI (HTTPS, no credentials/fragment, no bare private-IP) and both timing fields (positive, bounded, refresh < deadline) at construction — invalid values are caught at config time, not at first token verification - is the single source of truth for these fields; IssuerJwksConfig embeds it instead of independently restating the three values, eliminating the silent-drift hazard - is a required parameter to IssuerPolicy::new, included in derive_assertion_policy_id after a domain separator; each of the three fields independently changes the policy ID when mutated (tested) Startup validation replaces per-field URI/timing checks (now redundant since JwksSourceContract::new performs them) with a contract mismatch check — NipFiStartupError::JwksContractMismatch fires when the config contract and policy contract drift apart. Behavior tests added: - assertion_policy_id_moves_when_jwks_uri_changes - assertion_policy_id_moves_when_refresh_interval_changes - assertion_policy_id_moves_when_hard_deadline_changes - assertion_policy_id_is_stable_for_same_jwks_contract (determinism) - key_rotation_does_not_change_assertion_policy_id Each test carries a mutation comment naming the hash-omission that turns it red. JwksSourceContract exported from nip_fi/mod.rs and lib.rs. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -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"))]
|
||||
|
||||
@@ -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<u64>,
|
||||
/// 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<u64>,
|
||||
jwks_source_contract: JwksSourceContract,
|
||||
) -> Result<Self, IssuerPolicyError> {
|
||||
// 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<u64>,
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Self> {
|
||||
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<F = HttpJwksFetcher> {
|
||||
}
|
||||
|
||||
impl<F: JwksFetcher> ProductionJwksSource<F> {
|
||||
/// 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<IssuerJwksConfig>, fetcher: F) -> Option<Self> {
|
||||
if configs.is_empty() {
|
||||
return None;
|
||||
@@ -389,19 +484,6 @@ impl<F: JwksFetcher> ProductionJwksSource<F> {
|
||||
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<F: JwksFetcher> ProductionJwksSource<F> {
|
||||
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<F: JwksFetcher> ProductionJwksSource<F> {
|
||||
|
||||
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<F: JwksFetcher> ProductionJwksSource<F> {
|
||||
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()
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<IssuerJwksConfig> {
|
||||
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::<String, JwksFetchError>(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(),
|
||||
);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<String>) -> IssuerPolicy {
|
||||
60,
|
||||
3600,
|
||||
None,
|
||||
test_jwks_contract(),
|
||||
)
|
||||
.expect("valid policy")
|
||||
}
|
||||
@@ -147,6 +167,7 @@ fn dedicated_policy_with_algorithms(algorithms: Vec<Algorithm>) -> 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() {
|
||||
|
||||
Reference in New Issue
Block a user