From 7ca847acdc68a973223fe4e559182f23f82b89ea Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 1 Sep 2026 18:18:28 -0400 Subject: [PATCH 1/9] docs(nip-fi): rewrite NIP-FI as stateless OSS Buzz spec v2 Remove the relay-side authority engine (bindings, receipts, lifecycle, invalidation, leases, delegation, enrollment modes) entirely. The new spec settles exactly five things: assertion contract (JWT claims, npub binding, NIP-42 pairing), offline JWKS verification (multi-issuer, fail-closed), session policy (required finite max-lifetime knob, no in-band renewal), admin disconnect API (session-only model with an explicit non-normative note documenting the deny-until-TTL alternative for Will/Tyler to adjudicate), and the deletions surface. The PR 3 / 70895b355 multi-issuer JWKS runtime (FederatedAssertionVerifier, ProductionJwksSource, IssuerRegistry, DenialClass) is the complete implementation surface for the new spec. No Rust code changes. Companion docs (NIP-FI-CONF, DELEG, EDGE, LIFECYCLE, MODEL) and migrations 0041/0042 are flagged for deletion in separate work. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI.md | 892 +++++++++++++++++--------------------------- 1 file changed, 341 insertions(+), 551 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index b5b35ca5dd7..49a145a8693 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -1,14 +1,12 @@ NIP-FI ====== -Federated identity authorization — core ----------------------------------------- +Federated identity authorization — stateless core +--------------------------------------------------- `draft` `optional` `relay` -**Protocol dependencies**: NIP-01 and either NIP-42 or NIP-98. Optional -profiles are defined by NIP-FI-EDGE, NIP-FI-LIFECYCLE, NIP-FI-DELEG, and -NIP-FI-CONF. +**Protocol dependencies**: NIP-01, NIP-42. The key words "MUST", "MUST NOT", "REQUIRED", "SHOULD", "SHOULD NOT", and "MAY" in this document are to be interpreted as described in BCP 14 (RFC 2119 @@ -16,485 +14,341 @@ and RFC 8174) when, and only when, they appear in all capitals. ## Abstract -NIP-FI authorizes a Nostr key only when four independent facts agree: a valid -issuer-qualified identity assertion, fresh proof of that Nostr key, current -identity-to-key binding state, and current local policy for the exact operation. -The identity provider never signs Nostr events, and an assertion never replaces -Nostr proof. +NIP-FI authorizes a Nostr key when two independent facts agree: a valid +issuer-qualified identity assertion that names the key, and fresh NIP-42 proof +of possession of that key. No relay-side identity state is required. The +relay verifies the assertion offline against configured per-issuer JWKS +snapshots; every identity decision beyond key verification is the deployment +adapter's responsibility. -Bindings outlive individual assertions. Assertions and authorization leases do -not outlive their evidence. This core defines the portable client-attached -assertion transport, direct enrollment, atomic final admission, bounded -sessions, privacy-preserving denial responses, and the smallest useful binding -lifecycle. Companion profiles add trusted edges, extended lifecycle operations, -and delegation without changing the core admission rule. +This NIP defines the assertion contract, the offline verification procedure, +session lifetime policy, and an authenticated adapter→relay disconnect API. +Enrollment, rotation, revocation decisions, identity↔key registry, one-identity +one-key enforcement, audit, and SCIM are adapter concerns outside this spec. -This NIP does not define an identity provider, database schema, operator API, -public identity projection, application membership policy, or user interface. +## Terms -## Terms and identifier classes - -- **domain** (`D`): an authorization boundary selected only by authenticated - server routing and configuration. - **identity** (`i`): the exact tuple `(iss, sub)` returned by assertion - validation. Email, display name, employee number, and a bare `sub` are not - identities. -- **target context** (`R_t`): the server-resolved method, authority, path and - query, body semantics, transport, operation, and resource. -- **actor** (`k`): the 32-byte public key returned by Nostr-proof validation. -- **request context** (`R`): `R_t` sealed with `k`. -- **binding**: a durable, versioned association `(D, i, k)` with immutable - provenance `attested-key`, `tofu`, or `provisioned`. -- **retired pair**: a durable denial fact for an exact `(D, i, k)`. -- **revoked key**: a durable denial fact for `(D, k)`. -- **prepared authorization**: immutable, read-only evidence and witnesses for a - possible admission. -- **committed authorization**: authority returned only after final revalidation - and atomic commit. -- **lease**: a cached committed decision for one actor and bounded operation - set. A lease is not a binding. - -Identity and authorization-state comparisons preserve every tuple component. -Equal `sub` values under different `iss` values are distinct identities; equal -`(i, k)` pairs under different domains are distinct bindings, retired pairs, -and authorization state. [FI-TRACE-CROSS-DOMAIN-COLLISION] - -Every identifier is either **interoperability-critical** or -**deployment-local**. Header names, public response bytes, token type values, -and trace identifiers are interoperability-critical and fixed here. -`assertion_policy_id`, `transport_contract_id`, domain IDs, snapshot versions, -binding versions, policy versions, and correlation IDs are deployment-local; -their values are opaque outside a deployment, while their stability and -invalidation behavior are normative. - -## Core security invariants - -These labels are the normative home of the NIP-FI invariants. Companion -profiles may add witnesses and bounds but cannot weaken them. - -1. **`FI-INV-01 — partial bijection.`** Active bindings are one-to-one within a - domain: one identity has at most one active key and one key has at most one - active identity. [FI-TRACE-BINDING-CONFLICT] -2. **`FI-INV-02 — durable binding.`** Assertion expiry removes neither a - binding nor its provenance. Fresh eligible evidence may authorize the same - binding later. [FI-TRACE-ASSERTION-REFRESH] -3. **`FI-INV-03 — tombstone monotonicity.`** Ordinary authorization never - removes a retired-pair or revoked-key fact and never recreates a retired - pair. [FI-TRACE-TOMBSTONE-REPLAY] -4. **`FI-INV-04 — server-owned context.`** Every admitted operation uses one - server-resolved domain, target, resource, operation, and proven actor. - Unauthenticated input cannot replace them. [FI-TRACE-DOMAIN-SPOOF] -5. **`FI-INV-05 — independent evidence.`** Direct authorization requires a - current assertion and fresh Nostr proof. If the assertion names a key, it - equals the proven actor. [FI-TRACE-ASSERTION-KEY-MISMATCH] -6. **`FI-INV-06 — stable assertion policy.`** Assertion-policy identity changes - when accepted assertion semantics change, but not when only authenticated - key or status snapshot contents rotate. [FI-TRACE-VERIFIER-PARITY] -7. **`FI-INV-07 — current-snapshot verification.`** Evidence cannot survive - removal of the key or policy snapshot that authenticated it; a changed - snapshot requires revalidation. [FI-TRACE-JWKS-REMOVE] -8. **`FI-INV-08 — read-only preparation.`** Preparation creates no binding, - tombstone, replay claim, receipt, lease, publication, last-seen value, audit - authority, or application mutation. [FI-TRACE-FINAL-DENIAL-NO-MUTATION] -9. **`FI-INV-09 — atomic final admission.`** Enrollment, replay claims, - receipts, and required authorization evidence commit only after complete - final revalidation, all or none. [FI-TRACE-PREPARED-STALE] -10. **`FI-INV-10 — explicit lifecycle authority.`** Retirement, revocation, - rotation, and profile-defined lifecycle changes occur only through their - separately authorized transition. [FI-TRACE-LIFECYCLE-AUTHORITY] -11. **`FI-INV-11 — evidence-bounded leases.`** A lease ends no later than every - evidence, snapshot, proof, binding, local-policy, and implementation bound - on which it depends. [FI-TRACE-LEASE-BOUND] -12. **`FI-INV-12 — current-owner delegation.`** When NIP-FI-DELEG is claimed, - delegation requires the exact current eligible owner binding, fresh - delegate proof, capability intersection, and a positive finite deadline. - [FI-DELEG-OWNER-CURRENT] -13. **`FI-INV-13 — privacy-safe denial.`** Public rejection is many-to-one and - reveals no identity, key, claim, binding, tombstone, enrollment mode, key - identifier, or private policy fact. [FI-TRACE-DENIAL-ORACLE] -14. **`FI-INV-14 — fail closed.`** Unreadable, ambiguous, stale beyond policy, - or inconsistent evidence or authoritative state cannot produce authority. - [FI-TRACE-DEPENDENCY-FAIL-CLOSED] -15. **`FI-INV-15 — uniform authority.`** Every protected ingress in a domain - uses the same current domain policy and final-admission authority. An - uncovered or competing path is unavailable. [FI-TRACE-AUTHORITY-UNIFORM] -16. **`FI-INV-16 — canonical verifier.`** Assertion transports feed one closed, - provider-neutral normalized-result contract and cannot fork final - admission. [FI-TRACE-VERIFIER-PARITY] + validation. Email, display name, employee number, and a bare `sub` are not + identities. Equal `sub` values under different `iss` values are distinct + identities. [FI-TRACE-CROSS-DOMAIN-COLLISION] +- **actor** (`k`): the 32-byte public key returned by NIP-42 proof validation. +- **assertion**: a compact JWS minted by the deployment adapter, binding `i` + to `k`. +- **adapter**: the deployment-specific identity authority (e.g. an Okta OIDC + integration) that authenticates employees and mints assertions. The relay + trusts only the adapter's assertion; it does not contact the IdP directly. -## Client-attached transport +## Assertion contract + +The assertion is a compact JWS carrying the following claims. + +### Required claims + +| Claim | Type | Semantics | +|---|---|---| +| `iss` | string | Exact issuer URI. The relay selects an issuer policy by exact match; no normalization is applied. | +| `sub` | string | Opaque, stable, non-reassignable subject identifier for the account lifetime. Never an email address or display name. | +| `nostr_pubkey` | string | Lowercase hexadecimal encoding of exactly one 32-byte Nostr public key. Other encodings deny. | +| `iat` | NumericDate | Issuance time. | +| `exp` | NumericDate | Expiry time. MUST be finite. The deployment MUST configure a positive finite maximum TTL; the relay enforces both the token `exp` and the configured `maximum_assertion_age`. | + +### Optional claims + +| Claim | Type | Semantics | +|---|---|---| +| `aud` | string or array | Required when configured; the relay requires an exact match to the configured audience value. | +| `nbf` | NumericDate | Not-before time. When present, the relay enforces `nbf <= now + skew`. | + +### Token type + +Policy selects exactly one token class before parsing claims: -Server configuration selects `client-attached` before protected traffic is -accepted. Request fields cannot select, negotiate, or downgrade transport. -Failure never falls back to another transport. [FI-TRACE-TRANSPORT-CLOSED] +- **`nip-fi+jwt`**: a dedicated assertion whose protected `typ` is exactly + `nip-fi+jwt`. +- **`at+jwt` access token**: a resource access token whose protected `typ` is + exactly `at+jwt`. When this class is selected the assertion MUST contain a + non-empty `client_id` claim. The issuer MUST guarantee that + resource-owner-subject tokens and client-subject tokens cannot share `(iss, + sub)` coordinates. -The client sends exactly one field on the request or WebSocket upgrade: +OIDC ID tokens always deny, even when `iss`, `aud`, and `sub` match. A +generic or absent `typ` has no accepted class. Failure under one class never +triggers validation under another. [FI-TRACE-TOKEN-CLASS] + +### Time bounds + +The relay enforces all of the following. Each configures a positive finite +value; a missing configuration denies. + +- `now < exp` — equality at expiry is expired +- `iat <= now + skew` — `skew` is a non-negative finite maximum, default 0 +- `now < iat + maximum_assertion_age` — caps total assertion age independent of `exp` +- `nbf <= now + skew` — when `nbf` is present + +[FI-TRACE-ASSERTION-VALIDATION] + +### Assertion–key binding + +`nostr_pubkey` MUST name the exact key the client proves via NIP-42. The relay +denies any token whose `nostr_pubkey` does not match the NIP-42 `pubkey`. +[FI-TRACE-ASSERTION-KEY-MISMATCH] + +This is the entire identity-to-key binding. There is no relay-side binding +ledger; the assertion is the binding claim, and it is the adapter's +responsibility to ensure the assertion names the correct key. + +### Policy identity + +```text +AssertionPolicyId = H(canonical assertion-policy contract) +TransportContractId = H(canonical transport contract) +``` + +`AssertionPolicyId` covers the canonical issuer, audience, token class, +allowed algorithms, key-source contract, identity/key/claim mapping, time and +size rules, and compiled verifier behavior. JWKS key rotation changes the +snapshot, not the policy ID. `TransportContractId` covers the client-attached +field, parsing, attachment, and no-fallback semantics. + +## Client-attached transport + +The client sends exactly one field on the WebSocket upgrade request: ```text Nostr-Federated-Identity: Bearer ``` -`Authorization` remains reserved for NIP-98. Assertion and provenance fields -from any other profile are absent. Missing, repeated, comma-combined, empty, -malformed, non-Bearer, or mixed-profile fields deny. Assertions never appear in -URLs, query parameters, Nostr events, tags, filters, application history, or -public identity projections. [FI-TRACE-TRANSPORT-CLOSED] +`Authorization` remains reserved for NIP-98. Missing, repeated, +comma-combined, empty, malformed, non-Bearer, or mixed-profile fields deny. +Assertions MUST NOT appear in URLs, query parameters, Nostr events, tags, or +filters. [FI-TRACE-TRANSPORT-CLOSED] + +Server configuration selects `client-attached` before any protected traffic is +accepted. Request fields cannot select, negotiate, or downgrade the transport. +Failure never falls back to another transport. + +## Verification + +The relay verifies assertions **offline** against configured per-issuer JWKS +snapshots. No IdP contact occurs at admission time. -The core transport contract has deployment-local identity -`transport_contract_id`. It deterministically identifies the exact field, -parsing, request-attachment, no-fallback, and context-preservation semantics. -Changing any of those semantics changes the ID; changing request data does not. -[FI-TRACE-CONTRACT-IDENTITIES] +### Multi-issuer registry -## Assertion validation +The relay maintains one [`IssuerRegistry`](../crates/buzz-auth/src/nip_fi/config.rs): +a map from exact `iss` strings to issuer policies. The `iss` carried in the +signed token selects exactly one policy; unknown issuers deny. A +single-issuer deployment is a registry of length one. [FI-TRACE-CROSS-DOMAIN-COLLISION] -A configured assertion policy accepts exactly one bounded compact JWS and -returns this closed result: +The existing `FederatedAssertionVerifier` and `ProductionJwksSource` +(merged in PR 3 / `70895b355`) implement this section. + +### JWKS snapshot + +Each issuer policy configures: + +- `jwks_uri`: HTTPS URI selecting the authenticated key source. SSRF-protected + at both URI validation and DNS-resolution time; no credentials, fragments, + or private-IP endpoints accepted. +- `refresh_interval_seconds`: positive, ≤ 1 year, strictly less than + `key_snapshot_hard_deadline_seconds`. +- `key_snapshot_hard_deadline_seconds`: the outer time bound after which no + assertion verified under this snapshot can authorize. + +The snapshot is re-fetched periodically. A key added to the JWKS is accepted +after the next fetch; a key removed from the JWKS causes any assertion verified +under that key to deny on next revalidation. [FI-TRACE-JWKS-ADD] +[FI-TRACE-JWKS-REMOVE] + +The snapshot is authenticated: no external consumer can relabel one issuer's +JWKS as another's. The maximum number of keys per snapshot is bounded before +any attacker-controlled `kid` lookup. + +### Verification procedure ```text -VerifiedAssertion = ( - identity = (iss, sub), - asserted_key?, - claims_or_capabilities, - authority_deadlines, // non-empty - assertion_policy_id, - transport_contract_id, - revalidation_dependencies -) +VerifyAssertion(token, D, R_t): + // 1. Select issuer policy + (header, claims) := BoundedJwsDecode(token) or DENY(evidence_rejected) + policy := IssuerRegistry[claims.iss] or DENY(evidence_rejected) + + // 2. Validate token class, typ, and algorithm + ValidateTokenClass(policy, header) or DENY(evidence_rejected) + AssertAsymmetricAlgorithm(header.alg) or DENY(evidence_rejected) + + // 3. Validate signature against current authenticated JWKS + snapshot := policy.key_source.get_snapshot() or DENY(authorization_unavailable) + key := snapshot.find(header.kid) or DENY(evidence_rejected) + VerifySignature(token, key) or DENY(evidence_rejected) + + // 4. Validate claims + AssertExactIss(claims.iss, policy.iss) or DENY(evidence_rejected) + AssertAudienceMatch(claims.aud, policy.aud) or DENY(evidence_rejected) + AssertTimeBounds(claims, policy) or DENY(evidence_rejected) // [FI-TRACE-ASSERTION-VALIDATION] + k_claimed := ParseHexKey(claims.nostr_pubkey) or DENY(evidence_rejected) + + return VerifiedAssertion(identity=(claims.iss, claims.sub), asserted_key=k_claimed, + authority_deadlines=ComputeDeadlines(claims, snapshot)) ``` -The verifier rejects ambiguous protected-header or claim members, unknown -critical headers, `alg=none`, symmetric algorithms, algorithm/key mismatch, -incompatible JWK usage, ambiguous key selection, and signatures not valid -under exactly one accepted asymmetric key. It bounds the assertion, headers, -claims, subject, key identifiers, and authenticated key set before lookup or -logging. [FI-TRACE-ASSERTION-VALIDATION] - -The exact `iss` selects an authenticated policy and key source; `iss` and at -least one `aud` value exactly match configured values. `sub` is a non-empty -bounded string. Each policy configures a non-negative finite `skew`, a positive -finite `maximum_assertion_age`, and, for `current-status`, a positive finite -`maximum_status_age`; a missing value denies. `exp` and `iat` are finite -NumericDate values satisfying `now < exp`, `iat <= now + skew`, and -`now < iat + maximum_assertion_age`. Optional `nbf` satisfies -`nbf <= now + skew`. Arithmetic is overflow-safe and equality at an expiry is -expired. [FI-TRACE-ASSERTION-VALIDATION] - -The Nostr-key claim is named `nostr_pubkey`. When present it MUST be a -lowercase hexadecimal encoding of exactly one 32-byte Nostr public key; other -encodings and aliases deny. In `attested-key` enrollment policy and wherever -current matching issuer attestation is required, this exact claim MUST be -present and equal the proven actor; authorization claims or capabilities use a -closed bounded input set and deterministic canonical encoding. Unchecked claims -never enter the result. [FI-TRACE-VERIFIER-PARITY] - -### Token class +The verifier is **fail-closed**: any unreadable, missing, ambiguous, or +expired input denies. A missing JWKS snapshot denies with +`authorization_unavailable`; all other failures deny with `evidence_rejected`. +[FI-TRACE-DEPENDENCY-FAIL-CLOSED] -Policy selects exactly one token class before parsing claims: +### Admission at connection -- **`at+jwt` access token**: a Buzz-resource access token whose protected - `typ` is exactly `at+jwt` and whose `aud` contains the configured Buzz - resource audience. This class selects tokens carrying the RFC 9068 `at+jwt` - type but validates them under this document's claim contract; it does not - implement the full RFC 9068 validation profile, and the long-form media type - `application/at+jwt` is not accepted; -- **dedicated Buzz assertion**: a separately minted assertion whose protected - `typ` is exactly `nip-fi+jwt`. - -OIDC ID Tokens always deny, even when `iss`, `aud`, and `sub` match. A generic -or absent type has no accepted class: claim presence alone cannot prove a token -disjoint from an OIDC ID token, since an issuer can mint an ID token carrying -`client_id`, and the only authenticated discriminator is `typ`. Failure under -one class never triggers validation under another. An `at+jwt` access token MUST -contain one non-empty bounded `client_id`. Issuer policy MUST distinguish a -resource-owner token from a token -whose subject represents the OAuth client, including a client-credentials token, -using authenticated claim semantics and mutually exclusive validation rules. A -token that admits both interpretations denies. If client-subject tokens are -accepted, the issuer MUST guarantee that their `(iss, sub)` coordinates cannot -collide with resource-owner coordinates; otherwise that token class is -ineligible. Token class and every class-specific validation rule are inputs to -`assertion_policy_id`. [FI-TRACE-TOKEN-CLASS] - -### Policy identity and snapshots - -Core has exactly two semantic contract identities: +On WebSocket upgrade: -```text -assertion_policy_id = H(canonical assertion-policy contract) -transport_contract_id = H(canonical transport contract) +1. Extract `Nostr-Federated-Identity` header; missing or malformed → deny + `missing_evidence` or `evidence_rejected`. +2. Call `VerifyAssertion`; any error → deny per the rejection table. +3. Complete NIP-42 handshake; validate AUTH event, extract `k`. +4. Assert `verified.asserted_key == k`; mismatch → deny `authorization_denied`. + [FI-TRACE-ASSERTION-KEY-MISMATCH] +5. Admit the connection. The session's authority deadline is the minimum of all + `authority_deadlines`; see Session policy. + +## Session policy + +### Maximum connection lifetime + +Every NIP-FI deployment MUST configure a positive finite +`max_connection_lifetime_seconds`. This is a **required deployment knob**; +there is no default that permits an indefinite session. Operators MUST select +a value; infosec policy governs the specific bound. + +A connected session MUST be terminated no later than `connection_time + max_connection_lifetime_seconds`, +regardless of assertion expiry. + +The effective session deadline is: + +``` +session_deadline = min( + connection_time + max_connection_lifetime_seconds, + min(authority_deadlines), // from VerifiedAssertion + key_snapshot_hard_deadline // from the issuer policy +) ``` -Each uses one implementation-defined but deterministic, versioned encoding and -collision-resistant hash within a deployment. `assertion_policy_id` covers the -canonical issuer, audience, token class, allowed algorithms, authenticated -key/status-source contracts, identity/key/claim mapping, time and size rules, -normalization, freshness class, and compiled verifier behavior. The verifier -fingerprint is an input, not a third identity. `transport_contract_id` covers -the client-attached field, parsing, attachment, context preservation, and -no-fallback semantics; a companion transport may define its own canonical -contract under that same identity slot. A semantic change changes exactly its -owning ID. [FI-TRACE-CONTRACT-IDENTITIES] - -Mutable contents and deployment state are not contract identities. They remain -in `revalidation_dependencies`: authenticated assertion-snapshot version, -verification-key identity, key-snapshot hard deadline, optional status -source/version/deadline, binding/lifecycle/local-policy/resource versions, -proof and replay witnesses, and a confidential handle to the exact compact JWS. -Adding, removing, or replacing an accepted key changes the snapshot version, -not `assertion_policy_id`. Changed dependencies require revalidation under -current state; a retained key may continue, while an absent key denies. -Unknown-key refresh is bounded and coalesced and has no attacker-triggered -stale-key fallback. [FI-TRACE-JWKS-ADD] [FI-TRACE-JWKS-REMOVE] - -The base contract compares the current authenticated snapshot and makes no -anti-rollback promise. A deployment claiming rollback prevention records a -separately authenticated monotonic floor and tests it. [deployment artifact: -assertion-policy review] - -### Freshness class - -Each policy declares exactly one server-owned freshness class, included in -`assertion_policy_id`: - -- **`offline-jwt`** validates the JWT and authenticated key snapshot only. - `upstream_authority_deadline` is the minimum of `exp`, - `iat + maximum_assertion_age`, and the key-snapshot hard deadline. Token age - bounds assertions minted before revocation; it cannot bound an issuer that - continues minting accepted assertions afterward. Enabling this class therefore - requires deployment evidence that revocation stops new accepted issuance, and - discovery reports the unconditional residual bound as unknown (`null`). It - MUST NOT advertise a finite unconditional residual bound. [deployment - artifact: issuer revocation review] -- **`current-status`** additionally requires an authenticated witness - `(iss, sub, token_or_session_id?, active=true, observed_at, valid_until, - status_version, authenticated_source_id)`. Issuer, subject, and optional - session identifier exactly match the assertion. Ambiguous, unauthenticated, - inactive, or expired status denies. `valid_until` is finite and no later than - `observed_at + maximum_status_age`. The upstream deadline is the minimum of - the offline assertion deadlines and `valid_until`. Source outage cannot mint - or extend a witness; an already verified witness remains usable only until - its existing `valid_until`. [FI-TRACE-CURRENT-STATUS-STALE] - -A current-status deployment advertises a tested positive -`maximum_residual_upstream_revocation_seconds`. Prepared evidence and leases -close within that value after upstream revocation, including a revocation racing -final admission. Poll/cache age, event-delivery and processing delay, and -enforcement delay all fit within the advertised value. A push implementation -may close authority sooner but cannot claim a value below its tested worst case. -[FI-TRACE-CURRENT-STATUS-REVOKED] - -An external capability projection whose removal is required to close authority -within a declared revocation bound MUST enter authoritative local-policy state, -not `claims_or_capabilities` from the assertion. That state is reread during -preparation, final admission, and protected lease use. A deployment that carries -such a projection only in assertions cannot claim a revocation bound for its -changes. [FI-TRACE-CAPABILITY-REVOCATION] - -Before enabling an issuer, the operator records authoritative evidence that -`sub` is stable for the account lifetime, never reassigned, and not intentionally -derived from mutable profile data. An issuer that cannot provide this property -is ineligible. [deployment artifact: issuer subject-stability review] - -## Nostr proof and body semantics - -The actor is always returned by fresh Nostr-proof validation, never by an -assertion or unsigned field. NIP-42 binds its AUTH event to the current -challenge, relay URL, connection, and freshness window. NIP-98 binds its event -to the exact server-resolved URL, method, and freshness window. All evidence -agrees with the same `D` and `R_t`. [FI-TRACE-DOMAIN-SPOOF] - -Each protected HTTP operation declares in server policy whether its body is -authorization-relevant; clients cannot select the declaration. - -For a relevant body, the NIP-98 event contains exactly one `payload` tag equal -to lowercase hexadecimal SHA-256 of the **body bytes**: the complete content -after transfer decoding and before any content decoding. Absence, duplication, -mismatch, validation of only a prefix, or substitution of the body bytes after -validation denies. For an irrelevant body, no -authorization decision, target, capability, or effect selector derives from a -body field not bound by NIP-98. A `payload` tag present on an operation whose -body is declared authorization-irrelevant is validated identically against the -body bytes; duplication or mismatch denies. [FI-TRACE-BODY-BINDING] - -Every operation has finite body and spool bounds. A known oversized body is -rejected before hashing; a stream is rejected at octet `limit + 1`; admission -waits for EOF. Before EOF there is no application effect, replay mutation, -receipt, or partial digest authority. Quota failure cleans up staged bytes and -denies. [FI-TRACE-BODY-BOUNDS] - -## Direct preparation - -The following is normative pseudocode; every read is from authoritative state. +Equality at any deadline is expired. Arithmetic is overflow-safe. +[FI-TRACE-LEASE-BOUND] + +### Re-authentication + +There is **no in-band session renewal**. When a session expires, the relay +closes the WebSocket. The client must open a new connection with a fresh +assertion on the upgrade request and complete a fresh NIP-42 proof. A silent +re-mint riding an existing adapter/IdP session is an adapter implementation +detail; the relay never sees anything other than a new upgrade request. + +### Reconnect after expiry + +A client whose session expired due to normal TTL expiry may reconnect +immediately provided the adapter can issue a fresh assertion. Session expiry +does not imply key revocation or identity loss; that is the adapter's domain. + +## Admin disconnect API + +The adapter can terminate live relay sessions for a specific public key via an +authenticated `disconnect` call. + +### Semantics (session-only) + +A disconnect call causes the relay to close all live WebSocket connections +whose proven `k` equals the target pubkey. This is a **session-only** +operation: it closes existing connections but does not prevent the key from +reconnecting. After disconnection, a client holding a still-valid JWT can +reconnect immediately. + +> **Non-normative note — open product question for Will/Tyler:** +> +> The session-only model means a revoked employee can reconnect until their +> assertion TTL expires and the adapter stops issuing new assertions. The +> residual window equals at most `max_connection_lifetime_seconds` (for an +> existing session) plus the remaining JWT TTL (for an immediate reconnect +> after being booted). +> +> The alternative is a **deny-until-TTL** model: the relay holds a +> memory-resident deny-list entry for the pubkey keyed to the adapter's stated +> TTL, and any reconnect attempt for that key is denied `authorization_denied` +> until the entry expires. This closes the reconnect window at the cost of +> relay in-memory state and a TTL-propagation contract between adapter and relay. +> +> This document intentionally leaves that decision unresolved. The current +> normative text describes session-only. If deny-until-TTL is chosen, Section 6 +> must be revised to add: the TTL parameter on the disconnect call, the +> deny-list data structure (keyed by pubkey, value = absolute expiry), the +> deny-list check at admission (step 4), and the expiry/eviction rule. + +### Transport + +The disconnect endpoint is an authenticated adapter→relay API, not a public +Nostr protocol. Authentication MUST use the same JWKS verification surface +the relay uses for client assertions: the adapter call carries a +`Nostr-Federated-Identity` header whose assertion is verified against the +configured adapter issuer policy before any action is taken. + +### Request + +The adapter sends a signed request naming the target pubkey: ```text -PrepareDirect(request, assertion, proof): - (D, R_t, operation, resource) := ResolveTargetContext(request) or DENY - e := ValidateClientAttached(assertion, D, R_t) or DENY - k := ValidateNostrProof(proof, D, R_t) or DENY - R := SealActor(R_t, k) - i := e.identity - - if e.asserted_key exists and e.asserted_key != k: DENY(key_mismatch) - atomically read B_D(i), B_D(k), T_D(i,k), Y_D(k), enrollment policy, - local policy, resource, and all dependency versions - if k in Y_D: DENY(key_revoked) - if (i,k) in T_D: DENY(pair_retired) - - if B_D(i) = B_D(k) = binding(i,k): - proposal := existing(binding.version, binding.provenance) - else if B_D(i) exists or B_D(k) exists: - DENY(binding_conflict) - else if enrollment policy = attested-key: - if e.asserted_key != k: DENY(attestation_required) - proposal := enroll(i, k, attested-key) - else if enrollment policy = tofu: - proposal := enroll(i, k, e.asserted_key = k ? attested-key : tofu) - else: - DENY(binding_required) - - EvaluateLocalPolicy(D, R, operation, resource, k, - e.claims_or_capabilities) or DENY - return PreparedAuthorization(evidence, proposal, witnesses, deadlines) +POST /api/nip-fi/disconnect HTTP/1.1 +Nostr-Federated-Identity: Bearer +Content-Type: application/json + +{"pubkey": ""} ``` -TOFU is optional private deployment posture and is not self-advertised. It -accepts that a stolen assertion for a never-enrolled identity can bind an -attacker's proven key; deployments enabling it retain a passing -FI-TRACE-TOFU-THEFT artifact. Binding provenance is immutable. A policy change -affects only future creation. [deployment artifact: TOFU risk review] - -Preparation, including first-use enrollment, is read-only and produces no -authoritative mutation. [FI-TRACE-FINAL-DENIAL-NO-MUTATION] - -## Final admission - -A prepared value is consumed at most once. Final admission first requires an -exact domain, context, operation, resource, actor, and transport match; both -contract IDs unchanged; and every bound live. A changed dependency is reread -and re-evaluated from authoritative evidence. [FI-TRACE-PREPARED-STALE] - -Two verified assertion results are **equivalent** when: - -1. identity-class fields are byte-equal: `iss`, `sub`, asserted-key presence and - value, canonical claims/capabilities, `assertion_policy_id`, and - `transport_contract_id`; -2. each bounds-class deadline — every `authority_deadlines` member, the - key-snapshot hard deadline, and any status deadline — is live now and is no - later than its prepared value; and -3. provenance-class fields — snapshot version, verification-key identity, - status source and version, binding, lifecycle, local-policy, and resource - versions, proof and replay witnesses, the confidential JWS handle, cache - metadata, ordering, and retrieval time — are ignored after successful - current revalidation. - -Every `revalidation_dependencies` member is bounds-class if it is a deadline -and provenance-class otherwise. Any new or unclassified assertion-content field -belongs to the identity class. A fresher assertion cannot silently extend a -prepared decision. [FI-TRACE-PREPARED-STALE] - -Final admission atomically rereads binding, tombstone, revocation, enrollment, -policy, resource, status, replay, receipt, and invalidation witnesses; -recomputes the complete decision; claims applicable proof replay identities; -creates an eligible proposed binding; and appends its request-bound receipt and -required authorization evidence. All commit or none. A concurrent identical -enrollment may recompute as the same `existing` binding; conflicting enrollment -commits at most one winner. [FI-TRACE-CONCURRENT-ENROLLMENT] - -A failed admission rolls back all authority mutation. The application operation -runs only after committed authorization. If it cannot share the transaction, a -request-bound idempotent receipt prevents the same proof from creating a second -effect. [FI-TRACE-FINAL-DENIAL-NO-MUTATION] - -## Base lifecycle - -Retirement, revocation, and rotation require separate privileged authority -bound to the exact domain, transition, identity, old binding version when -present, target key when present, and request. Each atomically rechecks current -state, appends immutable lifecycle history, and invalidates dependent leases -after commit. Every new target key supplies fresh target-bound Nostr proof and -any policy-required current matching issuer attestation. [FI-TRACE-LIFECYCLE-AUTHORITY] - -- **RetirePair** removes one exact active binding and durably retires its pair. -- **RevokeKey** records the key as revoked even if inactive; if active, it also - removes the binding and retires that pair. Repeating the same authorized - revocation is idempotent. -- **Rotate** replaces one exact active binding with one unused, unrevoked, - non-retired target key, retires the old pair, and creates a fresh binding - version. The replacement provenance is `attested-key` when current matching - issuer attestation was validated and `provisioned` otherwise. Rotation does - not globally revoke the old key. Rotation continues one grant onto a new key - rather than establishing a new one: the replacement preserves every - profile-defined administrative bound carried by the binding it replaces, as an - opaque field core neither interprets nor clears. Only the authority that set - such a bound can change it. [FI-TRACE-LIFECYCLE-AUTHORITY] - -Failure or stale state causes no partial mutation. Ordinary authorization cannot -perform or undo these transitions. Extended disablement, re-enablement, -provisioning, and administrative expiry are defined only by NIP-FI-LIFECYCLE. - -## Request and session bounds - -HTTP authority covers one exact request and is never reusable. - -A WebSocket lease is scoped to one actor, domain, operation set, binding -version, normalized result, policy/resource versions, and invalidation -witnesses. Its deadline is the earliest assertion, upstream-authority, -key-snapshot, proof/connection, local-policy, and implementation deadline. -Arithmetic is overflow-safe and equality is expired. [FI-TRACE-LEASE-BOUND] - -Before each protected use, the service checks actor, domain, operation, -resource, deadline, binding version, contract IDs, snapshot/status versions, -policy versions, and invalidation state. Changed dependencies require current -revalidation to an equivalent result; unreadable or ineligible state denies. -A lease for one key never authorizes another key on the same connection. -[FI-TRACE-MULTI-KEY-SESSION] - -Expiry ends the lease, not the binding. Renewal requires a new connection with -a fresh assertion attached to its WebSocket upgrade, fresh Nostr proof, -preparation, and final admission; there is no in-band renewal path. Confidential -assertion revalidation material is destroyed on expiry, close, or invalidation. +The relay verifies the assertion, confirms the caller identity is an authorized +adapter principal, then closes all matching live connections. An unknown or +unprovable pubkey is not an error; the relay responds `200` with `{"disconnected": 0}`. + +### Response + +| Condition | Status | Body | +|---|---|---| +| Authorized; action taken or no-op | `200` | `{"disconnected": }` where `n` is the count of sessions closed | +| Missing or invalid assertion | `401` / `403` | Per the rejection table | +| Malformed request body | `400` | `bad request\n` | ## Rejection and privacy Public class is a function only of evidence the requester supplied, never of private per-principal server state; `authorization_unavailable` is the sole -exception and reveals only that a required authoritative dependency is -unreadable, never any per-principal fact. Replay status is a function of -committed per-principal server state, not of the supplied evidence alone; -replayed evidence is therefore classed `authorization_denied`, indistinguishable -from any other private-state denial, so that resubmitting captured evidence -reveals nothing about whether the original request committed. Under the -private-posture rule, even `key_mismatch` joins the private-state anonymity set. - -| Private condition | Public class | Nostr prefix and exact text | HTTP response | +exception and reveals only that a required dependency is unreadable. + +| Private condition | Public class | Nostr text | HTTP response | |---|---|---|---| -| assertion/proof absent | `missing_evidence` | `auth-required: authentication required` | `401`; `WWW-Authenticate: Nostr`; `Content-Type: text/plain; charset=utf-8`; `authentication required\n` | -| malformed, invalid, or expired evidence | `evidence_rejected` | `restricted: evidence rejected` | `403`; `Content-Type: text/plain; charset=utf-8`; `evidence rejected\n` | -| replayed evidence; key mismatch; attestation required; binding conflict; retired pair; revoked key; lifecycle gate; binding required/expired; local policy denial | `authorization_denied` | `restricted: authorization denied` | `403`; `Content-Type: text/plain; charset=utf-8`; `authorization denied\n` | -| required current dependency unreadable | `authorization_unavailable` | `restricted: authorization unavailable` | `503`; `Content-Type: text/plain; charset=utf-8`; `authorization unavailable\n` | - -Nostr text is the exact UTF-8 text after an applicable NIP-42/NIP-01 prefix. -A denial decided on a WebSocket upgrade request, before any NIP-42 proof -exists, is the HTTP response in the table, sent instead of `101`; a denial -decided after the connection is established is the Nostr text. For HTTP, the -compared denial contract is closed over the -status, complete body, -and exact values of only the header fields named in the table; header order and -other fields are outside that contract and their values cannot depend on the -private condition. The body is the shown UTF-8 bytes with one LF and no other -bytes. The `Nostr` challenge satisfies RFC 9110 Section 15.5.2. -Responses contain no free text, reason code, request ID, issuer, subject, key, -claim, binding state, enrollment posture, token material, or timing hint. All -private conditions in `authorization_denied` produce byte-identical responses. -[FI-TRACE-DENIAL-ORACLE] - -NIP-FI defines no public identity projection. Public events, tags, filters, -discovery, responses, logs, metrics, and traces contain no raw assertions or -unredacted `iss`, `sub`, email, display name, or private claim. Access-controlled -authoritative stores retain only what enforcement and investigation require. -A separate presentation protocol cannot confer NIP-FI authority. -[FI-TRACE-PRIVACY-NONPUBLIC] +| assertion or proof absent | `missing_evidence` | `auth-required: authentication required` | `401`; `WWW-Authenticate: Nostr`; `Content-Type: text/plain; charset=utf-8`; body `authentication required\n` | +| malformed, invalid, or expired evidence | `evidence_rejected` | `restricted: evidence rejected` | `403`; `Content-Type: text/plain; charset=utf-8`; body `evidence rejected\n` | +| assertion–key mismatch; local policy denial; adapter-initiated disconnect (session-only model) | `authorization_denied` | `restricted: authorization denied` | `403`; `Content-Type: text/plain; charset=utf-8`; body `authorization denied\n` | +| required JWKS snapshot unreadable | `authorization_unavailable` | `restricted: authorization unavailable` | `503`; `Content-Type: text/plain; charset=utf-8`; body `authorization unavailable\n` | + +A denial decided on a WebSocket upgrade is the HTTP response in place of `101`. +A denial decided after the connection is established is the Nostr text. +Responses contain no free text, reason code, issuer, subject, key, claim, or +timing hint. [FI-TRACE-DENIAL-ORACLE] + +NIP-FI defines no public identity projection. Raw assertions, `iss`, `sub`, +email, display name, and private claims MUST NOT appear in public events, tags, +filters, discovery, logs, metrics, or traces. [FI-TRACE-PRIVACY-NONPUBLIC] + +## Out of scope + +The following are adapter and deployment concerns. This spec defines no +normative behavior for them: + +- Identity↔key registry, key ownership records, and the one-identity one-key + constraint: adapter-side. +- Key rotation, re-enrollment after device loss: adapter-side. +- Revocation signaling to the adapter/IdP: adapter-side; the adapter stops + issuing assertions, which closes the relay window within assertion TTL. +- SCIM, HR system integration, employee offboarding automation: adapter-side. +- Audit logging beyond what the relay operator chooses to retain: adapter-side. +- Delegation: out of scope. +- Companion profiles (NIP-FI-EDGE, NIP-FI-LIFECYCLE, NIP-FI-DELEG, NIP-FI-CONF): removed. ## Discovery @@ -513,119 +367,55 @@ A relay SHOULD advertise core support in NIP-11 as: } ``` -NIP-FI-EDGE owns the optional `edge_transports` member and its exact type, -placement, and value semantics. For `current-status`, the final value is a tested -positive integer. Discovery never states enrollment mode or TOFU posture and -never exposes issuer URLs, audiences, claim names, tenant IDs, or -deployment-local identifiers. For a fixed -set of claimed profiles, the complete public discovery output is byte-identical -for every enrollment policy, including `attested-key`, private `tofu`, and any -companion profile mode: no field, flag, value, omission, ordering, or object shape -may distinguish the configured mode. Profile documents own only non-enrollment -public claims. -[FI-TRACE-DISCOVERY-PRIVATE] - -## Worked example (non-normative) - -A protected HTTP POST under `client-attached` with NIP-98 proof and an -authorization-relevant body. Credentials are elided; the NIP-FI-CONF exit -fixture pins the complete request compared objects. - -```text -POST /media HTTP/1.1 -Host: relay.example -Nostr-Federated-Identity: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIs... -Authorization: Nostr eyJpZCI6IjE1ZTI3ZDc0Li4uIiwicHVia2V5IjoiOTljNzQ4Li4u... -Content-Type: application/octet-stream -Content-Length: 4 - -abcd -``` - -The bearer JWS validates under the configured assertion policy: exact `iss` -and `aud`, token class `at+jwt`, live time claims, and `nostr_pubkey` equal to -the NIP-98 event's `pubkey`. The NIP-98 event binds the server-resolved method -and URL, and its single `payload` tag equals the SHA-256 of the four body -bytes. Admission then follows Direct preparation and Final admission; success -returns the application response, and every failure class returns exactly the -bytes fixed in the Rejection table. On a WebSocket upgrade the same header -attaches to the upgrade request and NIP-42 supplies the proof after connect. - -## Core behavioral oracles +Discovery MUST NOT state issuer URLs, audiences, claim names, tenant IDs, or +deployment-local identifiers. [FI-TRACE-DISCOVERY-PRIVATE] -A core claim covers every applicable oracle below at one implementation and -policy revision. NIP-FI-CONF defines evidence and mutation-adequacy rules. +## Behavioral oracles | ID | Required outcome | |---|---| -| `FI-TRACE-TRANSPORT-CLOSED` | Exact one-header input succeeds; missing, repeated, combined, malformed, mixed, URL, and fallback variants deny. | -| `FI-TRACE-ASSERTION-VALIDATION` | Valid boundary input passes; each signature, key-selection, issuer, audience, time, size, ambiguity, and missing-configuration negative denies. | -| `FI-TRACE-TOKEN-CLASS` | An `at+jwt` access token and a dedicated `nip-fi+jwt` assertion pass only their selected class. ID tokens, wrong or generic types, client-only audiences, absent or ambiguous `client_id`, resource-owner/client-subject ambiguity, and every attempted cross-class fallback deny. | -| `FI-TRACE-CONTRACT-IDENTITIES` | Mutate each assertion semantic, transport semantic, and mutable dependency independently: semantic mutations change only their owning contract ID; snapshot/binding/lifecycle/policy/resource/status mutations change neither ID but force current revalidation. | -| `FI-TRACE-VERIFIER-PARITY` | Equal authoritative input and policy produce the same canonical normalized result. | -| `FI-TRACE-JWKS-ADD` | Retained-key rotation revalidates successfully under the changed snapshot version. | -| `FI-TRACE-JWKS-REMOVE` | Evidence and leases under a removed key deny after snapshot change. | -| `FI-TRACE-CURRENT-STATUS-REVOKED` | Revocation, including one racing final admission, closes authority within the advertised tested bound. | -| `FI-TRACE-CURRENT-STATUS-STALE` | Inactive/ambiguous status denies; an issuer, subject, or session-identifier mismatch denies; expiry equality, outage, delayed events, and changed status versions cannot mint or extend a witness. | -| `FI-TRACE-CAPABILITY-REVOCATION` | Removal of a revocation-bounded external capability projection from authoritative local policy closes prepared evidence and lease use within the declared bound; assertion-only projection cannot satisfy this oracle. | -| `FI-TRACE-BODY-BINDING` | Exact complete relevant body passes; absent/duplicate/mutated/partial/substituted payload variants deny without effects; a payload tag on an irrelevant-body operation validates identically and denies on duplication or mismatch. | -| `FI-TRACE-BODY-BOUNDS` | Oversized, over-quota, and pre-EOF variants deny with bounded work, cleanup, and no effects. | -| `FI-TRACE-DOMAIN-SPOOF` | Client routing and forwarded authority cannot replace server-owned context. | -| `FI-TRACE-ASSERTION-KEY-MISMATCH` | Mismatch denies with no mutation and the private-state response. | -| `FI-TRACE-BINDING-CONFLICT` | A binding conflict denies without replacing either existing binding. | -| `FI-TRACE-TOMBSTONE-REPLAY` | Fresh eligible evidence for a retired pair or revoked key denies without recreation. | -| `FI-TRACE-ASSERTION-REFRESH` | Fresh evidence reuses the same eligible durable binding after prior assertion expiry. | -| `FI-TRACE-PREPARED-STALE` | Changed identity-class witnesses or extended bounds deny; provenance-only rotation revalidates. | -| `FI-TRACE-CONCURRENT-ENROLLMENT` | Identical first use converges; conflicting first use commits at most one winner. | -| `FI-TRACE-FINAL-DENIAL-NO-MUTATION` | Every failed phase leaves all authoritative stores and effects unchanged. | -| `FI-TRACE-LIFECYCLE-AUTHORITY` | Unprivileged/stale transitions deny; authorized retirement/revocation/rotation is atomic. | -| `FI-TRACE-LEASE-BOUND` | A lease ends at its earliest bound; equality at any bound is expired. | -| `FI-TRACE-MULTI-KEY-SESSION` | One actor's lease never authorizes another key on the same connection. | -| `FI-TRACE-DENIAL-ORACLE` | Each private row produces its exact fixed bytes on every surface where its condition can be decided — HTTP, a WebSocket upgrade, or after connect; all private-state rows compare byte-identical. | -| `FI-TRACE-DEPENDENCY-FAIL-CLOSED` | Each unreadable authoritative dependency denies. | -| `FI-TRACE-AUTHORITY-UNIFORM` | Every protected ingress reaches one current final-admission authority. | -| `FI-TRACE-CROSS-DOMAIN-COLLISION` | Equal subjects across issuers and equal pairs across domains remain distinct. | +| `FI-TRACE-TRANSPORT-CLOSED` | Exact one-header input succeeds; missing, repeated, combined, malformed, and fallback variants deny. | +| `FI-TRACE-ASSERTION-VALIDATION` | Valid boundary input passes; each signature, key-selection, issuer, audience, time, size, and missing-configuration negative denies. | +| `FI-TRACE-TOKEN-CLASS` | `at+jwt` and `nip-fi+jwt` pass only their selected class; ID tokens, wrong or generic types, and cross-class fallback deny. | +| `FI-TRACE-ASSERTION-KEY-MISMATCH` | Mismatch between `nostr_pubkey` and the NIP-42 proven key denies with the private-state response. | +| `FI-TRACE-JWKS-ADD` | A key added to the JWKS is accepted after the next snapshot refresh. | +| `FI-TRACE-JWKS-REMOVE` | Connections verified under a removed key deny on next revalidation or reconnect. | +| `FI-TRACE-DEPENDENCY-FAIL-CLOSED` | An unreadable JWKS snapshot denies `authorization_unavailable`; no degraded Nostr-only access. | +| `FI-TRACE-LEASE-BOUND` | A session closes at its earliest deadline; equality at any deadline is expired. | +| `FI-TRACE-DENIAL-ORACLE` | Each public-class row produces its exact fixed bytes; all private-state rows compare byte-identical. | +| `FI-TRACE-DISCOVERY-PRIVATE` | Complete discovery bytes do not expose issuer, audience, or deployment-private state. | +| `FI-TRACE-CROSS-DOMAIN-COLLISION` | Equal `sub` values under different `iss` values remain distinct identities. | | `FI-TRACE-PRIVACY-NONPUBLIC` | Private identity does not enter public surfaces. | -| `FI-TRACE-DISCOVERY-PRIVATE` | Complete discovery bytes remain identical across attested-key, TOFU, and companion enrollment modes. | -| `FI-TRACE-TOFU-THEFT` | Stolen-assertion first use denies unless private TOFU is enabled and the attacker also proves its chosen key. | - -## Relationship to other work (non-normative) - -NIP-FI binds an access token to a key the resource server itself verifies, the -goal DPoP (RFC 9449) and mTLS-bound tokens (RFC 8705) reach through a `cnf` -claim. Here the proof is the NIP-42 or NIP-98 event the relay already -validates, so no second proof is defined and the issuer need not attest the -key; `nostr_pubkey` is the optional `cnf` analogue. Unlike those profiles the -binding is durable server state rather than a per-token claim: a stolen -assertion cannot reach an enrolled identity without its key, and revocation is -a local fact rather than a token-lifetime race. One identity, one key per -domain is stricter than WebAuthn's many-credentials-per-account model because -the Nostr key is itself the public identity; additional devices do not create -additional active bindings. Two contract identities plus explicit dependency -versions exist because folding a mutable key snapshot into policy identity would make benign -rotation change policy lineage, while omitting it would let evidence under a -removed key survive. Denial responses deliberately collapse the conditions that -RFC 6750 error codes distinguish. `trusted-proxy-hmac-v2` in NIP-FI-EDGE is a -fixed-component request MAC in the family of HTTP Message Signatures (RFC 9421) -and AWS SigV4, without negotiation and with length-prefixed canonicalization. ## Security considerations -Issuer compromise can impersonate a principal but cannot prove an uncompromised -bound Nostr key. Assertion theft cannot use an existing binding without that -key; private TOFU intentionally retains first-use theft risk. Snapshot -revalidation limits removed-key reuse but the base policy accepts authenticated -key-source rollback as residual issuer risk. Two-phase admission closes the -binding and policy TOCTOU window only when every authoritative witness is reread -atomically. Availability failures deny rather than degrade to Nostr-only access. +**Assertion theft.** A stolen assertion cannot authorize without also proving +the named `nostr_pubkey` via NIP-42. The relay's assertion–key binding check +is the primary control against assertion replay across keys. + +**TTL window after revocation.** Offline JWT verification means the relay +cannot observe IdP-side revocation until the current assertion expires. The +deployment adapter MUST configure a `max_connection_lifetime_seconds` and +assertion TTL consistent with the organization's acceptable revocation latency. +For the session-only disconnect model, the residual window is bounded by max( +existing session remaining lifetime, assertion TTL ). + +**SSRF.** The JWKS fetcher implements SSRF protection: HTTPS-only URI +validation, DNS resolution with IP deny-list enforcement, address pinning to +prevent DNS rebinding TOCTOU, and redirect denial. The complete IANA +Special-Purpose address deny table is implemented; see `crates/buzz-core/src/network.rs`. + +**Issuer compromise.** A compromised assertion issuer can impersonate any +identity but cannot prove possession of an enrolled Nostr key. The NIP-42 +proof remains an independent control. + +**Algorithm confusion.** The verifier enforces asymmetric algorithms only; +`alg=none` and symmetric algorithms deny. The exact `kid`-based key selection +is bounded before any attacker-controlled lookup. ## Sources - NIP-42 authentication: -- NIP-98 HTTP authentication: - JWT BCP: - JWT access-token profile: - DPoP: -- OAuth 2.0 mTLS client certificate-bound tokens: -- HTTP Message Signatures: -- Non-normative composed model: [NIP-FI-MODEL.md](NIP-FI-MODEL.md) From cea687a5b0dc9b43de60daf5b7d9758cc82472fa Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 1 Sep 2026 18:19:46 -0400 Subject: [PATCH 2/9] docs(nip-fi): delete companion profiles superseded by stateless design NIP-FI-CONF, NIP-FI-DELEG, NIP-FI-EDGE, NIP-FI-LIFECYCLE, and NIP-FI-MODEL all presuppose relay-side binding/lifecycle state that the v2 stateless spec removes. A deferred doc that assumes removed state is worse than absence. Edge transport and delegation can be re-proposed as separate profiles if they become relevant under the stateless design. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI-CONF.md | 375 ------------------------------ docs/nips/NIP-FI-DELEG.md | 186 --------------- docs/nips/NIP-FI-EDGE.md | 417 ---------------------------------- docs/nips/NIP-FI-LIFECYCLE.md | 269 ---------------------- docs/nips/NIP-FI-MODEL.md | 96 -------- 5 files changed, 1343 deletions(-) delete mode 100644 docs/nips/NIP-FI-CONF.md delete mode 100644 docs/nips/NIP-FI-DELEG.md delete mode 100644 docs/nips/NIP-FI-EDGE.md delete mode 100644 docs/nips/NIP-FI-LIFECYCLE.md delete mode 100644 docs/nips/NIP-FI-MODEL.md diff --git a/docs/nips/NIP-FI-CONF.md b/docs/nips/NIP-FI-CONF.md deleted file mode 100644 index c6ba387977f..00000000000 --- a/docs/nips/NIP-FI-CONF.md +++ /dev/null @@ -1,375 +0,0 @@ -NIP-FI-CONF -=========== - -Conformance evidence profile ----------------------------- - -`draft` `optional` - -**Dependencies**: NIP-FI core. Applies additionally to any claimed -NIP-FI-EDGE, NIP-FI-LIFECYCLE, and NIP-FI-DELEG profile. - -The key words "MUST", "MUST NOT", "REQUIRED", "SHOULD", "SHOULD NOT", and -"MAY" in this document are to be interpreted as described in BCP 14 (RFC 2119 -and RFC 8174) when, and only when, they appear in all capitals. - -## Abstract - -NIP-FI core and its profiles state required behavior. This profile states what -counts as evidence that an implementation has it: the claim unit, the evidence -rules, the complete denial-fixture enumeration, mutation adequacy, and the -interoperability exit test. - -This profile is separately claimable and is never advertised in discovery: -conformance is a property of a reviewed revision, not a wire feature. It -defines no wire behavior, denial mapping, invariant, or admission rule; where -it names one, NIP-FI core or the owning profile is normative. - -## Claim unit - -A conformance claim names exactly one immutable tuple: - -```text -(implementation revision, - adapter revision, - build artifact digest, - deployment revision, - governing document revision, - exit fixture digest, - claimed profiles, - assertion_policy_id, - transport_contract_id, - enrollment mode) -``` - -Changing any element creates a new claim. Results from one tuple MUST NOT be -carried into another. A report contains every applicable oracle from core and -every claimed profile exactly once, with status `pass` or `not-applicable` -only, except that `FI-CONF-INTEROP-EXIT` alone may instead carry `deferred` -under the condition in **Interoperability exit test**. Blank, skipped, -expected-failure, and not-run results cannot support a claim -(`FI-CONF-CLAIM-COMPLETE`). - -Enrollment mode is part of the claim unit and is private: it is recorded in -the access-controlled report, never in discovery or any public artifact. - -## Evidence rules - -Each passing oracle records the claim tuple, a stable test identifier and -adapter entry point, the command with start time, end time, exit status, and -any random seed, the synthetic input or a privacy-safe digest of it, the -before-and-after authoritative state relevant to the oracle, the expected and -observed outcomes, and artifact locations with SHA-256 digests. Stateful -oracles use an isolated database or namespace and inspect committed state -rather than inferring it from a response. Concurrency oracles record every -contender and the single serialized outcome. Time-boundary oracles use a -controlled clock. - -Adapters MUST drive public or production-equivalent entry points. A storage -helper MAY inspect state or inject a dependency outage; it MUST NOT replace the -operation under test. Calling an internal authorization function without -traversing the protected ingress does not satisfy ingress coverage. - -None of the following satisfies any oracle: searching source, documentation, -schemas, or binaries for a token; asserting that a route calls a named -function; recording a test name without its execution result; using a mock to -prove a deployed network boundary; citing a check from another revision; or -marking an oracle passed because the feature is configured. - -`FI-TRACE-TOFU-THEFT` takes an access-controlled **configuration** witness -only; under the private-posture rule no discovery witness for enrollment mode -can exist. Discovery invariance is proved separately by -`FI-TRACE-DISCOVERY-PRIVATE`. - -Requirements marked `[deployment artifact: ...]` in core or a profile are -evidenced by the named access-controlled review record at the claimed -deployment revision, not by a behavioral oracle. A claim listing an artifact -without the record is incomplete. - -Reports and artifacts hold private deployment detail and MUST remain access -controlled. They MUST NOT enter public reports, examples, discovery, or -protocol output, and MUST NOT contain raw assertions, secrets, or unredacted -`iss`, `sub`, or claim values. The shared exit fixture is exempt: its values -are synthetic by construction and name no real principal, issuer, or key. - -## Denial fixtures - -`FI-TRACE-DENIAL-ORACLE` requires one fixture per **private condition**, not -one per public class; a per-class suite compares a class against itself. The -enumeration below is the required fixture set (`FI-CONF-DENIAL-FIXTURES`). The -public-class column restates NIP-FI core, which owns the mapping and the bytes. - -| # | Private condition | Public class | Defined by | -|---|---|---|---| -| 1 | assertion, proof, or delegation evidence absent | `missing_evidence` | core | -| 2 | edge provenance absent or incomplete on an edge-required route (assertion may be present) | `missing_evidence` | NIP-FI-EDGE | -| 3 | evidence present but rejected: signature, key selection, issuer, audience, time, size, ambiguity, token class, body binding, or edge provenance (present but rejected) | `evidence_rejected` | core, NIP-FI-EDGE | -| 4 | replayed evidence — committed replay identity already claimed | `authorization_denied` | core, NIP-FI-EDGE | -| 5 | `key_mismatch` — asserted key is not the proven actor | `authorization_denied` | core | -| 6 | `attestation_required` — attested-key enrollment without a matching key claim | `authorization_denied` | core | -| 7 | `binding_conflict` — either side of the active relation is taken | `authorization_denied` | core | -| 8 | `pair_retired` | `authorization_denied` | core | -| 9 | `key_revoked` | `authorization_denied` | core | -| 10 | `policy_denied` — local operation policy | `authorization_denied` | core | -| 11 | `binding_required` — enrollment policy creates no binding at this request: provisioned mode with no binding, or any unrecognized policy value | `authorization_denied` | core | -| 12 | `identity_disabled` | `authorization_denied` | NIP-FI-LIFECYCLE | -| 13 | `explicit_replacement_required` — pending lineage | `authorization_denied` | NIP-FI-LIFECYCLE | -| 14 | `binding_expired` — administrative expiry | `authorization_denied` | NIP-FI-LIFECYCLE | -| 15 | `delegation_not_current` — owner or relationship no longer current | `authorization_denied` | NIP-FI-DELEG | -| 16 | `dependency_unreadable` | `authorization_unavailable` | core | - -Private-condition names are fixture identifiers, not wire values; a deployment -MAY use other internal reason codes if every enumerated condition has a -fixture. Rows for an unclaimed profile are `not-applicable` with absence -evidence. A profile that introduces a private condition MUST add its row; an -unenumerated condition escapes this oracle entirely. - -**Enumeration agreement.** `policy_denied` and `dependency_unreadable` are the -*prose-only allowlist*: core conditions that core states in prose and does not -name symbolically. The suite MUST check mechanically at the claimed head, by -symbol and never by row number, and every check MUST be green on the unmutated -documents before any mutant is scored: - -1. every symbol core denies by name has a row here attributed to core with the - same public class; -2. the set of symbols in core-attributed rows equals core's symbolic denial set - together with the allowlist, exactly, and the allowlist is disjoint from - that set; and -3. for each claimed profile that owns a private-denial-condition table, the - set of `(identifier, public class)` pairs in that table equals the set of - pairs attributed to that profile here, exactly; a row with multiple owners - contributes its pair to each. - -If a later core names an allowlisted symbol, check 2's disjointness fails -until the allowlist entry is deleted, and check 1 validates the promoted -symbol's class. - -**Anonymity comparison.** Every `authorization_denied` row is in the -private-state anonymity set. Between two private conditions on one -implementation, every response byte as transmitted MUST agree — transfer -framing included — except values a server cannot hold constant across two -instants, such as `Date`. This is wider than the interoperability object -below: within one implementation, any byte that varies by private condition is -a disclosure, whatever field it sits in. - -**Interoperability compared object.** Between two implementations, comparison -is over what core pins and nothing more. Over Nostr: the complete relay message -excluding only the event or subscription identifier echoed from the request, -as compact JSON with no insignificant whitespace per NIP-01. Over HTTP: the -status code; the content per RFC 9110 Section 6.4, after transfer decoding with -chunk framing and trailers excluded; and the exact values of only the header -fields core's denial table names, field names matched case-insensitively per -RFC 9110 Section 5.1. `Content-Length` is not pinned. Header order and unnamed -fields are outside the object, and their values MUST NOT depend on the private -condition. A field core names that an implementation cannot hold constant MUST -be reported with the reason, and its value MUST be independent of the private -condition. If core later pins another field, it joins with no edit here. - -**Run discipline.** The oracle runs a fixed positive iteration count on a -pinned isolated runner at the exact claimed head. Before the run the operator -records the environment, public-response corpus, bounds, sampling method, -statistical rule, noise treatment, and acceptance threshold. A breach fails the -gate, MUST NOT trigger an automatic retry, and is retained and investigated -before a separately authorized rerun. - -`authorization_unavailable` is observably distinct from `authorization_denied`. -This is accepted residual: it discloses no per-principal state, and collapsing -it would make fail-closed behavior undiagnosable. - -**Negative control.** The suite MUST include an implementation deliberately -patched to vary its denial response by private condition, and it MUST fail -this oracle. - -## Mutation adequacy - -An oracle that cannot fail is untested text that reads as tested. The -denominator is the **listed oracle**: every table row whose first cell names -exactly one complete literal oracle identifier, in NIP-FI core, in each claimed -normative profile, and in this document when CONF is claimed — selected by -that cell, not by section title. It is not the set of normative sentences, RFC -2119 keywords, or invariant labels, none of which two readers enumerate alike. - -For each listed oracle the suite MUST retain at least one **mutant**: an -implementation variant that violates a requirement that oracle governs, -together with that oracle's failing output (`FI-CONF-MUTATION`). Evidence is -the exact patch identity, the oracle identifier, and the retained failure -output at the claimed head. For this document's own oracles the implementation -under test includes the conformance suite and its report; a mutant is a single -variant of the suite or report that the entry's own oracle rejects. - -While `FI-CONF-INTEROP-EXIT` is validly deferred it remains in claim -completeness but is excluded from this section's mutation and global-control -obligations, since its failing output cannot exist without the run. Both -obligations attach with the run and MUST be discharged before either -implementation's interoperable conformance claim is accepted. No other -oracle's obligation under this section is deferrable. - -Normative prose outside the oracle tables remains binding but is not a second -denominator. Prose that no listed oracle can detect is untestable text: add the -oracle that detects it, or delete it. - -1. **One at a time.** Mutants are applied singly against an otherwise - unmodified implementation, so layered defenses cannot mask each other. -2. **Attribution.** The kill MUST come from the entry's own oracle. A mutant - killed only by another oracle establishes coverage for neither. -3. **One entry per mutant.** A mutant satisfies only the entry it was selected - for, even when it also kills other oracles. -4. **Reachability.** The suite MUST witness that a fixture reaches the mutated - decision, not merely the enclosing operation. -5. **Survivors are recorded.** A mutant its named oracle fails to kill is a - defect in the specification or the suite. It is recorded with that - disposition and MUST NOT be waived or replaced by an easier mutant. - -Two global controls bound the suite. A deny-everything implementation MUST -fail every positive oracle; an allow-everything implementation MUST fail every -negative oracle. Neither substitutes for per-entry mutants. - -## Interoperability exit test - -A claim of core conformance requires evidence that the documents alone are -sufficient to build against (`FI-CONF-INTEROP-EXIT`). Two implementations that -have not shared code and have not consulted a common reference implementation -each produce, from NIP-FI core and any claimed profile documents alone: - -- one valid `client-attached` request, over WebSocket upgrade and over HTTP, - compared over its signing inputs as defined below; and -- one byte-exact public denial response for each of the four public classes, - on each transport where the class can be decided, compared over the - interoperability compared object under **Denial fixtures**. - -Independence is a claim about code, not inputs: two implementations given -different issuers, keys, or clocks cannot produce equal bytes. The run is -therefore parameterized by a **shared exit fixture** that both sides load and -neither side authors: - -- one issuer identity and one JWK set, including the private key needed to - mint assertions and the `kid` selecting it; -- one assertion per denial class and one for the valid request, each as - complete pre-signature protected-header and claim-set JSON values — - including `alg`, `typ`, `kid`, every member the policy allows, and fixed - `iss`, `sub`, `aud`, `nostr_pubkey`, `client_id`, `iat`, `exp`, and token - class; -- one Nostr secret key for the proof, with the complete unsigned event fields - for each transport — the NIP-98 event over HTTP and the NIP-42 event with - its challenge and relay values over the WebSocket upgrade — including - `created_at`; -- one frozen evaluation instant, and the skew and lifetime bounds in force; - and -- the domain, target resource, operation, and enrollment policy for each case. - -The canonical fixture is authored by this document's editors, not by any -claiming implementation, and MUST be published as a single file at -`docs/nips/fixtures/nip-fi-conf-exit.json` in the same repository as these -documents, with its SHA-256 digest, before any `FI-CONF-INTEROP-EXIT` run. -Both sides MUST load that file, MUST verify the digest before the run, and -MUST record the digest with the evidence; a run against any other fixture -instance is not `FI-CONF-INTEROP-EXIT` evidence. While the canonical fixture -is unpublished, the claim tuple's exit fixture digest records the reserved -value `pending-canonical-fixture`, valid only in a claim whose -`FI-CONF-INTEROP-EXIT` result is `deferred`. Publication changes the element -and therefore creates a new claim. - -**Request compared object.** Signature octets are excluded, because conforming -implementations need not agree on them (randomized `ES256` and fresh-aux -BIP-340 do not) and no document here pins JWS or JSON member order. The -compared object is the **signing inputs**: for each transport's Nostr proof, -the NIP-01 serialization the event id is taken over, compared against its own -transport's serialization; for the assertion, the decoded protected header and -claim set compared as JSON values with member order excluded. Every value the -compared object depends on MUST be pinned in the fixture. - -The exchanged artifact per case is the complete request and response frame on -each transport — for HTTP the request line, headers, and body and the response -status, headers, and body; for Nostr the complete client and relay messages — -so that a mismatch can be explained from fields outside the compared object. - -The test passes when outputs compare equal over their compared objects and -each implementation accepts the other's valid request and reproduces the -other's denials. Exit evidence includes the exchanged artifacts and each -implementation's statement of independence. A divergence traced to an -underspecified value is a defect in the specification, not in either -implementation, and is fixed there. - -**Negative control.** One implementation is patched to emit a denial that -differs from the other only outside the compared object — a header core does -not name, or reordered fields — and the run MUST still pass. A run that fails -this control is comparing more than core pins; the exit test is then the -defect. The control is retained with the evidence. - -`FI-CONF-INTEROP-EXIT` is REQUIRED only once a second implementation meeting -the independence conditions exists. Until then a conformance claim MUST record -it as deferred with the machine-readable reason -`no-independent-implementation`. A deferred exit test MUST be run and passed -before the second implementation's conformance claim is accepted, and the -first implementation's claim MUST be re-evidenced against that run. - -## Applicability - -`not-applicable` requires a machine-readable reason and behavioral proof that -the surface is absent: - -- edge oracles only when no trusted-edge profile is accepted, none is - advertised, and executable cases reject every trusted-edge evidence shape; -- snapshot-rotation oracles only when no local key or status snapshot source - is configured and executable evidence proves the absence; -- `FI-TRACE-TOFU-THEFT` only when TOFU is neither configurable nor configured - and executable first-use cases deny; -- `FI-TRACE-CURRENT-STATUS-STALE` and `FI-TRACE-CURRENT-STATUS-REVOKED` only - when every configured assertion policy declares freshness class - `offline-jwt` and executable cases prove a presented witness is never - consulted; -- `FI-TRACE-CAPABILITY-REVOCATION` only when no external capability - projection requiring a declared revocation bound is configured, and - executable evidence proves no assertion capability or local-policy value - claims such a bound; -- lifecycle and delegation oracles only when the profile is unclaimed, - disabled, and denied on every ingress; and -- every other oracle is required for an enforcing deployment. - -An implementation that supports an optional surface runs its oracles even when -one deployed domain does not activate it. - -## Release gate - -Before NIP-FI enforcement or discovery is enabled, reviewers verify, at one -reviewed revision, that: - -- one immutable claim tuple passes every applicable oracle other than a - validly deferred `FI-CONF-INTEROP-EXIT`; -- if the canonical fixture was published before the review, the tuple's exit - fixture digest is not `pending-canonical-fixture`; -- the protected-ingress inventory has no uncovered or competing authority; -- every listed oracle, other than a validly deferred `FI-CONF-INTEROP-EXIT`, - has a killed, attributed, reachable mutant and every survivor is recorded; -- the denial-fixture enumeration is complete for the claimed profiles and its - negative control fails as required; -- the interoperability exit test has passed against an independent - implementation, or is recorded as deferred because none exists; -- every named deployment artifact exists at the claimed deployment revision; - and -- public and operational sinks pass privacy-canary inspection. - -Documentation review, source review, and static scans are review inputs. They -close no item in this gate. - -## Behavioral oracles - -| ID | Required outcome | -|---|---| -| `FI-CONF-CLAIM-COMPLETE` | A report missing an applicable oracle, duplicating one, carrying a result from another claim tuple, claiming a status other than `pass`/`not-applicable` — or `deferred` on any oracle other than `FI-CONF-INTEROP-EXIT` — or omitting mutant evidence for any oracle other than a deferred `FI-CONF-INTEROP-EXIT`, or recording the exit fixture digest `pending-canonical-fixture` with any `FI-CONF-INTEROP-EXIT` result other than `deferred`, is rejected. | -| `FI-CONF-DENIAL-FIXTURES` | Every enumerated private condition has a fixture; core and each claimed profile pass exact identifier/class/owner enumeration agreement; anonymity-set responses compare byte-identical; the distinguishing negative control fails. | -| `FI-CONF-MUTATION` | Every listed oracle — except `FI-CONF-INTEROP-EXIT` while validly deferred, per **Mutation adequacy** — has a singly-applied, attributed, reachability-witnessed mutant killed by that entry's own oracle; the deny-everything and allow-everything global controls fail every oracle **Mutation adequacy** requires of them, with retained evidence; survivors are recorded, not waived. | -| `FI-CONF-INTEROP-EXIT` | Two independent implementations produce, from the documents alone, valid requests equal over the request compared object and per-class denials equal over the denial compared object, and accept each other's output. | - -## Security considerations - -Conformance evidence is a privileged artifact: it enumerates private denial -conditions, enrollment posture, and deployment topology that the protocol -deliberately keeps off the wire. Publishing a report, a fixture corpus, or a -mutant catalogue would disclose exactly what `FI-INV-13` and -`FI-TRACE-DISCOVERY-PRIVATE` protect. - -A passing suite bounds the behaviors it exercises and nothing else. Mutation -adequacy raises the cost of a masked defect; it does not prove absence of -defects, and a claim that cites this profile as proof of security rather than -of tested behavior is misusing it. diff --git a/docs/nips/NIP-FI-DELEG.md b/docs/nips/NIP-FI-DELEG.md deleted file mode 100644 index 9b9134c2b3d..00000000000 --- a/docs/nips/NIP-FI-DELEG.md +++ /dev/null @@ -1,186 +0,0 @@ -NIP-FI-DELEG -============ - -Delegated agent authorization profile --------------------------------------- - -`draft` `optional` `relay` - -**Protocol dependency**: NIP-FI core. - -The key words "MUST", "MUST NOT", "REQUIRED", "SHOULD", "SHOULD NOT", and -"MAY" in this document are to be interpreted as described in BCP 14 (RFC 2119 -and RFC 8174) when, and only when, they appear in all capitals. - -## Scope - -This profile authorizes a delegate key from separately validated delegation -evidence rooted in a currently eligible NIP-FI owner binding. The delegate -proves its own key. It does not present a federated assertion and never receives -or inherits the owner's binding. Because a trusted edge inserts assertion and -provenance fields on every request it forwards, and `FI-DELEG-PATH-SEPARATION` -denies any such field on a delegated request, delegated requests cannot traverse -a route that requires edge provenance; they use ingress on which NIP-FI-EDGE is -not required. - -This profile defines the normalized delegation result and its additional -preparation, final-admission, and lease witnesses. It does not define a wire -format for creating delegation relationships; NIP-OA or another protocol may -supply the evidence if it satisfies this contract. - -## Delegation evidence - -A validator returns this closed result: - -```text -DelegationEvidence = ( - domain, - owner_key, - delegate_key, - relationship_id, - relationship_revision, - audience, - operations, - conditions, - resource_or_target, - not_before?, - mandatory_expiry -) -``` - -`relationship_id` and `relationship_revision` are deployment-local dependency -identifiers. All other fields are interoperability-critical in meaning even -when their concrete encoding belongs to the supplying delegation protocol. - -The evidence authenticates every field, has one unambiguous owner and delegate, -matches the server-owned domain and exact request or target, and has a finite -expiry satisfying `now < mandatory_expiry`; equality at an expiry is expired. -Optional `not_before` satisfies `not_before <= now + skew`, using the -configured delegated `skew`; arithmetic is overflow-safe. A missing -configured `skew` denies. The proven actor equals `delegate_key`. -[FI-DELEG-EVIDENCE-CLOSED] - -A delegated request carries fresh request-appropriate Nostr proof and no -`Nostr-Federated-Identity` or profile provenance field. Mixed direct and -delegated evidence denies rather than selecting a path. [FI-DELEG-PATH-SEPARATION] - -## Private denial conditions - -This profile defines exactly this private condition identifier and owning public -class for NIP-FI-CONF enumeration agreement: - -| Private condition identifier | Public class | -|---|---| -| `delegation_not_current` | `authorization_denied` | - -The identifier is a fixture name, not a wire value. Adding, removing, renaming, -or reclassifying it requires the same change in NIP-FI-CONF's denial-fixture -table. - -## Preparation - -Preparation resolves the exact server-owned domain, target context, operation, -resource, and proven delegate actor before validating delegation evidence. It -then atomically reads: - -- the active owner binding and exact binding version; -- every owner tombstone, key-revocation, administrative, and profile lifecycle - gate applicable to that binding; -- the exact relationship identifier and revision; -- current local policy and resource versions; and -- every invalidation dependency and deadline. - -The owner binding is current and authorization-eligible at preparation. A -cached owner lease is not authority. The requested capability is the -intersection of the delegation's operation, audience, conditions, and target -with current local policy; an unsupported operation or empty intersection -denies. [FI-DELEG-OWNER-CURRENT] - -Preparation remains read-only under `FI-INV-08`. It cannot create or change an -owner or delegate binding, identity, provenance, lifecycle fact, relationship, -last-seen value, replay claim, receipt, lease, or application effect. -[FI-DELEG-NO-BINDING] - -## Final admission - -Core final admission additionally requires: - -1. the exact delegation evidence and delegate proof remain live; -2. domain, actor, target, audience, operation, resource, and relationship match - the prepared value; -3. the exact current owner binding and binding version remain eligible; -4. relationship identity and revision remain current; -5. current capability intersection equals the prepared intersection; and -6. changed dependencies are reread and the complete delegated decision is - recomputed before atomic commit. - -Any mismatch, expiry, owner retirement, owner key revocation, owner binding -version change, relationship change, unreadable dependency, or unsupported -capability denies. Rotation makes the former owner key non-current; its -relationships do not transfer to the new key. [FI-DELEG-OWNER-CURRENT] - -The delegated path creates no owner or delegate binding and cannot consume an -enrollment opportunity. Its receipt identifies the delegate actor and exact -owner-binding and relationship dependencies without publishing identity -material. [FI-DELEG-NO-BINDING] - -## Delegated leases - -A deployment configures a positive finite delegated maximum and a non-negative -finite delegated `skew`. The lease deadline is no later than the minimum of: - -- delegation expiry; -- delegate proof or connection bound; -- owner binding administrative bound, when applicable; -- current relationship bound; -- local policy bound; -- the lease issue instant plus the configured delegated maximum; and -- any stronger owner-assertion bound the deployment requires. - -Missing finite configuration denies. Equality is expired and arithmetic is -overflow-safe. [FI-DELEG-LEASE-BOUND] - -Before each protected use, the service checks the delegate actor, owner binding -and version, relationship and revision, capability intersection, target, -resource, local policy, deadline, and invalidation state. It closes or rejects -the lease within the deployment's tested revocation-detection bound after any -owner or relationship dependency becomes ineligible. The claimed bound is no -smaller than measured worst-case detection plus enforcement delay. -[FI-DELEG-INVALIDATION-BOUND] - -Owner retirement, revocation, rotation, disablement under NIP-FI-LIFECYCLE, or -binding replacement invalidates dependent delegates on the same effective -schedule as owner authority. A delegate lease never authorizes another delegate -or owner key on the same connection. [FI-DELEG-OWNER-CURRENT] - -## Discovery - -A relay claiming this profile MAY add `"delegation": true` to the NIP-11 -`federated_identity` object only when owner-current resolution, the positive -finite maximum, uniform final admission, and all profile oracles are active. It -does not advertise relationship IDs, owner keys, private delegation protocol -names, or policy detail. [FI-DELEG-DISCOVERY] - -## Behavioral oracles - -| ID | Required outcome | -|---|---| -| `FI-DELEG-EVIDENCE-CLOSED` | Valid closed evidence passes; unauthenticated, ambiguous, wrong-domain/actor/target/audience, not-yet-valid, and expiry-equality variants deny. | -| `FI-DELEG-PATH-SEPARATION` | Delegation plus any direct assertion/provenance field denies; neither path falls back to the other. | -| `FI-DELEG-OWNER-CURRENT` | Exact current owner succeeds; retirement, revocation, rotation, replacement, stale owner version, stale relationship, and unreadable owner state deny without inheritance. | -| `FI-DELEG-NO-BINDING` | Successful, denied, and concurrent delegated requests create or change no owner/delegate binding or lifecycle state. | -| `FI-DELEG-LEASE-BOUND` | Every authority bound and equality boundary closes the lease; absent finite maximum denies. | -| `FI-DELEG-INVALIDATION-BOUND` | Measured owner/relationship revocation closes prepared evidence and live leases within the claimed detection bound. | -| `FI-DELEG-DISCOVERY` | Discovery is false/absent until the complete active profile passes; public output contains no relationship or owner detail. | - -NIP-FI-CONF defines evidence packaging and mutation adequacy. Each uppercase -requirement above names the oracle that detects its violation. - -## Security considerations - -Delegation expands authority only by intersection and never by copying owner -capabilities. A stolen delegation still requires the delegate key. A stolen -delegate key is bounded by the relationship and finite lease. Owner rotation -cannot silently transfer delegation because the exact owner key and binding -version are dependencies. Implementations should invalidate by dependency index -rather than wait for incidental delegate traffic. diff --git a/docs/nips/NIP-FI-EDGE.md b/docs/nips/NIP-FI-EDGE.md deleted file mode 100644 index 1f54d36e37d..00000000000 --- a/docs/nips/NIP-FI-EDGE.md +++ /dev/null @@ -1,417 +0,0 @@ -# NIP-FI-EDGE: Trusted Edge Profile - -`draft` `optional` - -## Scope - -This profile lets a trusted enterprise edge deliver federated assertion evidence to -a NIP-FI verifier. It defines two constructions: - -- `trusted-proxy-hmac-v2`, a portable request-bound HMAC envelope; and -- a private authenticated-edge assertion adapter, for platforms that provide an - equivalent closed trust boundary without the stock envelope. - -NIP-FI-EDGE is optional. A deployment can implement NIP-FI core using only -`client-attached`. Claiming this profile does not weaken core assertion validation, -independent Nostr proof, binding, lifecycle, policy, final-admission, or lease rules. -The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, -**SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **NOT RECOMMENDED**, **MAY**, and -**OPTIONAL** are to be interpreted as described in BCP 14 when, and only when, -they appear in all capitals. - -Every identifier this document serializes on the wire — header names, the -profile identifier, provenance envelope fields, and proof transport codes — is -interoperability-critical. `transport_contract_id` remains deployment-local as -core classifies it: its value is opaque outside a deployment, while the -canonical contract semantics this profile contributes to it are normative and -fixed here. Local adapter revision identifiers and key identifiers are -deployment-local and MUST NOT appear in public discovery. - -## Common trusted-edge requirements - -Server-owned listener, route, and authorization-domain configuration selects exactly -one edge profile before protected traffic is accepted. Request evidence cannot -select, negotiate, or downgrade that profile. Missing, repeated, comma-combined, -malformed, oversized, mixed-profile, or profile-inconsistent evidence denies without -fallback to `client-attached` or another edge profile. - -Every trusted edge MUST: - -1. strip every inbound copy of each `Nostr-Federated-Identity`, - `Nostr-Federated-Identity-Provenance`, and `Nostr-Federated-Identity-Client-Peer` - field, and of every other edge profile's assertion, identity, capability, - provenance, and client-peer field, before inserting its own fields. A trusted - edge MUST NOT remove or modify the `Authorization` field, which remains reserved - for the independent NIP-98 proof and MUST reach final admission unmodified; -2. cryptographically authenticate the immediate edge to the accepting origin and - isolate the origin from direct or alternate ingress; -3. integrity-protect every request component used by authorization other than - an independent Nostr proof, which is protected by its own signature; -4. apply a positive finite provenance deadline that is included in final admission - and every resulting lease; -5. validate a closed upstream identity and authorization claim set and produce the - same normalized assertion result required by core; -6. preserve the server-resolved domain, operation, resource, method, authority, - path/query, body semantics, proof transport, and Nostr actor key through final - admission; and -7. keep assertions, credentials, signatures, MACs, raw client addresses, and private - claims out of URLs, public protocol output, logs, metrics, and traces. - -Header presence, source address, private-network location, hostname, or reachability -alone is not provenance. Accepting unsigned identity or capability headers, or -accepting signed headers without authenticating and isolating the immediate caller, -is nonconformant. A trusted edge that strips, rewrites, or reorders the -`Authorization` field, or that admits a proof-transport-`0x02` request whose -`Authorization` field did not arrive at the verifier byte-identical to the -client-sent value, is nonconformant. - -An adapter's reviewed contract MUST identify its accepting origins, direct-origin -controls, field-stripping point, immediate-caller authentication, protected request -components, upstream assertion and policy validation, freshness bounds, independent -Nostr-proof path, compromise impact, and conformance evidence. It MUST deny when any -part of this boundary is absent or unreadable. - -### Authenticated-edge assertion adapters - -A deployment MAY install a private authenticated-edge adapter instead of HMAC-v2. -The adapter MUST satisfy all common requirements and demonstrate together: -origin isolation, cryptographically authenticated immediate caller, inbound-field -stripping, integrity of the complete authorization-relevant request, bounded -assertion and policy freshness, no direct-origin fallback, and the core final- -admission path with independent Nostr proof. - -The adapter maps only its closed, validated claim set into the normalized result. -An opaque edge token is acceptable only inside this complete contract; opacity does -not make an unchecked header authoritative. Vendor names, issuer details, caller -identities, private field names, capability semantics, and adapter identifiers MUST -NOT appear in NIP-11 or portable examples. - -## `trusted-proxy-hmac-v2` - -The stock profile identifier is `trusted-proxy-hmac-v2`. Core computes the -`transport_contract_id` from a canonical contract that includes this profile's exact -wire format, protected components, replay rules, deadline rules, configured code -meanings, and adapter semantics. Changing any of those inputs produces a different -contract identity; the profile identifier itself remains stable. The proxy strips -all inbound assertion, provenance, and client-peer fields and inserts exactly one of -each: - -```text -Nostr-Federated-Identity: Bearer -Nostr-Federated-Identity-Provenance: v2... -Nostr-Federated-Identity-Client-Peer: -``` - -The assertion field follows core's compact-JWS and size rules. `timestamp` is -canonical unsigned decimal without leading zeroes, except zero is `0`. `nonce` and -`mac` are canonical unpadded base64url. Padding, the standard base64 alphabet, -ignored whitespace, or another encoding denies. The proxy generates a fresh nonce -containing at least 128 bits from a cryptographically secure random source. The -decoded MAC is exactly 32 octets. Finite field and decoded-nonce maxima are applied -before decoding, replay lookup, hashing, or allocation. - -`client-peer` is at most 64 ASCII octets. IPv4 uses dotted decimal with no leading -zeroes. IPv6 uses lowercase RFC 5952 text. The edge converts an observed IPv4-mapped -IPv6 address to canonical IPv4 before constructing the field; a textual mapped IPv6 -field is noncanonical. Empty, repeated, comma-combined, whitespace-padded, non-IP, -or noncanonical values deny. After verification, the verifier MAY retain only a -domain-separated keyed digest of this value in bounded private state. - -The profile uses HMAC-SHA-256 with a deployment secret containing at least 256 bits. -Let `LP(x) = uint64be(len(x)) || x`, where length is in octets. The literal prefix is -14 ASCII octets and is not length-prefixed. The pre-MAC input is exactly: - -```text -"NIP-FI-PROXY-2" || -LP(timestamp_u64be) || LP(nonce_bytes) || LP(SHA256(jwt_ascii)) || -LP(authorization_domain_id) || -LP(method_ascii) || LP(authority_ascii) || LP(path_and_query_ascii) || -LP(SHA256(payload_octets)) || LP(proof_transport_octet) || LP(client_peer_ascii) -``` - -`mac = HMAC-SHA-256(secret, pre_mac_input)`. The transmitted `mac` is canonical -unpadded base64url of the raw 32-octet result. The verifier compares it in constant -time. - -### Canonical components - -- **Timestamp:** Parse canonical decimal into an unsigned 64-bit integer, rejecting - overflow, then serialize it as exactly eight-byte big-endian. Freshness checks are - separate from serialization. -- **Nonce:** Decode the exact canonical base64url field before serialization. -- **Assertion:** Hash the exact ASCII compact-JWS octets after the one space in - `Bearer `. No whitespace, Unicode, JSON, or base64 normalization is allowed. -- **Authorization domain:** Configuration contains a canonical lowercase, - hyphenated RFC 9562 UUID named `authorization_domain_uuid`. Parse its 32 displayed - hexadecimal digits into the exact 16 UUID octets in display/network order. For - example, `00112233-4455-6677-8899-aabbccddeeff` becomes - `00112233445566778899aabbccddeeff`. UTF-8 UUID text, hashing, truncation, - namespace derivation, mixed-endian GUID encoding, uppercase, and unhyphenated - configuration are forbidden. The UUID is generated once, is immutable for the - domain's lifetime, and is shared through authenticated proxy/verifier - configuration. Duplicate UUIDs among active domains MUST fail startup. -- **Method:** Use the exact uppercase ASCII method token after trusted route - resolution. Lowercase or noncanonical input denies; the verifier does not repair it. -- **Authority:** Use server-configured lowercase ASCII host plus explicit effective - decimal port. IPv6 uses brackets and RFC 5952. Userinfo, a trailing dot, an omitted - port, percent encoding, or an authority derived solely from `Host`, `Forwarded`, or - `X-Forwarded-Host` denies. -- **Path and query:** Use the exact post-rewrite ASCII origin-form. Empty path becomes - `/`; a present query includes `?`. Percent octets and hex case, an empty query, - repeated names, and parameter order are preserved. No decoding, sorting, - dot-segment removal, or re-encoding may occur after the edge snapshot. An - unaccounted rewrite denies. -- **Payload:** Hash the complete HTTP payload octets after transfer-coding removal and - before content-coding decompression. These are exactly the octets forwarded by the - edge and exposed to verification. HTTP framing, chunk delimiters, and trailers are - excluded; `Content-Encoding` is not decoded. A WebSocket upgrade uses the empty - payload. Substitution of the protected octets after the snapshot denies. -- **Proof transport:** Serialize exactly one assigned octet from the registry below. -- **Client peer:** Serialize the exact canonical ASCII field value. - -No authorization decision, target, resource, capability, or effect selector -derives from any request or connection component outside the protected pre-MAC -components, except an independent Nostr proof validated on its own signature, -such as the NIP-98 event in `Authorization` or the NIP-42 event after connect, -which the MAC does not protect; body interpretation follows the server-resolved -body semantics, never unprotected transport metadata such as `Content-Type` or -`Content-Encoding`. - -### Freshness, replay, and key rotation - -The deployment configures a positive finite `maximum_provenance_age` and a -non-negative finite `future_skew`. Evidence is live exactly when, using overflow-safe -comparisons: - -```text -timestamp <= now + future_skew -now < timestamp + maximum_provenance_age -``` - -Equality at the age bound is expired. A direct lease deadline is no later than -`timestamp + maximum_provenance_age` and every core assertion, proof, policy, and -state deadline. - -Absent, malformed, stale, future-dated, wrong-key, or mismatched provenance denies. -On a route that requires edge provenance, absent or incomplete provenance — -including provenance that omits the proxy-authenticated end-client peer — maps -to the `missing_evidence` public class, regardless of whether an assertion is -present. Provenance that is present and complete but fails verification maps to -`evidence_rejected`. -A v1 envelope denies. A verifier MAY try only a configured finite set of active -secrets. Rotation does not change nonce identity: replay uniqueness is scoped to -`(authorization_domain_id, trusted-proxy-hmac-v2, nonce)` and is independent of the -secret that verifies the MAC. A committed nonce is retained through at least -`timestamp + maximum_provenance_age`. - -Preparation consumes neither nonce nor Nostr-proof replay identity. Final admission -atomically consumes both with any enrollment, receipt, and authorization decision. -A failed or rolled-back admission consumes neither. Two concurrent admissions with -the same nonce commit at most one authorization. The proxy-to-verifier hop still -requires confidentiality and integrity. - -## Proof-transport code registry - -| Code | Meaning and allocation policy | -|---|---| -| `0x00` | Invalid; MUST deny. | -| `0x01` | NIP-42 connection proof. | -| `0x02` | NIP-98 HTTP proof. | -| `0x03` | Git smart-HTTP session proof: the proxy verifies a session-scoped Nostr authorization for a Git smart-HTTP request before forwarding. Reserved; allocation completes on publication of its transport contract (see below). | -| `0x04` | Blossom media proof: the proxy verifies a Blossom media-HTTP authorization event for the request before forwarding. Reserved; allocation completes on publication of its transport contract (see below). | -| `0x05`–`0x7f` | Unassigned; allocation requires a published stable specification. | -| `0x80`–`0xfe` | Private use under an explicit shared proxy/verifier contract only. | -| `0xff` | Reserved for a future extended encoding; invalid in HMAC-v2. | - -An allocation MUST define exact proof validation, request binding, freshness, replay -identity and window, and conformance vectors. Assigned semantics never change; an -incompatible meaning receives a new code. Unknown, unconfigured, or private-use -codes without the same configured contract at proxy and verifier deny. Private-use -codes MUST NOT be advertised as portable NIP-FI-EDGE interoperability. - -Codes `0x03` and `0x04` are reserved to fix their meanings and prevent -reassignment; their transport contracts are not yet published, so their -allocations are not complete. Until the contract for such a code is published, -the code is valid only under an explicit shared proxy/verifier contract, -exactly as for private use, and MUST NOT be presented as portable NIP-FI-EDGE -interoperability. - -## Bounded payload acquisition - -Every protected `(authorization_domain_id, route, proof_transport_code)` tuple MUST -configure a finite `maximum_payload_octets` and finite per-request -`maximum_spool_octets >= maximum_payload_octets`. Zero is allowed only for a route -that requires an empty payload. Proxy and verifier configuration MUST agree and is -part of the transport contract. - -If trusted `Content-Length` exceeds the route limit, the edge denies before reading, -hashing, JWT verification, replay lookup, or authoritative mutation. For absent, -unknown, or streamed length, acquisition uses a bounded counter and spool and stops -on octet `limit + 1`. Incremental SHA-256 is allowed, but no digest or prefix can -authorize until EOF proves completeness. - -Spooling uses memory or access-controlled temporary storage with finite per-request -and aggregate quotas, cleanup on every outcome, no public or log output, and no reuse -across requests. Quota exhaustion fails closed and creates no nonce claim, proof -claim, receipt, lease, or application mutation. At or below the limit, the exact -captured payload is replayed unchanged. HMAC verification and core final admission -complete before application effects. Forwarding to a rollback-safe private spool is -not an application effect; forwarding to a parser, decoder, handler, or origin that -can act is. - -A content decoder, multipart parser, Git/Blossom handler, framework, or intermediary -that cannot expose and replay the exact stage defined above before effects cannot -claim HMAC-v2 for that route. It MUST use core `client-attached` or another specified -edge profile, never a partial-body MAC. - -## Normative HMAC-v2 vectors - -All vector integers and lengths are big-endian. Common values are: - -```text -secret_hex = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f -nonce_hex = 000102030405060708090a0b0c0d0e0f -nonce_base64url = AAECAwQFBgcICQoLDA0ODw -authorization_domain_uuid = 00112233-4455-6677-8899-aabbccddeeff -authorization_domain_id_hex = 00112233445566778899aabbccddeeff -jwt_ascii = eyJhbGciOiJFUzI1NiIsInR5cCI6Im5pcC1maStqd3QifQ.eyJpc3MiOiJodHRwczovL2lkLmV4YW1wbGUiLCJzdWIiOiIxMjMifQ.c2ln -assertion_digest_hex = 6103b52a52730bc065d65673247603a63c9810488c90d0ada3d8d227eee5285f -``` - -The fixture JWT represents a separately minted `nip-fi+jwt` assertion and is opaque -test input; its deliberately synthetic signature is not an assertion-validation -vector. Implementations MUST reproduce each field, complete pre-MAC input, -diagnostic input digest, raw MAC, and wire MAC exactly -(`FI-TRACE-EDGE-VECTORS`). - -### Vector 1: HTTP / NIP-98 / non-empty payload - -```text -timestamp_decimal = 1700000000 -timestamp_u64be_hex = 000000006553f100 -method_ascii = POST -authority_ascii = api.example:443 -path_and_query_ascii = /upload?part=1&part=2&x=%2F -payload_hex = 68656c6c6f0a -body_digest_hex = 5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03 -proof_transport_hex = 02 -client_peer_ascii = 203.0.113.9 -pre_mac_input_hex = 4e49502d46492d50524f58592d320000000000000008000000006553f1000000000000000010000102030405060708090a0b0c0d0e0f00000000000000206103b52a52730bc065d65673247603a63c9810488c90d0ada3d8d227eee5285f000000000000001000112233445566778899aabbccddeeff0000000000000004504f5354000000000000000f6170692e6578616d706c653a343433000000000000001b2f75706c6f61643f706172743d3126706172743d3226783d25324600000000000000205891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03000000000000000102000000000000000b3230332e302e3131332e39 -pre_mac_input_sha256 = df2870230d2170595dccd17d9e61a82282d8cd8b978ac18bff07419ed59091d5 -mac_hex = 761d3ecbf609f0f558b4a02a1a18a25070f3dbe89fce9cac59a80bce4436ade5 -mac_base64url = dh0-y_YJ8PVYtKAqGhiiUHDz2-ifzpysWagLzkQ2reU -provenance = v2.1700000000.AAECAwQFBgcICQoLDA0ODw.dh0-y_YJ8PVYtKAqGhiiUHDz2-ifzpysWagLzkQ2reU -``` - -### Vector 2: WebSocket / NIP-42 / empty payload / mapped peer - -The edge observed `::ffff:192.0.2.128` and emitted canonical `192.0.2.128`. - -```text -timestamp_decimal = 1 -timestamp_u64be_hex = 0000000000000001 -method_ascii = GET -authority_ascii = relay.example:443 -path_and_query_ascii = / -payload_hex = -body_digest_hex = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -proof_transport_hex = 01 -client_peer_ascii = 192.0.2.128 -pre_mac_input_hex = 4e49502d46492d50524f58592d32000000000000000800000000000000010000000000000010000102030405060708090a0b0c0d0e0f00000000000000206103b52a52730bc065d65673247603a63c9810488c90d0ada3d8d227eee5285f000000000000001000112233445566778899aabbccddeeff0000000000000003474554000000000000001172656c61792e6578616d706c653a34343300000000000000012f0000000000000020e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855000000000000000101000000000000000b3139322e302e322e313238 -pre_mac_input_sha256 = 67564d241499491b3ea53b31d6111fbc9efac37a294f6ce591519e4bf21b53e9 -mac_hex = f71a179a018637a0582cf3de39ccb7b976216c18ada312127d4c983c14af4b20 -mac_base64url = 9xoXmgGGN6BYLPPeOcy3uXYhbBitoxISfUyYPBSvSyA -``` - -### Vector 3: IPv6 authority and path/query byte preservation - -```text -timestamp_decimal = 1700000000 -timestamp_u64be_hex = 000000006553f100 -method_ascii = GET -authority_ascii = [2001:db8::1]:443 -path_and_query_ascii = /a%2Fb?b=2&a=1&a=0 -payload_hex = -body_digest_hex = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -proof_transport_hex = 02 -client_peer_ascii = 2001:db8::2 -pre_mac_input_hex = 4e49502d46492d50524f58592d320000000000000008000000006553f1000000000000000010000102030405060708090a0b0c0d0e0f00000000000000206103b52a52730bc065d65673247603a63c9810488c90d0ada3d8d227eee5285f000000000000001000112233445566778899aabbccddeeff000000000000000347455400000000000000115b323030313a6462383a3a315d3a34343300000000000000122f61253246623f623d3226613d3126613d300000000000000020e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855000000000000000102000000000000000b323030313a6462383a3a32 -pre_mac_input_sha256 = 8a93a29c4ac30b0f2551d346d0636040b639bb1f109d287e93ce44ddaed73e33 -mac_hex = df2936f81d752f3d6bac2a36d3381c38db2c9abc3570236cb121274ad34a6161 -mac_base64url = 3yk2-B11Lz1rrCo20zgcONssmrw1cCNssSEnStNKYWE -``` - -### Serialization and negative matrix - -The following timestamp values MUST serialize as shown before freshness evaluation: - -| Decimal | `uint64be` hex | -|---:|---| -| `0` | `0000000000000000` | -| `1` | `0000000000000001` | -| `255` | `00000000000000ff` | -| `256` | `0000000000000100` | -| `18446744073709551615` | `ffffffffffffffff` | - -`00`, `01`, `+1`, surrounding whitespace, negative values, and -`18446744073709551616` deny before MAC comparison. The maximum value above is an -encoding vector; ordinary freshness policy will reject it. - -Every implementation MUST run these normative negative cases: - -| Class | Required cases and result | -|---|---| -| Envelope | Absent/repeated/comma-combined fields, `v1`, missing/extra component, padding, alternate alphabet, nonce below 16 octets or above configured max, and MAC lengths 31 or 33 all deny. | -| Domain | Uppercase/nonhyphenated UUID config fails configuration; mixed-endian UUID bytes or any one-bit domain transplant fails the baseline MAC; duplicate active UUID fails startup. | -| Request | Mutating assertion, method, authority, path/query, body, proof code, or peer while retaining Vector 1's MAC denies. | -| Metadata | Mutating `Content-Type` or `Content-Encoding` in flight changes no authorization decision, target, capability, or effect selector; a request whose server-resolved body semantics no longer hold denies. | -| Path | `%2F`→`%2f`, decoding to `/`, reordering repeated query values, or adding/removing an empty `?` fails the baseline MAC. | -| Authority | Unbracketed or non-RFC-5952 IPv6, uppercase host, trailing dot, or missing port denies before MAC comparison. | -| Peer | Textual `::ffff:192.0.2.128`, padded IPv4, uppercase/noncanonical IPv6, or whitespace denies before MAC comparison. | -| Proof | `0x00`, `0xff`, unknown stock code, or private code without a shared configured contract denies. | -| Body | Known and unknown lengths `0`, `limit-1`, and `limit` may proceed only after EOF; `limit+1`, disconnect before EOF, aggregate-quota exhaustion, or any post-snapshot substitution of the protected octets denies with no replay or authoritative mutation. | -| Replay | Concurrent final admissions of one valid envelope commit at most one; preparation and failed final admission consume none; secret rotation does not create a new nonce namespace. | -| Fallback | Direct ingress, mixed evidence, and failed HMAC never retry as `client-attached` or another adapter. | - -## Discovery and conformance - -A relay that completely implements the stock profile MAY add exactly -`"edge_transports": ["trusted-proxy-hmac-v2"]` inside the top-level NIP-11 -`federated_identity` object. `edge_transports` is an array of unique ASCII string -profile identifiers in ascending bytewise order; this document assigns only the -single value shown. A relay that does not completely implement the stock profile -MUST omit the member. It MUST NOT advertise private adapters, keys, domains, field -names, or code contracts. No request may select behavior from this discovery -member; server-owned configuration selects the edge profile. Claiming FI-EDGE -requires every configured edge profile to pass the applicable core conformance suite -and these profile traces: - -| Trace | Required oracle | -|---|---| -| `FI-TRACE-EDGE-VECTORS` | Reproduce all three normative vectors field-for-field, including each complete pre-MAC input, diagnostic input digest, raw MAC, and wire MAC; reproduce all five timestamp serialization rows; every listed serialization and negative-matrix case produces its required denial or configuration failure. | -| `FI-TRACE-PROXY-SPOOF` | Direct ingress, unsigned/header-only identity, unauthenticated caller, or invalid provenance denies without fallback. | -| `FI-TRACE-PROXY-REPLAY` | Two HMAC-v2 final admissions using one nonce commit at most one; preparation consumes neither. A private adapter proves its declared replay semantics. | -| `FI-TRACE-PROXY-CROSS-REQUEST` | Each protected component mutation denies. HMAC-v2 covers assertion, domain, method, authority, path/query, complete body, proof transport, and peer. On a `0x02` route the `Authorization` bytes at final admission equal the client-sent bytes, witnessed at both points; an edge that substitutes a valid proof from the same actor fails the witness. | -| `FI-TRACE-EDGE-BODY-BOUNDS` | Known and streamed boundary cases prove bounded work/storage, EOF completeness, cleanup, and no pre-authorization effect. | -| `FI-TRACE-EDGE-KEY-ROTATION` | A finite active-key set accepts an intended overlap without allowing nonce reuse or an unknown key. | - -The conformance record binds the exact implementation, adapter, deployment, -assertion policy, transport contract, configured code meanings, and vector revision. -Two HMAC-v2 implementations interoperate only when they reproduce all valid vector -bytes exactly, reject every negative, agree on UUID and code configuration, and -preserve atomic replay and bounded complete-body behavior. - -## Security considerations - -HMAC-v2 limits header spoofing, replay, and cross-request transplantation only when -its secret remains confidential, the edge snapshots the final routed request, the -origin authenticates that edge, and final admission atomically consumes replay state. -It does not replace TLS or independent Nostr proof. A compromised edge or shared -secret can forge federated evidence within its configured domains; use distinct -secrets and UUIDs to limit blast radius. - -Authenticated-edge adapters intentionally shift more proof to deployment controls. -A hostname, private network, or opaque token is not an equivalent construction unless -the complete boundary obligations above are demonstrated. Body buffering and replay -state are attacker-controlled resource surfaces, so all field, payload, spool, -aggregate, key-set, and retention bounds fail closed. diff --git a/docs/nips/NIP-FI-LIFECYCLE.md b/docs/nips/NIP-FI-LIFECYCLE.md deleted file mode 100644 index fdd08d4dfb3..00000000000 --- a/docs/nips/NIP-FI-LIFECYCLE.md +++ /dev/null @@ -1,269 +0,0 @@ -# NIP-FI-LIFECYCLE: Binding Lifecycle Profile - -`draft` `optional` - -## Abstract - -This profile extends NIP-FI with provisioned enrollment, identity disablement, -re-enablement, and an administrative binding-expiry gate. It is for deployments -whose binding changes require separately authorized operator or enterprise -workflows. It does not change NIP-FI assertion validation, Nostr proof, final -admission, or public denial semantics. - -The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, -**SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this -document are to be interpreted as described in BCP 14 when, and only when, they -appear in all capitals as shown here. - -## Dependencies and claim - -An implementation of this profile implements NIP-FI Core and advertises only -the boolean `"lifecycle": true` inside its NIP-11 `federated_identity` object. -This boolean claims support for this profile; it deliberately reveals neither an -enrollment mode nor lifecycle state. For a fixed set of claimed profiles, the -complete discovery output MUST be byte-identical whether enrollment is -attested-key, TOFU, or provisioned and whether lifecycle facts exist. A server -MUST NOT advertise the claim until every protected ingress in the advertised -authorization domain applies this profile through the same final-admission -authority (`FI-LC-CLAIM`). - -This profile contributes lifecycle dependencies and deadlines to the core -prepared decision and lease. They compose with core dependencies by set union; -the earliest applicable deadline wins. This profile cannot weaken, replace, or -bypass a core check. - -## Additional state - -For authorization domain `D`, this profile adds: - -```text -X_D : set of disabled identities -Q_D : identity -> pending lineage - -PendingLineage = ( - identity, - old_key, - old_binding_version -) -``` - -It also permits a core binding to carry `binding_not_after`, an optional -administrative deadline. The pending lineage names one exact retired pair and -binding version. There is at most one pending lineage per identity. - -`binding_not_after` bounds the grant represented by **one binding**; it is not a -bound on the identity. Rotation continues the same grant: the replacement binding -preserves the carried bound, and only expiry authority changes it. Retirement, -revocation, and disablement end the existing grant. Re-enablement, provisioning, -and ordinary enrollment establish a *new* grant that carries no prior bound — so -retirement of a bound pair followed by ordinary enrollment under `attested-key` -or `tofu` policy yields an unbounded binding, and that is conformant. A deadline that must survive the end -of a grant — an identity-scoped access bound — belongs in the capability -projection of authoritative local-policy state, which core already requires for -any projection whose removal must close authority within a declared bound and -which is reread at preparation, final admission, and every protected lease use. -`binding_not_after` is not that mechanism and cannot substitute for it: it does -not survive the end of the grant that carries it, so a deployment relying on it -for identity-scoped expiry cannot claim a revocation bound for that expiry -(`FI-LC-ADMIN-EXPIRY`). - -A binding carrying a reached bound is **active** for every core relation and -eligibility test in this profile and in core, including the core partial -bijection and `TargetEligible` below. It is ineligible for authorization, not -absent from the binding relation. - -`X_D`, `Q_D`, and `binding_not_after` are deployment-local state. Their versions -are revalidation dependencies, not contract identities. A change invalidates a -prepared decision and every dependent lease unless complete final-admission -recomputation produces the required current result. - -Ordinary authorization MUST deny when its identity is disabled, when pending -lineage exists for that identity, or when `now >= binding_not_after`; it MUST -NOT clear, consume, or alter any of those facts (`FI-LC-ORDINARY-GATES`). An -absent `binding_not_after` has no administrative expiry. Assertion `exp`, -`iat`, refresh, or maximum age never creates, renews, extends, or clears it. -Time passage alone creates no tombstone, lineage, or history. - -## Private denial conditions - -This profile defines exactly these private condition identifiers and owning -public classes for NIP-FI-CONF enumeration agreement: - -| Private condition identifier | Public class | -|---|---| -| `identity_disabled` | `authorization_denied` | -| `explicit_replacement_required` | `authorization_denied` | -| `binding_expired` | `authorization_denied` | - -The identifiers are fixture names, not wire values. Adding, removing, renaming, -or reclassifying one requires the same change in NIP-FI-CONF's denial-fixture -table. - -## Common transition contract - -Each transition below requires privileged authority distinct from an ordinary -federated assertion and Nostr proof. That authority MUST be bound to the exact -`D`, transition name, identity, request, old binding version when present, and -target key when present (`FI-LC-AUTHORITY`). The deployment defines how that -authority is obtained; role names, approval count, and operator APIs are out of -scope. - -A transition MUST, in one atomic commit: - -1. validate that privileged authority and fresh target-key evidence; -2. read and recheck the applicable core binding relation, retired pairs, - revoked keys, `X_D`, `Q_D`, policy, and dependency versions; -3. apply exactly the state changes specified below; -4. append immutable lifecycle history identifying the transition and versions; - and -5. advance lifecycle state so dependent prepared decisions and leases cannot - authorize after commit. - -A stale precondition, denied transition, unreadable dependency, or failed commit -MUST leave all authoritative state unchanged (`FI-LC-ATOMIC`). Lease -invalidation MAY be delivered asynchronously, but authorization use after the -commit MUST recheck the advanced dependency before allowing an operation. - -Every transition that creates a binding MUST state whether it **continues** an -existing grant, and therefore preserves that grant's administrative bound, or -**establishes** a new grant carrying no prior bound. The two cases partition the -binding-creating transitions with no remainder: core rotation continues, and -provisioning, re-enablement, and ordinary enrollment establish. A profile that -adds a binding-creating transition without this declaration cannot claim -conformance (`FI-LC-CLAIM`). - -`TargetEligible(i, k, allow_disabled)` means that `k` is not revoked, `(i, k)` -is not retired, neither `i` nor `k` has an active binding, and `i` is not -disabled unless `allow_disabled` is true. Every new target key requires fresh, -request-bound Nostr proof by that key. If domain policy requires issuer key -attestation, the transition also requires a current assertion for `i` whose key -claim equals `k`. Supplied stale, absent, wrong-identity, or mismatched required -attestation denies; it is never ignored as optional evidence -(`FI-LC-TARGET-PROOF`). - -A replacement binding records `attested-key` provenance only when current -matching issuer attestation was validated; otherwise it records `provisioned`. -TOFU provenance can arise only from the core ordinary first-use extension and -is never inherited by a replacement. - -## Privileged transitions - -### Provision binding - -```text -ProvisionBinding(i, k): - require domain enrollment policy = provisioned - require TargetEligible(i, k, false) - require Q_D(i) is absent - require fresh target-key evidence - create Binding(i, k, new_version, provisioned) -``` - -The transition creates no authorization lease. Later use requires a current -assertion, fresh Nostr proof, and ordinary final admission. Ordinary -request-time authorization under `provisioned` policy MUST NOT create a binding -(`FI-LC-PROVISION`). - -### Disable identity - -```text -DisableIdentity(i): - add i to X_D - if Binding(i, k, old_version) exists: - remove Binding(i, k, old_version) - add (i, k) to the core retired-pair set - set Q_D(i) = (i, k, old_version) -``` - -Applying an authorized disablement repeatedly is idempotent. It MUST NOT erase -or replace existing lineage. If `i` has no active binding, disablement creates -no lineage (`FI-LC-DISABLE`). - -### Re-enable identity - -```text -ReenableIdentity(i, expected_lineage?, k_new): - require i is in X_D - require Q_D(i) is absent when expected_lineage is absent, - otherwise require Q_D(i) = expected_lineage - require TargetEligible(i, k_new, true) - require fresh target-key evidence - remove i from X_D - consume expected_lineage when present - create Binding(i, k_new, new_version, ReplacementProvenance(evidence)) -``` - -Clearing disabled state and creating the target binding are inseparable. There -is no clear-only transition: it would permit a later ordinary enrollment to -capture the identity. An operator that intends to provision later leaves the -identity disabled until the target and fresh proof are available -(`FI-LC-REENABLE`). - -### Set administrative expiry - -```text -SetAdministrativeExpiry(i, k, old_version, binding_not_after?): - require exact current Binding(i, k, old_version) - require separate privileged expiry authority - replace it with Binding(i, k, new_version, - same_provenance, binding_not_after?) -``` - -This transition changes neither side of the pair nor its provenance. Setting, -replacing, or clearing the bound advances the binding version. At equality the -binding is ineligible but remains durable and occupies both sides of the core -partial bijection. This transition is the only expiry authority: no other -transition in this profile or in core sets, replaces, or clears the bound, and -core rotation carries it onto the replacement binding unchanged. Only this or -another applicable privileged transition can restore access; ordinary -authorization cannot renew the bound (`FI-LC-ADMIN-EXPIRY`). - -## One-shot lineage and concurrency - -Consumption of `Q_D` and creation of its replacement binding MUST be one -compare-and-commit operation over the exact pending lineage. Of two concurrent -re-enablings presenting the same lineage, at most one can commit. The loser -observes changed state and denies without creating a binding, consuming another -lineage, or changing history (`FI-LC-QD-ONCE`). - -A lifecycle transition racing ordinary final admission is ordered by the same -authoritative state transaction or dependency check. If the lifecycle commit -wins, the ordinary operation denies; if final admission wins first, the -lifecycle transition still invalidates subsequent lease use. No ordering -permits authority from a disabled identity, consumed lineage, or expired -binding after the corresponding state change is observed. - -## Behavioral oracles - -Each oracle is normative. A conforming implementation produces the stated -result at final admission and retains no partial authoritative mutation from a -denied case. - -| ID | Setup and required result | -|---|---| -| `FI-LC-CLAIM` | For a fixed profile set, compare complete discovery bytes across attested-key, TOFU, and provisioned configurations and across lifecycle states: they are identical. If one protected ingress omits lifecycle gates or uses a different lifecycle lineage, the domain cannot advertise the profile and the uncovered ingress fails closed. If any claimed profile defines a binding-creating transition that declares neither grant continuation nor grant establishment, the domain cannot advertise that profile. | -| `FI-LC-ORDINARY-GATES` | Fresh assertion and proof for a disabled identity, an identity with pending lineage, and a binding at administrative-expiry equality each deny without changing lifecycle state. | -| `FI-LC-AUTHORITY` | An ordinary assertion plus valid Nostr proof, but no transition-specific authority, cannot perform any transition; mutation of any authority-bound field denies. | -| `FI-LC-ATOMIC` | Inject failure at each transition write boundary; no binding, tombstone, disabled fact, lineage, history entry, or dependency version is partially committed. | -| `FI-LC-TARGET-PROOF` | Missing, stale, wrong-key, wrong-request, or mismatched required attestation for a new target denies without mutation. | -| `FI-LC-PROVISION` | Ordinary first use in provisioned mode denies; authorized provisioning creates one binding and no lease; later current ordinary admission may use it. | -| `FI-LC-DISABLE` | Disabling an active identity atomically disables it, retires its exact pair, records exact lineage, and closes subsequent lease use; replay is idempotent and preserves lineage. | -| `FI-LC-REENABLE` | Re-enablement creates an eligible proven binding in the same commit that clears disabled state; absent or wrong expected lineage and a clear-only attempt deny. | -| `FI-LC-ADMIN-EXPIRY` | Before the bound the binding may authorize; at equality it denies while still occupying the relation, so a target eligibility test for either side of that pair fails. Rotating the expired binding to a new key carries the bound: the replacement denies at the same instant. No non-expiry transition clears it. Only an authorized version-checked update by the expiry authority changes the bound. Conversely, retirement, revocation, or re-enablement of the bound pair followed by an authorized or ordinary new grant produces an unbounded binding, which is the required result and not an escape. | -| `FI-LC-QD-ONCE` | Two concurrent re-enablings consume the same `Q_D` lineage; exactly one commits and the loser leaves every authoritative store unchanged. | -| `FI-LC-RACE` | Race each transition against prepared ordinary admission and lease use; no operation authorizes after observing the advanced lifecycle or binding dependency. | - -## Security considerations - -Privileged authority compromise can provision or replace enterprise bindings; -deployments should apply controls proportionate to that authority. This profile -makes the authority request-bound and transitions atomic, but does not define -approval UX or key custody. - -Disabled identities, retired pairs, revoked keys, and pending lineage serve -different purposes. Re-enablement removes only the exact disabled fact and -optional exact lineage named by its transition. No transition in this profile -removes a core revoked-key or retired-pair fact. - -Administrative expiry is local policy, not upstream revocation freshness. It -cannot extend an assertion, status witness, Nostr proof, or lease deadline. diff --git a/docs/nips/NIP-FI-MODEL.md b/docs/nips/NIP-FI-MODEL.md deleted file mode 100644 index 821477755cc..00000000000 --- a/docs/nips/NIP-FI-MODEL.md +++ /dev/null @@ -1,96 +0,0 @@ -NIP-FI-MODEL -============ - -Composed authorization model (non-normative) --------------------------------------------- - -This companion is explanatory. It defines no requirement, invariant, wire -value, denial mapping, or conformance claim. Normative requirements live in -[NIP-FI](NIP-FI.md) and the claimed profile documents. In particular, -`FI-INV-01` through `FI-INV-16` are defined only by NIP-FI core. - -## State sketch - -One useful implementation model keeps these authoritative relations per domain: - -```text -B_D : active identity-to-key relation -T_D : retired identity/key pairs -Y_D : revoked keys -H_D : immutable lifecycle history -V_D : binding and lifecycle versions -``` - -NIP-FI-LIFECYCLE adds disabled identities and pending replacement lineage. -NIP-FI-DELEG adds relationship state but no delegate binding. NIP-FI-EDGE adds -transport-provenance and replay witnesses. Implementations may use different -storage as long as their observable behavior satisfies the owning normative -documents. - -## Composed direct decision - -The core decision can be read as this equation: - -```text -validated issuer-qualified identity -+ fresh request/connection-bound Nostr proof -+ current durable partial-bijection state -+ current local policy -+ atomic final admission -= authority for exactly the proven key and operation -``` - -Preparation gathers immutable evidence and snapshots every dependency without -mutation. Final admission compares exact context and stable contract identities, -checks all deadlines, revalidates changed snapshots, recomputes from current -binding and policy state, then commits replay claims, optional enrollment, and a -receipt atomically. The special concurrent-enrollment normalization is narrow: -an `enroll(i,k)` proposal may become the same eligible `existing(i,k)` result; -a different winner is not equivalent. - -## Profile composition - -Profiles contribute witnesses, never alternate final authority: - -```text -core witnesses -∪ EDGE provenance/replay witnesses -∪ LIFECYCLE eligibility/lineage witnesses -∪ DELEG owner/relationship witnesses -``` - -The lease deadline is the minimum of every bound in the resulting set. A missing -or unreadable required witness denies. A profile cannot remove a core witness, -extend a core deadline, replace the proven actor, or create a second admission -lineage. - -For direct authorization, the path dependency is the normalized assertion and -its current snapshot/status witnesses. For delegated authorization, it is the -exact eligible owner binding plus relationship evidence; direct assertion fields -are absent. Both paths share context resolution, Nostr-proof validation, local -policy, read-only preparation, and atomic final admission. - -## Lifecycle intuition - -Bindings are durable; leases are ephemeral. Retirement makes one exact pair -permanently ineligible for ordinary recreation. Revocation makes a key -ineligible throughout the domain. Rotation retires the old pair and creates a -new binding version but does not globally revoke the old key. Extended lifecycle -operations may add disabled identity and one-shot pending-lineage state, as -specified by NIP-FI-LIFECYCLE. - -## Privacy intuition - -Private reasons collapse to fixed public bytes. In particular, binding -conflicts, tombstones, lifecycle gates, key mismatch, enrollment requirements, -and local-policy decisions are indistinguishable. Operational diagnostics may -retain bounded private reason codes, but such records are not protocol objects -and never become authorization witnesses. - -## Reading order - -1. NIP-FI for core state, wire behavior, invariants, and direct admission. -2. NIP-FI-EDGE for a trusted-enterprise edge. -3. NIP-FI-LIFECYCLE for provisioning, disablement, and re-enablement. -4. NIP-FI-DELEG for delegated agents. -5. NIP-FI-CONF for claim and evidence rules. From 98eebf52cf5af8585e09bdf29abfa55741d860e6 Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 1 Sep 2026 18:28:24 -0400 Subject: [PATCH 3/9] docs(nip-fi): address pass-1 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL: replace unsigned disconnect body with signed command JWT. The disconnect endpoint now requires a command JWT verified against the same per-issuer JWKS surface as client assertions. The JWT carries iss, sub, aud, iat, exp, jti (replay guard), cmd='disconnect', and target_pubkey. The relay verifies the signature, asserts cmd and target_pubkey match the request body, checks jti replay, and confirms the caller is an authorized adapter principal — all fail-closed. IMPORTANT (time bounds): separate required JWT claims (iat, exp) from policy knobs (maximum_assertion_age required positive finite; skew non-negative finite with default 0; nbf optional). IMPORTANT (key binding honesty): document that require_attested_key in IssuerPolicy is the enforcement primitive and that NIP-FI v2 conformance requires startup validation forcing it true — a follow-on code change. Remove the 'complete implementation surface' claim from the spec text. IMPORTANT (at+jwt subject contract): restore closed v1 rule — issuer policy names one authenticated marker claim and two non-empty disjoint value sets; absent/unknown/ambiguous classification denies; client-subject acceptance requires recorded non-collision posture. IMPORTANT (residual-window formula): distinguish upstream-revocation (no disconnect call) from session-only disconnect. Upstream revocation: max(existing session remaining lifetime, assertion TTL). Disconnect: two sequential sub-windows — live session bounded by max_connection_lifetime_seconds, then reconnect bounded by min(remaining assertion TTL, max_connection_lifetime_seconds). Both places in the doc now use the same scenario-specific formula. MINOR: fix broken relative link ../crates -> ../../crates/buzz-auth/... MINOR: 'enrolled Nostr key' -> 'assertion-named Nostr key' Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI.md | 124 +++++++++++++++++++++++++++++++++----------- 1 file changed, 95 insertions(+), 29 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 49a145a8693..64a8ab80fd6 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -67,10 +67,17 @@ Policy selects exactly one token class before parsing claims: - **`nip-fi+jwt`**: a dedicated assertion whose protected `typ` is exactly `nip-fi+jwt`. - **`at+jwt` access token**: a resource access token whose protected `typ` is - exactly `at+jwt`. When this class is selected the assertion MUST contain a - non-empty `client_id` claim. The issuer MUST guarantee that - resource-owner-subject tokens and client-subject tokens cannot share `(iss, - sub)` coordinates. + exactly `at+jwt`. When this class is selected: + - The assertion MUST contain a non-empty `client_id` claim. + - The issuer policy MUST name exactly one authenticated marker claim and two + non-empty, disjoint value sets: one for resource-owner subjects and one for + client-subject tokens. A token whose marker value matches neither set, both + sets, or whose marker claim is absent is ambiguous and denies. + - When client-subject tokens are admitted, the issuer policy MUST record the + non-collision posture: the issuer MUST guarantee that resource-owner and + client-subject `(iss, sub)` coordinates are disjoint. + - Absent, unknown, or ambiguous classification always denies; no fallback to + the other class is attempted. OIDC ID tokens always deny, even when `iss`, `aud`, and `sub` match. A generic or absent `typ` has no accepted class. Failure under one class never @@ -78,13 +85,17 @@ triggers validation under another. [FI-TRACE-TOKEN-CLASS] ### Time bounds -The relay enforces all of the following. Each configures a positive finite -value; a missing configuration denies. +**Required claims:** `iat` and `exp` MUST be present; absence denies. + +**Policy knobs:** the relay enforces the following rules. `maximum_assertion_age` +is a required positive finite configuration; a missing or non-positive +configuration denies. `skew` is a non-negative finite maximum with default `0`; +it narrows acceptable bounds and cannot be omitted to mean "unchecked". - `now < exp` — equality at expiry is expired -- `iat <= now + skew` — `skew` is a non-negative finite maximum, default 0 +- `iat <= now + skew` — issuance is not in the future beyond allowable skew - `now < iat + maximum_assertion_age` — caps total assertion age independent of `exp` -- `nbf <= now + skew` — when `nbf` is present +- `nbf <= now + skew` — when `nbf` is present (optional claim; absence is not an error) [FI-TRACE-ASSERTION-VALIDATION] @@ -135,13 +146,18 @@ snapshots. No IdP contact occurs at admission time. ### Multi-issuer registry -The relay maintains one [`IssuerRegistry`](../crates/buzz-auth/src/nip_fi/config.rs): +The relay maintains one [`IssuerRegistry`](../../crates/buzz-auth/src/nip_fi/config.rs): a map from exact `iss` strings to issuer policies. The `iss` carried in the signed token selects exactly one policy; unknown issuers deny. A single-issuer deployment is a registry of length one. [FI-TRACE-CROSS-DOMAIN-COLLISION] The existing `FederatedAssertionVerifier` and `ProductionJwksSource` -(merged in PR 3 / `70895b355`) implement this section. +(merged in PR 3 / `70895b355`) implement the verification procedure described +here. The `require_attested_key` flag in `IssuerPolicy` is the per-issuer +enforcement primitive for the unconditional `nostr_pubkey` requirement in this +section; conformance to NIP-FI v2 requires startup validation that forces this +flag true for every configured issuer. That integration is a follow-on code +change outside this PR. ### JWKS snapshot @@ -263,11 +279,22 @@ reconnect immediately. > **Non-normative note — open product question for Will/Tyler:** > -> The session-only model means a revoked employee can reconnect until their -> assertion TTL expires and the adapter stops issuing new assertions. The -> residual window equals at most `max_connection_lifetime_seconds` (for an -> existing session) plus the remaining JWT TTL (for an immediate reconnect -> after being booted). +> The session-only model means a revoked employee retains access until the +> relay closes their connection and they can no longer obtain a fresh +> assertion. There are two distinct residual sub-windows: +> +> - **Existing session:** a live session that received a disconnect call +> continues until the relay closes it (bounded by whatever session deadline +> applies — at most `max_connection_lifetime_seconds` from connection time). +> - **Immediate reconnect:** after being disconnected, a client holding a +> still-valid assertion can reconnect immediately. That new session is +> bounded by `min(remaining assertion TTL, max_connection_lifetime_seconds)`. +> +> These windows are sequential, not additive. The worst-case window after a +> disconnect call is `max_connection_lifetime_seconds` (for the live session) +> followed immediately by a new session bounded by `min(remaining assertion TTL, +> max_connection_lifetime_seconds)`. The reconnect window closes when the +> adapter stops issuing new assertions. > > The alternative is a **deny-until-TTL** model: the relay holds a > memory-resident deny-list entry for the pubkey keyed to the adapter's stated @@ -284,33 +311,61 @@ reconnect immediately. ### Transport The disconnect endpoint is an authenticated adapter→relay API, not a public -Nostr protocol. Authentication MUST use the same JWKS verification surface -the relay uses for client assertions: the adapter call carries a -`Nostr-Federated-Identity` header whose assertion is verified against the -configured adapter issuer policy before any action is taken. +Nostr protocol. -### Request +### Request binding + +Authentication MUST use a short-lived signed command JWT. The adapter mints +a compact JWS verified by the relay against the same configured per-issuer JWKS +snapshot it uses for client assertions — no additional key material or Nostr +key is required. -The adapter sends a signed request naming the target pubkey: +The command JWT MUST carry the following claims: + +| Claim | Requirement | +|---|---| +| `iss` | Exact issuer URI matching an authorized adapter issuer in the registry. | +| `sub` | Adapter principal identifier. The relay checks this is an authorized adapter principal. | +| `aud` | Audience matching the relay's configured audience value for this issuer. | +| `iat` | Issuance time. MUST satisfy `now < iat + command_ttl` and `iat <= now + skew`. | +| `exp` | Expiry time. MUST be finite; relay enforces `now < exp`. | +| `jti` | Unique, non-guessable identifier for this command. The relay rejects any command whose `jti` has already been seen within its expiry window (replay denial). | +| `cmd` | Exactly `"disconnect"` (literal string). | +| `target_pubkey` | Lowercase hexadecimal encoding of the target 32-byte Nostr public key — the same encoding required for the assertion `nostr_pubkey` claim. | + +The relay verifies the command JWT using `VerifyAssertion` (selecting the +adapter issuer policy), then asserts: + +1. `cmd == "disconnect"` — any other value denies. +2. `target_pubkey` matches the pubkey in the request body — mismatch denies `403`. +3. `jti` has not been seen within its expiry window — replay denies `403`. +4. Caller identity is an authorized adapter principal — unauthorized caller denies `403`. + +Any failure is fail-closed: the relay takes no action and returns the +appropriate error. The command TTL MUST be short (deployment policy governs; +60 seconds is a reasonable upper bound). + +### Request ```text POST /api/nip-fi/disconnect HTTP/1.1 -Nostr-Federated-Identity: Bearer +Nostr-Federated-Identity: Bearer Content-Type: application/json {"pubkey": ""} ``` -The relay verifies the assertion, confirms the caller identity is an authorized -adapter principal, then closes all matching live connections. An unknown or -unprovable pubkey is not an error; the relay responds `200` with `{"disconnected": 0}`. +The relay verifies the command JWT, confirms `target_pubkey` in the JWT matches +the body `pubkey` field, confirms the caller is an authorized adapter principal, +then closes all matching live connections. An unknown or unprovable pubkey is +not an error; the relay responds `200` with `{"disconnected": 0}`. ### Response | Condition | Status | Body | |---|---|---| | Authorized; action taken or no-op | `200` | `{"disconnected": }` where `n` is the count of sessions closed | -| Missing or invalid assertion | `401` / `403` | Per the rejection table | +| Missing or invalid command JWT | `401` / `403` | Per the rejection table | | Malformed request body | `400` | `bad request\n` | ## Rejection and privacy @@ -397,8 +452,19 @@ is the primary control against assertion replay across keys. cannot observe IdP-side revocation until the current assertion expires. The deployment adapter MUST configure a `max_connection_lifetime_seconds` and assertion TTL consistent with the organization's acceptable revocation latency. -For the session-only disconnect model, the residual window is bounded by max( -existing session remaining lifetime, assertion TTL ). + +For upstream revocation without an explicit disconnect call (adapter stops +issuing assertions; no active session termination), the residual window is +`max(existing session remaining lifetime, assertion TTL)`: whichever is +longer governs when access finally ceases. + +For the session-only disconnect model (adapter issues a disconnect call that +closes the live session), the residual window has two sequential parts: the +live session closes within `max_connection_lifetime_seconds`, after which a +reconnect is bounded by `min(remaining assertion TTL, max_connection_lifetime_seconds)`. +The reconnect window closes when the adapter stops issuing new assertions. +See the non-normative note in the Admin disconnect section for the open product +question on the deny-until-TTL alternative. **SSRF.** The JWKS fetcher implements SSRF protection: HTTPS-only URI validation, DNS resolution with IP deny-list enforcement, address pinning to @@ -406,7 +472,7 @@ prevent DNS rebinding TOCTOU, and redirect denial. The complete IANA Special-Purpose address deny table is implemented; see `crates/buzz-core/src/network.rs`. **Issuer compromise.** A compromised assertion issuer can impersonate any -identity but cannot prove possession of an enrolled Nostr key. The NIP-42 +identity but cannot prove possession of the assertion-named Nostr key. The NIP-42 proof remains an independent control. **Algorithm confusion.** The verifier enforces asymmetric algorithms only; From b7cfb5e688a84f0233343e14a6eaa4d6cd6f4a49 Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 1 Sep 2026 18:35:09 -0400 Subject: [PATCH 4/9] docs(nip-fi): address pass-2 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL: replace VerifyAssertion with dedicated VerifyCommandJwt for the disconnect command. The command JWT carries 'typ: nip-fi-command+jwt' (domain-separated from assertion types); signed 'method' and 'path' claims bind it to exactly POST /api/nip-fi/disconnect; a dedicated 'maximum_command_age' policy knob governs TTL; and jti replay prevention uses an atomic (iss, jti) reservation held until the command's effective expiry. The procedure reuses IssuerRegistry, JWKS, signature, audience, and time-bound primitives but produces a closed CommandResult — not a VerifiedAssertion. VerifyAssertion is not called. IMPORTANT (disconnect window): single correct formula in both the non-normative note and Security considerations. After a successful disconnect, old-session remainder is zero; the only residual is a reconnect bounded by min(remaining assertion authority, max_connection_lifetime_seconds, remaining key-snapshot hard deadline). Contradictory two-part sequential formulation removed. IMPORTANT (aud optionality): 'aud' promoted to unconditional required claim in the required-claims table, matching the existing verifier contract (IssuerPolicy requires non-empty audience; VerifyAssertion always validates it). MINOR: NIP-FI-MODEL added to the removed companion profiles list. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI.md | 151 +++++++++++++++++++++++++++++++------------- 1 file changed, 107 insertions(+), 44 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 64a8ab80fd6..45fe615da1e 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -50,6 +50,7 @@ The assertion is a compact JWS carrying the following claims. | `iss` | string | Exact issuer URI. The relay selects an issuer policy by exact match; no normalization is applied. | | `sub` | string | Opaque, stable, non-reassignable subject identifier for the account lifetime. Never an email address or display name. | | `nostr_pubkey` | string | Lowercase hexadecimal encoding of exactly one 32-byte Nostr public key. Other encodings deny. | +| `aud` | string or array | Audience. MUST be present. The relay requires an exact match to the configured audience value for this issuer. | | `iat` | NumericDate | Issuance time. | | `exp` | NumericDate | Expiry time. MUST be finite. The deployment MUST configure a positive finite maximum TTL; the relay enforces both the token `exp` and the configured `maximum_assertion_age`. | @@ -57,7 +58,6 @@ The assertion is a compact JWS carrying the following claims. | Claim | Type | Semantics | |---|---|---| -| `aud` | string or array | Required when configured; the relay requires an exact match to the configured audience value. | | `nbf` | NumericDate | Not-before time. When present, the relay enforces `nbf <= now + skew`. | ### Token type @@ -280,26 +280,28 @@ reconnect immediately. > **Non-normative note — open product question for Will/Tyler:** > > The session-only model means a revoked employee retains access until the -> relay closes their connection and they can no longer obtain a fresh -> assertion. There are two distinct residual sub-windows: +> adapter stops issuing new assertions. After a successful disconnect call +> (all matching sessions closed), there is no surviving old-session window. +> The only remaining access window is a **reconnect** using a still-valid +> assertion: that new session is bounded by > -> - **Existing session:** a live session that received a disconnect call -> continues until the relay closes it (bounded by whatever session deadline -> applies — at most `max_connection_lifetime_seconds` from connection time). -> - **Immediate reconnect:** after being disconnected, a client holding a -> still-valid assertion can reconnect immediately. That new session is -> bounded by `min(remaining assertion TTL, max_connection_lifetime_seconds)`. +> ``` +> min( +> remaining assertion authority, // min(remaining exp, remaining iat + maximum_assertion_age) +> max_connection_lifetime_seconds, +> remaining key-snapshot hard deadline +> ) +> ``` > -> These windows are sequential, not additive. The worst-case window after a -> disconnect call is `max_connection_lifetime_seconds` (for the live session) -> followed immediately by a new session bounded by `min(remaining assertion TTL, -> max_connection_lifetime_seconds)`. The reconnect window closes when the -> adapter stops issuing new assertions. +> This window closes when the adapter stops issuing new assertions for the +> identity. If the disconnect call is asynchronous or best-effort, the spec +> would need to define a completion-bound contract; the current normative text +> assumes synchronous close. > > The alternative is a **deny-until-TTL** model: the relay holds a > memory-resident deny-list entry for the pubkey keyed to the adapter's stated > TTL, and any reconnect attempt for that key is denied `authorization_denied` -> until the entry expires. This closes the reconnect window at the cost of +> until the entry expires. This eliminates the reconnect window at the cost of > relay in-memory state and a TTL-propagation contract between adapter and relay. > > This document intentionally leaves that decision unresolved. The current @@ -313,12 +315,17 @@ reconnect immediately. The disconnect endpoint is an authenticated adapter→relay API, not a public Nostr protocol. -### Request binding +### Command JWT -Authentication MUST use a short-lived signed command JWT. The adapter mints -a compact JWS verified by the relay against the same configured per-issuer JWKS -snapshot it uses for client assertions — no additional key material or Nostr -key is required. +Authentication uses a short-lived signed command JWT with a dedicated token +type. The relay verifies it with a **dedicated command verifier** that reuses +the same `IssuerRegistry`, bounded JWS parsing, issuer-bound JWKS snapshots, +signature verification, audience, and time-bound primitives as assertion +verification, but operates over a distinct token type and produces a closed +command result. The `VerifyAssertion` primitive is not used here. + +The command JWT protected header MUST carry `"typ": "nip-fi-command+jwt"`. +Any other `typ` value denies before claim parsing. The command JWT MUST carry the following claims: @@ -327,23 +334,67 @@ The command JWT MUST carry the following claims: | `iss` | Exact issuer URI matching an authorized adapter issuer in the registry. | | `sub` | Adapter principal identifier. The relay checks this is an authorized adapter principal. | | `aud` | Audience matching the relay's configured audience value for this issuer. | -| `iat` | Issuance time. MUST satisfy `now < iat + command_ttl` and `iat <= now + skew`. | +| `iat` | Issuance time. MUST satisfy `iat <= now + skew`. | | `exp` | Expiry time. MUST be finite; relay enforces `now < exp`. | -| `jti` | Unique, non-guessable identifier for this command. The relay rejects any command whose `jti` has already been seen within its expiry window (replay denial). | -| `cmd` | Exactly `"disconnect"` (literal string). | +| `jti` | Unique, non-guessable identifier for this command. Used for replay prevention; see below. | +| `method` | Exactly `"POST"` (uppercase literal). Binds the command to the HTTP method. | +| `path` | Exactly `"/api/nip-fi/disconnect"` (literal string). Binds the command to the endpoint. | +| `cmd` | Exactly `"disconnect"` (literal string). Operation selector. | | `target_pubkey` | Lowercase hexadecimal encoding of the target 32-byte Nostr public key — the same encoding required for the assertion `nostr_pubkey` claim. | -The relay verifies the command JWT using `VerifyAssertion` (selecting the -adapter issuer policy), then asserts: +The `maximum_command_age` policy knob is a required positive finite +configuration per authorized adapter issuer. The relay enforces +`now < iat + maximum_command_age` in addition to `now < exp`. A missing or +non-positive configuration denies. -1. `cmd == "disconnect"` — any other value denies. -2. `target_pubkey` matches the pubkey in the request body — mismatch denies `403`. -3. `jti` has not been seen within its expiry window — replay denies `403`. -4. Caller identity is an authorized adapter principal — unauthorized caller denies `403`. +The `VerifyCommandJwt` procedure: -Any failure is fail-closed: the relay takes no action and returns the -appropriate error. The command TTL MUST be short (deployment policy governs; -60 seconds is a reasonable upper bound). +```text +VerifyCommandJwt(token, request_method, request_path): + // 1. Bounded decode and type check + (header, claims) := BoundedJwsDecode(token) or DENY(evidence_rejected) + assert header.typ == "nip-fi-command+jwt" or DENY(evidence_rejected) + + // 2. Select issuer policy; verify signature + policy := IssuerRegistry[claims.iss] or DENY(evidence_rejected) + AssertAsymmetricAlgorithm(header.alg) or DENY(evidence_rejected) + snapshot := policy.key_source.get_snapshot() or DENY(authorization_unavailable) + key := snapshot.find(header.kid) or DENY(evidence_rejected) + VerifySignature(token, key) or DENY(evidence_rejected) + + // 3. Validate claims + AssertExactIss(claims.iss, policy.iss) or DENY(evidence_rejected) + AssertAudienceMatch(claims.aud, policy.aud) or DENY(evidence_rejected) + AssertCommandTimeBounds(claims, policy) or DENY(evidence_rejected) + // enforces: now < exp, iat <= now + skew, now < iat + maximum_command_age + assert claims.method == request_method or DENY(evidence_rejected) + assert claims.path == request_path or DENY(evidence_rejected) + assert claims.cmd == "disconnect" or DENY(evidence_rejected) + target_k := ParseHexKey(claims.target_pubkey) or DENY(evidence_rejected) + + // 4. Atomically reserve jti to prevent replay + // The reservation is keyed by (iss, jti) and held until the command's + // effective expiry: min(exp, iat + maximum_command_age). The check and + // insert MUST be atomic; a non-atomic "seen then insert" permits concurrent + // replay. + AtomicReserveJti(claims.iss, claims.jti, effective_expiry) or DENY(authorization_denied) + + // 5. Authorize caller + AssertAuthorizedAdapterPrincipal(claims.iss, claims.sub) or DENY(authorization_denied) + + return CommandResult(target_pubkey=target_k, caller=(claims.iss, claims.sub)) +``` + +The relay then asserts that `CommandResult.target_pubkey` matches the `pubkey` +field in the request body; mismatch denies `403`. Any failure at any step is +fail-closed: no action is taken. + +The command TTL MUST be short; deployment policy governs. A maximum of 60 +seconds is a reasonable upper bound for `maximum_command_age`. + +This verifier and the disconnect API endpoint are follow-on code changes +outside this PR, in the same way that the `require_attested_key` enforcement +integration is. ### Request @@ -403,7 +454,7 @@ normative behavior for them: - SCIM, HR system integration, employee offboarding automation: adapter-side. - Audit logging beyond what the relay operator chooses to retain: adapter-side. - Delegation: out of scope. -- Companion profiles (NIP-FI-EDGE, NIP-FI-LIFECYCLE, NIP-FI-DELEG, NIP-FI-CONF): removed. +- Companion profiles (NIP-FI-EDGE, NIP-FI-LIFECYCLE, NIP-FI-DELEG, NIP-FI-CONF, NIP-FI-MODEL): removed. ## Discovery @@ -454,17 +505,29 @@ deployment adapter MUST configure a `max_connection_lifetime_seconds` and assertion TTL consistent with the organization's acceptable revocation latency. For upstream revocation without an explicit disconnect call (adapter stops -issuing assertions; no active session termination), the residual window is -`max(existing session remaining lifetime, assertion TTL)`: whichever is -longer governs when access finally ceases. - -For the session-only disconnect model (adapter issues a disconnect call that -closes the live session), the residual window has two sequential parts: the -live session closes within `max_connection_lifetime_seconds`, after which a -reconnect is bounded by `min(remaining assertion TTL, max_connection_lifetime_seconds)`. -The reconnect window closes when the adapter stops issuing new assertions. -See the non-normative note in the Admin disconnect section for the open product -question on the deny-until-TTL alternative. +issuing assertions; no active session termination), access persists until the +earliest of the live session's remaining authority deadlines, bounded by +`max_connection_lifetime_seconds`. After the session closes, a reconnect +is bounded by the remaining assertion authority and `max_connection_lifetime_seconds` +(and the key-snapshot hard deadline); once the adapter stops issuing assertions +for the identity, no reconnect can succeed. + +For the session-only disconnect model (adapter issues a successful disconnect +call that closes all matching sessions), there is no surviving old-session +window. The only remaining access is a reconnect using a still-valid assertion, +bounded by: + +``` +min( + remaining assertion authority, // min(remaining exp, remaining iat + maximum_assertion_age) + max_connection_lifetime_seconds, + remaining key-snapshot hard deadline +) +``` + +This window closes when the adapter stops issuing new assertions. See the +non-normative note in the Admin disconnect section for the open product question +on the deny-until-TTL alternative. **SSRF.** The JWKS fetcher implements SSRF protection: HTTPS-only URI validation, DNS resolution with IP deny-list enforcement, address pinning to From e3bb41b29d0297a99838739b4c0e375b27241c3a Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 1 Sep 2026 18:40:27 -0400 Subject: [PATCH 5/9] docs(nip-fi): address pass-3 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IMPORTANT (reconnect window): distinguish individual-session bound from cumulative residual access. After a successful synchronous disconnect, cumulative access (assuming issuance stops) is bounded by max(0, min(exp, iat+max_assertion_age, key_snapshot_deadline) - now). max_connection_lifetime_seconds only partitions that interval into sessions; it does not shorten the total window. State explicitly that continued issuance after disconnect extends access without a protocol-level bound. Same formula used in both the non-normative note and Security considerations. IMPORTANT (jti admission ordering): reorder VerifyCommandJwt steps so AtomicReserveJti is the final admission step immediately before side effects — after signature/claim validation, principal authorization, and signed-target/body agreement. A failed-authorization or mismatched-body request no longer burns the signed command identity. Procedure now accepts request_body_pubkey as a parameter; body agreement is a pure check inside the procedure. Make maximum_command_age upper bound normative: 0 < maximum_command_age <= 60 seconds. MINOR: narrow Request prose to reference VerifyCommandJwt directly rather than re-describing steps now inside the procedure. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI.md | 97 +++++++++++++++++++++++---------------------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 45fe615da1e..84acac7e529 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -279,24 +279,25 @@ reconnect immediately. > **Non-normative note — open product question for Will/Tyler:** > -> The session-only model means a revoked employee retains access until the -> adapter stops issuing new assertions. After a successful disconnect call -> (all matching sessions closed), there is no surviving old-session window. -> The only remaining access window is a **reconnect** using a still-valid -> assertion: that new session is bounded by +> The session-only model means a revoked employee retains access until their +> assertion's effective authority expires. After a successful disconnect call +> (all matching sessions closed synchronously), there is no surviving +> old-session window. If the adapter also stops issuing new assertions at +> that point, cumulative residual access is bounded by: > > ``` -> min( -> remaining assertion authority, // min(remaining exp, remaining iat + maximum_assertion_age) -> max_connection_lifetime_seconds, -> remaining key-snapshot hard deadline -> ) +> max(0, min(exp, iat + maximum_assertion_age, key_snapshot_hard_deadline) - now) > ``` > -> This window closes when the adapter stops issuing new assertions for the -> identity. If the disconnect call is asynchronous or best-effort, the spec -> would need to define a completion-bound contract; the current normative text -> assumes synchronous close. +> `max_connection_lifetime_seconds` only partitions that interval into +> individual sessions; it does not shorten the total window. If the adapter +> continues issuing new assertions after the disconnect call, cumulative +> access extends indefinitely — the session-only protocol places no +> protocol-level bound on that case. +> +> If the disconnect call is asynchronous or best-effort, the spec would need +> to define a completion-bound contract; the current normative text assumes +> synchronous close. > > The alternative is a **deny-until-TTL** model: the relay holds a > memory-resident deny-list entry for the pubkey keyed to the adapter's stated @@ -343,14 +344,15 @@ The command JWT MUST carry the following claims: | `target_pubkey` | Lowercase hexadecimal encoding of the target 32-byte Nostr public key — the same encoding required for the assertion `nostr_pubkey` claim. | The `maximum_command_age` policy knob is a required positive finite -configuration per authorized adapter issuer. The relay enforces -`now < iat + maximum_command_age` in addition to `now < exp`. A missing or -non-positive configuration denies. +configuration per authorized adapter issuer, with a normative upper bound of +60 seconds. The relay enforces `0 < maximum_command_age <= 60` and +`now < iat + maximum_command_age` in addition to `now < exp`. A missing, +non-positive, or out-of-range configuration denies. The `VerifyCommandJwt` procedure: ```text -VerifyCommandJwt(token, request_method, request_path): +VerifyCommandJwt(token, request_method, request_path, request_body_pubkey): // 1. Bounded decode and type check (header, claims) := BoundedJwsDecode(token) or DENY(evidence_rejected) assert header.typ == "nip-fi-command+jwt" or DENY(evidence_rejected) @@ -362,7 +364,7 @@ VerifyCommandJwt(token, request_method, request_path): key := snapshot.find(header.kid) or DENY(evidence_rejected) VerifySignature(token, key) or DENY(evidence_rejected) - // 3. Validate claims + // 3. Validate claims (pure verification — no side effects) AssertExactIss(claims.iss, policy.iss) or DENY(evidence_rejected) AssertAudienceMatch(claims.aud, policy.aud) or DENY(evidence_rejected) AssertCommandTimeBounds(claims, policy) or DENY(evidence_rejected) @@ -372,25 +374,26 @@ VerifyCommandJwt(token, request_method, request_path): assert claims.cmd == "disconnect" or DENY(evidence_rejected) target_k := ParseHexKey(claims.target_pubkey) or DENY(evidence_rejected) - // 4. Atomically reserve jti to prevent replay + // 4. Principal authorization (pure check — no side effects) + AssertAuthorizedAdapterPrincipal(claims.iss, claims.sub) or DENY(authorization_denied) + + // 5. Signed-target / request-body agreement (pure check — no side effects) + assert target_k == request_body_pubkey or DENY(authorization_denied) + + // 6. Atomically reserve jti — final admission step, immediately before side effects. // The reservation is keyed by (iss, jti) and held until the command's - // effective expiry: min(exp, iat + maximum_command_age). The check and - // insert MUST be atomic; a non-atomic "seen then insert" permits concurrent - // replay. + // effective expiry: min(exp, iat + maximum_command_age). This step MUST + // be the last mutation before disconnect side effects; performing it before + // steps 4 or 5 would burn the signed command identity on failed-authorization + // or mismatched-body requests, violating the fail-closed contract. + effective_expiry := min(claims.exp, claims.iat + policy.maximum_command_age) AtomicReserveJti(claims.iss, claims.jti, effective_expiry) or DENY(authorization_denied) - // 5. Authorize caller - AssertAuthorizedAdapterPrincipal(claims.iss, claims.sub) or DENY(authorization_denied) - return CommandResult(target_pubkey=target_k, caller=(claims.iss, claims.sub)) ``` -The relay then asserts that `CommandResult.target_pubkey` matches the `pubkey` -field in the request body; mismatch denies `403`. Any failure at any step is -fail-closed: no action is taken. - -The command TTL MUST be short; deployment policy governs. A maximum of 60 -seconds is a reasonable upper bound for `maximum_command_age`. +Any failure at any step is fail-closed: no side effects occur and the relay +returns the appropriate error. This verifier and the disconnect API endpoint are follow-on code changes outside this PR, in the same way that the `require_attested_key` enforcement @@ -406,10 +409,11 @@ Content-Type: application/json {"pubkey": ""} ``` -The relay verifies the command JWT, confirms `target_pubkey` in the JWT matches -the body `pubkey` field, confirms the caller is an authorized adapter principal, -then closes all matching live connections. An unknown or unprovable pubkey is -not an error; the relay responds `200` with `{"disconnected": 0}`. +The relay calls `VerifyCommandJwt` passing the request method, path, and +body `pubkey` field; any failure denies per the rejection table. On success, +the relay closes all live connections whose proven `k` equals +`CommandResult.target_pubkey`. An unknown or unprovable pubkey is not an +error; the relay responds `200` with `{"disconnected": 0}`. ### Response @@ -513,21 +517,20 @@ is bounded by the remaining assertion authority and `max_connection_lifetime_sec for the identity, no reconnect can succeed. For the session-only disconnect model (adapter issues a successful disconnect -call that closes all matching sessions), there is no surviving old-session -window. The only remaining access is a reconnect using a still-valid assertion, -bounded by: +call that closes all matching sessions synchronously), there is no surviving +old-session window. If the adapter also stops issuing new assertions at that +point, cumulative residual access is bounded by: ``` -min( - remaining assertion authority, // min(remaining exp, remaining iat + maximum_assertion_age) - max_connection_lifetime_seconds, - remaining key-snapshot hard deadline -) +max(0, min(exp, iat + maximum_assertion_age, key_snapshot_hard_deadline) - now) ``` -This window closes when the adapter stops issuing new assertions. See the -non-normative note in the Admin disconnect section for the open product question -on the deny-until-TTL alternative. +`max_connection_lifetime_seconds` only partitions that interval into individual +sessions; it does not shorten the total window. If the adapter continues +issuing new assertions after the disconnect call, cumulative access extends +indefinitely — the session-only protocol places no protocol-level bound on that +case. See the non-normative note in the Admin disconnect section for the open +product question on the deny-until-TTL alternative. **SSRF.** The JWKS fetcher implements SSRF protection: HTTPS-only URI validation, DNS resolution with IP deny-list enforcement, address pinning to From 78b58e6654497bb1d548eb2150069d874b7fb83d Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 1 Sep 2026 18:41:12 -0400 Subject: [PATCH 6/9] docs(nip-fi): fix upstream-revocation paragraph in Security considerations The 'no reconnect can succeed' claim was framed as a consequence of max_connection_lifetime_seconds, which does not govern reconnect availability. Restate accurately: after the live session closes naturally, reconnect requires a fresh assertion; if the adapter has stopped issuing, none can be obtained; if the adapter continues issuing, access continues. Removes the last session-bound confation from the revocation analysis. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 84acac7e529..cc21e656ce7 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -510,11 +510,10 @@ assertion TTL consistent with the organization's acceptable revocation latency. For upstream revocation without an explicit disconnect call (adapter stops issuing assertions; no active session termination), access persists until the -earliest of the live session's remaining authority deadlines, bounded by -`max_connection_lifetime_seconds`. After the session closes, a reconnect -is bounded by the remaining assertion authority and `max_connection_lifetime_seconds` -(and the key-snapshot hard deadline); once the adapter stops issuing assertions -for the identity, no reconnect can succeed. +live session's effective authority deadlines expire. After the session closes +naturally, a reconnect requires a fresh assertion; if the adapter has stopped +issuing, no fresh assertion can be obtained and no reconnect can succeed. If +the adapter continues issuing assertions, access continues. For the session-only disconnect model (adapter issues a successful disconnect call that closes all matching sessions synchronously), there is no surviving From 106538b563c0f1180a631d2771de828ea25a375d Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 1 Sep 2026 18:51:44 -0400 Subject: [PATCH 7/9] docs(nip-fi): correct cumulative revocation bound; snapshot deadline not fixed The key_snapshot_hard_deadline is computed as now + configured seconds on every JWKS refresh (jwks/mod.rs), so it renews even on unchanged content and does not cap cumulative access across serial reconnects. Remove it from both residual-window formulas; the correct cumulative bound (assuming issuance stops at disconnect) is: max(0, min(exp, iat + maximum_assertion_age) - now) Add a note that snapshot failure, hard-deadline expiry, or key removal can terminate access earlier but are not reliable protocol-level bounds. Also fix the upstream-revocation paragraph: a still-valid compact JWS can reconnect without new issuance, so 'requires a fresh assertion' was wrong. Corrected to 'requires an assertion that remains valid when reverified'. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI.md | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index cc21e656ce7..6c1159a9cd2 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -286,11 +286,15 @@ reconnect immediately. > that point, cumulative residual access is bounded by: > > ``` -> max(0, min(exp, iat + maximum_assertion_age, key_snapshot_hard_deadline) - now) +> max(0, min(exp, iat + maximum_assertion_age) - now) > ``` > > `max_connection_lifetime_seconds` only partitions that interval into -> individual sessions; it does not shorten the total window. If the adapter +> individual sessions; it does not shorten the total window. A snapshot +> refresh failure, hard-deadline expiry without key replacement, or signing-key +> removal can terminate access earlier, but these are not reliable protocol-level +> bounds: the JWKS snapshot deadline renews on each refresh even when content is +> unchanged, so it does not cap cumulative access. If the adapter > continues issuing new assertions after the disconnect call, cumulative > access extends indefinitely — the session-only protocol places no > protocol-level bound on that case. @@ -511,9 +515,10 @@ assertion TTL consistent with the organization's acceptable revocation latency. For upstream revocation without an explicit disconnect call (adapter stops issuing assertions; no active session termination), access persists until the live session's effective authority deadlines expire. After the session closes -naturally, a reconnect requires a fresh assertion; if the adapter has stopped -issuing, no fresh assertion can be obtained and no reconnect can succeed. If -the adapter continues issuing assertions, access continues. +naturally, a reconnect requires an assertion that remains valid when reverified; +if the adapter has stopped issuing, no valid assertion can be obtained and no +reconnect can succeed. If the adapter continues issuing assertions, access +continues. For the session-only disconnect model (adapter issues a successful disconnect call that closes all matching sessions synchronously), there is no surviving @@ -521,15 +526,19 @@ old-session window. If the adapter also stops issuing new assertions at that point, cumulative residual access is bounded by: ``` -max(0, min(exp, iat + maximum_assertion_age, key_snapshot_hard_deadline) - now) +max(0, min(exp, iat + maximum_assertion_age) - now) ``` `max_connection_lifetime_seconds` only partitions that interval into individual -sessions; it does not shorten the total window. If the adapter continues -issuing new assertions after the disconnect call, cumulative access extends -indefinitely — the session-only protocol places no protocol-level bound on that -case. See the non-normative note in the Admin disconnect section for the open -product question on the deny-until-TTL alternative. +sessions; it does not shorten the total window. A snapshot refresh failure, +hard-deadline expiry without key replacement, or signing-key removal can +terminate access earlier, but these are not reliable protocol-level bounds: the +JWKS snapshot deadline renews on each refresh even when content is unchanged. +If the adapter continues issuing new assertions after the disconnect call, +cumulative access extends indefinitely — the session-only protocol places no +protocol-level bound on that case. See the non-normative note in the Admin +disconnect section for the open product question on the deny-until-TTL +alternative. **SSRF.** The JWKS fetcher implements SSRF protection: HTTPS-only URI validation, DNS resolution with IP deny-list enforcement, address pinning to From 53d347106dcf039ecefe7596202b60d474a4da6d Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 1 Sep 2026 18:59:31 -0400 Subject: [PATCH 8/9] docs(nip-fi): genericize terminology for OSS repo Replace all 'adapter' references with 'assertion issuer' / 'issuer' throughout. The spec's trust anchor is the configured issuer registry; the entity that authenticates users and mints assertions is the assertion issuer, which is the term now used everywhere. - 'adapter' Terms entry replaced with 'assertion issuer' definition - Okta example removed; 'an OIDC identity provider integration' used instead - 'employees' -> 'users'; 'employee number' -> 'opaque user ID' - 'SCIM, HR system integration, employee offboarding automation' -> 'directory integration and account-offboarding automation' - AssertAuthorizedAdapterPrincipal -> AssertAuthorizedIssuerPrincipal - 'adapter->relay disconnect API' -> 'issuer->relay disconnect API' - 'adapter-initiated disconnect' -> 'issuer-initiated disconnect' - 'adapter principal' -> 'issuer principal' in claim table and prose - All remaining adapter/adapter-side/adapter's prose occurrences renamed - PR body updated: 'adapter concern' -> 'issuer and deployment concern'; residual-window formula corrected to drop key_snapshot_hard_deadline No normative semantic changes. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI.md | 78 +++++++++++++++++++++++---------------------- 1 file changed, 40 insertions(+), 38 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 6c1159a9cd2..5e8d4d1658f 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -18,26 +18,28 @@ NIP-FI authorizes a Nostr key when two independent facts agree: a valid issuer-qualified identity assertion that names the key, and fresh NIP-42 proof of possession of that key. No relay-side identity state is required. The relay verifies the assertion offline against configured per-issuer JWKS -snapshots; every identity decision beyond key verification is the deployment -adapter's responsibility. +snapshots; every identity decision beyond key verification is the assertion +issuer's responsibility. This NIP defines the assertion contract, the offline verification procedure, -session lifetime policy, and an authenticated adapter→relay disconnect API. +session lifetime policy, and an authenticated issuer→relay disconnect API. Enrollment, rotation, revocation decisions, identity↔key registry, one-identity -one-key enforcement, audit, and SCIM are adapter concerns outside this spec. +one-key enforcement, audit, and directory integration are issuer concerns outside +this spec. ## Terms - **identity** (`i`): the exact tuple `(iss, sub)` returned by assertion - validation. Email, display name, employee number, and a bare `sub` are not + validation. Email, display name, opaque user ID, and a bare `sub` are not identities. Equal `sub` values under different `iss` values are distinct identities. [FI-TRACE-CROSS-DOMAIN-COLLISION] - **actor** (`k`): the 32-byte public key returned by NIP-42 proof validation. -- **assertion**: a compact JWS minted by the deployment adapter, binding `i` +- **assertion**: a compact JWS minted by the assertion issuer, binding `i` to `k`. -- **adapter**: the deployment-specific identity authority (e.g. an Okta OIDC - integration) that authenticates employees and mints assertions. The relay - trusts only the adapter's assertion; it does not contact the IdP directly. +- **assertion issuer**: the deployment-specific identity authority (e.g. an + OIDC identity provider integration) that authenticates users and mints + assertions. The relay trusts only the issuer's assertion; it does not + contact the IdP directly. ## Assertion contract @@ -106,7 +108,7 @@ denies any token whose `nostr_pubkey` does not match the NIP-42 `pubkey`. [FI-TRACE-ASSERTION-KEY-MISMATCH] This is the entire identity-to-key binding. There is no relay-side binding -ledger; the assertion is the binding claim, and it is the adapter's +ledger; the assertion is the binding claim, and it is the assertion issuer's responsibility to ensure the assertion names the correct key. ### Policy identity @@ -255,18 +257,18 @@ Equality at any deadline is expired. Arithmetic is overflow-safe. There is **no in-band session renewal**. When a session expires, the relay closes the WebSocket. The client must open a new connection with a fresh assertion on the upgrade request and complete a fresh NIP-42 proof. A silent -re-mint riding an existing adapter/IdP session is an adapter implementation +re-mint riding an existing issuer/IdP session is an issuer implementation detail; the relay never sees anything other than a new upgrade request. ### Reconnect after expiry A client whose session expired due to normal TTL expiry may reconnect -immediately provided the adapter can issue a fresh assertion. Session expiry -does not imply key revocation or identity loss; that is the adapter's domain. +immediately provided the issuer can supply a fresh assertion. Session expiry +does not imply key revocation or identity loss; that is the issuer's domain. ## Admin disconnect API -The adapter can terminate live relay sessions for a specific public key via an +The assertion issuer can terminate live relay sessions for a specific public key via an authenticated `disconnect` call. ### Semantics (session-only) @@ -279,10 +281,10 @@ reconnect immediately. > **Non-normative note — open product question for Will/Tyler:** > -> The session-only model means a revoked employee retains access until their +> The session-only model means a revoked user retains access until their > assertion's effective authority expires. After a successful disconnect call > (all matching sessions closed synchronously), there is no surviving -> old-session window. If the adapter also stops issuing new assertions at +> old-session window. If the issuer also stops issuing new assertions at > that point, cumulative residual access is bounded by: > > ``` @@ -294,7 +296,7 @@ reconnect immediately. > refresh failure, hard-deadline expiry without key replacement, or signing-key > removal can terminate access earlier, but these are not reliable protocol-level > bounds: the JWKS snapshot deadline renews on each refresh even when content is -> unchanged, so it does not cap cumulative access. If the adapter +> unchanged, so it does not cap cumulative access. If the issuer > continues issuing new assertions after the disconnect call, cumulative > access extends indefinitely — the session-only protocol places no > protocol-level bound on that case. @@ -304,10 +306,10 @@ reconnect immediately. > synchronous close. > > The alternative is a **deny-until-TTL** model: the relay holds a -> memory-resident deny-list entry for the pubkey keyed to the adapter's stated +> memory-resident deny-list entry for the pubkey keyed to the issuer's stated > TTL, and any reconnect attempt for that key is denied `authorization_denied` > until the entry expires. This eliminates the reconnect window at the cost of -> relay in-memory state and a TTL-propagation contract between adapter and relay. +> relay in-memory state and a TTL-propagation contract between issuer and relay. > > This document intentionally leaves that decision unresolved. The current > normative text describes session-only. If deny-until-TTL is chosen, Section 6 @@ -317,7 +319,7 @@ reconnect immediately. ### Transport -The disconnect endpoint is an authenticated adapter→relay API, not a public +The disconnect endpoint is an authenticated issuer→relay API, not a public Nostr protocol. ### Command JWT @@ -336,8 +338,8 @@ The command JWT MUST carry the following claims: | Claim | Requirement | |---|---| -| `iss` | Exact issuer URI matching an authorized adapter issuer in the registry. | -| `sub` | Adapter principal identifier. The relay checks this is an authorized adapter principal. | +| `iss` | Exact issuer URI matching an authorized issuer in the registry. | +| `sub` | Issuer principal identifier. The relay checks this is an authorized issuer principal. | | `aud` | Audience matching the relay's configured audience value for this issuer. | | `iat` | Issuance time. MUST satisfy `iat <= now + skew`. | | `exp` | Expiry time. MUST be finite; relay enforces `now < exp`. | @@ -348,7 +350,7 @@ The command JWT MUST carry the following claims: | `target_pubkey` | Lowercase hexadecimal encoding of the target 32-byte Nostr public key — the same encoding required for the assertion `nostr_pubkey` claim. | The `maximum_command_age` policy knob is a required positive finite -configuration per authorized adapter issuer, with a normative upper bound of +configuration per authorized issuer, with a normative upper bound of 60 seconds. The relay enforces `0 < maximum_command_age <= 60` and `now < iat + maximum_command_age` in addition to `now < exp`. A missing, non-positive, or out-of-range configuration denies. @@ -379,7 +381,7 @@ VerifyCommandJwt(token, request_method, request_path, request_body_pubkey): target_k := ParseHexKey(claims.target_pubkey) or DENY(evidence_rejected) // 4. Principal authorization (pure check — no side effects) - AssertAuthorizedAdapterPrincipal(claims.iss, claims.sub) or DENY(authorization_denied) + AssertAuthorizedIssuerPrincipal(claims.iss, claims.sub) or DENY(authorization_denied) // 5. Signed-target / request-body agreement (pure check — no side effects) assert target_k == request_body_pubkey or DENY(authorization_denied) @@ -437,7 +439,7 @@ exception and reveals only that a required dependency is unreadable. |---|---|---|---| | assertion or proof absent | `missing_evidence` | `auth-required: authentication required` | `401`; `WWW-Authenticate: Nostr`; `Content-Type: text/plain; charset=utf-8`; body `authentication required\n` | | malformed, invalid, or expired evidence | `evidence_rejected` | `restricted: evidence rejected` | `403`; `Content-Type: text/plain; charset=utf-8`; body `evidence rejected\n` | -| assertion–key mismatch; local policy denial; adapter-initiated disconnect (session-only model) | `authorization_denied` | `restricted: authorization denied` | `403`; `Content-Type: text/plain; charset=utf-8`; body `authorization denied\n` | +| assertion–key mismatch; local policy denial; issuer-initiated disconnect (session-only model) | `authorization_denied` | `restricted: authorization denied` | `403`; `Content-Type: text/plain; charset=utf-8`; body `authorization denied\n` | | required JWKS snapshot unreadable | `authorization_unavailable` | `restricted: authorization unavailable` | `503`; `Content-Type: text/plain; charset=utf-8`; body `authorization unavailable\n` | A denial decided on a WebSocket upgrade is the HTTP response in place of `101`. @@ -451,16 +453,16 @@ filters, discovery, logs, metrics, or traces. [FI-TRACE-PRIVACY-NONPUBLIC] ## Out of scope -The following are adapter and deployment concerns. This spec defines no +The following are issuer and deployment concerns. This spec defines no normative behavior for them: - Identity↔key registry, key ownership records, and the one-identity one-key - constraint: adapter-side. -- Key rotation, re-enrollment after device loss: adapter-side. -- Revocation signaling to the adapter/IdP: adapter-side; the adapter stops + constraint: issuer-side. +- Key rotation, re-enrollment after device loss: issuer-side. +- Revocation signaling to the issuer/IdP: issuer-side; the issuer stops issuing assertions, which closes the relay window within assertion TTL. -- SCIM, HR system integration, employee offboarding automation: adapter-side. -- Audit logging beyond what the relay operator chooses to retain: adapter-side. +- Directory integration and account-offboarding automation: issuer-side. +- Audit logging beyond what the relay operator chooses to retain: issuer-side. - Delegation: out of scope. - Companion profiles (NIP-FI-EDGE, NIP-FI-LIFECYCLE, NIP-FI-DELEG, NIP-FI-CONF, NIP-FI-MODEL): removed. @@ -509,20 +511,20 @@ is the primary control against assertion replay across keys. **TTL window after revocation.** Offline JWT verification means the relay cannot observe IdP-side revocation until the current assertion expires. The -deployment adapter MUST configure a `max_connection_lifetime_seconds` and +deployment MUST configure a `max_connection_lifetime_seconds` and assertion TTL consistent with the organization's acceptable revocation latency. -For upstream revocation without an explicit disconnect call (adapter stops +For upstream revocation without an explicit disconnect call (issuer stops issuing assertions; no active session termination), access persists until the live session's effective authority deadlines expire. After the session closes naturally, a reconnect requires an assertion that remains valid when reverified; -if the adapter has stopped issuing, no valid assertion can be obtained and no -reconnect can succeed. If the adapter continues issuing assertions, access +if the issuer has stopped issuing, no valid assertion can be obtained and no +reconnect can succeed. If the issuer continues issuing assertions, access continues. -For the session-only disconnect model (adapter issues a successful disconnect +For the session-only disconnect model (issuer issues a successful disconnect call that closes all matching sessions synchronously), there is no surviving -old-session window. If the adapter also stops issuing new assertions at that +old-session window. If the issuer also stops issuing new assertions at that point, cumulative residual access is bounded by: ``` @@ -534,7 +536,7 @@ sessions; it does not shorten the total window. A snapshot refresh failure, hard-deadline expiry without key replacement, or signing-key removal can terminate access earlier, but these are not reliable protocol-level bounds: the JWKS snapshot deadline renews on each refresh even when content is unchanged. -If the adapter continues issuing new assertions after the disconnect call, +If the issuer continues issuing new assertions after the disconnect call, cumulative access extends indefinitely — the session-only protocol places no protocol-level bound on that case. See the non-normative note in the Admin disconnect section for the open product question on the deny-until-TTL From dc5f99360d16f720d498aa18e121d7a3828c2c23 Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 1 Sep 2026 19:03:14 -0400 Subject: [PATCH 9/9] docs(nip-fi): fix upstream-revocation reconnect claim; genericize note heading IMPORTANT: the upstream-revocation paragraph incorrectly stated that stopping issuance means 'no valid assertion can be obtained and no reconnect can succeed.' Previously issued assertions that have not yet expired remain valid for reconnection until min(exp, iat + maximum_assertion_age). Stopping issuance prevents minting new assertions that extend the window; it does not invalidate already-issued ones. Replaced the false conclusion with the two correct cases. MINOR: genericize non-normative note heading from 'open product question for Will/Tyler' to 'open product question (session-only vs deny-until-TTL)'. PR body: remove Slack/parent-channel and Will/Tyler process framing. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 5e8d4d1658f..845a150a4ee 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -279,7 +279,7 @@ operation: it closes existing connections but does not prevent the key from reconnecting. After disconnection, a client holding a still-valid JWT can reconnect immediately. -> **Non-normative note — open product question for Will/Tyler:** +> **Non-normative note — open product question (session-only vs deny-until-TTL):** > > The session-only model means a revoked user retains access until their > assertion's effective authority expires. After a successful disconnect call @@ -517,10 +517,13 @@ assertion TTL consistent with the organization's acceptable revocation latency. For upstream revocation without an explicit disconnect call (issuer stops issuing assertions; no active session termination), access persists until the live session's effective authority deadlines expire. After the session closes -naturally, a reconnect requires an assertion that remains valid when reverified; -if the issuer has stopped issuing, no valid assertion can be obtained and no -reconnect can succeed. If the issuer continues issuing assertions, access -continues. +naturally, a reconnect requires an assertion that remains valid when reverified. +Previously issued assertions that have not yet expired remain valid for +reconnection until `min(exp, iat + maximum_assertion_age)` (subject to possible +earlier termination from a snapshot refresh failure, hard-deadline expiry without +key replacement, or signing-key removal). Stopping issuance prevents minting +assertions that extend this window; it does not invalidate already-issued +assertions. If the issuer continues issuing assertions, access continues. For the session-only disconnect model (issuer issues a successful disconnect call that closes all matching sessions synchronously), there is no surviving