From 187df22252fa24cce2f3295fb9df9f4dc211b30a Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 2 Sep 2026 17:24:14 -0400 Subject: [PATCH] docs(nip-fi): adopt deny-until-TTL and extend enforcement to HTTP ingress (#7254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec revision following two decisions: Option B admin deny (2026-09-02) and the HTTP ingress ruling (2026-09-02). Revises `docs/nips/NIP-FI.md` only. Follows #7214 (merged spec v2). ## What changed ### Admin disconnect: session-only → deny-until-TTL The disconnect operation proceeds in two steps, in order: 1. Insert a memory-resident deny entry keyed by `(iss, target_pubkey)` with absolute expiry `until` — atomically combined with the `(iss, jti)` replay-identity reservation as one all-or-nothing mutation. If the deny set is at capacity (per-issuer bound), the relay rejects `503`; neither the jti nor the deny entry is recorded, and the caller may safely retry the same signed command. 2. Close all live WebSocket connections for the target pubkey, synchronously. The single atomic admission mutation (jti reservation + deny-entry insertion) lives inside `VerifyCommandJwt` step 7, after all pure authorization checks. The endpoint only closes sessions on success. This ordering ensures a capacity failure leaves no state behind and makes the retry-safe 503 contract implementable. The deny set is RAM-cache only — no durable storage, no schema changes. The same operational posture as the JWKS snapshot. A relay restart MAY forget active entries; the issuer SHOULD re-push still-active deny entries on observed restart (same publish/cache pattern as JWKS). If the issuer stops issuing assertions and re-push completes before any expired-entry reconnection attempt, residual exposure after restart is bounded by `max(0, min(exp, iat + maximum_assertion_age) - now)`. If the issuer continues issuing or re-push does not complete in time, that formula does not apply and access may continue beyond it. **`until` claim:** Required on the disconnect command JWT. Because an assertion accepted at the future-skew boundary (`iat <= now + skew`) remains valid until `iat + maximum_assertion_age`, the latest possible authority deadline is `now + skew + maximum_assertion_age`. The relay enforces `until <= now + skew + maximum_assertion_age`. A value above this ceiling rejects `400`; a past `until` still closes live sessions — absent an active same-key entry it creates no future denial, while an active entry remains unchanged under the merge rule. **Capacity and eviction (per-issuer):** The relay MUST bound the deny set size **per issuer**. Capacity exhaustion under one issuer MUST NOT cause rejection of another issuer's commands; the `503` capacity check is evaluated against the command's own issuer bound. Implementations MUST evict only expired entries; when an issuer's partition is at capacity and all entries are still active, the relay MUST reject the new command `503` without removing any existing entry. There is no LRU eviction of active denies. **Issuer-global deny:** The deny entry applies to admission across all communities served by the relay under that issuer. Identity-level revocation is intentionally not community-partial. **Cross-replica propagation:** In a deployment with multiple relay processes, the deployment MUST propagate both the session-close and the deny entry to every process serving admissions for the issuer's communities. The mechanism is deployment-defined (e.g. the existing inter-process message bus, same posture as JWKS convergence). Propagation is asynchronous with no protocol-level completion bound. The issuer re-push duty is the recovery path for lost propagation, exactly as for relay restart. **Response shape:** A successful disconnect responds `{"disconnected": true}` regardless of how many sessions were closed. No session count is returned; a count would aggregate activity across communities and constitute an information leak. **Admission procedure:** Step 5 registers the session's proven `k` before the deny-set check (new step 6) — ensuring any connection that straddles a concurrent disconnect is caught by one side or the other. `FI-TRACE-DENY-SET` oracle covers the per-issuer capacity rule and the straddling termination requirement. ### HTTP ingress enforcement Without explicit enforcement, a protected HTTP surface (bridge, invites, media, git) with NIP-98-only authorization allows a principal holding an active key to mint fresh NIP-98 events indefinitely — NIP-98 proves key possession only, not identity. Without assertion verification there is no expiry bound; the key remains valid for as long as it is accepted. **Pairing rule:** in enforce mode, a protected HTTP request MUST carry both: ``` Authorization: Nostr Nostr-Federated-Identity: Bearer ``` The NIP-98 pubkey MUST equal the assertion `nostr_pubkey` claim. Missing, mismatched, or invalid evidence of either kind denies, fail closed. **Verification:** reuses `VerifyAssertion` unchanged — offline, same JWKS, same claim requirements, same denial classes. **Per-request:** HTTP is sessionless; every request re-verifies. No session lifetime, no cached admission. The cumulative residual bound applies per request. **Deny-set applicability:** the deny-until-TTL entry introduced above is consulted per HTTP request identically to WebSocket admission. **Protected surface:** deployment-configured set of routes, fail-closed default (unclassifiable routes treated as protected). No normative route names in the spec. `FI-TRACE-HTTP-INGRESS` oracle added. Security considerations updated with HTTP ingress bypass analysis. NIP-98 source reference added. ### Other changes - `authorization_denied` rejection table row updated to "active deny-set entry for pubkey". - Discovery: `maximum_residual_upstream_revocation_seconds` remains `null` — the deny-until-TTL model is best-effort RAM state and provides no unconditional finite revocation bound. - Rejection and privacy: explicit sentence for HTTP denial path. - Client-attached transport: opening sentence generalized to cover both WebSocket and HTTP. ## Scope Single file: `docs/nips/NIP-FI.md`. No code changes. References #7214. Channel: buzz-enterprise-identity-spec-v2 (#a6fe0b1c-987a-43c5-a974-71ee36678d78). --------- Signed-off-by: Will Pfleger Signed-off-by: Duncan Co-authored-by: Duncan --- docs/nips/NIP-FI.md | 356 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 286 insertions(+), 70 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 08fa5762ee..1d37ef7887 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -6,7 +6,7 @@ Federated identity authorization — stateless core `draft` `optional` `relay` -**Protocol dependencies**: NIP-01, NIP-42. +**Protocol dependencies**: NIP-01, NIP-42, NIP-98. 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 @@ -126,7 +126,8 @@ field, parsing, attachment, and no-fallback semantics. ## Client-attached transport -The client sends exactly one field on the WebSocket upgrade request: +The client sends exactly one `Nostr-Federated-Identity` field on the +WebSocket upgrade request or protected HTTP request (see HTTP ingress): ```text Nostr-Federated-Identity: Bearer @@ -221,7 +222,15 @@ On WebSocket upgrade: 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 +5. Register the session's proven `k` in the relay's session table, making it + visible to the disconnect close scan. Registration MUST occur before the + deny-set check in step 6. This ordering ensures any connection that straddles + a concurrent disconnect is caught by one side or the other: either the close + scan sees the registered session, or the deny-set check (step 6) sees the + inserted entry. +6. Check deny set for `(iss, k)`; active entry (`now < until`) → deny + `authorization_denied`. [FI-TRACE-DENY-SET] +7. Admit the connection. The session's authority deadline is the minimum of all `authority_deadlines`; see Session policy. ## Session policy @@ -268,51 +277,97 @@ does not imply key revocation or identity loss; that is the issuer's domain. The assertion issuer can terminate live relay sessions for a specific public key via an authenticated `disconnect` call. -### Semantics (session-only) +### Semantics (deny-until-TTL) -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. +A disconnect call causes the relay to: -> **Non-normative note — open product question (session-only vs deny-until-TTL):** +1. Insert a **deny entry** keyed by `(iss, target_pubkey)` into the relay's + in-memory deny set, with an absolute expiry of `until` (a Unix timestamp + carried in the signed command JWT; see Command JWT). If an entry for + `(iss, target_pubkey)` already exists, the relay MUST retain + `max(existing_until, command.until)` — an accepted disconnect MUST NOT + shorten an active deny. A past-`until` command still closes sessions but + MUST NOT clear or shorten an independently active entry. Any subsequent + connection or admission attempt for that pubkey under the same issuer is + denied `authorization_denied` until `now >= until`. If the deny set is at + capacity and a new entry cannot be inserted, the relay MUST reject the command + `503`; no sessions are closed and no replay state is consumed. +2. Close all live WebSocket connections whose proven `k` equals the target + pubkey, synchronously. + +The deny set is held **in relay memory only** — no durable storage, no schema +changes. A relay restart MAY forget active deny entries. If the issuer stops +issuing assertions and re-push completes before any expired-entry reconnection +attempt, the residual exposure after a restart is bounded by +`max(0, min(exp, iat + maximum_assertion_age) - now)`. If the issuer continues +issuing or re-push does not complete in time, that formula does not apply and +access may continue beyond it. [FI-TRACE-DENY-SET] + +The relay MUST bound the deny set size **per issuer**. Capacity exhaustion under +one issuer MUST NOT cause rejection of another issuer's commands; the `503` +capacity check is evaluated against the command's own issuer bound. +Implementations MUST evict only expired entries; when an issuer's partition is +at capacity and all entries are still active, the relay MUST reject the new +command `503` without removing any existing entry. + +The `until` timestamp MUST NOT exceed the maximum possible remaining assertion +validity for any assertion the issuer could currently mint. Because an +assertion accepted at the future-skew boundary (`iat <= now + skew`) remains +valid until `iat + maximum_assertion_age`, the latest possible authority deadline +is `now + skew + maximum_assertion_age`; the relay MUST enforce +`until <= now + skew + maximum_assertion_age` for this issuer policy. An +`until` that exceeds this ceiling is rejected `400`. Supplying an +`until` in the past is a no-op disconnect (sessions are still closed, deny entry +is immediately expired); this is not an error. + +The deny entry is keyed `(iss, target_pubkey)` and applies to admission +across **all communities** served by the relay under that issuer. +Identity-level revocation is intentionally not community-partial: a key revoked +by its issuer loses access in every community that issuer governs. + +In a deployment with multiple relay processes, the deployment MUST propagate +both the session-close and the deny entry to every process serving admissions +for the issuer's communities. The propagation mechanism is deployment-defined +(for example, the existing inter-process message bus — the same posture as JWKS +snapshot convergence); each process holds its own RAM copy. Propagation is +asynchronous with no protocol-level completion bound. The issuer re-push duty +specified below is the recovery path for lost propagation, exactly as for relay +restart. A success response from the receiving process does not imply +cluster-wide application. + +> **Note — adopted position (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 -> (all matching sessions closed synchronously), there is no surviving -> old-session window. If the issuer also stops issuing new assertions at -> that point, cumulative residual access is bounded by: +> The session-only model closes existing connections but places no +> protocol-level bound on reconnection. For session-only, if the issuer also +> stops issuing new assertions after a disconnect call, cumulative residual +> access is bounded by: > > ``` > 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. 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 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. +> individual sessions; it does not shorten the total window. If the issuer +> continues issuing new assertions, cumulative access extends indefinitely. > -> 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 **deny-until-TTL** model closes this reconnect window. The relay holds a +> memory-resident deny set keyed by `(iss, pubkey)`, with absolute expiry +> carried by the issuer as `until` in the disconnect command. Any admission +> attempt for that key is denied until the entry expires. The issuer must boot +> the deny TTL to outlast the longest live assertion it may have already +> issued; otherwise an unexpired assertion lets the client back in the moment +> the entry expires. > -> The alternative is a **deny-until-TTL** model: the relay holds a -> 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 issuer and relay. +> The deny set is RAM-cache, not a database — the same operational posture as +> the JWKS snapshot. A relay restart clears the set; the issuer, as the +> durable system of record for revocations, SHOULD re-push still-active denies +> when it observes a relay restart (same publish/cache pattern as JWKS). A +> fresh relay MAY consult the issuer before first admissions to close the +> startup race; this is non-normative. > -> 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. +> This design was chosen because session-only disconnection must outlast +> the live socket to mean anything as a revocation primitive; a self-expiring +> RAM entry preserves the zero-persistence guarantee while closing the window. ### Transport @@ -345,6 +400,7 @@ The command JWT MUST carry the following claims: | `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. | +| `until` | Unix timestamp (NumericDate) at which the deny entry expires. MUST NOT exceed `now + skew + maximum_assertion_age` for this issuer policy. The relay validates this ceiling; a value that exceeds it rejects `400`. | The `maximum_command_age` policy knob is a required positive finite configuration per authorized issuer, with a normative upper bound of @@ -376,21 +432,41 @@ VerifyCommandJwt(token, request_method, request_path, request_body_pubkey): 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) + until := claims.until or DENY(evidence_rejected) - // 4. Principal authorization (pure check — no side effects) + // 4. Validate until ceiling + deny_ceiling := now + policy.skew + policy.maximum_assertion_age + assert until <= deny_ceiling or REJECT(400) // out-of-range until, not an auth failure + + // 5. Principal authorization (pure check — no side effects) AssertAuthorizedIssuerPrincipal(claims.iss, claims.sub) or DENY(authorization_denied) - // 5. Signed-target / request-body agreement (pure check — no side effects) + // 6. Signed-target / request-body agreement (pure check — no side effects) + // The body carries the target pubkey so the relay can route the disconnect + // without parsing the JWS first; the signed claim MUST agree with this + // independently parsed input. `until` has no such external routing role — + // the signed claim is the sole authority and is not repeated in the body. 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). 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. + // 7. Atomically reserve jti and insert deny entry — single all-or-nothing + // admission mutation, after all pure authorization checks and before any + // session close. Combining both mutations here ensures a capacity failure + // leaves neither behind: the jti is not burned, and the caller may safely + // retry the same signed command. Performing the jti reservation alone + // (without the deny-entry insertion) would burn the command identity on a + // capacity failure, making the new 503 contract unimplementable. Capacity + // is checked against the per-issuer bound for claims.iss; a different + // issuer's capacity exhaustion does not produce a 503 here. + // On same-key collision: retain max(existing_until, until) — never shorten + // an active deny. A past-until command merges as max(existing, past) which + // preserves any active entry; a fresh entry with a past-until inserts with + // an already-expired value (immediately inactive for future admissions). effective_expiry := min(claims.exp, claims.iat + policy.maximum_command_age) - AtomicReserveJti(claims.iss, claims.jti, effective_expiry) or DENY(authorization_denied) + AtomicReserveJtiAndDenyEntry( + iss=claims.iss, jti=claims.jti, effective_expiry=effective_expiry, + target_pubkey=target_k, until=until + ) or DENY(authorization_denied) // replay: jti already reserved + or REJECT(503) // capacity: deny set full; neither mutation applied return CommandResult(target_pubkey=target_k, caller=(claims.iss, claims.sub)) ``` @@ -412,18 +488,131 @@ Content-Type: application/json ``` 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}`. +body `pubkey` field; any failure denies per the rejection table. `VerifyCommandJwt` +performs all pure authorization checks and then, as its single atomic admission +mutation (step 7), simultaneously reserves the `(iss, jti)` replay identity and +inserts the deny entry — both or neither. A capacity failure at that step rejects +`503`; neither the jti nor the deny entry is recorded, and the caller may safely +retry the same signed command. On success, the relay closes all live connections +whose proven `k` equals `CommandResult.target_pubkey`. The `until` expiry is taken +exclusively from the signed command JWT claim; the request body carries no `until` +field. An unknown or unprovable pubkey is not an error; the relay responds `200` +with `{"disconnected": true}`. An `until` value in the past is not an error; +sessions are closed. Absent an active same-key deny entry the past-`until` +creates no future denial; if an active entry already exists it remains unchanged +under the merge rule. ### Response | Condition | Status | Body | |---|---|---| -| Authorized; action taken or no-op | `200` | `{"disconnected": }` where `n` is the count of sessions closed | +| Authorized; action taken or no-op | `200` | `{"disconnected": true}` | | Missing or invalid command JWT | `401` / `403` | Per the rejection table | -| Malformed request body | `400` | `bad request\n` | +| Malformed request body or `until` exceeds ceiling | `400` | `bad request\n` | +| Deny set at capacity | `503` | `deny set full\n` | + +## HTTP ingress + +In enforce mode, every protected HTTP request MUST carry both a NIP-98 +authorization event and a NIP-FI assertion bound to the same key, and the +deployment verifies both. NIP-98 proves key possession; NIP-FI proves +identity. Without the assertion check, a principal holding an active key +can mint fresh NIP-98 events indefinitely and retain HTTP access for as long +as the key remains accepted — the assertion's TTL provides no bound because +the assertion is never examined. Pairing introduces the assertion lifetime +bound; an active deny-set entry blocks the next request immediately. + +### Protected surfaces + +A **protected HTTP surface** is a deployment-configured set of routes for +which the deployment enforces NIP-FI admission. In enforce mode, the +deployment MUST apply NIP-FI verification to all routes in the protected +set; unprotected routes outside the set are not governed by this spec. The +protected set MUST be configured fail-closed: a route that cannot be +classified as exempt MUST be treated as protected. Deployment operators +define the set; this spec assigns no normative route names. + +The NIP-FI issuer→relay administrative API (e.g. `/api/nip-fi/disconnect`) +is **not** a protected HTTP surface. It is a distinct administrative +transport governed exclusively by the command-JWT contract in Admin +disconnect API. It carries `Nostr-Federated-Identity` for its command JWS, +not for an identity assertion, and MUST NOT be subjected to the HTTP ingress +admission procedure. + +> **Non-normative examples of surfaces that may appear in a protected set:** +> HTTP API bridge, invite redemption, media storage, git smart-HTTP. + +### Request format + +Each protected HTTP request MUST present both of the following: + +```text +Authorization: Nostr +Nostr-Federated-Identity: Bearer +``` + +`Authorization` carries the NIP-98 authorization event as specified in NIP-98. +The field MUST be present exactly once, MUST use the `Nostr` scheme with a +single base64-encoded event value, and MUST NOT be repeated, comma-combined, +empty, use an alternative scheme, or carry a fallback credential. Missing, +repeated, comma-combined, empty, malformed, non-`Nostr`, or wrong-scheme +`Authorization` values deny. + +`Nostr-Federated-Identity` carries the compact-JWS assertion, identical in +format to the WebSocket transport field. The field names are distinct; they +serve different roles and MUST NOT be combined or substituted for each other. +A request presenting only one of the two is denied. The rules for +`Nostr-Federated-Identity` from Client-attached transport apply unchanged: +missing, repeated, comma-combined, empty, malformed, non-Bearer, or +mixed-profile fields deny. [FI-TRACE-TRANSPORT-CLOSED] + +### Admission procedure + +On each protected HTTP request: + +1. Extract `Nostr-Federated-Identity`; missing or malformed → deny + `missing_evidence` or `evidence_rejected`. +2. Call `VerifyAssertion`; any error → deny per the rejection table. +3. Validate the NIP-98 `Authorization` event per NIP-98; extract the proven + pubkey `k` from the event. An absent, malformed, or invalid NIP-98 event + → deny `missing_evidence` or `evidence_rejected` as appropriate. + For requests with an authorization-relevant body, the NIP-98 event MUST + contain exactly one `payload` tag whose value is the lowercase hexadecimal + SHA-256 hash of the exact consumed request body bytes. An absent, + duplicate, or mismatched `payload` tag on such a request → deny + `evidence_rejected`. [FI-TRACE-HTTP-INGRESS] +4. Assert `verified.asserted_key == k`; mismatch → deny `authorization_denied`. + [FI-TRACE-ASSERTION-KEY-MISMATCH] +5. Check deny set for `(iss, k)`; active entry (`now < until`) → deny + `authorization_denied`. [FI-TRACE-DENY-SET] +6. Admit the request. + +A body is **authorization-relevant** whenever any body byte influences the +authorization decision, target resource, requested capability, effect +selector, or state change. Every state-changing body is authorization-relevant. +A body may be classified non-authorization-relevant only if none of those +properties derive from body fields that are not otherwise bound. The +classification MUST be fail-closed: a body that cannot be classified as +non-authorization-relevant MUST be treated as authorization-relevant and a +`payload` tag required. + +### Per-request verification + +HTTP is sessionless. **Every** protected request re-executes the full +admission procedure above; there is no session lifetime, no cached admission +decision, and no carry-over from a prior request. The cumulative residual +bound `max(0, min(exp, iat + maximum_assertion_age) - now)` applies per +request — there is no per-connection lifetime partition to shorten it +further. Issuers SHOULD configure short assertion TTLs consistent with the +deployment's acceptable revocation latency. + +### Denial responses + +Denial on a protected HTTP request produces an HTTP response (not a Nostr +text frame). The same public denial classes, status codes, and fixed body +bytes from the Rejection and privacy table apply. The response contains no +free text, reason code, issuer, subject, key, claim, or timing hint. +[FI-TRACE-DENIAL-ORACLE] ## Rejection and privacy @@ -435,11 +624,12 @@ 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; issuer-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; active deny-set entry for pubkey | `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. +A denial decided on a protected HTTP request is the HTTP response. +A denial decided after a WebSocket 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] @@ -479,6 +669,19 @@ A relay SHOULD advertise core support in NIP-11 as: } ``` +`maximum_residual_upstream_revocation_seconds` is `null` for the +`offline-jwt` class because this spec provides no unconditional finite +upstream-revocation bound. The deny-until-TTL mechanism closes the +reconnect window when the issuer issues a well-formed disconnect command +and the relay retains the entry, but neither condition is guaranteed by +the protocol: `until` is an upper ceiling on deny duration, not a lower +bound ensuring denial outlasts all live assertions; a past or short +`until` is valid; and a relay restart MAY forget active entries. If the +issuer stops issuance after a successful deny, the residual ceiling is +`max(0, min(exp, iat + maximum_assertion_age) - now)` — but this is an +issuer-operational property, not a protocol invariant this field can +advertise unconditionally. + Discovery MUST NOT state issuer URLs, audiences, claim names, tenant IDs, or deployment-local identifiers. [FI-TRACE-DISCOVERY-PRIVATE] @@ -494,6 +697,8 @@ deployment-local identifiers. [FI-TRACE-DISCOVERY-PRIVATE] | `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-DENY-SET` | A pubkey in the deny set is denied `authorization_denied` on admission until `now >= until`; an expired or absent entry does not deny; a past-`until` command closes sessions — absent an active same-key entry it creates no future denial, while an active entry remains unchanged under the merge rule; a deny-set-full command is rejected `503` without closing sessions and without removing any existing entry; capacity is evaluated per issuer — one issuer's capacity exhaustion MUST NOT reject another issuer's command; a connection that passes the deny-set check before a concurrent deny-entry insertion but completes admission after MUST still be terminated (the session's proven `k` is registered before the deny-set check, ensuring the close scan catches it); two overlapping commands for the same `(iss, pubkey)` in either delivery order result in `until = max(until_A, until_B)` — delivery order does not shorten the longer deny; a past-`until` command arriving over an active entry leaves the active entry's `until` unchanged; a successful disconnect responds `{"disconnected": true}` regardless of how many sessions were closed; the deny entry applies across all communities served by the relay under that issuer. | +| `FI-TRACE-HTTP-INGRESS` | A protected HTTP request with both valid headers and matching pubkeys is admitted; absent, mismatched, or invalid assertion or NIP-98 event denies; a request presenting only one of the two denies; an active deny-set entry denies; a route that cannot be classified as exempt is treated as protected; repeated, comma-combined, wrong-scheme, or alternative-credential `Authorization` fields deny; an authorization-relevant body without exactly one matching `payload` tag denies; the NIP-FI administrative API is not a protected surface. | | `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. | @@ -521,25 +726,35 @@ 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 -old-session window. If the issuer also stops issuing new assertions at that -point, cumulative residual access is bounded by: +For the deny-until-TTL disconnect model (issuer issues a successful disconnect +call with an `until` timestamp), the relay inserts a deny entry for the target +pubkey with expiry `until` and then closes all matching sessions synchronously. Any +subsequent admission attempt for that pubkey is denied `authorization_denied` +until `now >= until`. The `until` ceiling enforced by the relay is +`now + skew + maximum_assertion_age`; this limits how long a deny entry may last — +it does not ensure denial outlasts all live assertions. A short or past +`until` is valid, and if the issuer continues issuing after the entry +expires, access resumes. The issuer SHOULD set `until` to outlast the +longest still-live assertion it has issued to ensure no unexpired assertion +slides through the expiry boundary; this is an operational recommendation, +not a protocol invariant. -``` -max(0, min(exp, iat + maximum_assertion_age) - now) -``` +A relay restart clears the in-memory deny set. The issuer SHOULD re-push +still-active entries on observed restart, but re-push is not required and +carries no specified completion bound. If the issuer stops issuing +assertions and re-push completes before any expired-entry reconnection +attempt, the residual exposure after restart is bounded by +`max(0, min(exp, iat + maximum_assertion_age) - now)`. If the issuer +continues issuing or re-push does not complete in time, that formula does +not apply and access may continue beyond it. -`max_connection_lifetime_seconds` only partitions that interval into 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. -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 -alternative. +**HTTP ingress bypass.** Without the pairing rule, a principal holding an +active key can mint fresh NIP-98 events indefinitely and retain HTTP access +for as long as the key remains accepted — the assertion TTL provides no +bound when the assertion is never examined. The pairing rule closes this +gap by requiring assertion verification on every protected HTTP request. +The per-request re-verification model means there is no cached admission +window; a deny-set entry takes effect on the very next request. **SSRF.** The JWKS fetcher implements SSRF protection: HTTPS-only URI validation, DNS resolution with IP deny-list enforcement, address pinning to @@ -557,6 +772,7 @@ is bounded before any attacker-controlled lookup. ## Sources - NIP-42 authentication: +- NIP-98 HTTP authorization: - JWT BCP: - JWT access-token profile: - DPoP: