mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
## What Comprehensive NIP-FI against `main`: one normative core plus four separately claimable profiles, replacing the single-document structure of #3726 (which was based on #1485's branch, not `main`). Six documents, 1,975 lines, docs only. - **`NIP-FI.md` (core, 632 lines)** — issuer-qualified identity `(iss, sub)`, independent Nostr proof, client-attached assertion, partial bijection with durable tombstones, atomic final admission, bounded leases, private denials with a closed response vocabulary, retire/revoke/rotate, two contract identities (`assertion_policy_id`, `transport_contract_id`), per-policy `skew` / `maximum_assertion_age` / `maximum_status_age` with missing configuration denying, a closed token-class rule (`at+jwt`, `nip-fi+jwt`, named compatibility; ID tokens always deny), declared freshness class (`offline-jwt` | `current-status`), server-declared body authorization relevance (NIP-98 payload-binding fix, including a `payload` tag on an irrelevant-body operation), BCP 14, "equivalent" defined over identity / bounds / provenance classes, a compact non-normative worked wire example, and a non-normative comparison with DPoP, mTLS-bound tokens, and HTTP Message Signatures. FI-INV-01..16 are normative core text. The behavioral-oracle table lists exactly 30 oracle IDs, one per row, with no shorthand. - **`NIP-FI-EDGE.md`** — trusted-edge surface: the `trusted-proxy-hmac-v2` envelope + canonicalization, or a private authenticated-edge adapter under a reviewed contract; `authorization_domain_id` derivation (exact 16 RFC 9562 UUID bytes); `proof_transport_code` registry (0x01 NIP-42, 0x02 NIP-98; 0x03 Git smart-HTTP and 0x04 Blossom reserved pending their transport contracts; 0x05–0x7f unassigned pending published stable specifications) + extension procedure; body-acquisition bounds; three normative test vectors. An independent Nostr proof (the NIP-98 event in `Authorization`, which reaches the verifier byte-identical, or the NIP-42 event after connect) is the only decision input outside the MAC; absent or incomplete provenance on an edge-required route is `missing_evidence`, present-but-failing provenance is `evidence_rejected`. Header-trust-without-provenance is nonconformant. - **`NIP-FI-LIFECYCLE.md`** — provision / disable / re-enable / administrative expiry (`binding_not_after`) / pending-replacement lineage, one conformance trace per privileged transition; every binding-creating transition declares whether it continues or establishes a grant; a private-condition table for CONF enumeration agreement. - **`NIP-FI-DELEG.md`** — delegated agents; explicit temporal boundaries matching core's inclusive-`nbf`/exclusive-`exp` idiom, with the delegated `skew` configured by this profile; lease deadline anchored to the lease issue instant; strict path separation — a delegated request carries no assertion or provenance field, so it cannot traverse an edge-provenance route and uses ingress on which NIP-FI-EDGE is not required. - **`NIP-FI-CONF.md`** — conformance evidence: an immutable claim tuple including the governing document revision and exit fixture digest; the complete 16-row denial-fixture enumeration with three mechanical enumeration-agreement checks; mutation adequacy with a countable denominator — one retained killed mutant per literal oracle-table row (30 core + 6 EDGE + 11 LIFECYCLE + 7 DELEG + 4 CONF = 58), rows selected structurally by their first cell, never by section title, with the release gate and CONF's own oracle rows stated in the same listed-oracle terms and a mutant defined for CONF's own report- and suite-subject oracles; an interoperability exit test compared over signing inputs (per-transport NIP-01 serialization for the NIP-98 and NIP-42 proofs; decoded protected header and claims as JSON values for the assertion), with a shared exit fixture pinning complete pre-signature header/claim JSON and complete unsigned event fields for both transports, and mandatory negative controls. `FI-CONF-INTEROP-EXIT` is `deferred` with reason `no-independent-implementation` until a second independent implementation exists; the canonical fixture is editor-authored at `docs/nips/fixtures/nip-fi-conf-exit.json` and is **not in this PR** — until it is published a claim records `pending-canonical-fixture`, valid only while the exit test is deferred. Explicit not-applicable dispositions, including `offline-jwt` deployments for the two current-status oracles and absence of a revocation-bounded external capability projection for `FI-TRACE-CAPABILITY-REVOCATION`. - **`NIP-FI-MODEL.md`** — non-normative companion; defines no requirement or conformance claim and is not claimable. ## Why The prior draft rated 9 (soundness) / 6 (minimalness) / 7 (elegance) / 7 (correctness) in adversarial + comparative review. This restructure keeps the two-invariant spine untouched, makes everything else a claimable profile, and collapses five stacked versioning mechanisms into two contract identities. Mutation adequacy counts one mutant per literal oracle-table row — a set two implementers enumerate identically — instead of "each normative requirement," which had four defensible readings. Resolved product calls (owner-approved): 1. Enrollment/denial posture is private — boolean enrollment discovery, TOFU extension claim not self-advertised, `key_mismatch → authorization_denied` joins the denial anonymity set, and replayed evidence is classed `authorization_denied` so resubmission reveals nothing about commit. 2. Revocation honesty — only `current-status` deployments may advertise an unconditional residual-revocation bound; `offline-jwt` advertises unbounded/unknown. Access tokens keep RFC 9068 `at+jwt`; `nip-fi+jwt` is reserved for a separately minted Buzz assertion. ## Acceptance bar - Nothing in core is deletable without losing a stated core guarantee. - From the core document plus the CONF exit fixture, a second implementer can produce a valid request equal over the request compared object (signing inputs), and a byte-exact public denial per class — no reference implementation. - Every oracle-table row ships a retained killed mutant satisfying only the entry it was selected for. - Both deployment profiles (trusted proxy = EDGE, client-held OIDC = core client-attached) pass the same lifecycle conformance suite. ## Status Ready at heade720a5cda. Every revision below is on this branch: the 2026-08-17 and 2026-08-18 review laps (Wren, Dawn, Perci, Sami, Mari, Quinn) closed at513e03b06,17d455a9b, and 4f913a813; the 2026-08-20 external line-by-line review (R1–R10, R12) closed across 56e74141e..772ba7a72; the 2026-08-20/21 adversarial lap (#6437) squash-merged as b8db13d88; the round-3 external review (R13, R14), the DELEG×EDGE composition note, and three terminology nits closed at e720a5cda; R11 is this description. Oracle census: 58 (30 core, 6 EDGE, 11 LIFECYCLE, 7 DELEG, 4 CONF). Known follow-ups, filed after merge and out of scope here: adapter-only edge deployments and FI-EDGE claimability; an EDGE private-condition table for CONF's enumeration-agreement check; an enumerable definition of the positive/negative oracle sets used by the global mutation controls; NIP-OA's clock-free verification versus NIP-FI-DELEG's wall-clock expiry. Supersedes #3726 as the spec vehicle; #1485 remains the design-history anchor. --------- Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Signed-off-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@buzz.block.builderlab.xyz> Signed-off-by: Tyler Longwell <tlongwell@squareup.com> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@buzz.block.builderlab.xyz> Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
633 lines
36 KiB
Markdown
633 lines
36 KiB
Markdown
NIP-FI
|
|
======
|
|
|
|
Federated identity authorization — core
|
|
----------------------------------------
|
|
|
|
`draft` `optional` `relay`
|
|
|
|
**Protocol dependencies**: NIP-01 and either NIP-42 or NIP-98. Optional
|
|
profiles are defined by NIP-FI-EDGE, NIP-FI-LIFECYCLE, NIP-FI-DELEG, and
|
|
NIP-FI-CONF.
|
|
|
|
The key words "MUST", "MUST NOT", "REQUIRED", "SHOULD", "SHOULD NOT", and
|
|
"MAY" in this document are to be interpreted as described in BCP 14 (RFC 2119
|
|
and RFC 8174) when, and only when, they appear in all capitals.
|
|
|
|
## Abstract
|
|
|
|
NIP-FI authorizes a Nostr key only when four independent facts agree: a valid
|
|
issuer-qualified identity assertion, fresh proof of that Nostr key, current
|
|
identity-to-key binding state, and current local policy for the exact operation.
|
|
The identity provider never signs Nostr events, and an assertion never replaces
|
|
Nostr proof.
|
|
|
|
Bindings outlive individual assertions. Assertions and authorization leases do
|
|
not outlive their evidence. This core defines the portable client-attached
|
|
assertion transport, direct enrollment, atomic final admission, bounded
|
|
sessions, privacy-preserving denial responses, and the smallest useful binding
|
|
lifecycle. Companion profiles add trusted edges, extended lifecycle operations,
|
|
and delegation without changing the core admission rule.
|
|
|
|
This NIP does not define an identity provider, database schema, operator API,
|
|
public identity projection, application membership policy, or user interface.
|
|
|
|
## Terms and identifier classes
|
|
|
|
- **domain** (`D`): an authorization boundary selected only by authenticated
|
|
server routing and configuration.
|
|
- **identity** (`i`): the exact tuple `(iss, sub)` returned by assertion
|
|
validation. Email, display name, employee number, and a bare `sub` are not
|
|
identities.
|
|
- **target context** (`R_t`): the server-resolved method, authority, path and
|
|
query, body semantics, transport, operation, and resource.
|
|
- **actor** (`k`): the 32-byte public key returned by Nostr-proof validation.
|
|
- **request context** (`R`): `R_t` sealed with `k`.
|
|
- **binding**: a durable, versioned association `(D, i, k)` with immutable
|
|
provenance `attested-key`, `tofu`, or `provisioned`.
|
|
- **retired pair**: a durable denial fact for an exact `(D, i, k)`.
|
|
- **revoked key**: a durable denial fact for `(D, k)`.
|
|
- **prepared authorization**: immutable, read-only evidence and witnesses for a
|
|
possible admission.
|
|
- **committed authorization**: authority returned only after final revalidation
|
|
and atomic commit.
|
|
- **lease**: a cached committed decision for one actor and bounded operation
|
|
set. A lease is not a binding.
|
|
|
|
Identity and authorization-state comparisons preserve every tuple component.
|
|
Equal `sub` values under different `iss` values are distinct identities; equal
|
|
`(i, k)` pairs under different domains are distinct bindings, retired pairs,
|
|
and authorization state. [FI-TRACE-CROSS-DOMAIN-COLLISION]
|
|
|
|
Every identifier is either **interoperability-critical** or
|
|
**deployment-local**. Header names, public response bytes, token type values,
|
|
and trace identifiers are interoperability-critical and fixed here.
|
|
`assertion_policy_id`, `transport_contract_id`, domain IDs, snapshot versions,
|
|
binding versions, policy versions, and correlation IDs are deployment-local;
|
|
their values are opaque outside a deployment, while their stability and
|
|
invalidation behavior are normative.
|
|
|
|
## Core security invariants
|
|
|
|
These labels are the normative home of the NIP-FI invariants. Companion
|
|
profiles may add witnesses and bounds but cannot weaken them.
|
|
|
|
1. **`FI-INV-01 — partial bijection.`** Active bindings are one-to-one within a
|
|
domain: one identity has at most one active key and one key has at most one
|
|
active identity. [FI-TRACE-BINDING-CONFLICT]
|
|
2. **`FI-INV-02 — durable binding.`** Assertion expiry removes neither a
|
|
binding nor its provenance. Fresh eligible evidence may authorize the same
|
|
binding later. [FI-TRACE-ASSERTION-REFRESH]
|
|
3. **`FI-INV-03 — tombstone monotonicity.`** Ordinary authorization never
|
|
removes a retired-pair or revoked-key fact and never recreates a retired
|
|
pair. [FI-TRACE-TOMBSTONE-REPLAY]
|
|
4. **`FI-INV-04 — server-owned context.`** Every admitted operation uses one
|
|
server-resolved domain, target, resource, operation, and proven actor.
|
|
Unauthenticated input cannot replace them. [FI-TRACE-DOMAIN-SPOOF]
|
|
5. **`FI-INV-05 — independent evidence.`** Direct authorization requires a
|
|
current assertion and fresh Nostr proof. If the assertion names a key, it
|
|
equals the proven actor. [FI-TRACE-ASSERTION-KEY-MISMATCH]
|
|
6. **`FI-INV-06 — stable assertion policy.`** Assertion-policy identity changes
|
|
when accepted assertion semantics change, but not when only authenticated
|
|
key or status snapshot contents rotate. [FI-TRACE-VERIFIER-PARITY]
|
|
7. **`FI-INV-07 — current-snapshot verification.`** Evidence cannot survive
|
|
removal of the key or policy snapshot that authenticated it; a changed
|
|
snapshot requires revalidation. [FI-TRACE-JWKS-REMOVE]
|
|
8. **`FI-INV-08 — read-only preparation.`** Preparation creates no binding,
|
|
tombstone, replay claim, receipt, lease, publication, last-seen value, audit
|
|
authority, or application mutation. [FI-TRACE-FINAL-DENIAL-NO-MUTATION]
|
|
9. **`FI-INV-09 — atomic final admission.`** Enrollment, replay claims,
|
|
receipts, and required authorization evidence commit only after complete
|
|
final revalidation, all or none. [FI-TRACE-PREPARED-STALE]
|
|
10. **`FI-INV-10 — explicit lifecycle authority.`** Retirement, revocation,
|
|
rotation, and profile-defined lifecycle changes occur only through their
|
|
separately authorized transition. [FI-TRACE-LIFECYCLE-AUTHORITY]
|
|
11. **`FI-INV-11 — evidence-bounded leases.`** A lease ends no later than every
|
|
evidence, snapshot, proof, binding, local-policy, and implementation bound
|
|
on which it depends. [FI-TRACE-LEASE-BOUND]
|
|
12. **`FI-INV-12 — current-owner delegation.`** When NIP-FI-DELEG is claimed,
|
|
delegation requires the exact current eligible owner binding, fresh
|
|
delegate proof, capability intersection, and a positive finite deadline.
|
|
[FI-DELEG-OWNER-CURRENT]
|
|
13. **`FI-INV-13 — privacy-safe denial.`** Public rejection is many-to-one and
|
|
reveals no identity, key, claim, binding, tombstone, enrollment mode, key
|
|
identifier, or private policy fact. [FI-TRACE-DENIAL-ORACLE]
|
|
14. **`FI-INV-14 — fail closed.`** Unreadable, ambiguous, stale beyond policy,
|
|
or inconsistent evidence or authoritative state cannot produce authority.
|
|
[FI-TRACE-DEPENDENCY-FAIL-CLOSED]
|
|
15. **`FI-INV-15 — uniform authority.`** Every protected ingress in a domain
|
|
uses the same current domain policy and final-admission authority. An
|
|
uncovered or competing path is unavailable. [FI-TRACE-AUTHORITY-UNIFORM]
|
|
16. **`FI-INV-16 — canonical verifier.`** Assertion transports feed one closed,
|
|
provider-neutral normalized-result contract and cannot fork final
|
|
admission. [FI-TRACE-VERIFIER-PARITY]
|
|
|
|
## Client-attached transport
|
|
|
|
Server configuration selects `client-attached` before protected traffic is
|
|
accepted. Request fields cannot select, negotiate, or downgrade transport.
|
|
Failure never falls back to another transport. [FI-TRACE-TRANSPORT-CLOSED]
|
|
|
|
The client sends exactly one field on the request or WebSocket upgrade:
|
|
|
|
```text
|
|
Nostr-Federated-Identity: Bearer <compact-JWS>
|
|
```
|
|
|
|
`Authorization` remains reserved for NIP-98. Assertion and provenance fields
|
|
from any other profile are absent. Missing, repeated, comma-combined, empty,
|
|
malformed, non-Bearer, or mixed-profile fields deny. Assertions never appear in
|
|
URLs, query parameters, Nostr events, tags, filters, application history, or
|
|
public identity projections. [FI-TRACE-TRANSPORT-CLOSED]
|
|
|
|
The core transport contract has deployment-local identity
|
|
`transport_contract_id`. It deterministically identifies the exact field,
|
|
parsing, request-attachment, no-fallback, and context-preservation semantics.
|
|
Changing any of those semantics changes the ID; changing request data does not.
|
|
[FI-TRACE-CONTRACT-IDENTITIES]
|
|
|
|
## Assertion validation
|
|
|
|
A configured assertion policy accepts exactly one bounded compact JWS and
|
|
returns this closed result:
|
|
|
|
```text
|
|
VerifiedAssertion = (
|
|
identity = (iss, sub),
|
|
asserted_key?,
|
|
claims_or_capabilities,
|
|
authority_deadlines, // non-empty
|
|
assertion_policy_id,
|
|
transport_contract_id,
|
|
revalidation_dependencies
|
|
)
|
|
```
|
|
|
|
The verifier rejects ambiguous protected-header or claim members, unknown
|
|
critical headers, `alg=none`, symmetric algorithms, algorithm/key mismatch,
|
|
incompatible JWK usage, ambiguous key selection, and signatures not valid
|
|
under exactly one accepted asymmetric key. It bounds the assertion, headers,
|
|
claims, subject, key identifiers, and authenticated key set before lookup or
|
|
logging. [FI-TRACE-ASSERTION-VALIDATION]
|
|
|
|
The exact `iss` selects an authenticated policy and key source; `iss` and at
|
|
least one `aud` value exactly match configured values. `sub` is a non-empty
|
|
bounded string. Each policy configures a non-negative finite `skew`, a positive
|
|
finite `maximum_assertion_age`, and, for `current-status`, a positive finite
|
|
`maximum_status_age`; a missing value denies. `exp` and `iat` are finite
|
|
NumericDate values satisfying `now < exp`, `iat <= now + skew`, and
|
|
`now < iat + maximum_assertion_age`. Optional `nbf` satisfies
|
|
`nbf <= now + skew`. Arithmetic is overflow-safe and equality at an expiry is
|
|
expired. [FI-TRACE-ASSERTION-VALIDATION]
|
|
|
|
The Nostr-key claim is named `nostr_pubkey`. When present it MUST be a
|
|
lowercase hexadecimal encoding of exactly one 32-byte Nostr public key; other
|
|
encodings and aliases deny. In `attested-key` enrollment policy and wherever
|
|
current matching issuer attestation is required, this exact claim MUST be
|
|
present and equal the proven actor; authorization claims or capabilities use a
|
|
closed bounded input set and deterministic canonical encoding. Unchecked claims
|
|
never enter the result. [FI-TRACE-VERIFIER-PARITY]
|
|
|
|
### Token class
|
|
|
|
Policy selects exactly one token class before parsing claims:
|
|
|
|
- **`at+jwt` access token**: a Buzz-resource access token whose protected
|
|
`typ` is exactly `at+jwt` and whose `aud` contains the configured Buzz
|
|
resource audience. This class selects tokens carrying the RFC 9068 `at+jwt`
|
|
type but validates them under this document's claim contract; it does not
|
|
implement the full RFC 9068 validation profile, and the long-form media type
|
|
`application/at+jwt` is not accepted;
|
|
- **dedicated Buzz assertion**: a separately minted assertion whose protected
|
|
`typ` is exactly `nip-fi+jwt`;
|
|
- **named compatibility access token**: absent or generic protected `typ=JWT`
|
|
only under an explicit issuer policy whose required and forbidden claims,
|
|
audience, issuer, key source, and validation rules are mutually exclusive
|
|
with every accepted ID-token and other JWT class.
|
|
|
|
OIDC ID Tokens always deny, even when `iss`, `aud`, and `sub` match. A generic
|
|
or absent type has no stock fallback. Failure under one class never triggers
|
|
validation under another. An `at+jwt` access token MUST contain one non-empty
|
|
bounded `client_id`. Issuer policy MUST distinguish a resource-owner token from a token
|
|
whose subject represents the OAuth client, including a client-credentials token,
|
|
using authenticated claim semantics and mutually exclusive validation rules. A
|
|
token that admits both interpretations denies. If client-subject tokens are
|
|
accepted, the issuer MUST guarantee that their `(iss, sub)` coordinates cannot
|
|
collide with resource-owner coordinates; otherwise that token class is
|
|
ineligible. Token class and every class-specific validation rule are inputs to
|
|
`assertion_policy_id`. [FI-TRACE-TOKEN-CLASS]
|
|
|
|
### Policy identity and snapshots
|
|
|
|
Core has exactly two semantic contract identities:
|
|
|
|
```text
|
|
assertion_policy_id = H(canonical assertion-policy contract)
|
|
transport_contract_id = H(canonical transport contract)
|
|
```
|
|
|
|
Each uses one implementation-defined but deterministic, versioned encoding and
|
|
collision-resistant hash within a deployment. `assertion_policy_id` covers the
|
|
canonical issuer, audience, token class, allowed algorithms, authenticated
|
|
key/status-source contracts, identity/key/claim mapping, time and size rules,
|
|
normalization, freshness class, and compiled verifier behavior. The verifier
|
|
fingerprint is an input, not a third identity. `transport_contract_id` covers
|
|
the client-attached field, parsing, attachment, context preservation, and
|
|
no-fallback semantics; a companion transport may define its own canonical
|
|
contract under that same identity slot. A semantic change changes exactly its
|
|
owning ID. [FI-TRACE-CONTRACT-IDENTITIES]
|
|
|
|
Mutable contents and deployment state are not contract identities. They remain
|
|
in `revalidation_dependencies`: authenticated assertion-snapshot version,
|
|
verification-key identity, key-snapshot hard deadline, optional status
|
|
source/version/deadline, binding/lifecycle/local-policy/resource versions,
|
|
proof and replay witnesses, and a confidential handle to the exact compact JWS.
|
|
Adding, removing, or replacing an accepted key changes the snapshot version,
|
|
not `assertion_policy_id`. Changed dependencies require revalidation under
|
|
current state; a retained key may continue, while an absent key denies.
|
|
Unknown-key refresh is bounded and coalesced and has no attacker-triggered
|
|
stale-key fallback. [FI-TRACE-JWKS-ADD] [FI-TRACE-JWKS-REMOVE]
|
|
|
|
The base contract compares the current authenticated snapshot and makes no
|
|
anti-rollback promise. A deployment claiming rollback prevention records a
|
|
separately authenticated monotonic floor and tests it. [deployment artifact:
|
|
assertion-policy review]
|
|
|
|
### Freshness class
|
|
|
|
Each policy declares exactly one server-owned freshness class, included in
|
|
`assertion_policy_id`:
|
|
|
|
- **`offline-jwt`** validates the JWT and authenticated key snapshot only.
|
|
`upstream_authority_deadline` is the minimum of `exp`,
|
|
`iat + maximum_assertion_age`, and the key-snapshot hard deadline. Token age
|
|
bounds assertions minted before revocation; it cannot bound an issuer that
|
|
continues minting accepted assertions afterward. Enabling this class therefore
|
|
requires deployment evidence that revocation stops new accepted issuance, and
|
|
discovery reports the unconditional residual bound as unknown (`null`). It
|
|
MUST NOT advertise a finite unconditional residual bound. [deployment
|
|
artifact: issuer revocation review]
|
|
- **`current-status`** additionally requires an authenticated witness
|
|
`(iss, sub, token_or_session_id?, active=true, observed_at, valid_until,
|
|
status_version, authenticated_source_id)`. Issuer, subject, and optional
|
|
session identifier exactly match the assertion. Ambiguous, unauthenticated,
|
|
inactive, or expired status denies. `valid_until` is finite and no later than
|
|
`observed_at + maximum_status_age`. The upstream deadline is the minimum of
|
|
the offline assertion deadlines and `valid_until`. Source outage cannot mint
|
|
or extend a witness; an already verified witness remains usable only until
|
|
its existing `valid_until`. [FI-TRACE-CURRENT-STATUS-STALE]
|
|
|
|
A current-status deployment advertises a tested positive
|
|
`maximum_residual_upstream_revocation_seconds`. Prepared evidence and leases
|
|
close within that value after upstream revocation, including a revocation racing
|
|
final admission. Poll/cache age, event-delivery and processing delay, and
|
|
enforcement delay all fit within the advertised value. A push implementation
|
|
may close authority sooner but cannot claim a value below its tested worst case.
|
|
[FI-TRACE-CURRENT-STATUS-REVOKED]
|
|
|
|
An external capability projection whose removal is required to close authority
|
|
within a declared revocation bound MUST enter authoritative local-policy state,
|
|
not `claims_or_capabilities` from the assertion. That state is reread during
|
|
preparation, final admission, and protected lease use. A deployment that carries
|
|
such a projection only in assertions cannot claim a revocation bound for its
|
|
changes. [FI-TRACE-CAPABILITY-REVOCATION]
|
|
|
|
Before enabling an issuer, the operator records authoritative evidence that
|
|
`sub` is stable for the account lifetime, never reassigned, and not intentionally
|
|
derived from mutable profile data. An issuer that cannot provide this property
|
|
is ineligible. [deployment artifact: issuer subject-stability review]
|
|
|
|
## Nostr proof and body semantics
|
|
|
|
The actor is always returned by fresh Nostr-proof validation, never by an
|
|
assertion or unsigned field. NIP-42 binds its AUTH event to the current
|
|
challenge, relay URL, connection, and freshness window. NIP-98 binds its event
|
|
to the exact server-resolved URL, method, and freshness window. All evidence
|
|
agrees with the same `D` and `R_t`. [FI-TRACE-DOMAIN-SPOOF]
|
|
|
|
Each protected HTTP operation declares in server policy whether its body is
|
|
authorization-relevant; clients cannot select the declaration.
|
|
|
|
For a relevant body, the NIP-98 event contains exactly one `payload` tag equal
|
|
to lowercase hexadecimal SHA-256 of the **body bytes**: the complete content
|
|
after transfer decoding and before any content decoding. Absence, duplication,
|
|
mismatch, validation of only a prefix, or substitution of the body bytes after
|
|
validation denies. For an irrelevant body, no
|
|
authorization decision, target, capability, or effect selector derives from a
|
|
body field not bound by NIP-98. A `payload` tag present on an operation whose
|
|
body is declared authorization-irrelevant is validated identically against the
|
|
body bytes; duplication or mismatch denies. [FI-TRACE-BODY-BINDING]
|
|
|
|
Every operation has finite body and spool bounds. A known oversized body is
|
|
rejected before hashing; a stream is rejected at octet `limit + 1`; admission
|
|
waits for EOF. Before EOF there is no application effect, replay mutation,
|
|
receipt, or partial digest authority. Quota failure cleans up staged bytes and
|
|
denies. [FI-TRACE-BODY-BOUNDS]
|
|
|
|
## Direct preparation
|
|
|
|
The following is normative pseudocode; every read is from authoritative state.
|
|
|
|
```text
|
|
PrepareDirect(request, assertion, proof):
|
|
(D, R_t, operation, resource) := ResolveTargetContext(request) or DENY
|
|
e := ValidateClientAttached(assertion, D, R_t) or DENY
|
|
k := ValidateNostrProof(proof, D, R_t) or DENY
|
|
R := SealActor(R_t, k)
|
|
i := e.identity
|
|
|
|
if e.asserted_key exists and e.asserted_key != k: DENY(key_mismatch)
|
|
atomically read B_D(i), B_D(k), T_D(i,k), Y_D(k), enrollment policy,
|
|
local policy, resource, and all dependency versions
|
|
if k in Y_D: DENY(key_revoked)
|
|
if (i,k) in T_D: DENY(pair_retired)
|
|
|
|
if B_D(i) = B_D(k) = binding(i,k):
|
|
proposal := existing(binding.version, binding.provenance)
|
|
else if B_D(i) exists or B_D(k) exists:
|
|
DENY(binding_conflict)
|
|
else if enrollment policy = attested-key:
|
|
if e.asserted_key != k: DENY(attestation_required)
|
|
proposal := enroll(i, k, attested-key)
|
|
else if enrollment policy = tofu:
|
|
proposal := enroll(i, k, e.asserted_key = k ? attested-key : tofu)
|
|
else:
|
|
DENY(binding_required)
|
|
|
|
EvaluateLocalPolicy(D, R, operation, resource, k,
|
|
e.claims_or_capabilities) or DENY
|
|
return PreparedAuthorization(evidence, proposal, witnesses, deadlines)
|
|
```
|
|
|
|
TOFU is optional private deployment posture and is not self-advertised. It
|
|
accepts that a stolen assertion for a never-enrolled identity can bind an
|
|
attacker's proven key; deployments enabling it retain a passing
|
|
FI-TRACE-TOFU-THEFT artifact. Binding provenance is immutable. A policy change
|
|
affects only future creation. [deployment artifact: TOFU risk review]
|
|
|
|
Preparation, including first-use enrollment, is read-only and produces no
|
|
authoritative mutation. [FI-TRACE-FINAL-DENIAL-NO-MUTATION]
|
|
|
|
## Final admission
|
|
|
|
A prepared value is consumed at most once. Final admission first requires an
|
|
exact domain, context, operation, resource, actor, and transport match; both
|
|
contract IDs unchanged; and every bound live. A changed dependency is reread
|
|
and re-evaluated from authoritative evidence. [FI-TRACE-PREPARED-STALE]
|
|
|
|
Two verified assertion results are **equivalent** when:
|
|
|
|
1. identity-class fields are byte-equal: `iss`, `sub`, asserted-key presence and
|
|
value, canonical claims/capabilities, `assertion_policy_id`, and
|
|
`transport_contract_id`;
|
|
2. each bounds-class deadline — every `authority_deadlines` member, the
|
|
key-snapshot hard deadline, and any status deadline — is live now and is no
|
|
later than its prepared value; and
|
|
3. provenance-class fields — snapshot version, verification-key identity,
|
|
status source and version, binding, lifecycle, local-policy, and resource
|
|
versions, proof and replay witnesses, the confidential JWS handle, cache
|
|
metadata, ordering, and retrieval time — are ignored after successful
|
|
current revalidation.
|
|
|
|
Every `revalidation_dependencies` member is bounds-class if it is a deadline
|
|
and provenance-class otherwise. Any new or unclassified assertion-content field
|
|
belongs to the identity class. A fresher assertion cannot silently extend a
|
|
prepared decision. [FI-TRACE-PREPARED-STALE]
|
|
|
|
Final admission atomically rereads binding, tombstone, revocation, enrollment,
|
|
policy, resource, status, replay, receipt, and invalidation witnesses;
|
|
recomputes the complete decision; claims applicable proof replay identities;
|
|
creates an eligible proposed binding; and appends its request-bound receipt and
|
|
required authorization evidence. All commit or none. A concurrent identical
|
|
enrollment may recompute as the same `existing` binding; conflicting enrollment
|
|
commits at most one winner. [FI-TRACE-CONCURRENT-ENROLLMENT]
|
|
|
|
A failed admission rolls back all authority mutation. The application operation
|
|
runs only after committed authorization. If it cannot share the transaction, a
|
|
request-bound idempotent receipt prevents the same proof from creating a second
|
|
effect. [FI-TRACE-FINAL-DENIAL-NO-MUTATION]
|
|
|
|
## Base lifecycle
|
|
|
|
Retirement, revocation, and rotation require separate privileged authority
|
|
bound to the exact domain, transition, identity, old binding version when
|
|
present, target key when present, and request. Each atomically rechecks current
|
|
state, appends immutable lifecycle history, and invalidates dependent leases
|
|
after commit. Every new target key supplies fresh target-bound Nostr proof and
|
|
any policy-required current matching issuer attestation. [FI-TRACE-LIFECYCLE-AUTHORITY]
|
|
|
|
- **RetirePair** removes one exact active binding and durably retires its pair.
|
|
- **RevokeKey** records the key as revoked even if inactive; if active, it also
|
|
removes the binding and retires that pair. Repeating the same authorized
|
|
revocation is idempotent.
|
|
- **Rotate** replaces one exact active binding with one unused, unrevoked,
|
|
non-retired target key, retires the old pair, and creates a fresh binding
|
|
version. The replacement provenance is `attested-key` when current matching
|
|
issuer attestation was validated and `provisioned` otherwise. Rotation does
|
|
not globally revoke the old key. Rotation continues one grant onto a new key
|
|
rather than establishing a new one: the replacement preserves every
|
|
profile-defined administrative bound carried by the binding it replaces, as an
|
|
opaque field core neither interprets nor clears. Only the authority that set
|
|
such a bound can change it. [FI-TRACE-LIFECYCLE-AUTHORITY]
|
|
|
|
Failure or stale state causes no partial mutation. Ordinary authorization cannot
|
|
perform or undo these transitions. Extended disablement, re-enablement,
|
|
provisioning, and administrative expiry are defined only by NIP-FI-LIFECYCLE.
|
|
|
|
## Request and session bounds
|
|
|
|
HTTP authority covers one exact request and is never reusable.
|
|
|
|
A WebSocket lease is scoped to one actor, domain, operation set, binding
|
|
version, normalized result, policy/resource versions, and invalidation
|
|
witnesses. Its deadline is the earliest assertion, upstream-authority,
|
|
key-snapshot, proof/connection, local-policy, and implementation deadline.
|
|
Arithmetic is overflow-safe and equality is expired. [FI-TRACE-LEASE-BOUND]
|
|
|
|
Before each protected use, the service checks actor, domain, operation,
|
|
resource, deadline, binding version, contract IDs, snapshot/status versions,
|
|
policy versions, and invalidation state. Changed dependencies require current
|
|
revalidation to an equivalent result; unreadable or ineligible state denies.
|
|
A lease for one key never authorizes another key on the same connection.
|
|
[FI-TRACE-MULTI-KEY-SESSION]
|
|
|
|
Expiry ends the lease, not the binding. Renewal requires a new connection with
|
|
a fresh assertion attached to its WebSocket upgrade, fresh Nostr proof,
|
|
preparation, and final admission; there is no in-band renewal path. Confidential
|
|
assertion revalidation material is destroyed on expiry, close, or invalidation.
|
|
|
|
## Rejection and privacy
|
|
|
|
Public class is a function only of evidence the requester supplied, never of
|
|
private per-principal server state; `authorization_unavailable` is the sole
|
|
exception and reveals only that a required authoritative dependency is
|
|
unreadable, never any per-principal fact. Replay status is a function of
|
|
committed per-principal server state, not of the supplied evidence alone;
|
|
replayed evidence is therefore classed `authorization_denied`, indistinguishable
|
|
from any other private-state denial, so that resubmitting captured evidence
|
|
reveals nothing about whether the original request committed. Under the
|
|
private-posture rule, even `key_mismatch` joins the private-state anonymity set.
|
|
|
|
| Private condition | Public class | Nostr prefix and exact text | HTTP response |
|
|
|---|---|---|---|
|
|
| assertion/proof absent | `missing_evidence` | `auth-required: authentication required` | `401`; `WWW-Authenticate: Nostr`; `Content-Type: text/plain; charset=utf-8`; `authentication required\n` |
|
|
| malformed, invalid, or expired evidence | `evidence_rejected` | `restricted: evidence rejected` | `403`; `Content-Type: text/plain; charset=utf-8`; `evidence rejected\n` |
|
|
| replayed evidence; key mismatch; attestation required; binding conflict; retired pair; revoked key; lifecycle gate; binding required/expired; local policy denial | `authorization_denied` | `restricted: authorization denied` | `403`; `Content-Type: text/plain; charset=utf-8`; `authorization denied\n` |
|
|
| required current dependency unreadable | `authorization_unavailable` | `restricted: authorization unavailable` | `503`; `Content-Type: text/plain; charset=utf-8`; `authorization unavailable\n` |
|
|
|
|
Nostr text is the exact UTF-8 text after an applicable NIP-42/NIP-01 prefix.
|
|
A denial decided on a WebSocket upgrade request, before any NIP-42 proof
|
|
exists, is the HTTP response in the table, sent instead of `101`; a denial
|
|
decided after the connection is established is the Nostr text. For HTTP, the
|
|
compared denial contract is closed over the
|
|
status, complete body,
|
|
and exact values of only the header fields named in the table; header order and
|
|
other fields are outside that contract and their values cannot depend on the
|
|
private condition. The body is the shown UTF-8 bytes with one LF and no other
|
|
bytes. The `Nostr` challenge satisfies RFC 9110 Section 15.5.2.
|
|
Responses contain no free text, reason code, request ID, issuer, subject, key,
|
|
claim, binding state, enrollment posture, token material, or timing hint. All
|
|
private conditions in `authorization_denied` produce byte-identical responses.
|
|
[FI-TRACE-DENIAL-ORACLE]
|
|
|
|
NIP-FI defines no public identity projection. Public events, tags, filters,
|
|
discovery, responses, logs, metrics, and traces contain no raw assertions or
|
|
unredacted `iss`, `sub`, email, display name, or private claim. Access-controlled
|
|
authoritative stores retain only what enforcement and investigation require.
|
|
A separate presentation protocol cannot confer NIP-FI authority.
|
|
[FI-TRACE-PRIVACY-NONPUBLIC]
|
|
|
|
## Discovery
|
|
|
|
A relay SHOULD advertise core support in NIP-11 as:
|
|
|
|
```json
|
|
{
|
|
"limitation": { "federated_identity": true },
|
|
"federated_identity": {
|
|
"core": "client-attached",
|
|
"assertion_freshness": {
|
|
"class": "offline-jwt",
|
|
"maximum_residual_upstream_revocation_seconds": null
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
NIP-FI-EDGE owns the optional `edge_transports` member and its exact type,
|
|
placement, and value semantics. For `current-status`, the final value is a tested
|
|
positive integer. Discovery never states enrollment mode or TOFU posture and
|
|
never exposes issuer URLs, audiences, claim names, tenant IDs, or
|
|
deployment-local identifiers. For a fixed
|
|
set of claimed profiles, the complete public discovery output is byte-identical
|
|
for every enrollment policy, including `attested-key`, private `tofu`, and any
|
|
companion profile mode: no field, flag, value, omission, ordering, or object shape
|
|
may distinguish the configured mode. Profile documents own only non-enrollment
|
|
public claims.
|
|
[FI-TRACE-DISCOVERY-PRIVATE]
|
|
|
|
## Worked example (non-normative)
|
|
|
|
A protected HTTP POST under `client-attached` with NIP-98 proof and an
|
|
authorization-relevant body. Credentials are elided; the NIP-FI-CONF exit
|
|
fixture pins the complete request compared objects.
|
|
|
|
```text
|
|
POST /media HTTP/1.1
|
|
Host: relay.example
|
|
Nostr-Federated-Identity: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIs...
|
|
Authorization: Nostr eyJpZCI6IjE1ZTI3ZDc0Li4uIiwicHVia2V5IjoiOTljNzQ4Li4u...
|
|
Content-Type: application/octet-stream
|
|
Content-Length: 4
|
|
|
|
abcd
|
|
```
|
|
|
|
The bearer JWS validates under the configured assertion policy: exact `iss`
|
|
and `aud`, token class `at+jwt`, live time claims, and `nostr_pubkey` equal to
|
|
the NIP-98 event's `pubkey`. The NIP-98 event binds the server-resolved method
|
|
and URL, and its single `payload` tag equals the SHA-256 of the four body
|
|
bytes. Admission then follows Direct preparation and Final admission; success
|
|
returns the application response, and every failure class returns exactly the
|
|
bytes fixed in the Rejection table. On a WebSocket upgrade the same header
|
|
attaches to the upgrade request and NIP-42 supplies the proof after connect.
|
|
|
|
## Core behavioral oracles
|
|
|
|
A core claim covers every applicable oracle below at one implementation and
|
|
policy revision. NIP-FI-CONF defines evidence and mutation-adequacy rules.
|
|
|
|
| ID | Required outcome |
|
|
|---|---|
|
|
| `FI-TRACE-TRANSPORT-CLOSED` | Exact one-header input succeeds; missing, repeated, combined, malformed, mixed, URL, and fallback variants deny. |
|
|
| `FI-TRACE-ASSERTION-VALIDATION` | Valid boundary input passes; each signature, key-selection, issuer, audience, time, size, ambiguity, and missing-configuration negative denies. |
|
|
| `FI-TRACE-TOKEN-CLASS` | An `at+jwt` access token and a dedicated `nip-fi+jwt` assertion pass only their selected class. ID tokens, wrong/generic types outside a named compatibility policy, client-only audiences, absent or ambiguous `client_id`, resource-owner/client-subject ambiguity, and every attempted cross-class fallback deny. |
|
|
| `FI-TRACE-CONTRACT-IDENTITIES` | Mutate each assertion semantic, transport semantic, and mutable dependency independently: semantic mutations change only their owning contract ID; snapshot/binding/lifecycle/policy/resource/status mutations change neither ID but force current revalidation. |
|
|
| `FI-TRACE-VERIFIER-PARITY` | Equal authoritative input and policy produce the same canonical normalized result. |
|
|
| `FI-TRACE-JWKS-ADD` | Retained-key rotation revalidates successfully under the changed snapshot version. |
|
|
| `FI-TRACE-JWKS-REMOVE` | Evidence and leases under a removed key deny after snapshot change. |
|
|
| `FI-TRACE-CURRENT-STATUS-REVOKED` | Revocation, including one racing final admission, closes authority within the advertised tested bound. |
|
|
| `FI-TRACE-CURRENT-STATUS-STALE` | Inactive/ambiguous status denies; an issuer, subject, or session-identifier mismatch denies; expiry equality, outage, delayed events, and changed status versions cannot mint or extend a witness. |
|
|
| `FI-TRACE-CAPABILITY-REVOCATION` | Removal of a revocation-bounded external capability projection from authoritative local policy closes prepared evidence and lease use within the declared bound; assertion-only projection cannot satisfy this oracle. |
|
|
| `FI-TRACE-BODY-BINDING` | Exact complete relevant body passes; absent/duplicate/mutated/partial/substituted payload variants deny without effects; a payload tag on an irrelevant-body operation validates identically and denies on duplication or mismatch. |
|
|
| `FI-TRACE-BODY-BOUNDS` | Oversized, over-quota, and pre-EOF variants deny with bounded work, cleanup, and no effects. |
|
|
| `FI-TRACE-DOMAIN-SPOOF` | Client routing and forwarded authority cannot replace server-owned context. |
|
|
| `FI-TRACE-ASSERTION-KEY-MISMATCH` | Mismatch denies with no mutation and the private-state response. |
|
|
| `FI-TRACE-BINDING-CONFLICT` | A binding conflict denies without replacing either existing binding. |
|
|
| `FI-TRACE-TOMBSTONE-REPLAY` | Fresh eligible evidence for a retired pair or revoked key denies without recreation. |
|
|
| `FI-TRACE-ASSERTION-REFRESH` | Fresh evidence reuses the same eligible durable binding after prior assertion expiry. |
|
|
| `FI-TRACE-PREPARED-STALE` | Changed identity-class witnesses or extended bounds deny; provenance-only rotation revalidates. |
|
|
| `FI-TRACE-CONCURRENT-ENROLLMENT` | Identical first use converges; conflicting first use commits at most one winner. |
|
|
| `FI-TRACE-FINAL-DENIAL-NO-MUTATION` | Every failed phase leaves all authoritative stores and effects unchanged. |
|
|
| `FI-TRACE-LIFECYCLE-AUTHORITY` | Unprivileged/stale transitions deny; authorized retirement/revocation/rotation is atomic. |
|
|
| `FI-TRACE-LEASE-BOUND` | A lease ends at its earliest bound; equality at any bound is expired. |
|
|
| `FI-TRACE-MULTI-KEY-SESSION` | One actor's lease never authorizes another key on the same connection. |
|
|
| `FI-TRACE-DENIAL-ORACLE` | Each private row produces its exact fixed bytes on every surface where its condition can be decided — HTTP, a WebSocket upgrade, or after connect; all private-state rows compare byte-identical. |
|
|
| `FI-TRACE-DEPENDENCY-FAIL-CLOSED` | Each unreadable authoritative dependency denies. |
|
|
| `FI-TRACE-AUTHORITY-UNIFORM` | Every protected ingress reaches one current final-admission authority. |
|
|
| `FI-TRACE-CROSS-DOMAIN-COLLISION` | Equal subjects across issuers and equal pairs across domains remain distinct. |
|
|
| `FI-TRACE-PRIVACY-NONPUBLIC` | Private identity does not enter public surfaces. |
|
|
| `FI-TRACE-DISCOVERY-PRIVATE` | Complete discovery bytes remain identical across attested-key, TOFU, and companion enrollment modes. |
|
|
| `FI-TRACE-TOFU-THEFT` | Stolen-assertion first use denies unless private TOFU is enabled and the attacker also proves its chosen key. |
|
|
|
|
## Relationship to other work (non-normative)
|
|
|
|
NIP-FI binds an access token to a key the resource server itself verifies, the
|
|
goal DPoP (RFC 9449) and mTLS-bound tokens (RFC 8705) reach through a `cnf`
|
|
claim. Here the proof is the NIP-42 or NIP-98 event the relay already
|
|
validates, so no second proof is defined and the issuer need not attest the
|
|
key; `nostr_pubkey` is the optional `cnf` analogue. Unlike those profiles the
|
|
binding is durable server state rather than a per-token claim: a stolen
|
|
assertion cannot reach an enrolled identity without its key, and revocation is
|
|
a local fact rather than a token-lifetime race. One identity, one key per
|
|
domain is stricter than WebAuthn's many-credentials-per-account model because
|
|
the Nostr key is itself the public identity; additional devices do not create
|
|
additional active bindings. Two contract identities plus explicit dependency
|
|
versions exist because folding a mutable key snapshot into policy identity would make benign
|
|
rotation change policy lineage, while omitting it would let evidence under a
|
|
removed key survive. Denial responses deliberately collapse the conditions that
|
|
RFC 6750 error codes distinguish. `trusted-proxy-hmac-v2` in NIP-FI-EDGE is a
|
|
fixed-component request MAC in the family of HTTP Message Signatures (RFC 9421)
|
|
and AWS SigV4, without negotiation and with length-prefixed canonicalization.
|
|
|
|
## Security considerations
|
|
|
|
Issuer compromise can impersonate a principal but cannot prove an uncompromised
|
|
bound Nostr key. Assertion theft cannot use an existing binding without that
|
|
key; private TOFU intentionally retains first-use theft risk. Snapshot
|
|
revalidation limits removed-key reuse but the base policy accepts authenticated
|
|
key-source rollback as residual issuer risk. Two-phase admission closes the
|
|
binding and policy TOCTOU window only when every authoritative witness is reread
|
|
atomically. Availability failures deny rather than degrade to Nostr-only access.
|
|
|
|
## Sources
|
|
|
|
- NIP-42 authentication: <https://github.com/nostr-protocol/nips/blob/6d2979b3f503a8539c983efbcdcf901bbcf9ed23/42.md>
|
|
- NIP-98 HTTP authentication: <https://github.com/nostr-protocol/nips/blob/6d2979b3f503a8539c983efbcdcf901bbcf9ed23/98.md>
|
|
- JWT BCP: <https://www.rfc-editor.org/rfc/rfc8725>
|
|
- JWT access-token profile: <https://www.rfc-editor.org/rfc/rfc9068>
|
|
- DPoP: <https://www.rfc-editor.org/rfc/rfc9449>
|
|
- OAuth 2.0 mTLS client certificate-bound tokens: <https://www.rfc-editor.org/rfc/rfc8705>
|
|
- HTTP Message Signatures: <https://www.rfc-editor.org/rfc/rfc9421>
|
|
- Non-normative composed model: [NIP-FI-MODEL.md](NIP-FI-MODEL.md)
|