feat(buzz-auth): add production NIP-FI federated assertion runtime (#7109)

Depends on #6994 (merged).

Stack: PR 2 #6994 (merged) → this PR (#7109) → PR 4 #7148 → PR 5

## What

Production assertion runtime for NIP-FI Phase A: JWKS caching layer,
SSRF-hardened HTTP fetcher, startup validation gate, NIP-11 discovery
serialization, federated assertion verifier with sealed key-source
authority, and supporting invariant tests.

## Changes

### `crates/buzz-auth/src/nip_fi/jwks/`

`ProductionJwksSource<F>` implements the sealed `IssuerKeySource` trait:

- `HttpJwksFetcher`: reqwest-backed fetch with SSRF protection — URI
validation (HTTPS, no credentials, no fragment, no host matched by the
shared enumerated deny policy), per-fetch DNS resolution rejecting any
resolved address matched by the shared enumerated deny policy, address
pinning to prevent DNS rebinding TOCTOU, redirect denial, incremental
body streaming capped at 512 KiB before any parse
- IPv6 host extraction via typed `Url::host()` accessor (strips brackets
before SSRF check and provides the correct bare input form for reqwest
`resolve()` pinning; the bracketed form from `host_str()` fails
`IpAddr::parse` and does not match the URL authority key)
- Complete-operation deadline via `tokio::time::timeout` covering DNS
resolution through body streaming
- Bounded periodic refresh with configurable interval and hard snapshot
deadline
- Cancellation-safe RAII refresh permit: dropped on future cancellation
so the next caller can re-fetch
- Content-digest-gated generation counter: identical re-fetches preserve
generation; key rotations advance it
- Injectable clock (`now_fn: Arc<dyn Fn() -> DateTime<Utc> + Send +
Sync>`): production uses `Arc::new(Utc::now)`; all four deadline
creation and expiry checks use `(self.now_fn)()`, enabling
controlled-time testing without wall-clock sleep

`JwksSourceContract`: a closed value type that is the single source of
truth for the three deployment fields whose change alters which keys the
runtime trusts and how long it trusts them:

- `jwks_uri` — selects the authenticated key source; validated at
construction (HTTPS, no credentials/fragment, no bare private-IP host);
stored as the `Url`-normalized form so that equivalent spellings
(uppercase host, explicit default port `:443`, dot-segment paths like
`/.well-known/./jwks.json`) converge to the same `AssertionPolicyId`
- `refresh_interval_seconds` — defines bounded refresh behavior;
positive, ≤ 1 year, strictly < `key_snapshot_hard_deadline_seconds`
- `key_snapshot_hard_deadline_seconds` — defines the source's accepted
time rule; every `VerifiedAssertion.revalidation_dependencies` deadline
derives from this

`JwksSourceContract` is a required `IssuerPolicy` input and is included
in `derive_assertion_policy_id` after a domain separator.
`IssuerJwksConfig` embeds the contract instead of independently
restating these fields — startup validation rejects any contract
mismatch (`NipFiStartupError::JwksContractMismatch`).

### `crates/buzz-auth/src/nip_fi/verifier.rs`

- `FederatedAssertionVerifier<S>`: provider-neutral verifier over a
closed multi-issuer registry and sealed `IssuerKeySource`
- `Arc<S>: IssuerKeySource` forwarding impl (blanket seal for `Arc<S>`
in the sealed module) — one `Arc<ProductionJwksSource>` can be shared
across multiple verifiers; all observe JWKS refreshes through the shared
cache without rebuilding the verifier
- `AssertionKeySet`: crate-private constructor seals issuer binding — no
external crate can relabel issuer B's JWKS as issuer A
- Sealed `IssuerKeySource` trait closes the authority-construction seam
at both ends

### `crates/buzz-core/src/network.rs`

Renamed `is_private_ip` to `is_not_global_unicast` (compat alias
retained) and restored the complete IANA deny/exception table from this
branch's own history (`272dacadb`). The predicate is an enumerated
deny/explicit exception policy: addresses covered by a named deny rule
are rejected; addresses not covered by any explicit deny rule (e.g.
`fe00::1`) pass through. Deny rules are derived from the IANA
Special-Purpose Address Space registries (last updated 2025-10-09), with
globally-reachable exceptions carved out explicitly (e.g. PCP/TURN
anycast inside 2001::/23).

Blocked IPv4 classes: loopback (127/8), private RFC 1918 (10/8,
172.16/12, 192.168/16), link-local (169.254/16), unspecified (0/8),
broadcast, CGNAT/RFC 6598 (100.64/10), benchmarking/RFC 2544
(198.18/15), IETF Protocol Assignments (192.0.0.0/24, globally reachable
exceptions: 192.0.0.9 PCP anycast RFC 7723 and 192.0.0.10 TURN anycast
RFC 8155), documentation/RFC 5737 (192.0.2/24, 198.51.100/24,
203.0.113/24), deprecated 6to4 relay anycast (192.88.99.0/24, RFC 7526,
global=None → conservative deny), multicast/RFC 5771 (224/4), reserved
class-E (240/4).

Blocked IPv6 classes: loopback (::1), unspecified (::), ULA (fc00::/7),
link-local (fe80::/10), deprecated site-local (fec0::/10, RFC 3879),
multicast (ff00::/8), IETF Protocol Assignments envelope (2001::/23,
globally reachable exceptions: 2001:1::1–::3 PCP/TURN/DNS-SD anycast,
2001:3::/32 AMT RFC 7450, 2001:4:112::/48 AS112-v6 RFC 7535,
2001:20::/28 ORCHIDv2 RFC 7343, 2001:30::/28 DETs RFC 9374),
documentation (2001:db8::/32 RFC 3849, 3fff::/20 RFC 9637), 6to4
(2002::/16, RFC 3056), Discard-Only (100::/64, RFC 6666), Dummy IPv6
Prefix (100:0:0:1::/64, RFC 9780), SRv6 SIDs (5f00::/16, RFC 9252),
NAT64 local-use (64:ff9b:1::/48, RFC 8215). IPv4 embedded in mapped,
compatible, NAT64 well-known (64:ff9b::/96), and SIIT IPv4-translated
(::ffff:0:0:0/96) forms is checked recursively. All three callers (JWKS
boundary, webhook SSRF, link-preview SSRF) inherit the complete
predicate through the inline `is_private_ip` compatibility alias.

### Invariant coverage

**Canonical URI convergence.**
`jwks_contract_uri_canonicalization_convergence_and_divergence` asserts
that uppercase host, explicit `:443`, and dot-segment path
(`/.well-known/./jwks.json`) each produce the same `AssertionPolicyId`
as the canonical form; a genuinely different host or path diverges.
Mutation: storing raw input bytes instead of `parsed.to_string()` turns
the three convergence assertions red.

**Resolved-target and pin-input seam.**
`resolved_target_and_pin_key_seam_public_ipv6_and_fec0_rejection`
carries a public `2606:4700::1` URI through all three stages of
`fetch_jwks_inner`: `extract_url_host_and_port` yields the bare host (no
brackets), `resolve_and_check_ssrf` takes the IP-literal fast path and
returns the accepted `IpAddr`, and the extracted host string equals the
URL authority form (verifying the correct bare input to reqwest's
`resolve()` pin call). `fec0::1` traverses the same extraction and SSRF
stages and is rejected as `InvalidUri`. Network-free: both addresses are
IP literals with no DNS lookup. Mutation: restoring `host_str()`
brackets the address, `IpAddr::parse` fails, the SSRF fast path is
unreachable, and all three assertions flip red.

**Controlled original-deadline rotation.**
`shared_arc_source_verifier_rejects_expired_a1_accepts_a2` uses an
`AtomicI64`-backed injectable clock to advance past A1's original
absolute deadline without wall-clock sleep. A1's deadline is computed at
T0 and never mutated. The clock advances to T0 + HARD_DEADLINE_SECS + 1;
`get_snapshot` fires a re-fetch and installs A2. One unchanged
`FederatedAssertionVerifier` then rejects A1-signed tokens (deadline
enforced by the `key_set` read path) and accepts A2-signed tokens,
proves A2's generation is strictly greater, and confirms A2's deadline
is later than A1's original. Mutation oracle: replace the shared `Arc`
with an independently constructed source built from the same configs and
sharing the same controlled clock, warmed with a separate A1 fetch
before advancement. Post-advancement, `key_set()` on the verifier's
independent source filters the expired A1 snapshot (`filter(|c| now <
c.hard_deadline)`) and returns no keys — the verifier never re-fetches
and never observes A2. A1-reject stays green (the independent cache is
also expired, so no A1 keys are served), but A2-accept flips red,
because the verifier never observes A2. A2 acceptance is the reliable
shared-source oracle.

**Complete SSRF classifier boundary.** JWKS-boundary tests cover every
newly restored class through `validate_jwks_uri` (URI-validation path):
`192.0.0.1` (IETF Protocol Assignments interior), `192.0.0.9`/`.10`
(PCP/TURN anycast global exceptions), `192.88.99.1` (deprecated 6to4
anycast), `2001:2::1` (2001::/23 interior), `2001:1::1` (2001::/23
global exception), `100::1` (Discard-Only), `3fff::1` (documentation),
and `5f00::1` (SRv6 SIDs). URI validation and resolved-target
enforcement share the same `is_not_global_unicast` predicate, so these
URI-path tests exercise the complete classifier table. All pass
mutation: removing any deny branch makes the rejection assertion red;
removing any exception branch makes the acceptance assertion red. The
resolved-target enforcement path is covered separately by
`resolved_target_and_pin_key_seam_public_ipv6_and_fec0_rejection` for
`::1` (loopback), public `2606:4700::1`, and `fec0::1`.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com>
This commit is contained in:
Will Pfleger
2026-09-01 13:00:40 -04:00
committed by GitHub
parent ac4aa94b05
commit 70895b355f
15 changed files with 3409 additions and 352 deletions

View File

@@ -361,7 +361,7 @@ pub const ALL_KINDS: &[u32] // 80 entries (KIND_AUTH excluded — never stored)
|----------|---------|
| `filters_match(filters, event)` | OR across filters, AND within each filter. Includes NIP-01 prefix matching on event IDs. |
| `verify_event(event)` | Schnorr signature + SHA-256 ID check. CPU-bound — callers use `spawn_blocking`. |
| `is_private_ip(ip)` | SSRF protection: IPv4 unspecified/loopback/private/link-local/CGNAT/benchmarking/broadcast + IPv6 loopback/ULA/link-local/multicast/documentation + IPv4-mapped IPv6. |
| `is_not_global_unicast(ip)` | SSRF protection: enumerated-deny policy — blocks a specific set of non-public address classes and accepts everything else (including addresses not covered by an explicit deny rule, e.g. `fe00::1`). Blocked IPv4 classes: loopback, private (RFC 1918), link-local, CGNAT (RFC 6598), benchmarking (RFC 2544), IETF Protocol Assignments (192.0.0.0/24, exceptions: 192.0.0.9 PCP anycast, 192.0.0.10 TURN anycast), documentation (RFC 5737: 192.0.2/24, 198.51.100/24, 203.0.113/24), deprecated 6to4 relay anycast (192.88.99.0/24, RFC 7526), multicast (RFC 5771, 224/4), reserved/class-E (240/4). Blocked IPv6 classes: loopback, unspecified, ULA (fc00::/7), link-local (fe80::/10), deprecated site-local (fec0::/10, RFC 3879), multicast (ff00::/8), IETF Protocol Assignments envelope (2001::/23, global exceptions: 2001:1::1–::3 anycast, 2001:3::/32 AMT, 2001:4:112::/48 AS112-v6, 2001:20::/28 ORCHIDv2, 2001:30::/28 DETs), documentation (2001:db8::/32, 3fff::/20), 6to4 (2002::/16), Discard-Only (100::/64), Dummy prefix (100:0:0:1::/64), SRv6 SIDs (5f00::/16), NAT64 local-use (64:ff9b:1::/48). IPv4 embedded in mapped, compatible, NAT64 well-known (64:ff9b::/96), and SIIT IPv4-translated (::ffff:0:0:0/96) forms checked recursively. Compat alias: `is_private_ip`. |
**Does NOT:** store events, make network calls, spawn tasks, or depend on any async runtime.
@@ -746,12 +746,10 @@ Every security-sensitive operation uses an explicit, verified pattern. No implic
### SSRF Protection
`is_private_ip()` in `buzz-core` covers:
- IPv4: unspecified (0.0.0.0/8), loopback (127.0.0.0/8), private (10/8, 172.16/12, 192.168/16), link-local (169.254/16), CGNAT (100.64/10), benchmarking (198.18/15), broadcast (255.255.255.255)
- IPv6: loopback (::1), ULA (fc00::/7), link-local (fe80::/10), multicast (ff00::/8), documentation (2001:db8::/32)
- IPv4-mapped IPv6 (::ffff:0:0/96) — recursively checks the embedded IPv4 address
`is_not_global_unicast(ip)` (compat alias `is_private_ip`) in `buzz-core` is an enumerated-deny policy: it blocks a specific set of non-public address classes and accepts everything else, including addresses not covered by an explicit deny rule (e.g. `fe00::1`). Blocked IPv4 classes: loopback (127.0.0.0/8), private RFC 1918 (10/8, 172.16/12, 192.168/16), link-local (169.254/16), unspecified (0/8), broadcast, CGNAT/RFC 6598 (100.64/10), benchmarking/RFC 2544 (198.18/15), IETF Protocol Assignments (192.0.0.0/24, globally reachable exceptions: 192.0.0.9 PCP anycast RFC 7723 and 192.0.0.10 TURN anycast RFC 8155), documentation/RFC 5737 (192.0.2/24, 198.51.100/24, 203.0.113/24), deprecated 6to4 relay anycast (192.88.99.0/24, RFC 7526, global=None/blank → conservative deny), multicast/RFC 5771 (224/4), and reserved class-E (240/4). Blocked IPv6 classes: loopback (::1), unspecified (::), ULA (fc00::/7), link-local (fe80::/10), deprecated site-local (fec0::/10, RFC 3879), multicast (ff00::/8), IETF Protocol Assignments envelope (2001::/23, global exceptions: 2001:1::1–::3 PCP/TURN/DNS-SD anycast, 2001:3::/32 AMT RFC 7450, 2001:4:112::/48 AS112-v6 RFC 7535, 2001:20::/28 ORCHIDv2 RFC 7343, 2001:30::/28 DETs RFC 9374), documentation (2001:db8::/32 RFC 3849, 3fff::/20 RFC 9637), 6to4 (2002::/16, RFC 3056), Discard-Only (100::/64, RFC 6666), Dummy IPv6 Prefix (100:0:0:1::/64, RFC 9780), SRv6 SIDs (5f00::/16, RFC 9252), and NAT64 local-use (64:ff9b:1::/48, RFC 8215). IPv4 embedded in IPv4-mapped, IPv4-compatible, and NAT64 well-known (64:ff9b::/96, RFC 6052) forms is checked recursively against the IPv4 table; SIIT IPv4-translated (::ffff:0:0:0/96) follows the same path.
Applied in: `buzz-workflow` (CallWebhook action), `buzz-core` (shared utility).
Applied in: `buzz-auth` (JWKS boundary), `buzz-workflow` (CallWebhook action),
desktop `link_preview` (SSRF check).
### Audit Integrity

2
Cargo.lock generated
View File

@@ -939,10 +939,12 @@ dependencies = [
"base64 0.22.1",
"buzz-core",
"chrono",
"futures-util",
"hex",
"jsonwebtoken",
"nostr 0.44.7",
"rand 0.10.1",
"reqwest 0.13.4",
"serde",
"serde_json",
"sha2 0.11.0",

View File

@@ -104,7 +104,7 @@ chrono = { version = "0.4", features = ["serde"] }
jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs"] }
# HTTP client (webhook delivery)
reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false }
reqwest = { version = "0.13", features = ["json", "rustls", "stream"], default-features = false }
# Cryptography
sha2 = "0.11"

View File

@@ -14,6 +14,7 @@ dev = []
[dev-dependencies]
# `use_pem` enables EncodingKey::from_ec_pem for minting ES256 test assertions.
jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs", "use_pem"] }
tokio = { workspace = true, features = ["test-util"] }
[dependencies]
buzz-core = { workspace = true }
@@ -21,6 +22,8 @@ base64 = { workspace = true }
chrono = { workspace = true }
jsonwebtoken = { workspace = true }
nostr = { workspace = true }
futures-util = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }

