Files
buzz/crates/buzz-auth
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
..