Skip to content

feat(auth): add NIP-FI canonical assertion verifier and contracts - #6776

Open
wpfleger96 wants to merge 9 commits into
mainfrom
duncan/nip-fi-verifier-contracts
Open

feat(auth): add NIP-FI canonical assertion verifier and contracts#6776
wpfleger96 wants to merge 9 commits into
mainfrom
duncan/nip-fi-verifier-contracts

Conversation

@wpfleger96

Copy link
Copy Markdown
Member

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. No issuer URL, audience, or claim name is hardcoded; all are deployment configuration.
  • The two deterministic semantic contract identitiesAssertionPolicyId = 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.
  • 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 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.

Corrections applied to the mined source

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

  • Token-class selection with exact typ enforcement (at+jwt, nip-fi+jwt, named-compatibility JWT).
  • OIDC ID-token denial — denies even when iss, aud, and sub match.
  • 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.
  • missing_evidence and evidence_rejected denial classes added; spec-exact time arithmetic (now < exp, iat <= now + skew, now < iat + maximum_assertion_age, equality at expiry is expired).

Verification

New behavior tests in crates/buzz-auth/tests/nip_fi_verifier.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, token-class enforcement including ID-token denial, nostr_pubkey hex handling, time bounds, and multi-issuer selection (same subject across distinct issuers yields distinct identities and policy IDs).

Notes

Dependencies: none.

Introduce the closed, provider-neutral contract layer at the root of the
NIP-FI federated-identity dependency graph (Phase A, PR 1). This has no
dependencies on later PRs and defines no schema, migration, runtime JWKS
fetching, binding resolution, or request/proof binding.

The module provides:

- A multi-issuer assertion-policy registry keyed by exact `(iss, sub)`,
  with the two deterministic semantic contract identities
  (`AssertionPolicyId`, `TransportContractId`) derived by length-prefixed
  domain-separated hashing so benign JWKS rotation never changes policy
  lineage.
- The single `FederatedAssertionVerifier` (`FI-INV-16`) producing the
  origin-sealed, provider-neutral `VerifiedAssertion` result.
- The privacy-preserving four-class denial contract (`FI-INV-13`) with the
  byte-exact wire text, HTTP status, body, and headers fixed by the spec.