View File

@@ -46,12 +46,14 @@ 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,
RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass, TransportContractId,
VerifiedAssertion, VerifierError, CLIENT_ATTACHED_HEADER, NOSTR_PUBKEY_CLAIM,
OAUTH_CLIENT_ID_CLAIM,
validate_nip_fi_config, AssertionKeySet, AssertionPolicyId, CanonicalCapabilities,
ClientSubjectPosture, ConfidentialAssertion, DenialClass, FederatedAssertionVerifier,
FederatedIdentity, FederatedIdentityDiscovery, FreshnessClass, HttpJwksFetcher,
IssuerJwksConfig, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry,
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"))]

View File

@@ -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
@@ -560,6 +579,12 @@ impl IssuerRegistry {
pub fn is_empty(&self) -> bool {
self.policies.is_empty()
}
/// Iteration order is deliberately unspecified; callers must not depend on
/// registration order.
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.
@@ -625,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");
@@ -680,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())
}

View File

@@ -0,0 +1,66 @@
//! NIP-11 federated-identity discovery output.
//!
//! [`FederatedIdentityDiscovery`] serializes to the `federated_identity`
//! object required by the NIP-FI.md "Discovery" section of the NIP-11 relay
//! information document.
//!
//! ## 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 in the `federated_identity` discovery
/// document. Describes the claimed freshness posture without exposing any
/// issuer or deployment-private state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssertionFreshnessDiscovery {
/// The wire string identifying the freshness class.
pub class: FreshnessClassDiscovery,
/// `null` for `offline-jwt`; advertising a finite bound here requires a
/// live status witness that is not yet implemented.
pub maximum_residual_upstream_revocation_seconds: Option<u64>,
}
/// The freshness class as a stable NIP-FI wire string.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FreshnessClassDiscovery {
/// No revocation bound is claimed; JWKS snapshot validation only.
OfflineJwt,
}
/// The `federated_identity` NIP-11 discovery object. Fields never expose
/// enrollment mode, issuer, audience, or private state.
/// [FI-TRACE-DISCOVERY-PRIVATE]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FederatedIdentityDiscovery {
/// Fixed value `"client-attached"` for the core NIP-FI transport mode.
pub core: String,
/// The freshness contract claimed by this deployment.
pub assertion_freshness: AssertionFreshnessDiscovery,
}
impl FederatedIdentityDiscovery {
/// The only supported posture: claims no residual revocation bound, which
/// is the honest description of JWKS-only assertion verification.
pub fn offline_jwt() -> Self {
Self {
core: "client-attached".to_owned(),
assertion_freshness: AssertionFreshnessDiscovery {
class: FreshnessClassDiscovery::OfflineJwt,
maximum_residual_upstream_revocation_seconds: None,
},
}
}
}

View File

@@ -0,0 +1,738 @@
//! JWKS discovery, snapshot caching, and the production [`IssuerKeySource`]
//! implementation for federated-assertion verification.
//!
//! ## 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.** HTTP response streaming stops at
//! [`MAX_JWKS_RESPONSE_BYTES`] + 1 byte before any allocation for parsing.
//! Key count is bounded by [`super::config::MAX_JWKS_KEYS`] inside
//! [`AssertionKeySet::new`].
//!
//! - **Coalesced refresh.** A single in-flight refresh per issuer prevents
//! thundering-herd. Concurrent callers observe the snapshot just after the
//! 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 buzz_core::network::is_not_global_unicast;
use chrono::{DateTime, Duration, Utc};
use futures_util::StreamExt as _;
use jsonwebtoken::jwk::JwkSet;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
use tracing::warn;
use url::Url;
/// Maximum HTTP response body for a JWKS endpoint. Streaming stops at this
/// limit before any deserialization, preventing OOM from a malicious server.
pub const MAX_JWKS_RESPONSE_BYTES: usize = 512 * 1024; // 512 KiB
/// Hard upper bound on JWKS timing fields. Values above this are rejected at
/// config construction to prevent `u64`→`i64` conversion overflow and Chrono
/// range panics when computing snapshot deadlines.
pub const MAX_JWKS_TIMING_SECONDS: u64 = 365 * 24 * 3600; // 1 year
/// Hard deadline for the complete JWKS fetch: hostname resolution, connect,
/// headers, and body streaming combined. Applied via `tokio::time::timeout`
/// so a stalled resolver cannot keep `fetch_jwks` pending indefinitely.
pub const JWKS_REQUEST_TIMEOUT_SECS: u64 = 10;
/// Validate that a JWKS URI is safe to fetch: HTTPS scheme, no credentials,
/// no fragment, and the host (if a bare IP) is not private/reserved.
/// Hostname targets are resolved and checked at every fetch in `fetch_jwks`
/// to prevent DNS rebinding — this check catches the most common
/// misconfiguration at construction time.
pub fn validate_jwks_uri(uri: &str) -> Result<(), JwksFetchError> {
let parsed = Url::parse(uri).map_err(|_| JwksFetchError::InvalidUri)?;
if parsed.scheme() != "https" {
return Err(JwksFetchError::InvalidUri);
}
// Credentials in the URI are never legitimate for a public JWKS endpoint
// and would be forwarded to the server, leaking material in logs.
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(JwksFetchError::InvalidUri);
}
// Fragments are client-side only; their presence indicates a misconfigured URI.
if parsed.fragment().is_some() {
return Err(JwksFetchError::InvalidUri);
}
// Reject bare private/reserved IP targets at construction time.
if let Some(url::Host::Ipv4(addr)) = parsed.host() {
if is_not_global_unicast(&std::net::IpAddr::V4(addr)) {
return Err(JwksFetchError::InvalidUri);
}
}
if let Some(url::Host::Ipv6(addr)) = parsed.host() {
if is_not_global_unicast(&std::net::IpAddr::V6(addr)) {
return Err(JwksFetchError::InvalidUri);
}
}
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 normalized to its canonical `Url` serialization.
/// `Url::to_string()` lowercases the scheme and host, removes the default
/// HTTPS port, and resolves dot-segments — so equivalent URI spellings hash
/// identically. Validated at construction; only stored after parse succeeds.
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;
}
// Parse once, reject via validate_jwks_uri's rule-set, then store the
// canonical serialization produced by `Url::to_string()`. The `url`
// crate lowercases scheme and host, removes the default HTTPS port,
// and resolves dot-segments — guaranteeing that equivalent URI spellings
// (e.g. uppercase host, explicit `:443`, `.///../`) produce an identical
// stored string and therefore an identical `AssertionPolicyId` hash.
let canonical_uri = match Url::parse(&jwks_uri) {
Ok(parsed) => parsed.to_string(),
Err(_) => return None,
};
// Re-validate on the canonical form so that any normalisation that
// would introduce a forbidden form (e.g. port stripping that leaves
// a bare-IP host) is caught here rather than silently stored.
if validate_jwks_uri(&canonical_uri).is_err() {
return None;
}
Some(Self {
jwks_uri: canonical_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
/// via `spawn_blocking` to avoid blocking the async runtime.
///
/// Uses the `(host, port)` tuple form of `ToSocketAddrs` — not
/// `format!("{host}:{port}")` — so IPv6 literal hosts (returned without
/// brackets by `Url::host_str()`) are handled correctly without socket-address
/// ambiguity.
///
/// Rejecting *any* resolved address (not just the first) closes split-horizon
/// DNS attacks: if an attacker can cause one DNS record to resolve to a private
/// address, the entire request is blocked even when other records are public.
pub(crate) async fn resolve_and_check_ssrf(
host: &str,
port: u16,
) -> Result<std::net::IpAddr, JwksFetchError> {
// Fast path: if the host is already a parsed IP literal, skip the resolver.
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
if is_not_global_unicast(&ip) {
return Err(JwksFetchError::InvalidUri);
}
return Ok(ip);
}
// Hostname path: use the tuple form to avoid IPv6-bracket ambiguity.
let host_owned = host.to_owned();
let addrs: Vec<std::net::IpAddr> = tokio::task::spawn_blocking(move || {
use std::net::ToSocketAddrs;
(host_owned.as_str(), port)
.to_socket_addrs()
.map(|iter| iter.map(|sa| sa.ip()).collect::<Vec<_>>())
})
.await
.map_err(|_| JwksFetchError::NetworkError)?
.map_err(|_| JwksFetchError::NetworkError)?;
if addrs.is_empty() {
return Err(JwksFetchError::NetworkError);
}
for ip in &addrs {
if is_not_global_unicast(ip) {
return Err(JwksFetchError::InvalidUri);
}
}
Ok(addrs[0])
}
#[derive(Clone)]
struct CachedSnapshot {
key_set: AssertionKeySet,
fetched_at: DateTime<Utc>,
hard_deadline: DateTime<Utc>,
/// SHA-256 of the raw JWKS bytes. Suppresses generation advances when the
/// document is unchanged between refreshes. [FI-TRACE-JWKS-ADD/REMOVE]
content_digest: [u8; 32],
}
struct IssuerState {
snapshot: Option<CachedSnapshot>,
/// Advances only when `content_digest` changes; never wraps (saturating).
generation_counter: u64,
/// Owned permit for in-flight refresh. Held across the complete fetch +
/// state commit; dropped automatically if the caller future is cancelled.
/// `try_lock_owned()` succeeds iff no refresh is in progress.
refresh_permit: Arc<tokio::sync::Mutex<()>>,
}
impl IssuerState {
fn new() -> Self {
Self {
snapshot: None,
generation_counter: 0,
refresh_permit: Arc::new(tokio::sync::Mutex::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,
/// 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
/// URLs, or raw response content appear in these variants.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum JwksFetchError {
/// Non-HTTPS scheme, embedded credentials, fragment, bare
/// private/reserved IP host, or DNS resolved to a private/reserved address.
#[error("JWKS URI failed safety validation")]
InvalidUri,
/// Response body exceeded [`MAX_JWKS_RESPONSE_BYTES`].
#[error("JWKS response exceeded size limit")]
ResponseTooLarge,
/// Network failure, TLS error, request timeout, or non-2xx status.
#[error("JWKS HTTP request failed")]
NetworkError,
/// Response body was not parseable as a JWK Set.
#[error("JWKS response was not parseable")]
ParseError,
/// Parsed key set was empty or exceeded [`super::config::MAX_JWKS_KEYS`].
#[error("JWKS key set bounds violation")]
KeyCountBoundsViolation,
}
/// Sealed injection seam for JWKS HTTP fetching. Only types inside `buzz_auth`
/// may implement it — external types cannot name the private supertrait.
///
/// Implementations MUST:
/// - validate the URI (scheme, credentials, fragment, bare private-IP host)
/// before any I/O;
/// - resolve hostname targets and reject any private/reserved resolved address;
/// - deny redirects (3xx responses rejected as `NetworkError`);
/// - enforce a finite per-fetch deadline covering resolution, connect, headers,
/// and body streaming — the entire operation must be bounded;
/// - enforce [`MAX_JWKS_RESPONSE_BYTES`] via incremental streaming;
/// - reject non-2xx responses.
pub trait JwksFetcher: super::verifier::sealed::Sealed + Send + Sync + 'static {
/// Fetch and return the raw JSON body from the given JWKS URI.
fn fetch_jwks<'a>(
&'a self,
uri: &'a str,
) -> impl std::future::Future<Output = Result<String, JwksFetchError>> + Send + 'a;
}
/// Production [`JwksFetcher`] backed by `reqwest`. Each call to `fetch_jwks`
/// builds a dedicated pinned client — no shared connection state between fetches.
///
/// Per-fetch boundary enforcement:
/// - hostname DNS is resolved and every address checked against
/// `buzz_core::network::is_not_global_unicast` before the request is sent;
/// - the request is pinned to the validated address to prevent DNS rebinding
/// TOCTOU (the OS resolver is called once per fetch, not once per URL);
/// - the complete operation (resolution, connect, headers, body streaming) is
/// bounded by [`JWKS_REQUEST_TIMEOUT_SECS`] via `tokio::time::timeout`;
/// - 3xx responses are rejected as `NetworkError` — redirects are never followed;
/// - the body is streamed incrementally and stopped at
/// [`MAX_JWKS_RESPONSE_BYTES`] + 1.
#[derive(Clone, Debug)]
pub struct HttpJwksFetcher;
impl HttpJwksFetcher {
/// Builds a new fetcher. Security invariants are enforced per-request in
/// `fetch_jwks` — each call constructs a dedicated pinned client.
pub fn new() -> Self {
Self
}
}
impl Default for HttpJwksFetcher {
fn default() -> Self {
Self::new()
}
}
impl super::verifier::sealed::Sealed for HttpJwksFetcher {}
impl JwksFetcher for HttpJwksFetcher {
async fn fetch_jwks<'a>(&'a self, uri: &'a str) -> Result<String, JwksFetchError> {
with_deadline(
fetch_jwks_inner(uri),
std::time::Duration::from_secs(JWKS_REQUEST_TIMEOUT_SECS),
)
.await
}
}
/// Bound `fut` with a hard `tokio::time::timeout`. Elapsed maps to
/// `NetworkError`. Production passes `fetch_jwks_inner(uri)`; tests pass
/// `std::future::pending()` to verify the seam deterministically.
async fn with_deadline<F>(fut: F, timeout: std::time::Duration) -> Result<String, JwksFetchError>
where
F: std::future::Future<Output = Result<String, JwksFetchError>>,
{
tokio::time::timeout(timeout, fut)
.await
.map_err(|_| JwksFetchError::NetworkError)?
}
/// Extract the bare host string and port from a validated JWKS URI.
///
/// The host is extracted via the typed `Url::host()` accessor, **not**
/// `host_str()`. `host_str()` returns IPv6 literals with brackets (e.g.
/// `[2606:4700::1]`), which breaks `IpAddr::parse`: brackets are not valid,
/// so the fast path in `resolve_and_check_ssrf` would fail and fall through
/// to the DNS path, which may attempt to resolve `[2606:4700::1]` as a
/// hostname instead of an IP literal.
///
/// The extracted bare host string is also the correct input form for
/// `reqwest::ClientBuilder::resolve(host, addr)`, whose key must match the
/// URL authority form (bare, without brackets for IPv6). Whether the
/// connector-level pin behaves as expected under mutation is a runtime
/// boundary concern; this function's contract is that it produces the bare
/// form required as input.
///
/// This function is `pub(crate)` so tests can assert the extracted host string
/// directly and confirm the mutation (restoring `host_str()`) turns the
/// equivalence oracle red without making a live network request.
///
/// ## Mutation oracle
/// Restoring `Some(url::Host::Ipv6(addr)) => format!("[{}]", addr)` (the
/// `host_str()` form) causes the IPv6 host extraction test to fail: the
/// returned string carries brackets, `IpAddr::parse` rejects it, and the
/// extracted host no longer matches the bare URL authority form.
pub(crate) fn extract_url_host_and_port(uri: &str) -> Result<(String, u16), JwksFetchError> {
let parsed = Url::parse(uri).map_err(|_| JwksFetchError::InvalidUri)?;
let host = match parsed.host() {
Some(url::Host::Ipv4(addr)) => addr.to_string(),
// MUST use the typed accessor — `host_str()` returns `[2606:4700::1]`
// (with brackets) for IPv6 literals, which breaks IpAddr::parse.
Some(url::Host::Ipv6(addr)) => addr.to_string(),
Some(url::Host::Domain(d)) => d.to_owned(),
None => return Err(JwksFetchError::InvalidUri),
};
let port = parsed.port_or_known_default().unwrap_or(443);
Ok((host, port))
}
/// Inner fetch logic. Called only by `HttpJwksFetcher::fetch_jwks` via `with_deadline`.
async fn fetch_jwks_inner(uri: &str) -> Result<String, JwksFetchError> {
// Full URI validation first — scheme, credentials, fragment, bare
// private-IP host. This enforces the JwksFetcher contract for direct
// callers of HttpJwksFetcher regardless of whether ProductionJwksSource
// pre-validated the URI.
validate_jwks_uri(uri)?;
let (host, port) = extract_url_host_and_port(uri)?;
// Resolve and check every IP before sending. Pins DNS to the validated
// address to prevent rebinding TOCTOU between check and connect.
let safe_ip = resolve_and_check_ssrf(&host, port).await?;
// Build a per-request client that:
// - denies redirects (a 3xx to an internal host bypasses the URI check);
// - has no system proxy (proxy would resolve the original hostname itself);
// - pins this request to the validated IP.
let pinned_client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.resolve(&host, std::net::SocketAddr::new(safe_ip, port))
.build()
.map_err(|_| JwksFetchError::NetworkError)?;
let response = pinned_client
.get(uri)
.send()
.await
.map_err(|_| JwksFetchError::NetworkError)?;
// Reject non-2xx. A 3xx here means our no-redirect policy was somehow
// bypassed — treat as a network error.
if !response.status().is_success() {
return Err(JwksFetchError::NetworkError);
}
// Early-exit on Content-Length before streaming. A lying or absent
// Content-Length is caught by the incremental counter below.
if let Some(content_length) = response.content_length() {
if content_length as usize > MAX_JWKS_RESPONSE_BYTES {
return Err(JwksFetchError::ResponseTooLarge);
}
}
// Stream incrementally; stop at MAX_JWKS_RESPONSE_BYTES + 1 so we
// never buffer more than the limit before rejecting.
let mut body = Vec::with_capacity(MAX_JWKS_RESPONSE_BYTES.min(64 * 1024));
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| JwksFetchError::NetworkError)?;
if body.len().saturating_add(chunk.len()) > MAX_JWKS_RESPONSE_BYTES {
return Err(JwksFetchError::ResponseTooLarge);
}
body.extend_from_slice(&chunk);
}
String::from_utf8(body).map_err(|_| JwksFetchError::ParseError)
}
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)
}
/// Multi-issuer JWKS cache that performs bounded periodic refresh and never
/// serves snapshots past their hard deadline.
///
/// Must be constructed at startup after
/// [`super::startup::validate_nip_fi_config`] passes. Shared across async
/// tasks via the inner `Arc<RwLock<…>>`.
///
/// ## 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>,
states: Arc<RwLock<HashMap<String, Mutex<IssuerState>>>>,
fetcher: Arc<F>,
/// Clock used for `hard_deadline` computation and expiry checks. Always
/// `Arc::new(Utc::now)` in production; tests supply a controlled clock.
now_fn: Arc<dyn Fn() -> DateTime<Utc> + Send + Sync>,
}
impl<F: JwksFetcher> ProductionJwksSource<F> {
/// 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;
}
let mut config_map = HashMap::with_capacity(configs.len());
let mut state_map = HashMap::with_capacity(configs.len());
for c in configs {
if config_map.contains_key(&c.issuer) {
return None;
}
let issuer = c.issuer.clone();
state_map.insert(issuer.clone(), Mutex::new(IssuerState::new()));
config_map.insert(issuer, c);
}
Some(Self {
configs: config_map,
states: Arc::new(RwLock::new(state_map)),
fetcher: Arc::new(fetcher),
now_fn: Arc::new(Utc::now),
})
}
/// **Test-only.** Construct with an injectable clock so tests can advance
/// `now` past snapshot hard deadlines without wall-clock sleep.
#[cfg(test)]
pub(crate) fn new_with_clock(
configs: Vec<IssuerJwksConfig>,
fetcher: F,
now_fn: Arc<dyn Fn() -> DateTime<Utc> + Send + Sync>,
) -> 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 {
if config_map.contains_key(&c.issuer) {
return None;
}
let issuer = c.issuer.clone();
state_map.insert(issuer.clone(), Mutex::new(IssuerState::new()));
config_map.insert(issuer, c);
}
Some(Self {
configs: config_map,
states: Arc::new(RwLock::new(state_map)),
fetcher: Arc::new(fetcher),
now_fn,
})
}
async fn fetch_fresh(
&self,
issuer: &str,
prev_digest: Option<[u8; 32]>,
prev_generation: u64,
) -> Option<(CachedSnapshot, u64)> {
let config = self.configs.get(issuer)?;
let body = match self.fetcher.fetch_jwks(config.contract.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 content_digest: [u8; 32] = Sha256::digest(body.as_bytes()).into();
// Advance only when the document changed so key-rotation events are
// visible [FI-TRACE-JWKS-ADD/REMOVE] while identical refetches are
// stable. Saturating add prevents wrap on the (unreachable) u64 ceiling.
let generation = if Some(content_digest) == prev_digest {
prev_generation
} else {
prev_generation.saturating_add(1).max(1)
};
let now = (self.now_fn)();
// MAX_JWKS_TIMING_SECONDS ≤ ~31.5M < i64::MAX, so this conversion is
// 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));
let key_set = AssertionKeySet::new(issuer.to_owned(), generation, jwks, hard_deadline)?;
Some((
CachedSnapshot {
key_set,
fetched_at: now,
hard_deadline,
content_digest,
},
generation,
))
}
/// Returns the cached snapshot for `issuer`, refreshing inline if stale.
/// Returns `None` when no live snapshot is available and the fetch fails.
///
/// Coalesces concurrent callers: a second call while a refresh is in
/// flight returns the current snapshot immediately rather than starting a
/// second fetch. The refresh permit is an RAII guard — if this future is
/// cancelled while DNS, HTTP, or streaming is pending, the guard drops and
/// the permit is released, so the next caller can start a new fetch.
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 = (self.now_fn)();
let config = self.configs.get(issuer)?;
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.contract.refresh_interval_seconds()
}
};
if !needs_refresh {
return state.snapshot.as_ref().map(|c| c.key_set.clone());
}
// Try to acquire the per-issuer refresh permit. Failure means another
// caller is already fetching; return the current snapshot rather than
// starting a second fetch.
let permit = match Arc::clone(&state.refresh_permit).try_lock_owned() {
Ok(g) => g,
Err(_) => return state.snapshot.as_ref().map(|c| c.key_set.clone()),
};
let prev_digest = state.snapshot.as_ref().map(|c| c.content_digest);
let prev_generation = state.generation_counter;
drop(state);
drop(states);
let fresh = self.fetch_fresh(issuer, prev_digest, prev_generation).await;
// Re-acquire state to commit and release the permit atomically.
let states = self.states.read().await;
if let Some(state_mutex) = states.get(issuer) {
let mut st = state_mutex.lock().await;
if let Some((ref cached, new_generation)) = fresh {
st.generation_counter = new_generation;
st.snapshot = Some(cached.clone());
}
// Drop the permit only after the state commit is visible.
drop(permit);
let now2 = (self.now_fn)();
return st
.snapshot
.as_ref()
.filter(|c| now2 < c.hard_deadline)
.map(|c| c.key_set.clone());
}
drop(permit);
None
}
}
impl<F: JwksFetcher> super::verifier::sealed::Sealed for ProductionJwksSource<F> {}
impl<F: JwksFetcher> IssuerKeySource for ProductionJwksSource<F> {
/// Called per-request by the verifier after the cache has been warmed via
/// [`get_snapshot`][Self::get_snapshot].
///
/// Uses `try_read`/`try_lock` — safe to call from any async context.
/// Fails closed (returns `None`) when the lock is momentarily held by an
/// in-flight refresh, rather than blocking or panicking. [FI-INV-14]
fn key_set(&self, issuer: &str) -> Option<AssertionKeySet> {
let states = self.states.try_read().ok()?;
let state_mutex = states.get(issuer)?;
let state = state_mutex.try_lock().ok()?;
let now = (self.now_fn)();
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;

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +1,19 @@
//! NIP-FI federated-identity authorization — canonical assertion verifier and
//! contracts (Phase A, PR 1).
//!
//! This module is the closed, provider-neutral contract layer at the root of
//! the NIP-FI dependency graph. It defines:
//!
//! - 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`).
//!
//! 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.
//! NIP-FI federated-identity authorization — assertion verifier, JWKS runtime,
//! startup validation, and discovery.
/// The exact client-attached header field ([NIP-FI.md](../../../docs/nips/NIP-FI.md),
/// "Client-attached transport"). `Authorization` remains reserved for NIP-98.
/// The client-attached transport header for federated-identity assertions.
///
/// `Authorization` remains reserved for NIP-98; this separate header avoids
/// conflating authentication schemes at the relay ingress.
/// ([NIP-FI.md](../../../docs/nips/NIP-FI.md), "Client-attached transport")
pub const CLIENT_ATTACHED_HEADER: &str = "Nostr-Federated-Identity";
pub mod assertion;
pub mod config;
pub mod denial;
pub mod discovery;
pub mod jwks;
pub mod startup;
pub mod verifier;
pub use assertion::{
@@ -39,4 +26,12 @@ 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, JwksSourceContract,
ProductionJwksSource,
};
pub use startup::{validate_nip_fi_config, NipFiMode, NipFiStartupError};
pub use verifier::{AssertionKeySet, FederatedAssertionVerifier, IssuerKeySource, VerifierError};

View File

@@ -0,0 +1,134 @@
//! Startup validation for the NIP-FI assertion runtime.
//!
//! [`validate_nip_fi_config`] is the production entry point. It rejects any
//! configuration that would make the runtime unsafe, incomplete, or ambiguous
//! before the relay accepts any protected traffic. The relay MUST call this and
//! refuse to start on error in [`Enforce`][NipFiMode::Enforce] mode
//! (`FI-INV-14`, `FI-INV-15`).
use super::config::{FreshnessClass, IssuerRegistry};
use super::jwks::IssuerJwksConfig;
/// 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,
/// All protected routes deny unconditionally. Used when a prior
/// enforce-mode deployment was misconfigured and must fail closed while
/// the operator repairs configuration. [FI-INV-14]
DenyProtected,
}
/// 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 {
/// Registry has no entries; enforce mode requires at least one issuer.
#[error("NIP-FI enforce mode requires at least one issuer policy")]
EmptyRegistry,
/// The duplicate `iss` is omitted to avoid leaking configuration into
/// operational logs.
#[error("NIP-FI issuer registry contains a duplicate issuer")]
DuplicateIssuer,
/// Every registered issuer requires a JWKS endpoint in enforce mode.
#[error("NIP-FI issuer has no JWKS configuration")]
MissingJwksConfig,
/// Mismatched configs are rejected to prevent silent key-source confusion.
#[error("NIP-FI JWKS config issuer does not match any registered policy")]
UnmatchedJwksConfig,
/// 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.
#[error(
"NIP-FI current-status freshness is not yet supported; \
use offline-jwt posture"
)]
UnsupportedPosture,
}
/// Validates the complete NIP-FI runtime configuration. On error the relay
/// MUST refuse to start or fall back to [`NipFiMode::DenyProtected`].
pub fn validate_nip_fi_config(
mode: NipFiMode,
registry: &IssuerRegistry,
jwks_configs: &[IssuerJwksConfig],
) -> Result<(), NipFiStartupError> {
if let NipFiMode::Off | NipFiMode::DenyProtected = mode {
return Ok(());
}
if registry.is_empty() {
return Err(NipFiStartupError::EmptyRegistry);
}
// IssuerRegistry overwrites duplicates silently; assert uniqueness here so
// a misconfigured multi-issuer call-site is caught before traffic is served.
{
let mut seen = std::collections::HashSet::new();
for policy in registry.all_policies() {
if !seen.insert(policy.issuer()) {
return Err(NipFiStartupError::DuplicateIssuer);
}
}
}
// Reject current-status policies: the status witness is not yet
// implemented. Fail closed rather than advertise a freshness guarantee the
// verifier cannot satisfy.
for policy in registry.all_policies() {
if policy.freshness() == FreshnessClass::CurrentStatus {
return Err(NipFiStartupError::UnsupportedPosture);
}
}
// Build JWKS map, rejecting duplicates. Two configs for the same issuer
// would make the effective endpoint selection order-dependent.
let mut jwks_map: std::collections::HashMap<&str, &IssuerJwksConfig> =
std::collections::HashMap::with_capacity(jwks_configs.len());
for config in jwks_configs {
if jwks_map.insert(config.issuer.as_str(), config).is_some() {
return Err(NipFiStartupError::DuplicateIssuer);
}
}
for config in jwks_configs {
if registry.policy_for_issuer(&config.issuer).is_none() {
return Err(NipFiStartupError::UnmatchedJwksConfig);
}
// 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);
}
}
for policy in registry.all_policies() {
if !jwks_map.contains_key(policy.issuer()) {
return Err(NipFiStartupError::MissingJwksConfig);
}
}
Ok(())
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,182 @@
use super::*;
use crate::nip_fi::config::{FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass};
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(),
vec![format!("https://relay.example/api")],
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![JwtAlgorithm::ES256],
false,
0,
3600,
None,
test_contract(issuer),
)
.unwrap()
}
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),
test_contract(issuer),
)
.unwrap()
}
fn make_jwks_config(issuer: &str) -> IssuerJwksConfig {
IssuerJwksConfig {
issuer: issuer.to_owned(),
contract: test_contract(issuer),
}
}
#[test]
fn off_mode_accepts_empty_registry() {
let registry = IssuerRegistry::new();
assert!(validate_nip_fi_config(NipFiMode::Off, &registry, &[]).is_ok());
}
#[test]
fn deny_protected_mode_accepts_empty_registry() {
let registry = IssuerRegistry::new();
assert!(validate_nip_fi_config(NipFiMode::DenyProtected, &registry, &[]).is_ok());
}
#[test]
fn enforce_valid_config_passes() {
let issuer = "https://id.example";
let mut registry = IssuerRegistry::new();
registry.insert(make_offline_policy(issuer));
assert!(
validate_nip_fi_config(NipFiMode::Enforce, &registry, &[make_jwks_config(issuer)]).is_ok()
);
}
#[test]
fn enforce_multiple_issuers_passes() {
let issuers = [
"https://a.example",
"https://b.example",
"https://c.example",
];
let mut registry = IssuerRegistry::new();
for iss in &issuers {
registry.insert(make_offline_policy(iss));
}
let jwks: Vec<_> = issuers.iter().map(|i| make_jwks_config(i)).collect();
assert!(validate_nip_fi_config(NipFiMode::Enforce, &registry, &jwks).is_ok());
}
#[test]
fn enforce_empty_registry_rejects() {
let registry = IssuerRegistry::new();
let err = validate_nip_fi_config(NipFiMode::Enforce, &registry, &[]).unwrap_err();
assert_eq!(err, NipFiStartupError::EmptyRegistry);
}
#[test]
fn enforce_issuer_without_jwks_rejects() {
let issuer = "https://id.example";
let mut registry = IssuerRegistry::new();
registry.insert(make_offline_policy(issuer));
let err = validate_nip_fi_config(NipFiMode::Enforce, &registry, &[]).unwrap_err();
assert_eq!(err, NipFiStartupError::MissingJwksConfig);
}
#[test]
fn enforce_unmatched_jwks_config_rejects() {
let issuer = "https://id.example";
let mut registry = IssuerRegistry::new();
registry.insert(make_offline_policy(issuer));
let err = validate_nip_fi_config(
NipFiMode::Enforce,
&registry,
&[make_jwks_config("https://other.example")],
)
.unwrap_err();
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_jwks_contract_mismatch_rejects() {
let issuer = "https://id.example";
let mut registry = IssuerRegistry::new();
registry.insert(make_offline_policy(issuer));
// Config carries a different refresh interval than the policy (300 vs 600).
let mismatched_config = IssuerJwksConfig {
issuer: issuer.to_owned(),
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, &registry, &[mismatched_config]).unwrap_err(),
NipFiStartupError::JwksContractMismatch
);
}
/// Rejected regardless of whether a JWKS config is present — the verifier
/// has no status witness to satisfy the freshness guarantee.
#[test]
fn enforce_current_status_policy_always_rejects() {
let issuer = "https://id.example";
let mut registry = IssuerRegistry::new();
registry.insert(make_status_policy(issuer));
assert_eq!(
validate_nip_fi_config(NipFiMode::Enforce, &registry, &[]).unwrap_err(),
NipFiStartupError::UnsupportedPosture
);
assert_eq!(
validate_nip_fi_config(NipFiMode::Enforce, &registry, &[make_jwks_config(issuer)])
.unwrap_err(),
NipFiStartupError::UnsupportedPosture
);
}
/// Duplicate JWKS configs for the same issuer must not silently succeed.
#[test]
fn enforce_duplicate_jwks_issuer_in_configs_rejects() {
let issuer = "https://id.example";
let mut registry = IssuerRegistry::new();
registry.insert(make_offline_policy(issuer));
let jwks = vec![make_jwks_config(issuer), make_jwks_config(issuer)];
assert!(
validate_nip_fi_config(NipFiMode::Enforce, &registry, &jwks).is_err(),
"duplicate JWKS configs must not pass"
);
}

View File

@@ -49,9 +49,13 @@ 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 {}
// Blanket seal for `Arc<S>` so `Arc<ProductionJwksSource>` satisfies
// the sealed supertrait without requiring callers to implement it.
impl<S: Sealed> Sealed for std::sync::Arc<S> {}
}
/// One issuer's key source: a JWKS snapshot bound to the exact `iss` it
@@ -64,7 +68,7 @@ mod sealed {
/// construction seam: [`verify`] takes no snapshot argument, and this type has
/// no public constructor, so an external consumer cannot build a snapshot that
/// labels issuer B's JWKS as issuer A. Building a snapshot (and the source that
/// serves it) is the trusted configuration act PR 3's JWKS runtime performs at
/// serves it) is the trusted configuration act the `jwks` runtime performs at
/// startup, not a per-request or external input.
///
/// The crate-private constructor is a live regression: an external crate that
@@ -90,7 +94,7 @@ impl AssertionKeySet {
/// generation and a required key-snapshot hard deadline. Rejects a zero
/// generation, an empty issuer, an empty or oversized key set
/// ([`MAX_JWKS_KEYS`]), or a non-positive deadline. Crate-private: only the
/// trusted in-crate configuration path (PR 3's JWKS runtime) may bind key
/// trusted in-crate configuration path (the `jwks` runtime) may bind key
/// material to an issuer.
///
/// Bounding the key count here is the pre-lookup control (NIP-FI.md:166-171):
@@ -101,13 +105,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,
@@ -139,6 +136,13 @@ impl AssertionKeySet {
pub const fn generation(&self) -> u64 {
self.generation
}
/// The snapshot hard deadline. Test-only accessor for deadline-crossing
/// oracles; not compiled into production builds.
#[cfg(test)]
pub(crate) fn hard_deadline(&self) -> chrono::DateTime<chrono::Utc> {
self.hard_deadline
}
}
impl fmt::Debug for AssertionKeySet {
@@ -153,7 +157,7 @@ impl fmt::Debug for AssertionKeySet {
/// instead asks this source for the snapshot bound to the token's
/// signature-authenticated `iss`. A request-path caller therefore cannot
/// relabel one issuer's JWKS as another's — the cross-issuer bypass at the old
/// `verify(token, key_set)` seam. Configuring the source (PR 3's JWKS runtime)
/// `verify(token, key_set)` seam. Configuring the source (the `jwks` runtime)
/// is a trusted startup act, not per-request input.
///
/// This trait is sealed via a private supertrait, so it cannot be implemented
@@ -180,8 +184,27 @@ pub trait IssuerKeySource: sealed::Sealed {
fn key_set(&self, issuer: &str) -> Option<AssertionKeySet>;
}
/// Forwarding implementation so a single `Arc<S>` can be cheaply cloned and
/// shared across multiple [`FederatedAssertionVerifier`] instances while all
/// of them observe every refresh committed to the shared source.
///
/// This is the canonical sharing path for `ProductionJwksSource`, which is
/// not itself `Clone` (its internal `RwLock`-protected state is not cheaply
/// copyable). Wrap it in `Arc` at startup, then pass `Arc::clone(&source)` to
/// each verifier — all verifiers read from the same underlying cache and see
/// key rotations as soon as `get_snapshot` commits them.
///
/// The blanket seal (`impl<S: Sealed> Sealed for Arc<S>`) in the `sealed`
/// module ensures this forwarding impl remains crate-owned: an external crate
/// still cannot implement `IssuerKeySource` for its own type.
impl<S: IssuerKeySource> IssuerKeySource for std::sync::Arc<S> {
fn key_set(&self, issuer: &str) -> Option<AssertionKeySet> {
(**self).key_set(issuer)
}
}
/// A fixed issuer→snapshot key source for the in-crate verifier tests,
/// standing in for PR 3's JWKS runtime. It is `cfg(test)`-only — not behind a
/// standing in for the `jwks` runtime. It is `cfg(test)`-only — not behind a
/// downstream-selectable Cargo feature — so no dependent crate can enable it to
/// reconstruct the authority. An honest source returns only the snapshot bound
/// to the exact issuer requested, the invariant the real runtime source
@@ -352,7 +375,7 @@ impl<S: IssuerKeySource> FederatedAssertionVerifier<S> {
// is `evidence_rejected` (403), and this defers a valid one as
// `authorization_unavailable` (503) so a missing witness never
// masquerades as rejected evidence, nor invalid input as unavailable
// (NIP-FI.md:459-476). PR 3 adds the witness path additively.
// (NIP-FI.md:459-476).
if policy.freshness() == FreshnessClass::CurrentStatus {
return Err(VerifierError::StatusWitnessUnavailable);
}
@@ -726,8 +749,8 @@ fn parse_nostr_pubkey_claim(
}
}
/// Capture only the claim names the policy reads into a canonical set. For PR 1
/// the closed set is the `scope` claim, split on ASCII space; unchecked claims
/// Capture only the claim names the policy reads into a canonical set. The
/// closed set is the `scope` claim, split on ASCII space; unchecked claims
/// never enter the result.
fn capture_capabilities(
_policy: &IssuerPolicy,

View File

@@ -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,185 @@ 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 identical_contract_produces_stable_assertion_policy_id() {
// `AssertionPolicyId` is derived from the contract fields only — not from
// JWKS key material. This means JWKS key additions/removals (runtime
// rotation) cannot change the policy ID; only changes to the contract
// itself (JWKS URI, refresh interval, hard deadline) would do so.
//
// This test verifies the structural invariant: two `IssuerPolicy` values
// built from identical contracts produce the same `AssertionPolicyId`,
// regardless of when or how many times the ID is derived. Because key
// material never flows into `derive_assertion_policy_id`, the ID is
// stable for the lifetime of a given contract.
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 ID: key material is not part of the hash.
assert_eq!(
p1.id(),
p2.id(),
"identical contract must produce the same assertion_policy_id (key material is not hashed)"
);
}
#[test]
fn scope_capture_is_canonical_under_order_and_duplicates() {

View File

@@ -19,344 +19,390 @@ fn embedded_ipv4(v6: &std::net::Ipv6Addr, prefix: &[u8; 12]) -> Option<std::net:
.then(|| std::net::Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]))
}
/// Returns `true` if the IP address is in a private, reserved, or
/// loopback range. Used for SSRF protection — webhook targets must
/// not resolve to these addresses.
/// Enumerated-deny SSRF predicate: returns `true` when the address falls within
/// a blocked class, and `false` (accepted) for everything else, including
/// addresses not covered by any explicit deny rule (e.g. `fe00::1`).
///
/// Blocked ranges:
/// - IPv4 loopback 127.0.0.0/8
/// - IPv4 private 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
/// - IPv4 link-local 169.254.0.0/16
/// - IPv4 unspecified 0.0.0.0/8
/// - IPv4 broadcast 255.255.255.255
/// - IPv4 CGNAT 100.64.0.0/10 (RFC 6598) — cloud metadata risk
/// - IPv4 benchmarking 198.18.0.0/15 (RFC 2544)
/// - IPv6 loopback ::1
/// - IPv6 unspecified ::
/// - IPv6 ULA fc00::/7
/// - IPv6 link-local fe80::/10
/// - IPv6 multicast ff00::/8
/// - IPv6 documentation 2001:db8::/32 (RFC 3849) — should never appear in production
/// - IPv4-compatible and mapped IPv6 (checked recursively against IPv4 rules)
/// - IPv4-translated ::ffff:0:0:0/96 (embedded IPv4 checked recursively)
/// - NAT64 well-known 64:ff9b::/96 (embedded IPv4 checked recursively)
/// - NAT64 local-use 64:ff9b:1::/48 (RFC 8215)
/// - Teredo 2001::/32 (RFC 4380)
/// - 6to4 2002::/16 (RFC 3056)
pub fn is_private_ip(ip: &std::net::IpAddr) -> bool {
/// Blocked classes are drawn from the IANA IPv4 and IPv6 Special-Purpose
/// Address Space registries (last updated 2025-10-09): ranges whose
/// `Globally Reachable` column is `False`, `None`, or absent, plus multicast
/// space. Within otherwise-denied envelopes, explicitly global entries are
/// carved out as exceptions (e.g., PCP/TURN/DNS-SD anycast inside 2001::/23).
/// IPv4 embedded in IPv4-mapped, IPv4-compatible, and NAT64 well-known
/// (64:ff9b::/96) space is evaluated recursively against the IPv4 table —
/// registry global=True for the IPv6 wrapper does not bypass the
/// embedded-address check. SIIT IPv4-translated (::ffff:0:0:0/96) follows the
/// same recursive path. The local-use NAT64 prefix (64:ff9b:1::/48) is blocked
/// wholesale as a non-global range; its embedded IPv4 payload is not decoded.
///
/// Used for SSRF protection: rejects outbound targets in known non-public
/// address classes; addresses not covered by an explicit deny rule pass through.
/// Conservative posture: `None`/blank registry entries are treated as non-global.
///
/// Registries retrieved 2026-08-31; registries last updated 2025-10-09:
/// https://www.iana.org/assignments/iana-ipv4-special-registry/
/// https://www.iana.org/assignments/iana-ipv6-special-registry/
///
/// Compatibility alias: `is_private_ip` (see below).
///
/// Callers: `buzz-auth` JWKS boundary, `buzz-workflow` webhook SSRF check,
/// desktop `link_preview` SSRF check.
pub fn is_not_global_unicast(ip: &std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
let octets = v4.octets();
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| octets[0] == 0
|| v4.is_broadcast()
// Carrier-Grade NAT (RFC 6598) — 100.64.0.0/10
// Dangerous in cloud environments (AWS, GCP) where CGNAT can route to metadata services.
|| (octets[0] == 100 && (octets[1] & 0xC0) == 64)
// Benchmarking (RFC 2544) — 198.18.0.0/15
|| (octets[0] == 198 && (octets[1] & 0xFE) == 18)
let o = v4.octets();
v4.is_loopback() // 127.0.0.0/8
|| v4.is_private() // 10/8, 172.16/12, 192.168/16
|| v4.is_link_local() // 169.254.0.0/16
|| o[0] == 0 // 0.0.0.0/8 "This network"
|| v4.is_broadcast() // 255.255.255.255
|| (o[0] == 100 && (o[1] & 0xC0) == 64) // 100.64.0.0/10 Shared/CGNAT
|| (o[0] == 198 && (o[1] & 0xFE) == 18) // 198.18.0.0/15 Benchmarking
|| (o[0] & 0xF0) == 0xE0 // 224.0.0.0/4 Multicast
|| (o[0] & 0xF0) == 0xF0 // 240.0.0.0/4 Reserved
// 192.0.0.0/24 IETF Protocol Assignments.
// Globally reachable exceptions: 192.0.0.9 (PCP anycast, RFC 7723)
// and 192.0.0.10 (TURN anycast, RFC 8155).
|| (o[0] == 192 && o[1] == 0 && o[2] == 0
&& o[3] != 9 && o[3] != 10)
|| (o[0] == 192 && o[1] == 0 && o[2] == 2) // 192.0.2.0/24 TEST-NET-1
// 192.88.99.0/24 deprecated 6to4 relay anycast (RFC 7526).
// Registry global field is None/blank — conservative posture: block.
|| (o[0] == 192 && o[1] == 88 && o[2] == 99)
|| (o[0] == 198 && o[1] == 51 && o[2] == 100) // 198.51.100.0/24 TEST-NET-2
|| (o[0] == 203 && o[1] == 0 && o[2] == 113) // 203.0.113.0/24 TEST-NET-3
}
std::net::IpAddr::V6(v6) => {
// Check IPv4-compatible and mapped addresses against IPv4 rules.
// IPv4-compatible and IPv4-mapped addresses are checked against IPv4 rules.
if let Some(v4) = v6.to_ipv4() {
return is_private_ip(&std::net::IpAddr::V4(v4));
return is_not_global_unicast(&std::net::IpAddr::V4(v4));
}
let segments = v6.segments();
let s = v6.segments();
// NAT64 well-known prefix (RFC 6052). Preserve access to public IPv4
// destinations while rejecting embedded private/reserved addresses.
// NAT64 well-known prefix (RFC 6052): reachability follows the embedded
// IPv4 address (registry global=True, but SSRF policy checks payload).
if let Some(v4) = embedded_ipv4(v6, &NAT64_WELL_KNOWN_PREFIX) {
return is_private_ip(&std::net::IpAddr::V4(v4));
return is_not_global_unicast(&std::net::IpAddr::V4(v4));
}
// Legacy SIIT IPv4-translated addresses can route to the IPv4 value
// in their final four octets but are not recognized by `to_ipv4()`.
// SIIT IPv4-translated addresses (::ffff:0:0:0/96) route to the embedded
// IPv4 value and are not recognised by `to_ipv4()`.
if let Some(v4) = embedded_ipv4(v6, &IPV4_TRANSLATED_PREFIX) {
return is_private_ip(&std::net::IpAddr::V4(v4));
return is_not_global_unicast(&std::net::IpAddr::V4(v4));
}
v6.is_loopback()
|| v6.is_unspecified()
|| segments[0] & 0xfe00 == 0xfc00 // fc00::/7 ULA
|| segments[0] & 0xffc0 == 0xfe80 // fe80::/10 link-local
|| segments[0] & 0xff00 == 0xff00 // ff00::/8 multicast
|| (segments[0] == 0x0064
&& segments[1] == 0xff9b
&& segments[2] == 1) // 64:ff9b:1::/48 local-use NAT64
|| (segments[0] == 0x2001 && segments[1] == 0) // 2001::/32 Teredo
|| segments[0] == 0x2002 // 2002::/16 6to4
// RFC 3849 — documentation range, should never appear in production
|| (segments[0] == 0x2001 && segments[1] == 0x0db8)
if v6.is_loopback() || v6.is_unspecified() {
return true;
}
// 2001::/23 IETF Protocol Assignments envelope (registry global=False).
// All addresses within the /23 are non-global by default, with explicit
// globally-reachable exceptions carved out below.
//
// /23 check: segments[0]==0x2001 and top 7 bits of segments[1] are zero
// (i.e., segments[1] in [0x0000..0x01ff]).
if s[0] == 0x2001 && (s[1] >> 9) == 0 {
// Globally reachable exceptions inside 2001::/23 (registry global=True):
// 2001:1::1 PCP Anycast RFC 7723
// 2001:1::2 TURN Anycast RFC 8155
// 2001:1::3 DNS-SD SRP Anycast RFC 9665
// 2001:3::/32 AMT RFC 7450
// 2001:4:112::/48 AS112-v6 RFC 7535
// 2001:20::/28 ORCHIDv2 RFC 7343 (segments[1] in 0x0020..0x002f)
// 2001:30::/28 DETs Prefix RFC 9374 (segments[1] in 0x0030..0x003f)
let is_global_exception = (s[1] == 1
&& s[2] == 0
&& s[3] == 0
&& s[4] == 0
&& s[5] == 0
&& s[6] == 0
&& matches!(s[7], 1..=3))
|| s[1] == 3 // 2001:3::/32 AMT
|| (s[1] == 4 && s[2] == 0x0112) // 2001:4:112::/48 AS112-v6
|| (s[1] >> 4) == 0x0002 // 2001:20::/28 ORCHIDv2
|| (s[1] >> 4) == 0x0003; // 2001:30::/28 DETs
if !is_global_exception {
return true;
}
}
s[0] & 0xfe00 == 0xfc00 // fc00::/7 ULA
|| s[0] & 0xffc0 == 0xfe80 // fe80::/10 link-local
|| s[0] & 0xffc0 == 0xfec0 // fec0::/10 deprecated site-local (RFC 3879)
|| s[0] & 0xff00 == 0xff00 // ff00::/8 multicast
// 64:ff9b:1::/48 local-use NAT64 (RFC 8215)
|| (s[0] == 0x0064 && s[1] == 0xff9b && s[2] == 1)
// 100::/64 Discard-Only (RFC 6666)
|| (s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 0)
// 100:0:0:1::/64 Dummy IPv6 Prefix (RFC 9780)
|| (s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 1)
// 2001:db8::/32 Documentation (RFC 3849) — outside 2001::/23
|| (s[0] == 0x2001 && s[1] == 0x0db8)
|| s[0] == 0x2002 // 2002::/16 6to4 (RFC 3056)
// 3fff::/20 Documentation (RFC 9637)
|| (s[0] == 0x3fff && (s[1] >> 12) == 0)
|| s[0] == 0x5f00 // 5f00::/16 SRv6 SIDs (RFC 9252)
}
}
}
/// Compatibility alias; prefer [`is_not_global_unicast`].
#[inline]
pub fn is_private_ip(ip: &std::net::IpAddr) -> bool {
is_not_global_unicast(ip)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::IpAddr;
#[test]
fn test_loopback_v4() {
assert!(is_private_ip(&"127.0.0.1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_private_10() {
assert!(is_private_ip(&"10.0.0.1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_private_172() {
assert!(is_private_ip(&"172.16.0.1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_private_192() {
assert!(is_private_ip(&"192.168.1.1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_link_local() {
assert!(is_private_ip(&"169.254.1.1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_unspecified() {
assert!(is_private_ip(&"0.0.0.0".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_broadcast() {
assert!(is_private_ip(&"255.255.255.255".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_public_v4() {
assert!(!is_private_ip(&"8.8.8.8".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_loopback_v6() {
assert!(is_private_ip(&"::1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_unspecified_v6() {
assert!(is_private_ip(&"::".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_ula_v6() {
assert!(is_private_ip(&"fd00::1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_link_local_v6() {
assert!(is_private_ip(&"fe80::1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_public_v6() {
assert!(!is_private_ip(&"2606:4700::1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_documentation_range_v6() {
// 2001:db8::/32 — RFC 3849 documentation range, must be blocked
assert!(is_private_ip(&"2001:db8::1".parse::<IpAddr>().unwrap()));
assert!(is_private_ip(
&"2001:db8:ffff::1".parse::<IpAddr>().unwrap()
));
}
#[test]
fn test_ipv4_mapped_v6_private() {
// ::ffff:10.0.0.1 is an IPv4-mapped IPv6 address pointing to a private IPv4
assert!(is_private_ip(&"::ffff:10.0.0.1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_ipv4_mapped_v6_loopback() {
assert!(is_private_ip(
&"::ffff:127.0.0.1".parse::<IpAddr>().unwrap()
));
}
#[test]
fn test_ipv4_mapped_v6_public() {
assert!(!is_private_ip(&"::ffff:8.8.8.8".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_ipv4_compatible_v6_private() {
assert!(is_private_ip(&"::10.0.0.1".parse::<IpAddr>().unwrap()));
assert!(is_private_ip(&"::127.0.0.1".parse::<IpAddr>().unwrap()));
assert!(is_private_ip(
&"::169.254.169.254".parse::<IpAddr>().unwrap()
));
assert!(!is_private_ip(&"::8.8.8.8".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_nat64_well_known_prefix() {
let first = "64:ff9b::".parse().unwrap();
let last = "64:ff9b::ffff:ffff".parse().unwrap();
assert_eq!(
embedded_ipv4(&first, &NAT64_WELL_KNOWN_PREFIX),
Some("0.0.0.0".parse().unwrap())
);
assert_eq!(
embedded_ipv4(&last, &NAT64_WELL_KNOWN_PREFIX),
Some("255.255.255.255".parse().unwrap())
);
let embedded = "64:ff9b::172.16.1.2".parse().unwrap();
assert_eq!(
embedded_ipv4(&embedded, &NAT64_WELL_KNOWN_PREFIX),
Some("172.16.1.2".parse().unwrap())
);
assert!(is_private_ip(
&"64:ff9b::10.0.0.1".parse::<IpAddr>().unwrap()
));
assert!(is_private_ip(
&"64:ff9b::127.0.0.1".parse::<IpAddr>().unwrap()
));
assert!(is_private_ip(
&"64:ff9b::169.254.169.254".parse::<IpAddr>().unwrap()
));
assert!(!is_private_ip(
&"64:ff9b::8.8.8.8".parse::<IpAddr>().unwrap()
));
assert!(!is_private_ip(
&"64:ff9a:ffff:ffff:ffff:ffff:ffff:ffff"
.parse::<IpAddr>()
.unwrap()
));
assert!(!is_private_ip(&"64:ff9b::1:0:0".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_ipv4_translated_prefix() {
let first = "0:0:0:0:ffff:0:0:0".parse().unwrap();
let last = "0:0:0:0:ffff:0:ffff:ffff".parse().unwrap();
assert_eq!(
embedded_ipv4(&first, &IPV4_TRANSLATED_PREFIX),
Some("0.0.0.0".parse().unwrap())
);
assert_eq!(
embedded_ipv4(&last, &IPV4_TRANSLATED_PREFIX),
Some("255.255.255.255".parse().unwrap())
);
assert!(is_private_ip(
&"::ffff:0:10.0.0.1".parse::<IpAddr>().unwrap()
));
assert!(is_private_ip(
&"::ffff:0:127.0.0.1".parse::<IpAddr>().unwrap()
));
assert!(is_private_ip(
&"::ffff:0:169.254.169.254".parse::<IpAddr>().unwrap()
));
assert!(!is_private_ip(
&"::ffff:0:8.8.8.8".parse::<IpAddr>().unwrap()
));
assert!(!is_private_ip(
&"0:0:0:0:fffe:ffff:ffff:ffff".parse::<IpAddr>().unwrap()
));
assert!(!is_private_ip(
&"0:0:0:0:ffff:1:0:0".parse::<IpAddr>().unwrap()
));
}
#[test]
fn test_nat64_local_use_prefix_boundaries() {
assert!(is_private_ip(&"64:ff9b:1::".parse::<IpAddr>().unwrap()));
assert!(is_private_ip(
&"64:ff9b:1:ffff:ffff:ffff:ffff:ffff"
.parse::<IpAddr>()
.unwrap()
));
assert!(!is_private_ip(
&"64:ff9b::ffff:ffff:ffff:ffff:ffff"
.parse::<IpAddr>()
.unwrap()
));
assert!(!is_private_ip(&"64:ff9b:2::".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_teredo_prefix_boundaries() {
assert!(is_private_ip(&"2001::".parse::<IpAddr>().unwrap()));
assert!(is_private_ip(
&"2001:0:ffff:ffff:ffff:ffff:ffff:ffff"
.parse::<IpAddr>()
.unwrap()
));
assert!(!is_private_ip(
&"2000:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
.parse::<IpAddr>()
.unwrap()
));
assert!(!is_private_ip(&"2001:1::1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_6to4_prefix_boundaries() {
assert!(is_private_ip(&"2002::".parse::<IpAddr>().unwrap()));
assert!(is_private_ip(
&"2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
.parse::<IpAddr>()
.unwrap()
));
assert!(!is_private_ip(
&"2001:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
.parse::<IpAddr>()
.unwrap()
));
assert!(!is_private_ip(&"2003::1".parse::<IpAddr>().unwrap()));
fn blocked(s: &str) -> bool {
is_not_global_unicast(&s.parse::<IpAddr>().unwrap())
}
// CGNAT (RFC 6598) — 100.64.0.0/10
#[test]
fn test_cgnat_start() {
// 100.64.0.1 — start of CGNAT range
assert!(is_private_ip(&"100.64.0.1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_cgnat_end() {
// 100.127.255.254 — end of CGNAT range
assert!(is_private_ip(&"100.127.255.254".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_cgnat_below_range() {
// 100.63.255.255 — just below CGNAT range (100.0–100.63 is public)
assert!(!is_private_ip(&"100.63.255.255".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_cgnat_above_range() {
// 100.128.0.0 — just above CGNAT range (100.128+ is public)
assert!(!is_private_ip(&"100.128.0.0".parse::<IpAddr>().unwrap()));
fn public_v4() {
assert!(!blocked("1.1.1.1"));
assert!(!blocked("8.8.8.8"));
}
// Benchmarking (RFC 2544) — 198.18.0.0/15
#[test]
fn test_benchmarking_start() {
assert!(is_private_ip(&"198.18.0.1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_benchmarking_end() {
assert!(is_private_ip(&"198.19.255.254".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_benchmarking_below_range() {
// 198.17.255.255 — just below benchmarking range
assert!(!is_private_ip(&"198.17.255.255".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_benchmarking_above_range() {
// 198.20.0.0 — just above benchmarking range
assert!(!is_private_ip(&"198.20.0.0".parse::<IpAddr>().unwrap()));
fn public_v6_cloudflare() {
assert!(!blocked("2606:4700::1"));
}
// IPv6 multicast — ff00::/8
#[test]
fn test_ipv6_multicast_all_nodes() {
// ff02::1 — all-nodes multicast
assert!(is_private_ip(&"ff02::1".parse::<IpAddr>().unwrap()));
fn loopback_and_unspecified() {
assert!(blocked("127.0.0.1"));
assert!(blocked("0.0.0.0"));
assert!(blocked("::1"));
assert!(blocked("::"));
}
#[test]
fn test_ipv6_multicast_all_routers() {
// ff02::2 — all-routers multicast
assert!(is_private_ip(&"ff02::2".parse::<IpAddr>().unwrap()));
fn private_rfc1918() {
assert!(blocked("10.0.0.1"));
assert!(blocked("172.16.0.1"));
assert!(blocked("192.168.1.1"));
}
#[test]
fn test_ipv6_multicast_high() {
// ffff::1 — still in ff00::/8
assert!(is_private_ip(&"ffff::1".parse::<IpAddr>().unwrap()));
fn link_local() {
assert!(blocked("169.254.1.1"));
assert!(blocked("fe80::1"));
}
#[test]
fn test_ipv6_not_multicast() {
// fe00:: — just below ff00::/8 (not multicast, not link-local, not ULA)
assert!(!is_private_ip(&"fe00::1".parse::<IpAddr>().unwrap()));
fn broadcast() {
assert!(blocked("255.255.255.255"));
}
#[test]
fn cgnat() {
assert!(blocked("100.64.0.1"));
assert!(blocked("100.127.255.254"));
assert!(!blocked("100.63.255.255"));
assert!(!blocked("100.128.0.0"));
}
#[test]
fn benchmarking_v4() {
assert!(blocked("198.18.0.1"));
assert!(blocked("198.19.255.254"));
assert!(!blocked("198.17.255.255"));
assert!(!blocked("198.20.0.0"));
}
#[test]
fn multicast_and_reserved_v4() {
assert!(blocked("224.0.0.0"));
assert!(blocked("239.255.255.255"));
assert!(blocked("240.0.0.0"));
assert!(blocked("254.255.255.255"));
assert!(!blocked("223.255.255.255"));
}
// Most of 192.0.0.0/24 is non-global; 192.0.0.9 (PCP, RFC 7723) and
// 192.0.0.10 (TURN, RFC 8155) are the only globally-reachable exceptions.
#[test]
fn ietf_protocol_assignments() {
assert!(blocked("192.0.0.0"));
assert!(blocked("192.0.0.1"));
assert!(blocked("192.0.0.170")); // NAT64/DNS64 discovery — non-global
assert!(blocked("192.0.0.255"));
assert!(!blocked("192.0.0.9")); // PCP Anycast (RFC 7723) — global
assert!(!blocked("192.0.0.10")); // TURN Anycast (RFC 8155) — global
}
#[test]
fn documentation_v4() {
assert!(blocked("192.0.2.0"));
assert!(blocked("192.0.2.255"));
assert!(blocked("198.51.100.0"));
assert!(blocked("198.51.100.255"));
assert!(blocked("203.0.113.0"));
assert!(blocked("203.0.113.255"));
assert!(!blocked("192.0.1.255"));
assert!(!blocked("192.0.3.0"));
assert!(!blocked("198.51.99.255"));
assert!(!blocked("198.51.101.0"));
assert!(!blocked("203.0.112.255"));
assert!(!blocked("203.0.114.0"));
}
// Registry global field is None/blank; conservative posture: block.
#[test]
fn deprecated_6to4_anycast_v4() {
assert!(blocked("192.88.99.0"));
assert!(blocked("192.88.99.1"));
assert!(blocked("192.88.99.255"));
assert!(!blocked("192.88.98.255"));
assert!(!blocked("192.88.100.0"));
}
#[test]
fn ula_v6() {
assert!(blocked("fd00::1"));
assert!(blocked("fc00::1"));
}
#[test]
fn multicast_v6() {
assert!(blocked("ff02::1"));
assert!(blocked("ff02::2"));
assert!(blocked("ffff::1"));
assert!(!blocked("fe00::1"));
}
#[test]
fn ietf_protocol_assignments_v6_interior() {
assert!(blocked("2001::"));
assert!(blocked("2001:2::1"));
assert!(blocked("2001:10::1"));
assert!(blocked("2001:db8::1")); // Documentation — outside /23 but blocked separately
assert!(blocked("2001:1ff:ffff::1"));
assert!(!blocked("2001:200::1"));
}
#[test]
fn ietf_protocol_assignments_v6_global_exceptions() {
// PCP/TURN/DNS-SD anycast /128s — registry global=True
assert!(!blocked("2001:1::1")); // PCP Anycast (RFC 7723)
assert!(!blocked("2001:1::2")); // TURN Anycast (RFC 8155)
assert!(!blocked("2001:1::3")); // DNS-SD SRP Anycast (RFC 9665)
assert!(blocked("2001:1::4")); // not an exception
assert!(blocked("2001:1:1::1")); // not an exception
// 2001:3::/32 AMT — registry global=True
assert!(!blocked("2001:3::1"));
assert!(!blocked("2001:3:ffff::1"));
assert!(blocked("2001:4::1"));
// 2001:4:112::/48 AS112-v6 — registry global=True
assert!(!blocked("2001:4:112::1"));
assert!(!blocked("2001:4:112:ffff::1"));
assert!(blocked("2001:4:113::1"));
// 2001:20::/28 ORCHIDv2 — registry global=True
assert!(!blocked("2001:20::1"));
assert!(!blocked("2001:2f::1"));
assert!(blocked("2001:10::1"));
// 2001:30::/28 DETs — registry global=True
assert!(!blocked("2001:30::1"));
assert!(!blocked("2001:3f::1"));
assert!(!blocked("2001:3::1")); // AMT exception — distinct check
}
#[test]
fn documentation_v6() {
assert!(blocked("2001:db8::1"));
assert!(blocked("2001:db8:ffff::1"));
}
#[test]
fn six_to_four_v6() {
assert!(blocked("2002::"));
assert!(blocked("2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff"));
assert!(!blocked("2003::1"));
}
#[test]
fn discard_only_v6() {
assert!(blocked("100::1"));
assert!(blocked("100::ffff:ffff:ffff:ffff"));
assert!(!blocked("100:0:1::1")); // outside both discard and dummy ranges
}
#[test]
fn dummy_prefix_v6() {
assert!(blocked("100:0:0:1::"));
assert!(blocked("100:0:0:1:ffff:ffff:ffff:ffff"));
assert!(!blocked("100:0:0:2::1"));
}
#[test]
fn nat64_local_use_v6() {
assert!(blocked("64:ff9b:1::"));
assert!(blocked("64:ff9b:1:ffff:ffff:ffff:ffff:ffff"));
assert!(!blocked("64:ff9b:2::"));
}
#[test]
fn documentation_3fff_v6() {
assert!(blocked("3fff::1"));
assert!(blocked("3fff:0fff::1"));
assert!(!blocked("3fff:1000::1"));
assert!(!blocked("3ffe::1"));
}
#[test]
fn srv6_sids_v6() {
assert!(blocked("5f00::1"));
assert!(blocked("5f00:ffff::1"));
assert!(!blocked("5e00::1"));
assert!(!blocked("5fff::1")); // 5fff ≠ 5f00 — outside /16
}
#[test]
fn nat64_well_known_v6() {
assert!(blocked("64:ff9b::10.0.0.1")); // private embedded
assert!(blocked("64:ff9b::127.0.0.1")); // loopback embedded
assert!(blocked("64:ff9b::169.254.169.254")); // link-local embedded
assert!(!blocked("64:ff9b::8.8.8.8")); // public embedded — policy follows payload
assert!(!blocked("64:ff9a:ffff:ffff:ffff:ffff:ffff:ffff")); // different prefix
assert!(!blocked("64:ff9b::1:0:0")); // outside /96
}
#[test]
fn ipv4_translated_v6() {
assert!(blocked("::ffff:0:10.0.0.1"));
assert!(blocked("::ffff:0:127.0.0.1"));
assert!(!blocked("::ffff:0:8.8.8.8"));
assert!(!blocked("0:0:0:0:fffe:ffff:ffff:ffff")); // outside prefix
}
#[test]
fn ipv4_mapped_v6() {
assert!(blocked("::ffff:10.0.0.1"));
assert!(blocked("::ffff:127.0.0.1"));
assert!(!blocked("::ffff:8.8.8.8"));
}
#[test]
fn ipv4_compatible_v6() {
assert!(blocked("::10.0.0.1"));
assert!(blocked("::127.0.0.1"));
assert!(!blocked("::8.8.8.8"));
}
#[test]
fn deprecated_site_local_fec0() {
// fec0::/10 — deprecated IPv6 site-local (RFC 3879); blocked as non-global.
assert!(blocked("fec0::1"));
assert!(blocked("feff::1")); // fec0::/10 boundary
}
}