Commit Graph

3 Commits

Author SHA1 Message Date
Will Pfleger
70895b355f 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>
2026-09-01 13:00:40 -04:00
Will Pfleger
953a1798b2 feat(auth): add NIP-FI canonical assertion verifier and contracts (#6776)
The closed, provider-neutral contract layer at the root of the NIP-FI
federated-identity dependency graph — Phase A, PR 1 of the plan. It has
no dependencies on any other PR and defines no database schema,
migration, runtime JWKS fetching, binding resolution, enrollment, or
request/proof binding; those belong to later PRs.

## What this adds

- **Multi-issuer assertion-policy config** (`IssuerRegistry`,
`IssuerPolicy`) keyed by exact `iss`. Identity is issuer-qualified
`(iss, sub)` throughout — equal `sub` under different `iss` are distinct
identities. The subject coordinate is fixed to the JWT `sub` claim
(`SUBJECT_CLAIM`), never configurable, so no deployment can seal a
mutable attribute like `email` as identity. Issuer URL and audience
remain deployment configuration.
- **The two deterministic semantic contract identities** —
`AssertionPolicyId = H(canonical assertion-policy contract)` and
`TransportContractId = H(canonical transport contract)` — derived by
length-prefixed, domain-separated SHA-256 so a semantic change moves
exactly its owning ID while benign JWKS rotation never changes policy
lineage. Set-valued policy inputs (audiences, algorithms, subject-class
values, scope capture) are canonicalized before derivation, so the ID is
invariant under permutation and duplication. Config fields that a
freshness class never reads are rejected at construction (an
`offline-jwt` policy cannot carry `maximum_status_age`), so the
canonical encoding stays total over valid configs and semantically
identical policies always derive one ID.
- **The single canonical verifier** (`FederatedAssertionVerifier`,
`FI-INV-16`) producing the origin-sealed, provider-neutral
`VerifiedAssertion` normalized result. Its constructor is crate-private,
so unverified claims cannot be promoted into authority. The issuer→JWKS
authority is entirely crate-owned: `AssertionKeySet` has no public
constructor and `IssuerKeySource` is sealed, so no downstream crate can
relabel one issuer's keys as another's. The authenticated key set is
bounded (`MAX_JWKS_KEYS`) and the bound is folded into
`AssertionPolicyId`, so an unbounded attacker-controlled `kid` scan
cannot be driven; every snapshot requires a finite positive hard
deadline.
- **Sealed revalidation dependencies** — `RevalidationDependencies`
carries the key-snapshot hard deadline and a `ConfidentialAssertion`
handle to the exact compact JWS (no `Debug`/`Display`/`serde` leak, sole
read path `compact_jws()`), so a changed snapshot can revalidate the
same evidence: a retained key revalidates, a removed key denies.
- **The privacy-preserving four-class denial contract** (`DenialClass`,
`FI-INV-13`) with the byte-exact Nostr text, HTTP status, body,
`Content-Type`, and `WWW-Authenticate` values fixed by the spec's
rejection table. An unreadable required current dependency maps to
`authorization_unavailable`/503, never to rejected evidence, and
rejected evidence never masquerades as a 503 at either end of the
pipeline: all bounded, dependency-independent checks (compact structure,
header, signature shape, policy, algorithm, token class) precede
key-source lookup, and all offline validation (token-class, key,
signature, audience, claims, time) completes before any status-witness
deferral. A wrong-`typ` or structurally malformed token, or
malformed/invalidly-signed input naming a current-status issuer,
therefore denies with `evidence_rejected`/403 rather than being reported
as a 503 availability signal.

## Corrections applied to the mined source

Mined from the `buzz-auth` verifier core in
[#1476](https://github.com/block/buzz/pull/1476) and corrected to the
settled spec (the merged `docs/nips/NIP-FI*.md`,
[#5946](https://github.com/block/buzz/pull/5946)), which settled after
#1476 was written:

- Token-class selection with exact `typ` enforcement. Two classes are
offered: `at+jwt` and `nip-fi+jwt`. There is deliberately no
generic/absent-`typ` "named compatibility" class: it cannot be proven
disjoint from an OIDC ID token by claim presence alone (an issuer can
mint an ID token carrying `client_id`), and the only authenticated
discriminator is `typ`, which such a mode declines to constrain.
- OIDC ID-token denial — denies even when `iss`, `aud`, and `sub` match,
via exact `typ` mismatch against every accepted class.
- The fixed `nostr_pubkey` claim accepted only as lowercase hex of
exactly one 32-byte key; bech32 and other aliases deny.
- Resource-owner / client-subject ambiguity denial and required
`client_id` for `at+jwt`.
- JWK admissibility: a key's `use` and, when present, `key_ops` must
authorize signature verification (a key restricted to other operations
such as `encrypt` is rejected), and the selected JOSE algorithm is bound
to the key's required family and curve (ES256↔EC/P-256, ES384↔EC/P-384,
EdDSA↔OKP/Ed25519, RS/PS↔RSA). The JWK `alg` is advisory; the actual key
material is what signs, so a JWK declaring a matching `alg` over
mismatched material (a different family or curve) is rejected before
signature verification.
- Spec-exact time arithmetic (`now < exp`, `iat <= now + skew`, `now <
iat + maximum_assertion_age`, equality at expiry is expired).
`exp`/`iat`/`nbf` accept finite integer or fractional RFC 7519
`NumericDate` values with checked, overflow-safe conversion; non-finite
and out-of-range values deny.

## Verification

In-crate tests in `crates/buzz-auth/src/nip_fi/verifier/tests.rs` sign
real ES256 assertions against a fixed test key and cover: the happy
path, exact-wire-text for all four denial classes, deterministic and
semantic contract IDs, canonicalization invariance, token-class
enforcement including ID-token denial, JWK `key_ops` rejection,
`nostr_pubkey` hex handling, time bounds, and multi-issuer selection
(same subject across distinct issuers yields distinct identities and
policy IDs). They also cover the round-3 contracts: the key-set bound
(oversized rejected, at-bound accepted, empty rejected), fixed-`sub`
identity (identity is `sub` not a configured `email`; a token without
`sub` denies), offline-before-deferral (invalid-signature,
wrong-audience, malformed-claim, and expired under a current-status
issuer each deny 403/`evidence_rejected`), and the revalidation
contracts (dependencies carry the deadline and exact JWS; a retained key
revalidates, a removed key denies). And the round-4 contracts:
algorithm↔key-material binding, covered exactly per accepted algorithm
(a table-driven matrix asserts every policy-acceptable algorithm —
ES256, ES384, EdDSA, RS256/384/512, PS256/384/512 — matches only its
required family/curve and rejects every other, so each mapping mutation
fails individually; and an ES256 token against P-384, RSA, and Ed25519
material each deny `InvalidKey`); malformed evidence classified before
key lookup (wrong-`typ`, two-segment, four-segment, empty-signature, and
non-base64url-signature tokens against an empty source deny 403 not
503); `maximum_status_age` inapplicable under offline-jwt rejected at
construction while current-status still requires it; and fractional
`NumericDate` (finite fractional `iat`/`exp`/`nbf` within bounds verify,
non-finite and out-of-range deny). Compile-fail doctests guard the
sealed authority seam (`AssertionKeySet::new`, `IssuerKeySource`) and
the absence of the named-compatibility token class.

## Notes

- `Co-authored-by` attribution is preserved for the mined author. The
#1476 `buzz-auth` commits are authored by Cea Stapleton Cordasco (the
salvage map records #1476 under Franco; the git history on that PR is
Cea's — surfacing for the attribution/closure record).
- Amends `docs/nips/NIP-FI.md` to remove the "named compatibility access
token" class and its `FI-TRACE-TOKEN-CLASS` oracle reference, so the
normative spec matches the two-class root implementation (a
generic/absent `typ` cannot be proven disjoint from an OIDC ID token).
Removal-only, no neighbor redesign; a `docs/nips/` grep confirms no
dangling cross-reference to the class remains.
- Adds `jsonwebtoken 10.4.0` (`aws_lc_rs`) as a workspace dependency.

Dependencies: none.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-28 10:29:25 -04:00
Will Pfleger
d99ad131f1 refactor: rename sprout backend to buzz (#958)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <wpfleger96@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Will Pfleger <wpfleger96@gmail.com>
2026-06-10 19:29:51 -04:00