Mined from #1476's `buzz-auth` verifier core and corrected to the settled
spec (merged NIP-FI docs, #5946): token-class plus `typ` enforcement,
OIDC ID-token denial, the fixed lowercase-hex-only `nostr_pubkey` claim,
resource-owner/client-subject exclusivity, and spec-exact time arithmetic.
Identity is issuer-qualified throughout; no issuer, audience, or claim name
is hardcoded — all are deployment configuration.

Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 requested a review from a team as a code owner August 25, 2026 15:07
Duncan and others added 8 commits August 25, 2026 11:50
…ct gaps

Address pass-1 review of the NIP-FI verifier/contracts layer.

Bind each AssertionKeySet to the exact issuer it authenticates and reject
a token whose signed iss does not match the supplied snapshot, closing a
cross-issuer authentication bypass under caller misuse. Reject duplicate
JOSE/claim members with a duplicate-detecting deserializer so parser-
differential ambiguity cannot smuggle a different value past the signature
gate. Fold a versioned verifier-contract fingerprint and the normative size
bounds into assertion_policy_id and version transport_contract_id, so a
semantic change moves its owning ID while JWKS rotation does not. Model
resource-owner vs client-subject as a closed, mutually-exclusive
SubjectClassContract that denies ambiguous/unclassifiable subjects and gates
client-subject eligibility on a recorded non-collision posture. Require the
access-token-only client_id claim for named-compatibility policies so they
are provably exclusive with OIDC ID tokens. Preserve identity bytes exactly
(no trimming). Deny current-status freshness in this verifier until the
status-witness runtime lands, rather than sealing without the witness.

Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The pass-1 fix bound each AssertionKeySet to an issuer and compared that
label to the policy at verify time, but verify() still took the snapshot
as a caller argument and AssertionKeySet::new is public. A request-path
caller could therefore construct issuer B's JWKS labelled issuer A and mint
a sealed (A, victim) identity — the cross-issuer key-source confusion was
only detected for honest mislabelling, not prevented.

Make the key source structural: FederatedAssertionVerifier holds a trusted
IssuerKeySource and resolves the snapshot internally by the token's
signature-authenticated iss. verify() now takes only the token, so there is
no seam through which a caller can supply or relabel key material. A missing
snapshot for a registered issuer is an unreadable authoritative dependency
(KeySourceUnavailable -> AuthorizationUnavailable), not rejected evidence.
A defensive re-check still denies a source that violates its issuer-binding
contract. PR 3 supplies a live-refreshing IssuerKeySource additively.

Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…r-contracts

* origin/main:
  Qualify canonical relay images for staged delivery (#6781)
  feat(desktop): persist agent addressing across composer messages (#6714)
  feat: navigate images across message threads (#6705)
  Add database pressure observability (#6700)
  revert fixed mention highlight (#6716)
  highlight search terms in results and messages (#6702)
  fix(desktop): make lightbox zoom controls interactive (#6710)
  Support community deletion in versioned media buckets (#6738)
  Fix TipTap editor mount race (#6779)
  feat(buzz-agent): gate LLM tool calls on session/request_permission (#5712)
  Add staging dev relay image workflow (#6709)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
The pass-2 fix moved cross-issuer forgeability off the verify() request seam
but left it at the authority-construction seam: AssertionKeySet::new and the
IssuerKeySource trait were public, so an external buzz_auth consumer could
build a source returning issuer B's JWKS labelled as issuer A, pass it to the
public FederatedAssertionVerifier::new, and mint a sealed (A, victim) identity.

Make the accepted issuer->JWKS authority entirely crate-owned:
- IssuerKeySource gains a private sealed supertrait, so no external crate can
  implement it.
- AssertionKeySet::new becomes pub(crate); a test-utils-gated for_test keeps
  the crate-owned source usable from integration tests.
- A crate-owned, test-utils-gated StaticIssuerKeySource replaces the external
  MapKeySource the tests defined.

compile_fail doctests (compiled as external consumers) prove neither the trait
nor the constructor can be named from outside the crate.

The verifier's regression suite lived outside 'just test-unit' (which runs
buzz-auth --lib only), so it ran in no CI job. Add the integration suite
(--features test-utils) and the doctests to the unit-test recipe so the
cross-issuer regression and the seal are actually gated by CI.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…r-contracts

* origin/main:
  Remove public relay signing key fallback (#6729)
  docs(nest): make commit attribution policy-neutral (#6707)
  fix(desktop-messages): preserve inline agent mentions with persistent addressing (#6793)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
A downstream-selectable Cargo feature is not an access-control boundary:
features are unified and any dependent could enable buzz-auth's test-utils to
reach AssertionKeySet::for_test and the StaticIssuerKeySource, reconstruct a
relabelled issuer->JWKS authority, and mint a sealed (A, victim) identity from
issuer B's real key.

Remove for_test entirely, gate StaticIssuerKeySource and its sealed
IssuerKeySource impl on cfg(test) only, and move the verifier regression suite
into an in-crate #[cfg(test)] mod tests so it reaches the pub(crate)/cfg(test)
primitives with zero public feature surface. No supported feature set now
exposes an authority-relabelling path; the compile_fail doctests remain as a
default-feature seal regression.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Moving the constructor's only callers under cfg(test) left the non-test
lib build with no caller, so clippy -D warnings rejected it as dead code
(Rust Lint, Windows Rust). PR 3's JWKS runtime is the intended non-test
consumer; narrowly allow dead_code on this one item until it lands rather
than deferring the constructor or widening the lint. expect would misfire
because the lint does not trigger under cfg(test). Also repoint a stale
test comment at the two real compile-fail doctests.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The dead_code compile error previously masked this: clippy could not
reach empty_line_after_doc_comments until the lib built. The /// block
above test_jwks is module narrative about the shared source, not that
function's API docs, so convert it to // and remove the trailing blank.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant