From 405270bbe09b700fa69ab4bc707aca2ac18303ac Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Thu, 2 Jul 2026 20:28:03 -0400 Subject: [PATCH 1/6] docs(nips): add NIP-FI federated identity authorization draft Normative spec for authorizing a NIP-42/NIP-98-proven Nostr key only when a valid OIDC/JWT assertion resolves to the same active identity-to-key binding. Covers assertion transport (trusted-proxy and client-attached profiles), validation, per-domain binding bijection, enrollment modes (attested-key/provisioned/tofu), lease/session bounds, revocation and rotation, delegation composition, rejection semantics, discovery, and privacy. Companion formal model to follow on this branch. Generalizes the semantics implemented in #1476 into a provider-neutral standard per buzz-security thread 0d1aee9e. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- docs/nips/NIP-FI.md | 149 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 docs/nips/NIP-FI.md diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md new file mode 100644 index 0000000000..3d5c9f31ca --- /dev/null +++ b/docs/nips/NIP-FI.md @@ -0,0 +1,149 @@ +NIP-FI +====== + +Federated Identity Authorization +-------------------------------- + +`draft` `optional` `relay` + +**Depends on**: NIP-01 (basic event format), NIP-42 (Authentication of Clients to Relays). **Composes with**: NIP-98 (HTTP Auth), NIP-11 (Relay Information Document), NIP-OA (Owner Attestation). + +## Abstract + +This NIP defines how a relay or Nostr-adjacent HTTP service authorizes an already-authenticated Nostr key only when a valid federated identity assertion (an OIDC/JWT credential from an external identity provider) resolves to the same principal. It specifies assertion transport, validation, an identity-to-key binding lifecycle (enroll, conflict, revoke, rotate), session semantics, and failure behavior. + +The identity provider never becomes a Nostr signing authority, and the assertion never substitutes for Nostr proof of key control. This NIP is an authorization layer above NIP-42 and NIP-98, not a replacement for either. + +## Motivation + +Organizations deploying Nostr internally need relay access tied to their workforce identity system: an employee's relay privileges should follow their corporate identity, survive Nostr key rotation, and end at offboarding. Existing primitives each solve part of this: + +- NIP-42 proves control of a Nostr key to a connection but carries no external identity. +- NIP-05 maps an organization-controlled identifier to a pubkey, but by public DNS/HTTPS polling, not by a credential presented on the request being authorized. +- NIP-46 lets a signer demand out-of-band authentication (`auth_url`) but does not bind the resulting external subject to a key at the relay. + +Without a standard, each deployment invents an incompatible binding scheme, and the first large deployment's configuration becomes an accidental protocol. This NIP defines the contract so that any relay behind any OIDC-capable identity provider or generic OAuth2 reverse proxy (Okta, Auth0, Keycloak, oauth2-proxy, etc.) can interoperate with any conforming client. + +## Definitions + +- **assertion**: a JWT issued by a configured identity provider, presented alongside (never instead of) Nostr authentication. +- **federated identity** (`i`): the tuple `(iss, sub)` from a validated assertion. The `iss` value MUST be the exact validated issuer identifier and `sub` the exact non-empty subject string. A username, email, display name, or bare `sub` MUST NOT be used as a federated identity. +- **authorization domain** (`D`): the scope within which bindings apply, chosen by the service (an entire relay, or one tenant of a multi-tenant relay). Bindings MUST NOT cross domains implicitly. +- **binding**: an active record associating exactly one federated identity with exactly one 32-byte Nostr public key within a domain. +- **enrollment mode**: the domain's policy for creating bindings — `attested-key`, `provisioned`, or `tofu` (defined below). +- **Nostr proof**: a valid NIP-42 AUTH event (WebSocket) or NIP-98 event (HTTP) proving control of a key on the current connection or request. +- **lease**: a cached authorization decision for one `(domain, identity, key)`, bounded by the assertion's expiry. + +## Assertion transport + +An assertion reaches the verifier in an HTTP header on the request being authorized: the WebSocket upgrade request for relay connections, or each individual request for NIP-98-authenticated HTTP endpoints. Two transport profiles are defined; a service MUST document which it accepts. + +1. **Trusted proxy**: an authenticating reverse proxy (for example oauth2-proxy or an SSO-aware ingress) injects the assertion header after authenticating the user. This profile is conforming only if untrusted clients cannot reach the verifier directly and the proxy strips any inbound copy of the header before setting it. This is the recommended profile for browser-based clients, which cannot attach arbitrary WebSocket upgrade headers. +2. **Client-attached**: the client sends the assertion itself, in the `Authorization: Bearer` header or a service-configured header. + +The header name is deployment configuration; `Authorization` semantics apply when it is used. A value with a `Bearer ` prefix MUST be accepted with the prefix stripped. + +On a WebSocket connection, the assertion captured at upgrade is evaluated when a key performs NIP-42 AUTH — each authenticating key is authorized against that assertion independently. On HTTP, the assertion and the NIP-98 proof MUST arrive on the same request they authorize. + +Assertions MUST NOT be carried inside Nostr events, event tags, or subscription filters, and MUST NOT be written to relay-visible event history. + +## Assertion validation + +The verifier is configured, per accepted issuer, with: the issuer identifier, a signing-key source (a JWKS endpoint, discoverable via OIDC `/.well-known/openid-configuration`), accepted audience values, and a claim mapping. Validation MUST enforce all of the following; any failure MUST reject the assertion: + +1. The JWT signature verifies under a currently trusted key for an explicitly allowed **asymmetric** algorithm. Symmetric (HS*) and `none` algorithms MUST be rejected before any key lookup. +2. `iss` exactly equals the configured issuer identifier used to select the verification key. +3. At least one `aud` value exactly equals a configured audience. +4. `exp` is present and in the future; `nbf` and `iat`, when present, are not in the future — each within a bounded, configured clock skew. +5. The configured subject claim is present and a non-empty string. A configured claim that is absent when required, not of its expected type, or not unambiguously a single value MUST be rejected. +6. If a key claim is configured and present, it parses to exactly one 32-byte Nostr public key. Lowercase hex is the canonical encoding; `npub` bech32 MAY be accepted as a documented input normalization. + +A display-name claim MAY be extracted as mutable metadata. It MUST NOT participate in any authorization decision. + +Signing-key retrieval failures MUST fail closed. Verifiers SHOULD cache the key set with a bounded lifetime and SHOULD NOT refetch it in response to an unknown `kid` that was absent from a freshly fetched set, so that forged tokens cannot drive request floods to the identity provider. + +## Nostr proof + +The key being authorized is always the key returned by Nostr proof validation — a valid NIP-42 AUTH for the current WebSocket connection, or a valid NIP-98 event for the current HTTP request. It is never taken from an assertion claim, an unsigned request field, or client metadata. A bearer assertion alone MUST NOT authenticate a Nostr key. + +## Authorization + +Given a validated assertion yielding identity `i`, optional asserted key `k_a`, and expiry `exp`, and a Nostr proof yielding key `k`, the verifier evaluates one atomic decision in domain `D`: + +```text +Authorize(D, i, k_a?, k): + if k_a exists and k_a != k: DENY (key mismatch) + + b_i := active binding for i in D, if any + b_k := active binding for k in D, if any + + if b_i = (i, k) and b_k = (i, k): ALLOW (existing binding) + if b_i exists or b_k exists: DENY (binding conflict) + + # no active binding on either side: enrollment + attested-key: k_a required, else DENY; create (i, k); ALLOW + provisioned: DENY (binding must be pre-created by an operator) + tofu: create (i, k); ALLOW +``` + +The check and any insertion MUST be atomic for `(D, i, k)`: under concurrent first use of the same identity or key, at most one binding is created and every other attempt observes it (allow on exact match, deny on conflict). Storage failure or a lost race MUST deny — never fall back to an unchecked allow. + +### Enrollment modes + +- **`attested-key`**: the identity provider carries the user's Nostr public key in the configured key claim. First use binds only when the asserted key equals the proven key. This is the strongest mode and SHOULD be used when the identity provider can carry custom claims. +- **`provisioned`**: bindings are created only through an out-of-band administrative process; requests never create bindings. +- **`tofu`** (trust on first use): first use of an unbound identity with an unbound key creates the binding. A stolen assertion for a never-enrolled identity can bind an attacker's key in this mode; services offering it MUST document this risk. When an assertion in `tofu` mode carries a valid key claim, the binding SHOULD record the stronger `attested-key` provenance, and a binding's recorded provenance MUST NOT be downgraded by later requests. + +### Binding invariant + +Within a domain, active bindings form a partial bijection: an identity has at most one active key and a key has at most one active identity. Every state transition in this NIP preserves this invariant. + +## Session semantics + +For HTTP requests, the decision applies to that request only. + +For a NIP-42 WebSocket connection, the relay MAY cache the decision as a lease. A lease MUST NOT be honored past the assertion's `exp` (implementations MAY enforce a shorter maximum). At expiry the relay MUST require a fresh assertion, reject protected operations, or close the connection. When a relay learns a binding was revoked, it MUST invalidate matching leases; a relay that detects revocation by polling MUST NOT claim immediate revocation and SHOULD document its detection latency. + +When multiple keys authenticate on one connection (NIP-42 permits this), authorization is tracked per key. A lease for one key MUST NOT authorize operations attributed to another. + +## Revocation and rotation + +Revocation is an explicit administrative or policy transition: the binding is removed from the active set and a durable revocation record is retained. A subsequent valid assertion — including one whose key claim matches the revoked key — MUST NOT reactivate a revoked binding unless the domain's documented recovery policy explicitly authorizes that transition. This prevents a replayed, still-valid assertion from silently undoing revocation. + +Key rotation is likewise explicit, never a side effect of authorization: rotating `i` from `k_old` to `k_new` requires administrative or documented recovery authorization, an active `(i, k_old)` binding, no active binding for `k_new`, and — where the domain requires issuer attestation — a fresh assertion whose key claim equals `k_new`. The old binding is revoked and the new one created atomically, and leases for `k_old` are invalidated. A routine request presenting `i` with a new key while `(i, k_old)` is active is a binding conflict and MUST be denied. + +## Delegation + +Delegation is outside the base primitive but composes with it. A service MAY admit a key that presents no assertion when a separately validated delegation proof (for example a NIP-OA `auth` tag) establishes an owner key that holds an active binding in the domain. The delegate key MUST NOT acquire a federated identity binding of its own through this path, and the delegate's authorization is bounded by both the owner's binding state and the delegation's own conditions. Revoking the owner's binding revokes the delegate's admission on the same schedule as the owner's own leases. + +## Rejection semantics + +Machine-readable rejections reuse NIP-01/NIP-42 prefixes on `OK` and `CLOSED` messages: + +- `auth-required: ` — no assertion was presented, or no NIP-42 proof has been performed. +- `restricted: ` — the assertion or proof was presented but failed validation, mismatched, conflicted with an active binding, or the identity's enrollment/binding state does not permit the operation. + +HTTP endpoints respond `401` where `auth-required` applies and `403` where `restricted` applies. Rejection bodies MUST NOT echo assertion contents, claim values, or the conflicting party's identity or key. + +## Discovery + +A relay SHOULD advertise support in its NIP-11 document under `limitation` as `"federated_identity": true`, and MAY publish a `federated_identity` object naming its accepted transport profile(s), enrollment mode, and whether delegation is honored. It MUST NOT publish issuer-internal detail (tenant URLs, claim names, audiences) that is not already public. + +## Privacy + +Federated identities are typically personal data (employee identifiers). A conforming service MUST NOT publish `iss`, `sub`, assertion contents, or display-name claims in Nostr events or tags, and MUST NOT expose another user's binding state through rejection messages. Binding records, audit logs, and metrics are service-internal, and logs MUST NOT record raw bearer assertions. + +## Security considerations + +- **Issuer or proxy compromise** impersonates federated principals, but cannot satisfy Nostr proof for an already-bound uncompromised key, and in `attested-key` mode cannot bind an arbitrary key without also forging the key claim. +- **Assertion theft** cannot authorize an already-bound identity without control of the bound key. Its remaining power — enrolling a never-bound identity — exists only in `tofu` mode, which is why that mode is risk-labeled. +- **Header injection**: the trusted-proxy profile is void if clients can reach the verifier directly or the proxy forwards inbound copies of the assertion header. Deployments MUST verify both properties. +- **Algorithm confusion** is excluded by rejecting symmetric algorithms before key selection. +- **Availability vs. safety**: issuer, key-set, and storage outages deny. Availability MUST NOT override identity safety. +- **Cross-issuer collision**: identical `sub` values under different issuers are distinct identities and MUST never collide or inherit each other's bindings. + +A companion formal model of this protocol — state machine, safety and liveness properties, and attack traces — accompanies this specification. + +## Reference implementation + +Buzz relay: corporate identity enforcement layered above NIP-42/NIP-98/media/git/audio ingress, with JWKS validation, TOFU and attested-key enrollment, atomic binding with conflict detection, and NIP-OA delegation composition. From 65b9f0ae26ca92db2cd1081d343530b0584a894a Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 2 Jul 2026 20:32:01 -0400 Subject: [PATCH 2/6] docs(nips): add NIP-FI formal model Define the trust assumptions, authorization predicate, binding state machine, safety and liveness properties, and adversarial traces for federated identity authorization. Tighten the normative draft with a collision-free client assertion header and concrete NIP-11 discovery shape. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- docs/nips/NIP-FI-MODEL.md | 220 ++++++++++++++++++++++++++++++++++++++ docs/nips/NIP-FI.md | 24 +++-- 2 files changed, 238 insertions(+), 6 deletions(-) create mode 100644 docs/nips/NIP-FI-MODEL.md diff --git a/docs/nips/NIP-FI-MODEL.md b/docs/nips/NIP-FI-MODEL.md new file mode 100644 index 0000000000..831e75d8a3 --- /dev/null +++ b/docs/nips/NIP-FI-MODEL.md @@ -0,0 +1,220 @@ +# Scope + +This model specifies a relay or HTTP service authorizing a Nostr principal only when a valid federated identity assertion and a valid Nostr proof resolve to the same active identity-to-key binding. It models authorization, enrollment, revocation, and key rotation. It does not publish the federated identity on Nostr and does not make the identity provider a Nostr signing authority. + +The model is transport-independent. A concrete NIP must separately define how an assertion reaches a verifier and how support is advertised. NIP-42 and NIP-98 remain the mechanisms for proving control of a Nostr key; a bearer assertion alone is never a Nostr proof. + +# Terms and domains + +- `D`: authorization domain chosen by the service (for example one relay tenant). Bindings never cross domains implicitly. +- `I`: federated principal, the tuple `(iss, sub)`. `iss` is the assertion's exact validated issuer identifier and `sub` is its exact non-empty subject string. A username, email, display name, or bare `sub` is not an identity key. +- `K`: 32-byte Nostr public key. +- `A`: federated assertion. +- `P`: Nostr proof authenticating key `k`, such as a valid NIP-42 AUTH event or NIP-98 event. +- `now`: verifier time. +- `B_D`: active binding relation in domain `D`, a partial bijection between `I` and `K`. +- `R_D`: durable history of revoked bindings. +- `mode(D)`: enrollment policy, either `attested-key`, `provisioned`, or `tofu`. + +A binding record is: + +```text +Binding = (domain, identity, key, source, created_at, revoked_at?) +source = attested-key | provisioned | tofu +``` + +`display_name`, email, and similar values may be stored as mutable metadata but are never part of binding identity or an authorization decision. + +# Trust assumptions + +1. The verifier has an authenticated configuration for each accepted issuer: issuer identifier, allowed signing algorithms, key source, accepted audience(s), and claim mapping. +2. TLS and/or a trusted ingress boundary prevents attackers from injecting or replacing assertions. A reverse-proxy assertion header is trusted only when untrusted clients cannot reach the verifier directly and all inbound copies of that header are stripped before the trusted proxy sets it. +3. The issuer protects its signing keys and assigns stable, non-reassignable `sub` values within an issuer. If an issuer reassigns a subject, the model cannot distinguish the people. +4. The Nostr signature primitive is unforgeable and the concrete Nostr proof is fresh and bound to the target relay or HTTP request. +5. Binding-state transactions are serializable with respect to the same domain, identity, or key. The implementation may realize this with locks and unique constraints. +6. The verifier's clock is sufficiently accurate for assertion and proof freshness checks. + +Compromise of an accepted issuer or trusted ingress can impersonate federated principals. It still cannot satisfy Nostr proof for an already-bound uncompromised key, and in `attested-key` mode it cannot bind an arbitrary key unless the compromised issuer also attests that key. Theft of an assertion alone cannot authorize an already-bound identity without control of the bound Nostr key. + +# Assertion validity + +Let `ValidateAssertion(A, C, now)` return either `(i, k_a?, exp)` or failure under issuer configuration `C`. + +It succeeds only if all of the following hold: + +1. the signature validates under a currently trusted key and an explicitly allowed asymmetric algorithm; +2. `A.iss` exactly equals the configured issuer identifier used to select that key; +3. at least one `A.aud` value exactly equals an audience configured for this service; +4. `exp` exists and `now < exp`, allowing only a bounded configured clock skew; +5. if present, `nbf <= now` and `iat` is not unreasonably in the future; +6. the configured subject claim is a non-empty string; +7. `i = (A.iss, A.subject)`; and +8. if a configured Nostr-key claim is present, it parses to exactly one 32-byte key `k_a` (hex on the wire; bech32 may be accepted only as an explicitly documented input normalization). + +Unknown issuers, key IDs, algorithms, claims, and validation failures fail closed. Key retrieval failure also fails closed. A verifier must bound key-cache lifetime and refresh behavior; it must not accept a token merely because parsing succeeded. + +# Nostr-proof validity + +`ValidateProof(P, target, now) = k` only when the applicable Nostr standard verifies the event ID and Schnorr signature, freshness, and target binding: + +- NIP-42: kind, challenge, relay URL, and timestamp are valid; or +- NIP-98: kind, absolute request URL, HTTP method, timestamp, and payload hash when required are valid. + +A service may define another proof profile only if it has equivalent signer-control, freshness, and target/replay binding. The key used for the authorization decision is the key returned by proof validation, never an unsigned request field or assertion display claim. + +# Binding invariant + +For every domain `D`, active bindings are one-to-one: + +```text +∀ i, k1, k2: (i, k1) ∈ B_D ∧ (i, k2) ∈ B_D ⇒ k1 = k2 +∀ i1, i2, k: (i1, k) ∈ B_D ∧ (i2, k) ∈ B_D ⇒ i1 = i2 +``` + +Equivalently, an active identity has at most one key and an active key has at most one identity in a domain. + +# Authorization and enrollment transition + +Given domain `D`, assertion result `(i, k_a?, exp)`, and proof result `k`, evaluate one atomic transaction: + +```text +Authorize(D, i, k_a?, k): + if k_a exists and k_a != k: + DENY(key_mismatch) + + b_i := active binding in B_D for i, if any + b_k := active binding in B_D for k, if any + + if b_i = (i, k) and b_k = (i, k): + ALLOW(existing) + + if b_i exists or b_k exists: + DENY(binding_conflict) + + switch mode(D): + attested-key: + if k_a is absent: DENY(key_attestation_required) + atomically insert (i, k, attested-key) into B_D + ALLOW(created) + provisioned: + DENY(binding_required) + tofu: + atomically insert (i, k, source = k_a exists ? attested-key : tofu) into B_D + ALLOW(created) +``` + +If an insertion loses a race or storage is unavailable, deny; never fall back to an unchecked allow. The check and possible insertion must be linearizable for `(D, i, k)`. + +The resulting authorization lease is: + +```text +L = (D, i, k, binding_version, expires_at) +expires_at <= assertion.exp +``` + +An implementation may impose a shorter maximum lease. A lease authorizes only policy-selected operations in `D`; it does not authorize signing and does not imply that event authors may differ from `k`. + +# Session behavior + +For a single HTTP request, the assertion, Nostr proof, and authorization decision apply only to that request. + +For a NIP-42 WebSocket connection, a relay may cache `L`, but it must not use the lease after `expires_at`. It must require a fresh assertion and proof, reject protected operations, or terminate the connection. A relay that learns that the binding or federated session was revoked must invalidate matching leases. Implementations must document their maximum revocation-detection latency; they cannot claim immediate revocation if they only poll. + +If multiple keys authenticate on one NIP-42 connection, authorization is tracked independently per key. A lease for one `(i, k)` must not authorize another authenticated key. + +# Revocation and rotation + +Revocation is an explicit administrative transition: + +```text +Revoke(D, i, k): + require (i, k) ∈ B_D + atomically remove (i, k) from B_D + append immutable revocation record to R_D + invalidate cached leases for the binding as soon as observed +``` + +An assertion, including one with `k_a = k`, must not silently reactivate the same revoked binding unless the domain's explicit recovery policy authorizes that transition. This prevents replay of a still-valid assertion from undoing revocation. + +Key rotation is not an authorization side effect: + +```text +Rotate(D, i, k_old, k_new): + require explicit recovery/admin authorization + require (i, k_old) ∈ B_D + require no active binding for k_new + if issuer-attested rotation is required, require fresh k_a = k_new + atomically revoke (i, k_old) and create (i, k_new) + invalidate leases for k_old +``` + +A normal request that presents `i` with `k_new` while `k_old` is active is a conflict and must not rotate automatically. + +# Delegation + +Delegation is outside the base identity-binding primitive. A separate delegation standard may allow a bound owner key to authorize a delegate key. If supported, the verifier must first validate the delegation proof and derive the owner key, then require an active, unexpired authorization lease or binding for that owner. It must not create a federated identity binding for the delegate unless explicitly specified. Delegation expiry/revocation and allowed operations remain bounded by both the owner identity authorization and the delegation. + +# Safety properties + +Under the trust assumptions, for direct (non-delegated) authorization: + +1. **Proof possession:** every allowed protected operation is associated with a valid proof of control of its Nostr key. +2. **Federated authenticity:** every allowed protected operation is associated with a currently valid assertion for its issuer-qualified identity. +3. **Agreement:** if the issuer supplies a key claim, the asserted key, proven key, and bound key are equal. +4. **Binding consistency:** no two active identities share a key and no identity has two active keys in one domain. +5. **No implicit rotation:** conflicting assertions or proofs cannot replace an active binding. +6. **Domain separation:** authorization in one domain does not imply authorization in another. +7. **Lease boundedness:** no cached authorization survives assertion expiry; after revocation is observed, no matching cached authorization remains valid. +8. **Fail-closed storage and verification:** validation, key retrieval, or binding-state failures never produce allow. +9. **Privacy:** conforming protocol behavior need not publish `iss`, `sub`, JWTs, email, or display names in Nostr events or relay-visible event history. + +# Liveness properties + +Assuming the issuer, key source, binding store, and network are available: + +1. a valid assertion and matching proof for an existing active binding are eventually authorized; +2. an unbound pair is eventually authorized exactly once when the configured enrollment mode permits it; +3. after an authorized revocation/rotation and bounded cache invalidation, the old key is denied and the new valid binding can be authorized. + +Liveness is intentionally not guaranteed during issuer/JWKS/storage outage; availability must not override identity safety. + +# Representative attack traces + +| Trace | Required result | +|---|---| +| Valid assertion for `i`, attacker proves unbound `k_x`, `i` already bound to `k_v` | Deny `binding_conflict` | +| Valid assertion with key claim `k_v`, attacker proves `k_x` | Deny `key_mismatch` before mutation | +| Stolen assertion for never-enrolled `i`, attacker proves `k_x` | Deny in `attested-key`/`provisioned`; TOFU can bind and explicitly accepts this risk | +| Client injects trusted-proxy header while bypassing proxy | Deployment is non-conforming; verifier must reject direct/untrusted ingress | +| Assertion for issuer `A`, same `sub` as issuer `B` | Distinct identities; never collide or inherit binding | +| Assertion has wrong audience, expired `exp`, unknown algorithm/key, malformed subject/key | Deny without binding mutation | +| Concurrent first use of `(i,k1)` and `(i,k2)` | At most one commits; the other denies conflict | +| Reuse of valid WebSocket authorization after assertion expiry | Deny protected operation or reauthenticate/close | +| Fresh assertion for a revoked pair | Deny unless explicit recovery transition authorizes reactivation | +| New key presented for bound identity | Deny; require explicit rotation | +| Display name/email changes while `(iss,sub)` is stable | May update metadata; binding identity is unchanged | +| One NIP-42 connection authenticates `k1` and `k2`, only `k1` is bound | Only operations attributed to `k1` receive its lease | +| JWT or corporate identifier is accidentally published as event/tag | Non-conforming privacy failure; assertion transport must not enter relay event history | + +# Conformance hooks for the NIP + +The normative NIP should expose enough information for clients and operators to determine: + +- accepted assertion transport profile(s); +- issuer discovery or configured issuer and accepted audience rules without leaking private tenant data; +- whether a key claim is required; +- enrollment mode (`attested-key`, `provisioned`, or explicitly risk-labeled `tofu`); +- authorization lease/re-authentication behavior; +- machine-readable rejection classes using existing NIP-42 `auth-required:` and `restricted:` prefixes where applicable; +- privacy requirements and trusted-proxy deployment requirements. + +It should not standardize database schema, lock mechanism, Okta-specific claims, mutable display metadata, or an administration API. Those are implementation choices as long as the invariants and transitions above hold. + +# Sources + +- NIP-42 authentication: https://github.com/nostr-protocol/nips/blob/8f8444d05a8842c40211ded5d10af3521541f865/42.md +- NIP-98 HTTP auth: https://github.com/nostr-protocol/nips/blob/8f8444d05a8842c40211ded5d10af3521541f865/98.md +- NIP-05 issuer-controlled identifier mapping precedent: https://github.com/nostr-protocol/nips/blob/8f8444d05a8842c40211ded5d10af3521541f865/05.md +- NIP-46 external auth challenge precedent: https://github.com/nostr-protocol/nips/blob/8f8444d05a8842c40211ded5d10af3521541f865/46.md +- Companion protocol specification: [`NIP-FI.md`](NIP-FI.md) +- Buzz implementation semantics reviewed at `bd822f3ea8fc04b449501fd4738097c32d3da950` (PR #1476) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 3d5c9f31ca..5b37a2a35c 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -10,7 +10,7 @@ Federated Identity Authorization ## Abstract -This NIP defines how a relay or Nostr-adjacent HTTP service authorizes an already-authenticated Nostr key only when a valid federated identity assertion (an OIDC/JWT credential from an external identity provider) resolves to the same principal. It specifies assertion transport, validation, an identity-to-key binding lifecycle (enroll, conflict, revoke, rotate), session semantics, and failure behavior. +This NIP defines how a relay or Nostr-adjacent HTTP service authorizes an already-authenticated Nostr key only when a valid federated identity assertion resolves to the same principal and key. It specifies assertion transport, validation, an identity-to-key binding lifecycle (enroll, conflict, revoke, rotate), session semantics, and failure behavior. A separately validated delegation MAY derive narrower authority from a bound owner as described below; that exception does not turn the delegate into the federated principal. The identity provider never becomes a Nostr signing authority, and the assertion never substitutes for Nostr proof of key control. This NIP is an authorization layer above NIP-42 and NIP-98, not a replacement for either. @@ -38,10 +38,10 @@ Without a standard, each deployment invents an incompatible binding scheme, and An assertion reaches the verifier in an HTTP header on the request being authorized: the WebSocket upgrade request for relay connections, or each individual request for NIP-98-authenticated HTTP endpoints. Two transport profiles are defined; a service MUST document which it accepts. -1. **Trusted proxy**: an authenticating reverse proxy (for example oauth2-proxy or an SSO-aware ingress) injects the assertion header after authenticating the user. This profile is conforming only if untrusted clients cannot reach the verifier directly and the proxy strips any inbound copy of the header before setting it. This is the recommended profile for browser-based clients, which cannot attach arbitrary WebSocket upgrade headers. -2. **Client-attached**: the client sends the assertion itself, in the `Authorization: Bearer` header or a service-configured header. +1. **Trusted proxy**: an authenticating reverse proxy (for example oauth2-proxy or an SSO-aware ingress) injects the assertion after authenticating the user. The injected header name is deployment configuration. This profile is conforming only if untrusted clients cannot reach the verifier directly and the proxy strips every inbound copy of that header before setting it. This is the recommended profile for browser-based clients, which cannot attach arbitrary WebSocket upgrade headers. +2. **Client-attached**: the client sends the assertion itself in `Nostr-Federated-Identity: Bearer `. A verifier MAY additionally accept another documented header on WebSocket upgrades, including `Authorization: Bearer`; HTTP requests using NIP-98 MUST use `Nostr-Federated-Identity` because their `Authorization` header carries the `Nostr` proof. -The header name is deployment configuration; `Authorization` semantics apply when it is used. A value with a `Bearer ` prefix MUST be accepted with the prefix stripped. +Assertion acquisition and interactive OIDC login are outside this NIP. A client-attached assertion value MUST use the `Bearer` scheme; after removing that scheme, the value MUST contain exactly one JWT and no comma-separated alternatives. On a WebSocket connection, the assertion captured at upgrade is evaluated when a key performs NIP-42 AUTH — each authenticating key is authorized against that assertion independently. On HTTP, the assertion and the NIP-98 proof MUST arrive on the same request they authorize. @@ -86,7 +86,7 @@ Authorize(D, i, k_a?, k): tofu: create (i, k); ALLOW ``` -The check and any insertion MUST be atomic for `(D, i, k)`: under concurrent first use of the same identity or key, at most one binding is created and every other attempt observes it (allow on exact match, deny on conflict). Storage failure or a lost race MUST deny — never fall back to an unchecked allow. +The check and any insertion MUST be atomic for `(D, i, k)`: under concurrent first use of the same identity or key, at most one binding is created and every other attempt observes it (allow on exact match, deny on conflict). Storage failure or a race whose committed result cannot be read MUST deny — never fall back to an unchecked allow. ### Enrollment modes @@ -127,7 +127,19 @@ HTTP endpoints respond `401` where `auth-required` applies and `403` where `rest ## Discovery -A relay SHOULD advertise support in its NIP-11 document under `limitation` as `"federated_identity": true`, and MAY publish a `federated_identity` object naming its accepted transport profile(s), enrollment mode, and whether delegation is honored. It MUST NOT publish issuer-internal detail (tenant URLs, claim names, audiences) that is not already public. +A relay SHOULD advertise support in its NIP-11 document under `limitation` as `"federated_identity": true`. It MAY additionally include this top-level object: + +```json +{ + "federated_identity": { + "transports": ["trusted-proxy", "client-attached"], + "enrollment": "attested-key", + "delegation": false + } +} +``` + +`transports` contains the supported profile names from this NIP, `enrollment` is exactly one enrollment mode, and `delegation` states whether separately validated delegation may be honored. Unknown fields MUST be ignored. A relay MUST NOT publish issuer-internal detail (tenant URLs, claim names, audiences) that is not already public. ## Privacy From 5f09008091dfe7adb85fe8c6b6b65e1fa43946e4 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Thu, 2 Jul 2026 20:37:11 -0400 Subject: [PATCH 3/6] docs(nips): align NIP-FI race and session wording across spec and model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cold-read consistency fixes: the model's race clause still said a lost race must deny, contradicting the spec's exact-match-allows rule; and the spec's session text offered 'require a fresh assertion' at lease expiry, which has no defined mid-connection transport — assertions only arrive on an upgrade request. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- docs/nips/NIP-FI-MODEL.md | 2 +- docs/nips/NIP-FI.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/nips/NIP-FI-MODEL.md b/docs/nips/NIP-FI-MODEL.md index 831e75d8a3..1db0afa1d4 100644 --- a/docs/nips/NIP-FI-MODEL.md +++ b/docs/nips/NIP-FI-MODEL.md @@ -103,7 +103,7 @@ Authorize(D, i, k_a?, k): ALLOW(created) ``` -If an insertion loses a race or storage is unavailable, deny; never fall back to an unchecked allow. The check and possible insertion must be linearizable for `(D, i, k)`. +If a concurrent attempt finds the identical committed binding, it allows as `existing`; if the committed outcome cannot be read or storage is unavailable, deny — never fall back to an unchecked allow. The check and possible insertion must be linearizable for `(D, i, k)`. The resulting authorization lease is: diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 5b37a2a35c..948386831c 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -102,7 +102,7 @@ Within a domain, active bindings form a partial bijection: an identity has at mo For HTTP requests, the decision applies to that request only. -For a NIP-42 WebSocket connection, the relay MAY cache the decision as a lease. A lease MUST NOT be honored past the assertion's `exp` (implementations MAY enforce a shorter maximum). At expiry the relay MUST require a fresh assertion, reject protected operations, or close the connection. When a relay learns a binding was revoked, it MUST invalidate matching leases; a relay that detects revocation by polling MUST NOT claim immediate revocation and SHOULD document its detection latency. +For a NIP-42 WebSocket connection, the relay MAY cache the decision as a lease. A lease MUST NOT be honored past the assertion's `exp` (implementations MAY enforce a shorter maximum). At expiry the relay MUST reject protected operations or close the connection; a fresh assertion arrives only on a new connection's upgrade request. When a relay learns a binding was revoked, it MUST invalidate matching leases; a relay that detects revocation by polling MUST NOT claim immediate revocation and SHOULD document its detection latency. When multiple keys authenticate on one connection (NIP-42 permits this), authorization is tracked per key. A lease for one key MUST NOT authorize operations attributed to another. From 805f1131b8c946cfdcff4508d20f1758515a5990 Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 2 Jul 2026 20:38:44 -0400 Subject: [PATCH 4/6] docs(nips): align model lease expiry transition Remove the model's remaining impossible mid-connection assertion refresh option so its WebSocket transition exactly matches the normative transport. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- docs/nips/NIP-FI-MODEL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/nips/NIP-FI-MODEL.md b/docs/nips/NIP-FI-MODEL.md index 1db0afa1d4..c47d2dad29 100644 --- a/docs/nips/NIP-FI-MODEL.md +++ b/docs/nips/NIP-FI-MODEL.md @@ -118,7 +118,7 @@ An implementation may impose a shorter maximum lease. A lease authorizes only po For a single HTTP request, the assertion, Nostr proof, and authorization decision apply only to that request. -For a NIP-42 WebSocket connection, a relay may cache `L`, but it must not use the lease after `expires_at`. It must require a fresh assertion and proof, reject protected operations, or terminate the connection. A relay that learns that the binding or federated session was revoked must invalidate matching leases. Implementations must document their maximum revocation-detection latency; they cannot claim immediate revocation if they only poll. +For a NIP-42 WebSocket connection, a relay may cache `L`, but it must not use the lease after `expires_at`. It must reject protected operations or terminate the connection; obtaining a fresh assertion and proof requires a new connection under this transport profile. A relay that learns that the binding or federated session was revoked must invalidate matching leases. Implementations must document their maximum revocation-detection latency; they cannot claim immediate revocation if they only poll. If multiple keys authenticate on one NIP-42 connection, authorization is tracked independently per key. A lease for one `(i, k)` must not authorize another authenticated key. From 0db374cd3cc31d3192969fa4e2cb12bc2636ff21 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:43:05 -0500 Subject: [PATCH 5/6] docs(nips): harden NIP-FI contract Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- docs/nips/NIP-FI-MODEL.md | 549 ++++++++++++++++++++++++++++---------- docs/nips/NIP-FI.md | 327 +++++++++++++++++------ 2 files changed, 650 insertions(+), 226 deletions(-) diff --git a/docs/nips/NIP-FI-MODEL.md b/docs/nips/NIP-FI-MODEL.md index c47d2dad29..425645d4df 100644 --- a/docs/nips/NIP-FI-MODEL.md +++ b/docs/nips/NIP-FI-MODEL.md @@ -1,220 +1,481 @@ # Scope -This model specifies a relay or HTTP service authorizing a Nostr principal only when a valid federated identity assertion and a valid Nostr proof resolve to the same active identity-to-key binding. It models authorization, enrollment, revocation, and key rotation. It does not publish the federated identity on Nostr and does not make the identity provider a Nostr signing authority. +This model defines the state and transitions required by [NIP-FI](NIP-FI.md). It covers direct authorization, enrollment, lifecycle changes, leases, and delegation. It does not define an identity provider, storage schema, operator API, public identity projection, or application-specific admission policy. -The model is transport-independent. A concrete NIP must separately define how an assertion reaches a verifier and how support is advertised. NIP-42 and NIP-98 remain the mechanisms for proving control of a Nostr key; a bearer assertion alone is never a Nostr proof. +The model is transport-neutral except where transport is part of the authorization evidence. NIP-42 and NIP-98 prove control of a Nostr key. A federated assertion does not. -# Terms and domains +# Terms -- `D`: authorization domain chosen by the service (for example one relay tenant). Bindings never cross domains implicitly. -- `I`: federated principal, the tuple `(iss, sub)`. `iss` is the assertion's exact validated issuer identifier and `sub` is its exact non-empty subject string. A username, email, display name, or bare `sub` is not an identity key. -- `K`: 32-byte Nostr public key. -- `A`: federated assertion. -- `P`: Nostr proof authenticating key `k`, such as a valid NIP-42 AUTH event or NIP-98 event. +- `D`: an authorization domain selected from authenticated server routing and configuration. +- `i = (iss, sub)`: an issuer-qualified federated identity returned by assertion validation. +- `k`: a 32-byte Nostr public key returned by Nostr-proof validation. +- `A`: the exact compact-JWS assertion bytes. +- `N`: a fresh Nostr proof for the current connection or HTTP request. +- `R_t`: the server-owned target context `(method, authority, path_and_query, body_digest, transport, operation, resource)`. +- `R`: `R_t` sealed with the actor key returned by Nostr-proof validation. +- `C`: local admission policy and resource state for `(D, R)`. - `now`: verifier time. -- `B_D`: active binding relation in domain `D`, a partial bijection between `I` and `K`. -- `R_D`: durable history of revoked bindings. -- `mode(D)`: enrollment policy, either `attested-key`, `provisioned`, or `tofu`. -A binding record is: +Client fields, forwarded routing fields, assertion claims, and Nostr tags cannot select `D`, `R`, `C`, or the operation being admitted. + +# Persistent state + +For each domain `D`, the service maintains: ```text -Binding = (domain, identity, key, source, created_at, revoked_at?) -source = attested-key | provisioned | tofu +B_D : active binding relation +T_D : set of retired (identity, key) pairs +X_D : set of disabled identities +Y_D : set of revoked keys +Q_D : pending-replacement lineage +H_D : immutable lifecycle history +V_D : monotonic binding and lifecycle versions ``` -`display_name`, email, and similar values may be stored as mutable metadata but are never part of binding identity or an authorization decision. +An active binding is: + +```text +Binding = ( + domain, + identity, + key, + version, + provenance, // attested-key | provisioned | tofu + created_at, + binding_not_after? // optional administrative bound +) +``` + +`binding_not_after` is absent unless a separately authorized administrative action sets it. Assertion `exp`, `iat`, or maximum age never creates, renews, or extends this bound. Binding provenance is immutable. + +Pending replacement lineage identifies an exact old binding version and old pair. A recovery or re-enablement transition can consume it once. Time passage alone does not create lifecycle state. + +# Binding and lifecycle invariants + +The following labels are stable conformance references. + +**`FI-INV-01 — partial bijection.`** Active bindings are one-to-one within a domain: + +```text +forall i, k1, k2: + (i, k1) in B_D and (i, k2) in B_D implies k1 = k2 + +forall i1, i2, k: + (i1, k) in B_D and (i2, k) in B_D implies i1 = i2 +``` + +**`FI-INV-02 — durable binding.`** Assertion expiry does not remove, retire, or expire a binding. A fresh eligible assertion may authorize the same binding after an earlier assertion expires. + +**`FI-INV-03 — tombstone monotonicity.`** Ordinary authorization never removes an element from `T_D`, `X_D`, or `Y_D`, consumes `Q_D`, or recreates a retired pair. + +**`FI-INV-04 — server-owned context.`** Every allowed operation uses one server-resolved `D`, `R`, operation, resource, and actor. Unauthenticated input cannot replace any of them. -# Trust assumptions +**`FI-INV-05 — independent evidence.`** Direct authorization requires both a currently valid assertion and a fresh Nostr proof. The asserted key, when present, equals the proven key. -1. The verifier has an authenticated configuration for each accepted issuer: issuer identifier, allowed signing algorithms, key source, accepted audience(s), and claim mapping. -2. TLS and/or a trusted ingress boundary prevents attackers from injecting or replacing assertions. A reverse-proxy assertion header is trusted only when untrusted clients cannot reach the verifier directly and all inbound copies of that header are stripped before the trusted proxy sets it. -3. The issuer protects its signing keys and assigns stable, non-reassignable `sub` values within an issuer. If an issuer reassigns a subject, the model cannot distinguish the people. -4. The Nostr signature primitive is unforgeable and the concrete Nostr proof is fresh and bound to the target relay or HTTP request. -5. Binding-state transactions are serializable with respect to the same domain, identity, or key. The implementation may realize this with locks and unique constraints. -6. The verifier's clock is sufficiently accurate for assertion and proof freshness checks. +**`FI-INV-06 — stable verifier policy.`** Verifier-policy identity changes when accepted assertion semantics change, but not when verification keys rotate. Key rotation changes the JWKS generation. -Compromise of an accepted issuer or trusted ingress can impersonate federated principals. It still cannot satisfy Nostr proof for an already-bound uncompromised key, and in `attested-key` mode it cannot bind an arbitrary key unless the compromised issuer also attests that key. Theft of an assertion alone cannot authorize an already-bound identity without control of the bound Nostr key. +**`FI-INV-07 — current-key verification.`** A prepared result or lease cannot survive removal of its verification key. A generation change requires revalidation against the current key snapshot before use. -# Assertion validity +**`FI-INV-08 — read-only preparation.`** Preparation creates no binding, lifecycle fact, replay claim, receipt, lease, audit event, denial observation, publication, last-seen value, or application mutation. -Let `ValidateAssertion(A, C, now)` return either `(i, k_a?, exp)` or failure under issuer configuration `C`. +**`FI-INV-09 — atomic final admission.`** Enrollment, replay claims, receipts, and authorization audit evidence commit only after complete final revalidation. A denied or failed final admission leaves no authority mutation. -It succeeds only if all of the following hold: +**`FI-INV-10 — explicit lifecycle authority.`** Provisioning, retirement, disablement, revocation, rotation, recovery, re-enablement, and administrative-expiry changes occur only through their separately authorized transition. -1. the signature validates under a currently trusted key and an explicitly allowed asymmetric algorithm; -2. `A.iss` exactly equals the configured issuer identifier used to select that key; -3. at least one `A.aud` value exactly equals an audience configured for this service; -4. `exp` exists and `now < exp`, allowing only a bounded configured clock skew; -5. if present, `nbf <= now` and `iat` is not unreasonably in the future; -6. the configured subject claim is a non-empty string; -7. `i = (A.iss, A.subject)`; and -8. if a configured Nostr-key claim is present, it parses to exactly one 32-byte key `k_a` (hex on the wire; bech32 may be accepted only as an explicitly documented input normalization). +**`FI-INV-11 — evidence-bounded leases.`** A lease ends no later than every assertion, verification-key snapshot, proof, proxy, delegation, local-policy, binding-administrative, and implementation bound on which it depends. -Unknown issuers, key IDs, algorithms, claims, and validation failures fail closed. Key retrieval failure also fails closed. A verifier must bound key-cache lifetime and refresh behavior; it must not accept a token merely because parsing succeeded. +**`FI-INV-12 — current-owner delegation.`** Delegation requires an authorization-eligible owner binding at its exact current version, a fresh delegate proof, capability intersection, and a positive finite deadline. -# Nostr-proof validity +**`FI-INV-13 — privacy-safe denial.`** Public rejection is a many-to-one class. It does not reveal an identity, key, claim, binding, tombstone, enrollment mode, key identifier, or private policy fact. -`ValidateProof(P, target, now) = k` only when the applicable Nostr standard verifies the event ID and Schnorr signature, freshness, and target binding: +**`FI-INV-14 — fail closed.`** Unreadable, ambiguous, stale beyond policy, or inconsistent assertion, key, binding, lifecycle, replay, policy, resource, receipt, or audit state cannot produce authority. -- NIP-42: kind, challenge, relay URL, and timestamp are valid; or -- NIP-98: kind, absolute request URL, HTTP method, timestamp, and payload hash when required are valid. +**`FI-INV-15 — uniform authority.`** Every protected ingress in a domain uses the same current domain policy and final-admission authority. An uncovered route, competing authority, or different policy lineage makes enforcement unavailable and fails closed. -A service may define another proof profile only if it has equivalent signer-control, freshness, and target/replay binding. The key used for the authorization decision is the key returned by proof validation, never an unsigned request field or assertion display claim. +**`FI-INV-16 — canonical verifier.`** Assertion acceptance semantics have one provider-neutral implementation contract. Transport adapters cannot weaken or fork the verifier decision. -# Binding invariant +# Assertion-verifier model -For every domain `D`, active bindings are one-to-one: +A verifier policy has stable identity `policy_id` and contains at least: ```text -∀ i, k1, k2: (i, k1) ∈ B_D ∧ (i, k2) ∈ B_D ⇒ k1 = k2 -∀ i1, i2, k: (i1, k) ∈ B_D ∧ (i2, k) ∈ B_D ⇒ i1 = i2 +VerifierPolicy = ( + exact_issuer, + accepted_audiences, + allowed_asymmetric_algorithms, + authenticated_key_source_identity, + subject_rules, + optional_key_claim_rules, + time_and_skew_rules, + normalization_and_size_rules +) ``` -Equivalently, an active identity has at most one key and an active key has at most one identity in a domain. +`policy_id` excludes transport, JWKS bytes, key identifiers, key order, cache metadata, retrieval time, and JWKS generation. Any change to accepted assertion semantics changes `policy_id`. -# Authorization and enrollment transition +Each accepted key snapshot has an opaque generation `g`. Effective addition, removal, or replacement of a verification key changes `g`. Generation order need not be meaningful outside one verifier instance; equality is sufficient for witness comparison. -Given domain `D`, assertion result `(i, k_a?, exp)`, and proof result `k`, evaluate one atomic transaction: +`ValidateAssertion(A, D, now)` returns: ```text -Authorize(D, i, k_a?, k): - if k_a exists and k_a != k: +AssertionEvidence = ( + identity, + asserted_key?, + deadline, + policy_id, + jwks_generation, + verification_key_identity, + key_snapshot_hard_deadline, + assertion_digest, + confidential_revalidation_handle // yields the exact compact-JWS bytes +) +``` + +Validation succeeds only when the checks in NIP-FI all pass. The input is exactly one bounded compact JWS with unambiguous protected headers and claims. The algorithm is allowed and asymmetric, and key selection produces exactly one compatible key. Issuer, audience, time, stable subject, and optional asserted-key checks then pass under the same verifier contract. + +Unknown, duplicate, incompatible, or removed keys fail closed. Retrieval and refresh work is bounded and coalesced. A stale known key can be accepted only inside an explicit finite stale-known-key policy and never after the hard key-cache bound. + +Final admission denies when the current verifier policy identity differs from the prepared identity. If the current generation differs from the generation in prepared evidence or a lease, the verifier revalidates the original assertion under the current snapshot. Revalidation must reproduce the same identity, asserted key, policy identity, and live time bounds. A key addition can therefore preserve valid evidence when the old key remains accepted; key removal denies evidence signed by the removed key. + +# Assertion transport model + +Trusted listener and route configuration selects `R_t.transport_profile` as exactly `client-attached` or `trusted-proxy-hmac-v1`. The verifier does not infer a profile from attacker-controlled fields and does not fall back between profiles after missing, mixed, or rejected evidence. + +The `client-attached` profile carries exactly one `Nostr-Federated-Identity: Bearer ` field and no assertion-provenance field. Missing, repeated, combined, malformed, or mixed-profile fields deny. + +## Trusted-proxy provenance + +The trusted proxy removes every inbound assertion and provenance field and supplies exactly one `Nostr-Federated-Identity: Bearer ` field and one `Nostr-Federated-Identity-Provenance` field. The provenance value is exactly `v1...`. `timestamp` is canonical unsigned decimal without leading zeroes except `0`. `nonce` and `mac` are canonical unpadded base64url, decoding to at least 16 bytes and exactly 32 bytes respectively. Each nonce has at least 128 bits from a cryptographically secure random source. Configured finite field and nonce maxima apply before decoding, lookup, or replay storage. + +Let `LP(x)` be the eight-byte unsigned big-endian length of byte string `x`, followed by `x`. The stock MAC input is: + +```text +"NIP-FI-PROXY-1" || +LP(timestamp) || LP(nonce) || LP(SHA256(A)) || +LP(R_t.method) || LP(R_t.authority) || LP(R_t.path_and_query) || +LP(R_t.body_digest) +``` + +The secret has at least 256 bits. The parsed timestamp is an eight-byte unsigned big-endian Unix-seconds value in the MAC. The nonce uses its decoded bytes. The assertion and body digests are raw SHA-256 output. Method is the exact uppercase ASCII endpoint method. Authority is the server-configured lowercase ASCII host, explicit effective port, and bracketed IPv6 when applicable. Path and query are the exact post-routing ASCII origin-form, with `/` for an empty path, the leading `?` on a query, preserved percent-encoding and parameter order, and no fragment. Ambiguous or non-canonical values deny. + +The profile accepts time only when `timestamp <= now + future_skew` and `now < timestamp + maximum_provenance_age`, with finite configured bounds and overflow-safe comparisons. Equality at the age bound is expired. The verifier reconstructs every request component from authenticated server state, verifies the MAC in constant time against a finite active-secret set, and rejects a committed nonce. Header presence or network location is not provenance. An assertion on direct ingress or without a valid MAC is denied. + +The nonce is only claimed during final admission and retained through at least `timestamp + maximum_provenance_age`. An applicable proof replay identity is retained through its entire acceptance window. Preparation reserves neither and cannot cause later requests to fail. + +# Nostr-proof model + +`ValidateNostrProof(N, D, R_t, now)` returns `k` only when signature, event identity, freshness, and exact target binding pass: + +- NIP-42 binds the proof to the current challenge, relay URL, connection, and freshness window. +- NIP-98 binds the proof to the exact server-resolved URL, method, payload digest when required, and freshness window. + +The key used for authorization is always `k`, never an assertion claim or unsigned input. Applicable proof replay identity is claimed only during final admission. + +# Prepared authorization + +A prepared result is immutable evidence, not authority: + +```text +PreparedAuthorization = ( + exact_context, // D, R_t, R, operation, resource, actor + nostr_proof_evidence, + path_dependencies, // direct: DirectPrepared | + // delegated: DelegatedPrepared + policy_and_resource_witness, + proposal, // existing | enroll | delegated + all_deadlines, + invalidation_dependencies +) + +DirectPrepared = ( + assertion_evidence, + proxy_evidence?, + actor_binding_and_lifecycle_witness, + enrollment_mode_witness +) + +DelegatedPrepared = ( + delegation_evidence, + owner_binding_and_lifecycle_witness, + relationship_witness +) +``` + +For direct authorization, preparation is equivalent to: + +```text +PrepareDirect(request, A, N): + (D, R_t, operation, resource) := ResolveTargetContext(request) or DENY + proxy_evidence? := VerifyTransportProvenance(D, R_t, A) or DENY + k := ValidateNostrProof(N, D, R_t, now) or DENY + R := SealActor(R_t, k) + e := ValidateAssertion(A, D, now) or DENY + i := e.identity + + if e.asserted_key exists and e.asserted_key != k: DENY(key_mismatch) - b_i := active binding in B_D for i, if any - b_k := active binding in B_D for k, if any + atomically read B_D(i), B_D(k), T_D(i,k), X_D(i), Y_D(k), + Q_D(i), mode(D), C, and all versions - if b_i = (i, k) and b_k = (i, k): - ALLOW(existing) + if i in X_D: DENY(identity_disabled) + if k in Y_D: DENY(key_revoked) + if (i,k) in T_D: DENY(pair_retired) + if Q_D(i) exists: DENY(explicit_replacement_required) - if b_i exists or b_k exists: + if B_D(i) = B_D(k) = b(i,k): + if b.binding_not_after exists and now >= b.binding_not_after: + DENY(binding_expired) + proposal := existing(b.version, b.provenance) + else if B_D(i) exists or B_D(k) exists: DENY(binding_conflict) - - switch mode(D): - attested-key: - if k_a is absent: DENY(key_attestation_required) - atomically insert (i, k, attested-key) into B_D - ALLOW(created) - provisioned: + else if mode(D) = attested-key: + require e.asserted_key = k + proposal := enroll(i, k, attested-key) + else if mode(D) = provisioned: DENY(binding_required) - tofu: - atomically insert (i, k, source = k_a exists ? attested-key : tofu) into B_D - ALLOW(created) + else if mode(D) = tofu: + provenance := e.asserted_key = k ? attested-key : tofu + proposal := enroll(i, k, provenance) + + EvaluateEveryLocalAdmissionPolicy(D, R, operation, resource, k) or DENY + return PreparedAuthorization(evidence, proposal, witnesses, deadlines) ``` -If a concurrent attempt finds the identical committed binding, it allows as `existing`; if the committed outcome cannot be read or storage is unavailable, deny — never fall back to an unchecked allow. The check and possible insertion must be linearizable for `(D, i, k)`. +Preparation is read-only for an existing binding and every enrollment mode. It creates no audit event or denial observation. A denial has the same no-mutation property. -The resulting authorization lease is: +# Final admission + +`CommitAdmission(prepared, current_request)` is equivalent to: ```text -L = (D, i, k, binding_version, expires_at) -expires_at <= assertion.exp +require ExactContextMatch(prepared, current_request) +require every evidence and policy deadline is live + +if prepared.path_dependencies is DirectPrepared: + require CurrentVerifierPolicyIdentity( + D, prepared.direct.assertion_evidence.identity.iss + ) = prepared.direct.assertion_evidence.policy_id + if CurrentJwksGeneration(prepared.direct.assertion_evidence.policy_id) != + prepared.direct.assertion_evidence.jwks_generation: + revalidate the original assertion under the current generation +else: + require prepared.path_dependencies is DelegatedPrepared + revalidate its delegation, relationship, owner, target, and policy witnesses + +atomically: + reread every applicable binding, lifecycle, enrollment-mode, policy, resource, + replay, receipt, and invalidation witness + recompute the complete decision from current state + require the current result is equivalent and eligible + require every applicable proxy nonce and proof replay identity is unclaimed + claim applicable replay identities + create the proposed binding only if it remains eligible + append the request-bound authorization receipt and required audit evidence + +return CommittedAuthorization( + exact_actor, + binding_dependencies, + capabilities, + dependencies, + deadline +) ``` -An implementation may impose a shorter maximum lease. A lease authorizes only policy-selected operations in `D`; it does not authorize signing and does not imply that event authors may differ from `k`. +The atomic section either commits all authority mutations or none. Unreadable state denies. Changed state requires complete recomputation and may commit only a semantically equivalent current decision. A concurrent identical enrollment may be reread and recomputed as `existing`; a conflicting enrollment denies. A missing or unreadable committed result does not fall back to allow. + +The application operation runs only after committed authorization. When it cannot share the authorization transaction, a request-bound idempotent receipt or equivalent staging prevents the same proof from creating a second effect. -# Session behavior +# Enrollment modes -For a single HTTP request, the assertion, Nostr proof, and authorization decision apply only to that request. +- `attested-key`: a first binding requires `asserted_key = proven_key`; provenance is `attested-key`. +- `provisioned`: ordinary authorization never creates a binding; only `ProvisionBinding` can. +- `tofu`: eligible first use may bind a proven key without issuer key attestation. This mode is explicitly risk-labelled because a stolen assertion for a never-enrolled identity can bind an attacker's key. A matching claim records `attested-key` provenance. -For a NIP-42 WebSocket connection, a relay may cache `L`, but it must not use the lease after `expires_at`. It must reject protected operations or terminate the connection; obtaining a fresh assertion and proof requires a new connection under this transport profile. A relay that learns that the binding or federated session was revoked must invalidate matching leases. Implementations must document their maximum revocation-detection latency; they cannot claim immediate revocation if they only poll. +A mode change affects future creation only. It cannot rewrite an existing binding or its provenance. -If multiple keys authenticate on one NIP-42 connection, authorization is tracked independently per key. A lease for one `(i, k)` must not authorize another authenticated key. +# Lifecycle transitions -# Revocation and rotation +Each lifecycle transition requires separate privileged authority bound to `D`, the transition, identity, old binding version when present, target key when present, and request. It atomically rechecks relevant `B_D`, `T_D`, `X_D`, `Y_D`, `Q_D`, policy, and version state; appends `H_D`; advances `V_D`; and invalidates dependent leases after commit. -Revocation is an explicit administrative transition: +`TargetEligible(i, k, allow_disabled)` means `k` is not in `Y_D`, `(i,k)` is not in `T_D`, neither side has an active binding, and `i` is not in `X_D` unless `allow_disabled` is true for ReenableIdentity. Provision and rotation additionally require no pending lineage. Recovery and re-enablement require the exact lineage stated by their transition. + +`ReplacementProvenance(evidence)` records the evidence that authorized the new target: `attested-key` only for a current matching issuer key attestation, otherwise `provisioned` for a privileged lifecycle transition. TOFU provenance is created only by ordinary first use in `tofu` mode and is never inherited by a replacement key. ```text -Revoke(D, i, k): - require (i, k) ∈ B_D - atomically remove (i, k) from B_D - append immutable revocation record to R_D - invalidate cached leases for the binding as soon as observed +ProvisionBinding(i, k): + require mode(D) = provisioned + require TargetEligible(i, k, false) and Q_D(i) is absent + require fresh target-key proof and any required issuer attestation + create Binding(i, k, new_version, provisioned) + +RetirePair(i, k, old_version): + require exact current Binding(i, k, old_version) + remove it from B_D + add (i,k) to T_D + record Q_D(i) for old_version + +DisableIdentity(i): + add i to X_D + if an active binding exists, retire its pair and record Q_D(i) + +RevokeKey(k): + add k to Y_D even when k is inactive + if an active binding exists, retire its pair and record Q_D(i) + repeated authorized application is idempotent and preserves lineage + +Rotate(i, k_old, old_version, k_new): + require exact current Binding(i, k_old, old_version) + require k_new is not revoked and (i,k_new) is not retired + require k_new has no active binding and Q_D(i) is absent + require fresh target-key proof and any required issuer attestation + remove (i,k_old) from B_D and add (i,k_old) to T_D + create Binding(i, k_new, new_version, ReplacementProvenance(evidence)) + +Recover(i, pending_version, k_new): + require exact Q_D(i, pending_version) + require i is not disabled + require TargetEligible(i, k_new, false) + require fresh target-key proof and any required issuer attestation + consume Q_D(i, pending_version) + create Binding(i, k_new, new_version, ReplacementProvenance(evidence)) + +ReenableIdentity(i, pending_version?, k_new): + require i in X_D + require exact absent lineage or exact Q_D(i, pending_version) + require TargetEligible(i, k_new, true) + require fresh target-key proof and any required issuer attestation + remove i from X_D + consume supplied lineage when present + create Binding(i, k_new, new_version, ReplacementProvenance(evidence)) + +SetAdministrativeExpiry(i, k, old_version, binding_not_after?): + require exact current Binding(i, k, old_version) + require separate privileged expiry authority + create the same pair and provenance at new_version with the supplied bound ``` -An assertion, including one with `k_a = k`, must not silently reactivate the same revoked binding unless the domain's explicit recovery policy authorizes that transition. This prevents replay of a still-valid assertion from undoing revocation. +Rotation does not globally revoke `k_old`; revocation does. A retired pair remains retired after rotation, recovery, or re-enablement. Ordinary authorization cannot cross disabled, revoked, retired, pending, or administratively expired state. + +# Lease model -Key rotation is not an authorization side effect: +HTTP authorization applies to one exact request and has no reusable lease. + +A WebSocket lease is: + +```text +Lease = ( + D, + actor_key, + binding_dependencies, // direct actor | delegated owner + lifecycle_versions, + evidence_dependencies, // DirectEvidence | DelegatedEvidence + operations, + resources, + deadline, + invalidation_dependencies +) +``` + +`DirectEvidence` records `policy_id`, JWKS generation, verification-key identity, the key-snapshot hard-validity deadline, assertion digest, and a confidential handle that yields the exact assertion bytes. `DelegatedEvidence` records the exact owner binding and version, relationship identifier and revision, and delegation expiry. Revalidation material is retained only through the admission or lease that may need it and is destroyed on expiry, close, or invalidation. + +Before each protected use, the service verifies the key, domain, capability, resource, applicable binding and lifecycle versions, administrative bound, and deadline. Direct evidence requires a readable key snapshot before its hard-validity deadline; a changed JWKS generation requires revalidation of the original assertion under the current generation. Delegated evidence requires the exact current eligible owner binding and relationship revision. A lease for one key never authorizes another key on the same connection. + +For direct authorization: + +```text +lease.deadline <= min( + assertion_deadline, + key_snapshot_hard_deadline, + proof_or_connection_bound, + proxy_bound_if_present, + binding_not_after_if_present, + local_policy_bound, + implementation_maximum +) +``` + +Lease expiry removes session authority. It does not remove, renew, or retire the durable binding. + +# Delegation model + +Delegation is a separate evidence path. The delegate supplies fresh proof of `k_delegate`; both federated-assertion and assertion-provenance fields are absent. Separately validated evidence contains: ```text -Rotate(D, i, k_old, k_new): - require explicit recovery/admin authorization - require (i, k_old) ∈ B_D - require no active binding for k_new - if issuer-attested rotation is required, require fresh k_a = k_new - atomically revoke (i, k_old) and create (i, k_new) - invalidate leases for k_old +DelegationEvidence = ( + D, + owner_key, + delegate_key, + relationship_id, + relationship_revision, + audience, + operations, + resource_or_target, + not_before?, + mandatory_expiry +) ``` -A normal request that presents `i` with `k_new` while `k_old` is active is a conflict and must not rotate automatically. +Preparation and final admission both require the exact current, authorization-eligible owner binding and version. The proven delegate key must equal `delegate_key`. The admitted capability is the intersection of delegation evidence and current local policy. The path cannot create or change an owner or delegate binding, identity, provenance, lifecycle fact, or last-seen state. + +The delegated deadline is bounded by the delegation expiry, delegate proof, current owner administrative bound and lifecycle version, local policy, configured positive finite delegated maximum, and any stronger owner evidence a deployment requires. Rotation makes the former owner key non-current, so its delegations deny and do not transfer to the new key. -# Delegation +# Denial and privacy model -Delegation is outside the base identity-binding primitive. A separate delegation standard may allow a bound owner key to authorize a delegate key. If supported, the verifier must first validate the delegation proof and derive the owner key, then require an active, unexpired authorization lease or binding for that owner. It must not create a federated identity binding for the delegate unless explicitly specified. Delegation expiry/revocation and allowed operations remain bounded by both the owner identity authorization and the delegation. +Internal reasons map many-to-one to these public classes: -# Safety properties +```text +missing_evidence -> 401, auth-required: +evidence_rejected -> 403, restricted: +authorization_denied -> 403, restricted: +authorization_unavailable -> 503, restricted: +``` -Under the trust assumptions, for direct (non-delegated) authorization: +Internal reason, issuer, subject, key, binding existence, lifecycle state, enrollment mode, claim value, and key identifier remain private. Raw bearer material never enters protocol output, public events, logs, metrics, or traces. Pseudonymous access-controlled correlation is permitted only when bounded and needed for enforcement or investigation. -1. **Proof possession:** every allowed protected operation is associated with a valid proof of control of its Nostr key. -2. **Federated authenticity:** every allowed protected operation is associated with a currently valid assertion for its issuer-qualified identity. -3. **Agreement:** if the issuer supplies a key claim, the asserted key, proven key, and bound key are equal. -4. **Binding consistency:** no two active identities share a key and no identity has two active keys in one domain. -5. **No implicit rotation:** conflicting assertions or proofs cannot replace an active binding. -6. **Domain separation:** authorization in one domain does not imply authorization in another. -7. **Lease boundedness:** no cached authorization survives assertion expiry; after revocation is observed, no matching cached authorization remains valid. -8. **Fail-closed storage and verification:** validation, key retrieval, or binding-state failures never produce allow. -9. **Privacy:** conforming protocol behavior need not publish `iss`, `sub`, JWTs, email, or display names in Nostr events or relay-visible event history. +# Liveness -# Liveness properties +Liveness assumes available issuer keys, verifier policy, binding and lifecycle storage, replay storage, local policy, receipt and audit storage, and network: -Assuming the issuer, key source, binding store, and network are available: +1. An eligible existing binding with current evidence is eventually admitted. +2. An eligible unbound pair is eventually admitted exactly once when its enrollment mode permits creation. +3. After an authorized lifecycle transition and bounded invalidation, stale authority is denied and an eligible new binding can be admitted. -1. a valid assertion and matching proof for an existing active binding are eventually authorized; -2. an unbound pair is eventually authorized exactly once when the configured enrollment mode permits it; -3. after an authorized revocation/rotation and bounded cache invalidation, the old key is denied and the new valid binding can be authorized. +No liveness promise overrides `FI-INV-14`. Dependency outage may deny otherwise valid work. -Liveness is intentionally not guaranteed during issuer/JWKS/storage outage; availability must not override identity safety. +# Stable conformance traces -# Representative attack traces +Each trace identifier has the same meaning in NIP-FI, this model, and later executable conformance tests. -| Trace | Required result | +| ID | Setup and required result | |---|---| -| Valid assertion for `i`, attacker proves unbound `k_x`, `i` already bound to `k_v` | Deny `binding_conflict` | -| Valid assertion with key claim `k_v`, attacker proves `k_x` | Deny `key_mismatch` before mutation | -| Stolen assertion for never-enrolled `i`, attacker proves `k_x` | Deny in `attested-key`/`provisioned`; TOFU can bind and explicitly accepts this risk | -| Client injects trusted-proxy header while bypassing proxy | Deployment is non-conforming; verifier must reject direct/untrusted ingress | -| Assertion for issuer `A`, same `sub` as issuer `B` | Distinct identities; never collide or inherit binding | -| Assertion has wrong audience, expired `exp`, unknown algorithm/key, malformed subject/key | Deny without binding mutation | -| Concurrent first use of `(i,k1)` and `(i,k2)` | At most one commits; the other denies conflict | -| Reuse of valid WebSocket authorization after assertion expiry | Deny protected operation or reauthenticate/close | -| Fresh assertion for a revoked pair | Deny unless explicit recovery transition authorizes reactivation | -| New key presented for bound identity | Deny; require explicit rotation | -| Display name/email changes while `(iss,sub)` is stable | May update metadata; binding identity is unchanged | -| One NIP-42 connection authenticates `k1` and `k2`, only `k1` is bound | Only operations attributed to `k1` receive its lease | -| JWT or corporate identifier is accidentally published as event/tag | Non-conforming privacy failure; assertion transport must not enter relay event history | - -# Conformance hooks for the NIP - -The normative NIP should expose enough information for clients and operators to determine: - -- accepted assertion transport profile(s); -- issuer discovery or configured issuer and accepted audience rules without leaking private tenant data; -- whether a key claim is required; -- enrollment mode (`attested-key`, `provisioned`, or explicitly risk-labeled `tofu`); -- authorization lease/re-authentication behavior; -- machine-readable rejection classes using existing NIP-42 `auth-required:` and `restricted:` prefixes where applicable; -- privacy requirements and trusted-proxy deployment requirements. - -It should not standardize database schema, lock mechanism, Okta-specific claims, mutable display metadata, or an administration API. Those are implementation choices as long as the invariants and transitions above hold. +| `FI-TRACE-PROXY-SPOOF` | A valid assertion without valid proxy HMAC provenance, including direct ingress, denies. | +| `FI-TRACE-PROXY-REPLAY` | Two final admissions using the same proxy nonce produce at most one committed authorization. Preparation consumes neither. | +| `FI-TRACE-PROXY-CROSS-REQUEST` | Changing the assertion, method, authority, path/query, or body invalidates the HMAC and denies. | +| `FI-TRACE-AUTHORITY-UNIFORM` | Every protected ingress uses the same current domain policy and final-admission authority; uncovered, competing, or different-lineage paths fail closed. | +| `FI-TRACE-VERIFIER-PARITY` | The same assertion, policy, time, and key snapshot produce the same result on every transport. | +| `FI-TRACE-DOMAIN-SPOOF` | Client-selected domain or forwarded authority cannot replace server-owned context and denies on mismatch. | +| `FI-TRACE-ASSERTION-KEY-MISMATCH` | An asserted key different from the proven key denies before mutation. | +| `FI-TRACE-BINDING-CONFLICT` | A valid identity and key that conflict with either side of the active relation deny without replacement. | +| `FI-TRACE-TOMBSTONE-REPLAY` | A fresh assertion for a retired pair, disabled identity, revoked key, or pending replacement denies ordinary authorization. | +| `FI-TRACE-ASSERTION-REFRESH` | A fresh assertion can authorize the same eligible durable binding after the assertion used at enrollment expires. | +| `FI-TRACE-ADMIN-EXPIRY` | A fresh assertion after `binding_not_after` denies; only an explicit privileged transition can restore access. | +| `FI-TRACE-JWKS-ADD` | Generation changes, the old signing key remains accepted, revalidation passes, and the unchanged binding may authorize. | +| `FI-TRACE-JWKS-REMOVE` | Generation changes, the signing key is removed, and prepared evidence and leases signed by it deny. | +| `FI-TRACE-PREPARED-STALE` | A request, binding, lifecycle, policy, resource, mode, replay, or invalidation witness changes before final admission and the stale decision denies or is completely recomputed. | +| `FI-TRACE-FINAL-DENIAL-NO-MUTATION` | Preparation, denied local policy, and denied final admission create no binding, audit or denial observation, replay claim, receipt, lease, or application mutation. | +| `FI-TRACE-CONCURRENT-ENROLLMENT` | Identical eligible first uses converge on one binding version; conflicting first uses commit at most one winner. | +| `FI-TRACE-TOFU-THEFT` | Stolen assertion first use denies in attested and provisioned modes; only explicit risk-labelled TOFU may bind the attacker's proven key. | +| `FI-TRACE-DELEGATE-OWNER-ROTATED` | Owner rotation makes an old-owner delegation non-current and denies without inheritance. | +| `FI-TRACE-DELEGATION-EXPIRED` | Missing or expired finite delegation bounds deny. | +| `FI-TRACE-DENIAL-ORACLE` | Unknown, conflict, tombstone, and private-policy denials are not publicly distinguishable. | +| `FI-TRACE-DEPENDENCY-FAIL-CLOSED` | Unreadable current verifier, key, state, replay, policy, receipt, or audit dependency denies. | +| `FI-TRACE-MULTI-KEY-SESSION` | A lease for one authenticated key does not authorize another key on the same connection. | +| `FI-TRACE-CROSS-DOMAIN-COLLISION` | Equal `sub` values across issuers or equal pairs across domains remain distinct and cannot inherit authority. | +| `FI-TRACE-PRIVACY-NONPUBLIC` | Assertion and private identity material in protocol output, public history, or observability is a conformance failure. | # Sources -- NIP-42 authentication: https://github.com/nostr-protocol/nips/blob/8f8444d05a8842c40211ded5d10af3521541f865/42.md -- NIP-98 HTTP auth: https://github.com/nostr-protocol/nips/blob/8f8444d05a8842c40211ded5d10af3521541f865/98.md -- NIP-05 issuer-controlled identifier mapping precedent: https://github.com/nostr-protocol/nips/blob/8f8444d05a8842c40211ded5d10af3521541f865/05.md -- NIP-46 external auth challenge precedent: https://github.com/nostr-protocol/nips/blob/8f8444d05a8842c40211ded5d10af3521541f865/46.md -- Companion protocol specification: [`NIP-FI.md`](NIP-FI.md) -- Buzz implementation semantics reviewed at `bd822f3ea8fc04b449501fd4738097c32d3da950` (PR #1476) +- NIP-42 authentication: +- NIP-98 HTTP authentication: +- Companion protocol specification: [NIP-FI.md](NIP-FI.md) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 948386831c..d7958f7284 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -1,161 +1,324 @@ NIP-FI ====== -Federated Identity Authorization +Federated identity authorization -------------------------------- `draft` `optional` `relay` -**Depends on**: NIP-01 (basic event format), NIP-42 (Authentication of Clients to Relays). **Composes with**: NIP-98 (HTTP Auth), NIP-11 (Relay Information Document), NIP-OA (Owner Attestation). +**Protocol dependencies**: NIP-01, plus NIP-42 for WebSocket authorization or NIP-98 for HTTP authorization. **Optional composition**: NIP-11 discovery and a separately validated delegation protocol such as NIP-OA. ## Abstract -This NIP defines how a relay or Nostr-adjacent HTTP service authorizes an already-authenticated Nostr key only when a valid federated identity assertion resolves to the same principal and key. It specifies assertion transport, validation, an identity-to-key binding lifecycle (enroll, conflict, revoke, rotate), session semantics, and failure behavior. A separately validated delegation MAY derive narrower authority from a bound owner as described below; that exception does not turn the delegate into the federated principal. +This NIP defines how a relay or Nostr-adjacent HTTP service authorizes a Nostr key only when a valid federated identity assertion, fresh Nostr proof, current identity-to-key binding state, and the requested operation's local admission policy all agree. It defines cryptographically bound assertion transport, assertion and proof validation, read-only authorization preparation, final admission, enrollment, lifecycle state, bounded sessions, delegation, rejection behavior, discovery, and privacy. -The identity provider never becomes a Nostr signing authority, and the assertion never substitutes for Nostr proof of key control. This NIP is an authorization layer above NIP-42 and NIP-98, not a replacement for either. +The identity provider never becomes a Nostr signing authority. A bearer assertion never substitutes for Nostr proof of key control. Binding lifetime is independent of assertion lifetime: a fresh assertion can authorize an existing eligible binding after an earlier assertion expires, while every authorization lease remains bounded by the assertion used to create it. ## Motivation -Organizations deploying Nostr internally need relay access tied to their workforce identity system: an employee's relay privileges should follow their corporate identity, survive Nostr key rotation, and end at offboarding. Existing primitives each solve part of this: +Organizations may need relay access tied to an external identity system while preserving Nostr key ownership. NIP-42 proves control of a key on a relay connection, and NIP-98 proves control of a key for an HTTP request, but neither binds that key to an issuer-qualified external principal. Without a shared contract, deployments can disagree about assertion transport, key rotation, enrollment, lifecycle denial, and the point at which authorization may mutate state. -- NIP-42 proves control of a Nostr key to a connection but carries no external identity. -- NIP-05 maps an organization-controlled identifier to a pubkey, but by public DNS/HTTPS polling, not by a credential presented on the request being authorized. -- NIP-46 lets a signer demand out-of-band authentication (`auth_url`) but does not bind the resulting external subject to a key at the relay. - -Without a standard, each deployment invents an incompatible binding scheme, and the first large deployment's configuration becomes an accidental protocol. This NIP defines the contract so that any relay behind any OIDC-capable identity provider or generic OAuth2 reverse proxy (Okta, Auth0, Keycloak, oauth2-proxy, etc.) can interoperate with any conforming client. +This NIP defines a provider-neutral contract. It does not standardize an identity vendor, database schema, operator API, public identity projection, or application-specific membership policy. ## Definitions -- **assertion**: a JWT issued by a configured identity provider, presented alongside (never instead of) Nostr authentication. -- **federated identity** (`i`): the tuple `(iss, sub)` from a validated assertion. The `iss` value MUST be the exact validated issuer identifier and `sub` the exact non-empty subject string. A username, email, display name, or bare `sub` MUST NOT be used as a federated identity. -- **authorization domain** (`D`): the scope within which bindings apply, chosen by the service (an entire relay, or one tenant of a multi-tenant relay). Bindings MUST NOT cross domains implicitly. -- **binding**: an active record associating exactly one federated identity with exactly one 32-byte Nostr public key within a domain. -- **enrollment mode**: the domain's policy for creating bindings — `attested-key`, `provisioned`, or `tofu` (defined below). -- **Nostr proof**: a valid NIP-42 AUTH event (WebSocket) or NIP-98 event (HTTP) proving control of a key on the current connection or request. -- **lease**: a cached authorization decision for one `(domain, identity, key)`, bounded by the assertion's expiry. +- **assertion** (`A`): a JWT issued under an accepted verifier policy and presented as independent evidence alongside Nostr proof. +- **federated identity** (`i`): the exact tuple `(iss, sub)` from a validated assertion. `iss` is the exact accepted issuer identifier. `sub` is the exact non-empty subject string. A username, email address, display name, employee number, mutable profile field, or bare `sub` is not a federated identity. +- **authorization domain** (`D`): a boundary selected from authenticated server routing and configuration. A client-supplied domain, forwarded host value, assertion claim, or unsigned header cannot select `D`. +- **target context** (`R_t`): the server-resolved method, authority, path and query, body digest, transport, operation, and resource for the request being admitted. +- **request context** (`R`): `R_t` sealed with the acting key returned by Nostr-proof validation. Client input cannot supply or replace that key. +- **verifier policy identity** (`policy_id`): a stable identifier for assertion semantics, including issuer, audience, allowed algorithms, authenticated key-source identity, claim and normalization rules, and time bounds. It MUST change when those semantics change and MUST NOT include transport, rotating signing-key contents, key-set order, cache timestamps, or a JWKS generation. +- **JWKS generation** (`g`): an opaque identifier for one effective verification-key snapshot. It MUST change whenever the accepted key identifiers or key material change. +- **binding**: a durable, versioned record associating one identity with one 32-byte Nostr public key in `D`. Its immutable provenance is `attested-key`, `provisioned`, or `tofu`. It MAY carry a separately authorized administrative `binding_not_after` bound. It MUST NOT derive that bound from assertion `exp` or `iat`. +- **retired pair**: a durable denial fact for one exact `(D, i, k)` pair. Ordinary authorization can never recreate that pair. +- **disabled identity**: a durable denial fact that prevents an identity from authorizing or enrolling a key. +- **revoked key**: a durable denial fact that prevents a key from authorizing or binding to any identity in `D`. +- **pending replacement**: durable lineage identifying an old key and binding version that a separately authorized recovery or re-enablement transition may consume once. +- **Nostr proof**: a valid NIP-42 AUTH event or NIP-98 event proving control of a key for the current connection or request. +- **prepared authorization**: an immutable, non-authoritative, read-only result that seals verified evidence, server-owned context, state and policy witnesses, a possible enrollment proposal, and every expiry and invalidation dependency. Preparation creates no binding, lifecycle fact, replay claim, receipt, audit event, publication, lease, or application mutation. +- **committed authorization**: the result of revalidating a prepared authorization at final admission and atomically committing any allowed enrollment, replay claim, receipt, and required authorization audit evidence. +- **lease**: a cached committed decision for one actor, domain, operation set, and exact dependency versions. A lease is never a binding and cannot extend one. + +Within a domain, active bindings form a partial bijection: one identity has at most one active key, and one key has at most one active identity. ## Assertion transport -An assertion reaches the verifier in an HTTP header on the request being authorized: the WebSocket upgrade request for relay connections, or each individual request for NIP-98-authenticated HTTP endpoints. Two transport profiles are defined; a service MUST document which it accepts. +An assertion is captured on the request being authorized: the WebSocket upgrade for NIP-42 connections or the same HTTP request as its NIP-98 proof. Assertions MUST NOT appear in URLs, query parameters, Nostr events, tags, filters, application history, or public identity projections. + +Two transport profiles are defined. A service MUST advertise and accept only profiles it implements completely. + +### Client-attached profile + +This profile's discovery identifier is `client-attached`. The client sends exactly one `Nostr-Federated-Identity: Bearer ` field and no assertion-provenance field. A documented WebSocket profile MAY use `Authorization: Bearer`, but a NIP-98 HTTP request MUST reserve `Authorization` for its `Nostr` proof. Missing, repeated, comma-combined, malformed, empty, non-Bearer, or mixed-profile assertion fields are rejected. + +### Trusted-proxy HMAC profile + +This profile's discovery identifier is `trusted-proxy-hmac-v1`. The trusted proxy strips every inbound copy of all assertion and provenance fields, inserts exactly one `Nostr-Federated-Identity: Bearer ` field, and inserts exactly one `Nostr-Federated-Identity-Provenance` field. Header presence, source IP, or network topology alone is not trusted-proxy provenance. Unsigned forwarded identity MUST be rejected. + +The provenance field has this exact ASCII form: + +```text +v1... +``` + +`timestamp` is canonical unsigned decimal without leading zeroes, except that zero is `0`. `nonce` and `mac` are canonical unpadded base64url. The trusted proxy generates each nonce with at least 128 bits from a cryptographically secure random source. A decoded nonce contains at least 16 bytes, and a decoded MAC contains exactly 32 bytes. The verifier applies configured finite maximum provenance-field and nonce sizes before decoding, lookup, or replay storage. Missing, repeated, comma-combined, oversized, non-canonical, or extra components are malformed. + +The stock profile uses HMAC-SHA-256 with a deployment secret of at least 256 bits. Let `LP(x)` be the eight-byte unsigned big-endian length of byte string `x`, followed by `x`. The MAC input is: + +```text +"NIP-FI-PROXY-1" || +LP(timestamp) || LP(nonce) || LP(assertion_digest) || +LP(method) || LP(authority) || LP(path_and_query) || LP(body_digest) +``` -1. **Trusted proxy**: an authenticating reverse proxy (for example oauth2-proxy or an SSO-aware ingress) injects the assertion after authenticating the user. The injected header name is deployment configuration. This profile is conforming only if untrusted clients cannot reach the verifier directly and the proxy strips every inbound copy of that header before setting it. This is the recommended profile for browser-based clients, which cannot attach arbitrary WebSocket upgrade headers. -2. **Client-attached**: the client sends the assertion itself in `Nostr-Federated-Identity: Bearer `. A verifier MAY additionally accept another documented header on WebSocket upgrades, including `Authorization: Bearer`; HTTP requests using NIP-98 MUST use `Nostr-Federated-Identity` because their `Authorization` header carries the `Nostr` proof. +For the MAC, parsed `timestamp` is encoded as an eight-byte unsigned big-endian value. `nonce`, `assertion_digest`, `body_digest`, and `mac` are their decoded bytes. `assertion_digest` is SHA-256 over the exact JWT octets after the Bearer scheme. `method` is the exact uppercase ASCII method token accepted by the endpoint. `authority` is the server-configured lowercase ASCII host, with an explicit decimal effective port and brackets around IPv6. `path_and_query` is the exact ASCII origin-form received after trusted routing: an empty path becomes `/`, the query includes its leading `?`, and percent-encoding, parameter order, and repeated parameters are preserved. It contains no fragment. A proxy rewrite is complete before these values are computed. Ambiguous or non-canonical values are rejected. `body_digest` is SHA-256 over the exact request body, including the empty body used by a WebSocket upgrade. The verifier compares the MAC in constant time. -Assertion acquisition and interactive OIDC login are outside this NIP. A client-attached assertion value MUST use the `Bearer` scheme; after removing that scheme, the value MUST contain exactly one JWT and no comma-separated alternatives. +The profile configures a positive finite `maximum_provenance_age` and a non-negative finite `future_skew`. It accepts time only when `timestamp <= now + future_skew` and `now < timestamp + maximum_provenance_age`, using overflow-safe comparisons. Equality at the age bound is expired. -On a WebSocket connection, the assertion captured at upgrade is evaluated when a key performs NIP-42 AUTH — each authenticating key is authorized against that assertion independently. On HTTP, the assertion and the NIP-98 proof MUST arrive on the same request they authorize. +The verifier MUST reject an absent, repeated, malformed, stale, future-dated, wrong-key, or mismatched provenance value. It MUST reject a committed nonce. A committed nonce is retained through at least `timestamp + maximum_provenance_age`; an applicable Nostr-proof replay identity is retained through its entire acceptance window. The nonce and proof replay identity become consumed only during final admission. The MAC therefore cannot be replayed across an assertion, method, authority, path, query, or body. Secret selection and rotation may try only a configured finite set of active secrets and fail closed when none verifies. -Assertions MUST NOT be carried inside Nostr events, event tags, or subscription filters, and MUST NOT be written to relay-visible event history. +The proxy-to-verifier hop still requires confidentiality and integrity. Trusted listener and route configuration selects the profile in `R_t`. Direct ingress to a listener configured for this profile MUST reject assertion-bearing requests that lack valid provenance and MUST NOT fall back to `client-attached` after missing or rejected provenance. ## Assertion validation -The verifier is configured, per accepted issuer, with: the issuer identifier, a signing-key source (a JWKS endpoint, discoverable via OIDC `/.well-known/openid-configuration`), accepted audience values, and a claim mapping. Validation MUST enforce all of the following; any failure MUST reject the assertion: +For each accepted issuer, the verifier has authenticated configuration for the exact issuer identifier, accepted audiences, allowed asymmetric algorithms, key source, required `sub` semantics, optional Nostr-key claim, finite maximum assertion age, and bounded clock skew. Transport adapters supply assertion bytes but cannot change this contract. Validation enforces all of the following: -1. The JWT signature verifies under a currently trusted key for an explicitly allowed **asymmetric** algorithm. Symmetric (HS*) and `none` algorithms MUST be rejected before any key lookup. -2. `iss` exactly equals the configured issuer identifier used to select the verification key. -3. At least one `aud` value exactly equals a configured audience. -4. `exp` is present and in the future; `nbf` and `iat`, when present, are not in the future — each within a bounded, configured clock skew. -5. The configured subject claim is present and a non-empty string. A configured claim that is absent when required, not of its expected type, or not unambiguously a single value MUST be rejected. -6. If a key claim is configured and present, it parses to exactly one 32-byte Nostr public key. Lowercase hex is the canonical encoding; `npub` bech32 MAY be accepted as a documented input normalization. +1. The input is exactly one bounded compact JWS. Protected-header and claim member names are unambiguous. Unknown critical headers, `none`, symmetric algorithms, algorithm and key-type mismatch, and incompatible JWK `use` or `key_ops` are rejected before signature acceptance. +2. The signature verifies under exactly one currently accepted asymmetric key and explicitly allowed algorithm. A duplicate or ambiguous `kid` fails. A missing `kid` is accepted only when policy deterministically selects exactly one compatible key. +3. `iss` exactly equals the configured issuer used to select the policy and key source. +4. At least one `aud` value exactly equals an accepted audience. +5. `exp` and `iat` are finite numeric dates. The verifier requires `now < exp`, `iat <= now + skew`, and `now < iat + maximum_assertion_age`, using overflow-safe comparisons. An optional `nbf` requires `nbf <= now + skew`. Equality at an expiry or maximum-age bound is expired. +6. `sub` is a non-empty exact string and the issuer contract guarantees that it is stable, opaque, non-reassignable, and not intentionally derived from a profile or personally identifying claim. +7. If a Nostr-key claim is configured and present, it resolves unambiguously to one 32-byte public key. Lowercase hexadecimal is canonical. Any additional accepted encoding must normalize to that value without ambiguity. -A display-name claim MAY be extracted as mutable metadata. It MUST NOT participate in any authorization decision. +The verifier bounds assertion, header, claim, subject, key-identifier, and configured key-set sizes before lookup or observability. Attacker-controlled values, including `kid`, are never emitted unsanitized. -Signing-key retrieval failures MUST fail closed. Verifiers SHOULD cache the key set with a bounded lifetime and SHOULD NOT refetch it in response to an unknown `kid` that was absent from a freshly fetched set, so that forged tokens cannot drive request floods to the identity provider. +The validated result seals `i`, an optional asserted key `k_a`, the assertion deadline, `policy_id`, JWKS generation `g`, the verification-key identity, the key snapshot's hard-validity deadline, and confidential revalidation material that can recover the exact compact-JWS bytes. Display names, email addresses, and other profile claims do not enter this result. -## Nostr proof +Verifier-policy identity is independent of key rotation. Adding, overlapping, or removing issuer keys changes `g`, not `policy_id`. Final admission MUST deny if the current verifier policy identity differs from the prepared identity. Evidence prepared under generation `g_old` MUST be revalidated against the current key snapshot before final admission if the generation changed. Revalidation must reproduce the same identity, asserted key, policy identity, and live time bounds. A removed key, rollback to an unaccepted generation, unreadable current generation, or failed revalidation denies admission. A normal overlapping key rotation therefore does not require a new binding or policy lineage. -The key being authorized is always the key returned by Nostr proof validation — a valid NIP-42 AUTH for the current WebSocket connection, or a valid NIP-98 event for the current HTTP request. It is never taken from an assertion claim, an unsigned request field, or client metadata. A bearer assertion alone MUST NOT authenticate a Nostr key. +Signing-key retrieval fails closed. Refresh work MUST be bounded and coalesced. An unknown `kid` cannot trigger unbounded per-request retrieval and has no stale-key fallback. A previously known key MAY be used after a soft refresh failure only under a documented finite stale-known-key policy and never after its hard maximum age. -## Authorization +## Nostr proof and server-owned context -Given a validated assertion yielding identity `i`, optional asserted key `k_a`, and expiry `exp`, and a Nostr proof yielding key `k`, the verifier evaluates one atomic decision in domain `D`: +The authorized key is always returned by Nostr proof validation, never by an assertion claim or unsigned field. -```text -Authorize(D, i, k_a?, k): - if k_a exists and k_a != k: DENY (key mismatch) +- NIP-42 validation binds the AUTH event to the current challenge, relay URL, connection, and freshness window. +- NIP-98 validation binds the event to the exact server-resolved absolute request URL, method, payload digest when required, and freshness window. + +The service resolves `D`, operation, resource, transport, and authority from trusted server state. All evidence must agree with that same context. Unknown routes, effects, resources, domains, or transport provenance deny before preparation can become authority. + +Every protected ingress in a domain MUST use one canonical current domain policy and final-admission authority. A route with no such authority, a competing authority, or an authority at a different policy lineage makes enforcement unavailable and MUST fail closed. - b_i := active binding for i in D, if any - b_k := active binding for k in D, if any +## Read-only preparation and final admission - if b_i = (i, k) and b_k = (i, k): ALLOW (existing binding) - if b_i exists or b_k exists: DENY (binding conflict) +Authorization uses two phases. Implementations MAY combine the phases inside one transaction, but they MUST preserve the same no-mutation and revalidation properties. - # no active binding on either side: enrollment - attested-key: k_a required, else DENY; create (i, k); ALLOW - provisioned: DENY (binding must be pre-created by an operator) - tofu: create (i, k); ALLOW +```text +PrepareAuthorization(request, assertion?, nostr_proof?, delegation?): + (D, R_t, operation, resource) := ResolveTargetContext(request) or DENY + + if delegation is present: + require assertion and assertion-provenance fields are absent + ValidateNostrProof(nostr_proof, D, R_t) -> k or DENY + R := SealActor(R_t, k) + return PrepareDelegated(D, R, k, delegation) + + VerifyTransportProvenance(D, R_t, assertion) or DENY + ValidateNostrProof(nostr_proof, D, R_t) -> k or DENY + R := SealActor(R_t, k) + ValidateAssertion(assertion, D) -> (i, k_a?, deadline, policy_id, g) + if k_a exists and k_a != k: DENY(key_mismatch) + + atomically read B(i), B(k), retired(i,k), disabled(i), + revoked(k), pending(i), mode(D), and policy state + + if disabled(i): DENY(identity_disabled) + if revoked(k): DENY(key_revoked) + if retired(i,k): DENY(pair_retired) + if pending(i): DENY(explicit_replacement_required) + + if B(i) = B(k) = binding(i,k): + if binding.binding_not_after exists and + now >= binding.binding_not_after: DENY(binding_expired) + proposal := existing(binding.version, binding.provenance) + else if B(i) exists or B(k) exists: + DENY(binding_conflict) + else switch mode(D): + attested-key: + require k_a = k + proposal := enroll(i, k, attested-key) + provisioned: + DENY(binding_required) + tofu: + proposal := enroll(i, k, k_a = k ? attested-key : tofu) + + EvaluateEveryLocalAdmissionPolicy(D, R, operation, resource, k) or DENY + return PreparedAuthorization(all evidence, proposal, witnesses, and bounds) ``` -The check and any insertion MUST be atomic for `(D, i, k)`: under concurrent first use of the same identity or key, at most one binding is created and every other attempt observes it (allow on exact match, deny on conflict). Storage failure or a race whose committed result cannot be read MUST deny — never fall back to an unchecked allow. +An absent `binding_not_after` has no expiry. Assertion `exp`, `iat`, and maximum age never populate or extend it. Enrollment mode controls creation only; changing the mode does not rewrite or downgrade an existing eligible binding or its provenance. -### Enrollment modes +Preparation is read-only, including for Attested and TOFU first use. It creates or changes no binding, lifecycle, enrollment, replay, receipt, audit, observation, publication, last-seen, lease, or application state. A denial has the same no-mutation property. -- **`attested-key`**: the identity provider carries the user's Nostr public key in the configured key claim. First use binds only when the asserted key equals the proven key. This is the strongest mode and SHOULD be used when the identity provider can carry custom claims. -- **`provisioned`**: bindings are created only through an out-of-band administrative process; requests never create bindings. -- **`tofu`** (trust on first use): first use of an unbound identity with an unbound key creates the binding. A stolen assertion for a never-enrolled identity can bind an attacker's key in this mode; services offering it MUST document this risk. When an assertion in `tofu` mode carries a valid key claim, the binding SHOULD record the stronger `attested-key` provenance, and a binding's recorded provenance MUST NOT be downgraded by later requests. +Final admission consumes the prepared value exactly once: -### Binding invariant +```text +CommitAdmission(prepared, current_request): + require exact D, R, operation, resource, actor, and transport match + require every assertion, proof, proxy, delegation, and policy bound is live + if prepared is DirectPrepared: + require CurrentVerifierPolicyIdentity(D, prepared.direct.i.iss) = + prepared.direct.policy_id + if CurrentJwksGeneration(prepared.direct.policy_id) != + prepared.direct.g: + revalidate the assertion under the current generation + else: + require prepared is DelegatedPrepared + revalidate its delegation, relationship, owner, target, and policy witnesses + + atomically: + reread every applicable binding, lifecycle, enrollment-mode, policy, resource, + replay, and invalidation witness + unreadable state denies; changed state requires a complete recomputation + require the current result is equivalent and eligible + claim every applicable proxy nonce and proof replay identity + create the proposed binding only if enrollment remains eligible + append the required receipt and privacy-safe authorization audit evidence + + return CommittedAuthorization(exact actor, binding dependencies, + capabilities, dependencies, and deadline) +``` -Within a domain, active bindings form a partial bijection: an identity has at most one active key and a key has at most one active identity. Every state transition in this NIP preserves this invariant. +No committed authorization can be constructed directly from raw claims, a prepared value, cached policy, or earlier lease. A final-admission failure rolls back every authority mutation. Complete recomputation may accept only a semantically equivalent current result. If another request concurrently creates the identical eligible binding, this request may therefore recompute as `existing`; a conflicting winner denies. Storage failure or an unreadable committed result never falls back to allow. -## Session semantics +The admitted application operation runs only after committed authorization. If the operation cannot share the authorization transaction, the implementation must use a request-bound idempotent receipt or equivalent staging so a retry cannot create a second effect from the same proof. + +## Enrollment modes + +- **`attested-key`**: first use requires the assertion's key claim to equal the proven key. The created binding records `attested-key` provenance. +- **`provisioned`**: ordinary requests never create a binding. A separately authorized `ProvisionBinding` transition creates it without creating a lease; later direct use still requires a current assertion and fresh proof. +- **`tofu`**: first eligible use may create a binding without a key claim. This accepts the risk that a stolen assertion for a never-enrolled identity can bind an attacker's key. Deployments MUST label and document that risk. When a matching key claim is present, the binding records `attested-key`, not `tofu`. + +Binding provenance is immutable and cannot be downgraded by later requests. -For HTTP requests, the decision applies to that request only. +## Lifecycle transitions -For a NIP-42 WebSocket connection, the relay MAY cache the decision as a lease. A lease MUST NOT be honored past the assertion's `exp` (implementations MAY enforce a shorter maximum). At expiry the relay MUST reject protected operations or close the connection; a fresh assertion arrives only on a new connection's upgrade request. When a relay learns a binding was revoked, it MUST invalidate matching leases; a relay that detects revocation by polling MUST NOT claim immediate revocation and SHOULD document its detection latency. +Provisioning, retirement, disablement, revocation, rotation, recovery, re-enablement, and administrative-expiry changes are explicit privileged transitions, never side effects of ordinary authorization. Privileged authority is bound to the exact domain, operation, identity, old binding version when present, target key when present, and request. Ordinary assertion and Nostr proof cannot substitute for that authority. -When multiple keys authenticate on one connection (NIP-42 permits this), authorization is tracked per key. A lease for one key MUST NOT authorize operations attributed to another. +Every transition reads and rechecks the active relation and all applicable retired-pair, disabled-identity, revoked-key, and pending-replacement facts in one atomic transition. It appends immutable lifecycle history and triggers dependent lease invalidation after commit. Failure or stale state causes no partial mutation. -## Revocation and rotation +- **Provision binding**: allowed only in `provisioned` mode for an eligible identity and key. It creates a fresh binding version with `provisioned` provenance and no lease. +- **Retire pair**: removes the active binding, records its exact pair as retired, and records pending replacement lineage. +- **Disable identity**: records the identity as disabled. If an active binding exists, it retires that exact pair and records pending lineage. +- **Revoke key**: records the key as revoked even if it is not active. If active, it removes the binding, retires the exact pair, and records pending lineage. Repeating the same authorized revocation is idempotent and cannot erase lineage. +- **Rotate**: replaces one exact active old binding with an eligible new key, retires the old pair, and creates a fresh binding version. Rotation does not globally revoke the old key. +- **Recover**: consumes one exact pending-replacement lineage, preserves the retired old pair, and creates a fresh binding version for an eligible new key. A disabled identity uses Re-enable identity instead of Recover. +- **Re-enable identity**: requires the disabled identity and either no prior lineage or one exact pending lineage. It creates an eligible binding, clears the disabled state, and consumes present lineage exactly once. +- **Set administrative expiry**: requires one exact active binding version and sets, replaces, or clears `binding_not_after` under separate privileged policy. It advances the binding version and cannot change the pair or provenance. -Revocation is an explicit administrative or policy transition: the binding is removed from the active set and a durable revocation record is retained. A subsequent valid assertion — including one whose key claim matches the revoked key — MUST NOT reactivate a revoked binding unless the domain's documented recovery policy explicitly authorizes that transition. This prevents a replayed, still-valid assertion from silently undoing revocation. +Every new target key, including a provisioned key, requires fresh target-bound Nostr proof. When the domain requires issuer attestation for creation or replacement, the transition also requires a current assertion for the same identity with a key claim equal to the target key. Supplied stale, claimless, wrong-identity, or mismatched attestation is rejected; it cannot be treated as absent optional evidence. -Key rotation is likewise explicit, never a side effect of authorization: rotating `i` from `k_old` to `k_new` requires administrative or documented recovery authorization, an active `(i, k_old)` binding, no active binding for `k_new`, and — where the domain requires issuer attestation — a fresh assertion whose key claim equals `k_new`. The old binding is revoked and the new one created atomically, and leases for `k_old` are invalidated. A routine request presenting `i` with a new key while `(i, k_old)` is active is a binding conflict and MUST be denied. +An administrative `binding_not_after` is an authorization gate, not an implicit lifecycle transition. At or after the bound, the binding remains durable and occupies both sides of the partial bijection, but it is authorization-ineligible. Time passage alone creates no tombstone, pending lineage, or history. Restoring access requires `SetAdministrativeExpiry` or another applicable privileged lifecycle transition; ordinary authorization cannot renew the bound. ## Delegation -Delegation is outside the base primitive but composes with it. A service MAY admit a key that presents no assertion when a separately validated delegation proof (for example a NIP-OA `auth` tag) establishes an owner key that holds an active binding in the domain. The delegate key MUST NOT acquire a federated identity binding of its own through this path, and the delegate's authorization is bounded by both the owner's binding state and the delegation's own conditions. Revoking the owner's binding revokes the delegate's admission on the same schedule as the owner's own leases. +Delegation is a separate evidence path. The delegate presents fresh proof of its own key and no federated assertion. Separately validated delegation evidence seals the owner key, delegate key, relationship identifier and revision, allowed operations and conditions, exact request or target, and mandatory finite expiry. + +The service MUST resolve a current authorization-eligible owner binding and exact binding version at preparation and final admission. A cached owner lease is not substitute authority. The delegated operation is the intersection of the sealed delegation and local operation policy. The path creates or changes no owner or delegate binding, lifecycle fact, provenance, or last-seen state. + +A delegated lease requires a configured positive finite maximum. Its deadline is no later than every owner-binding, delegation, local-policy, implementation, and optional stronger owner-assertion bound. Missing finite configuration, stale owner state, actor or request mismatch, unsupported capability, unreadable dependency, or expired delegation denies. Owner retirement, disablement, key revocation, binding-version change, or relationship change invalidates dependent leases within the documented detection bound. + +## Session semantics + +HTTP authorization applies to one exact request. It does not imply a reusable lease. + +A WebSocket lease is scoped to one authenticated key, domain, operation set, direct-assertion or delegated-evidence dependencies, current binding and lifecycle versions, policy versions, and invalidation dependencies. A direct lease records its verifier policy identity, JWKS generation, verification-key identity, key-snapshot hard-validity deadline, and confidential revalidation material for the exact assertion. A delegated lease instead records the exact owner binding version and relationship revision. + +The deadline is the earliest applicable assertion `exp`, `iat + maximum_assertion_age`, key-snapshot hard-validity deadline, proof or proxy bound, administrative binding expiry, delegation expiry, local-policy limit, and configured finite implementation maximum. Equality is expired. + +Assertion expiry ends the lease, not the binding. Renewal requires a new connection carrying a fresh assertion on the upgrade request, followed by fresh NIP-42 proof and a complete new preparation and final admission. If the durable binding remains eligible, expiry of the assertion used for an earlier lease does not prevent the new decision. Exact assertion revalidation material is retained confidentially only through the admission or lease that may need it and is destroyed on expiry, close, or invalidation. + +Before each protected use, the service rechecks the binding and lifecycle versions, administrative bound, operation, resource, actor, and lease deadline. A direct lease also requires a live, readable key snapshot within its hard-validity deadline; a changed JWKS generation requires revalidation of the original assertion against the current generation. For a delegated lease, the service rechecks the exact current owner binding and relationship revision. When another dependency changes, the service rejects protected operations or closes the connection within its documented detection bound. A polling implementation cannot claim immediate invalidation. A lease for one key never authorizes an operation attributed to another key on the same connection. ## Rejection semantics -Machine-readable rejections reuse NIP-01/NIP-42 prefixes on `OK` and `CLOSED` messages: +Implementations may retain detailed private decision reasons for audit and conformance, including `key_mismatch`, `binding_conflict`, `pair_retired`, `identity_disabled`, `key_revoked`, `explicit_replacement_required`, and `binding_expired`. Public results map them to four stable, privacy-safe classes: -- `auth-required: ` — no assertion was presented, or no NIP-42 proof has been performed. -- `restricted: ` — the assertion or proof was presented but failed validation, mismatched, conflicted with an active binding, or the identity's enrollment/binding state does not permit the operation. +| Public code | Nostr prefix | HTTP status | Meaning | +|---|---|---:|---| +| `missing_evidence` | `auth-required:` | 401 | Required assertion, proof, or delegation evidence was absent. | +| `evidence_rejected` | `restricted:` | 403 | Presented evidence or transport provenance was rejected. | +| `authorization_denied` | `restricted:` | 403 | Current binding, lifecycle, delegation, or local operation policy denied access. | +| `authorization_unavailable` | `restricted:` | 503 | Required current state could not be verified. | -HTTP endpoints respond `401` where `auth-required` applies and `403` where `restricted` applies. Rejection bodies MUST NOT echo assertion contents, claim values, or the conflicting party's identity or key. +Responses MUST NOT identify another principal or key, distinguish a conflict from a tombstone, expose issuer or claim details, echo bearer material, or reveal private policy state. An unavailable dependency never becomes an allow. ## Discovery -A relay SHOULD advertise support in its NIP-11 document under `limitation` as `"federated_identity": true`. It MAY additionally include this top-level object: +A relay SHOULD advertise support in its NIP-11 document under `limitation` as `"federated_identity": true`. It MAY include this top-level object: ```json { "federated_identity": { - "transports": ["trusted-proxy", "client-attached"], + "transports": ["trusted-proxy-hmac-v1"], "enrollment": "attested-key", "delegation": false } } ``` -`transports` contains the supported profile names from this NIP, `enrollment` is exactly one enrollment mode, and `delegation` states whether separately validated delegation may be honored. Unknown fields MUST be ignored. A relay MUST NOT publish issuer-internal detail (tenant URLs, claim names, audiences) that is not already public. +`transports` contains only the exact identifiers `client-attached` and `trusted-proxy-hmac-v1` for profiles implemented completely. `enrollment` is exactly one configured mode. `delegation` is true only when owner-current resolution and a positive finite delegated maximum are configured. Unknown fields are ignored. -## Privacy +A service MUST NOT enter enforcement or advertise support until every configured protected operation uses the same canonical final-admission authority, unknown protected routes fail closed, and all applicable conformance traces pass at one reviewed revision. Discovery is selected by the same server-owned domain policy as authorization. It MUST NOT expose private issuer URLs, audiences, claim names, tenant identifiers, HMAC key identifiers, or implementation-only policy detail. -Federated identities are typically personal data (employee identifiers). A conforming service MUST NOT publish `iss`, `sub`, assertion contents, or display-name claims in Nostr events or tags, and MUST NOT expose another user's binding state through rejection messages. Binding records, audit logs, and metrics are service-internal, and logs MUST NOT record raw bearer assertions. - -## Security considerations +## Privacy -- **Issuer or proxy compromise** impersonates federated principals, but cannot satisfy Nostr proof for an already-bound uncompromised key, and in `attested-key` mode cannot bind an arbitrary key without also forging the key claim. -- **Assertion theft** cannot authorize an already-bound identity without control of the bound key. Its remaining power — enrolling a never-bound identity — exists only in `tofu` mode, which is why that mode is risk-labeled. -- **Header injection**: the trusted-proxy profile is void if clients can reach the verifier directly or the proxy forwards inbound copies of the assertion header. Deployments MUST verify both properties. -- **Algorithm confusion** is excluded by rejecting symmetric algorithms before key selection. -- **Availability vs. safety**: issuer, key-set, and storage outages deny. Availability MUST NOT override identity safety. -- **Cross-issuer collision**: identical `sub` values under different issuers are distinct identities and MUST never collide or inherit each other's bindings. +NIP-FI defines no public identity projection. Protocol events, tags, filters, discovery, errors, logs, metrics, and traces MUST NOT contain raw assertions or unredacted `iss`, `sub`, email, display name, or other private claims. Access-controlled binding, lifecycle, receipt, and audit state may retain the minimum identifiers required for enforcement and investigation. -A companion formal model of this protocol — state machine, safety and liveness properties, and attack traces — accompanies this specification. +Any separate presentation protocol is non-authoritative and cannot create, renew, prove, or revoke NIP-FI authorization. Implementations MUST bound metric and log cardinality and use redacted or pseudonymous correlation. -## Reference implementation +## Security considerations -Buzz relay: corporate identity enforcement layered above NIP-42/NIP-98/media/git/audio ingress, with JWKS validation, TOFU and attested-key enrollment, atomic binding with conflict detection, and NIP-OA delegation composition. +- **Issuer compromise** can impersonate principals but cannot prove an uncompromised already-bound Nostr key. In `attested-key` mode it must also forge the matching key claim to enroll an arbitrary key. +- **Assertion theft** cannot use an eligible existing binding without the bound key. TOFU intentionally retains first-use theft risk. +- **Proxy spoofing and replay** are limited by request-bound HMAC provenance, bounded time, one-time nonce consumption, exact assertion and body digests, and exact server-resolved routing values. +- **JWKS rotation and rollback** do not change stable policy identity. Final generation revalidation prevents a removed key or stale snapshot from authorizing. +- **Time-of-check/time-of-use races** are limited by read-only preparation and complete witness revalidation in final admission. +- **Lifecycle replay** cannot erase retired-pair, disabled-identity, revoked-key, or pending-replacement facts. Ordinary assertions never reactivate them. +- **Cross-domain and cross-request confusion** are prevented by server-owned context and exact evidence binding. +- **Availability attacks** on issuer, key retrieval, policy, binding, replay, or audit state fail closed. Refresh, replay, and observability work must be bounded. +- **Delegation confusion** is limited by exact owner and delegate keys, owner binding version, relationship revision, capability intersection, target binding, and finite expiry. + +## Stable conformance labels + +The companion model and later executable matrix use these stable trace identifiers. A conforming implementation must cover every applicable trace and its boundary and concurrency subcases at one reviewed revision. The model also defines the stable safety labels `FI-INV-01` through `FI-INV-16`. + +| ID | Required property | +|---|---| +| `FI-TRACE-PROXY-SPOOF` | A valid assertion without valid proxy HMAC provenance, including direct ingress, denies. | +| `FI-TRACE-PROXY-REPLAY` | Two final admissions using one proxy nonce produce at most one committed authorization; preparation consumes neither. | +| `FI-TRACE-PROXY-CROSS-REQUEST` | Changing the assertion, method, authority, path/query, or body invalidates provenance and denies. | +| `FI-TRACE-AUTHORITY-UNIFORM` | Every protected ingress uses the same current domain policy and final-admission authority. | +| `FI-TRACE-VERIFIER-PARITY` | The same assertion, policy, time, and key snapshot produce the same verifier result on every transport. | +| `FI-TRACE-DOMAIN-SPOOF` | Client-selected domain or forwarded authority cannot replace server-owned context. | +| `FI-TRACE-ASSERTION-KEY-MISMATCH` | An asserted key different from the proven key denies before mutation. | +| `FI-TRACE-BINDING-CONFLICT` | A pair that conflicts with either side of the active relation denies without replacement. | +| `FI-TRACE-TOMBSTONE-REPLAY` | Fresh evidence for a retired pair, disabled identity, revoked key, or pending replacement denies ordinary authorization. | +| `FI-TRACE-ASSERTION-REFRESH` | A fresh assertion can authorize the same eligible durable binding after an earlier assertion expires. | +| `FI-TRACE-ADMIN-EXPIRY` | A fresh assertion after administrative expiry denies; only an explicit privileged transition can restore access. | +| `FI-TRACE-JWKS-ADD` | A generation change with the old key retained revalidates and may authorize the unchanged binding. | +| `FI-TRACE-JWKS-REMOVE` | A generation change that removes the signing key denies prepared evidence and leases signed by it. | +| `FI-TRACE-PREPARED-STALE` | Changed request or decision witnesses deny or require a complete recomputation before admission. | +| `FI-TRACE-FINAL-DENIAL-NO-MUTATION` | Preparation, denied local policy, and denied final admission create no binding, audit or denial observation, replay claim, receipt, lease, or application mutation. | +| `FI-TRACE-CONCURRENT-ENROLLMENT` | Identical eligible first uses converge on one binding version; conflicting first uses commit at most one winner. | +| `FI-TRACE-TOFU-THEFT` | Stolen-assertion first use denies except under explicit risk-labelled TOFU. | +| `FI-TRACE-DELEGATE-OWNER-ROTATED` | Owner rotation makes an old-owner delegation non-current and denies without inheritance. | +| `FI-TRACE-DELEGATION-EXPIRED` | Missing or expired finite delegation bounds deny. | +| `FI-TRACE-DENIAL-ORACLE` | Unknown, conflict, tombstone, and private-policy denials are not publicly distinguishable. | +| `FI-TRACE-DEPENDENCY-FAIL-CLOSED` | An unreadable current verifier, key, state, replay, policy, receipt, or audit dependency denies. | +| `FI-TRACE-MULTI-KEY-SESSION` | A lease for one authenticated key does not authorize another key on the same connection. | +| `FI-TRACE-CROSS-DOMAIN-COLLISION` | Equal subjects across issuers or equal pairs across domains remain distinct. | +| `FI-TRACE-PRIVACY-NONPUBLIC` | Assertion or private identity material in protocol output, public history, or observability is a conformance failure. | + +The companion [formal model](NIP-FI-MODEL.md) gives the state machine, safety and liveness properties, and the complete form of these traces. From e81a8b7fb920de1827fd17357932a86a52c5ccfa Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:54:25 -0500 Subject: [PATCH 6/6] docs(nips): align client assertion transport Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- docs/nips/NIP-FI.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index d7958f7284..b7395f73b7 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -49,7 +49,7 @@ Two transport profiles are defined. A service MUST advertise and accept only pro ### Client-attached profile -This profile's discovery identifier is `client-attached`. The client sends exactly one `Nostr-Federated-Identity: Bearer ` field and no assertion-provenance field. A documented WebSocket profile MAY use `Authorization: Bearer`, but a NIP-98 HTTP request MUST reserve `Authorization` for its `Nostr` proof. Missing, repeated, comma-combined, malformed, empty, non-Bearer, or mixed-profile assertion fields are rejected. +This profile's discovery identifier is `client-attached`. The client sends exactly one `Nostr-Federated-Identity: Bearer ` field and no assertion-provenance field. The same field is used on WebSocket upgrades and NIP-98 HTTP requests; `Authorization` remains reserved for the Nostr proof where that protocol requires it. Missing, repeated, comma-combined, malformed, empty, non-Bearer, or mixed-profile assertion fields are rejected. ### Trusted-proxy HMAC profile