From e6bfef0765a94f96d9a201b759db700e5bbac93b Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 14:09:30 -0400 Subject: [PATCH 01/12] docs(nip-fi): adopt deny-until-TTL for admin disconnect Replace the session-only baseline with the Option B deny-until-TTL model decided on 2026-09-02. The disconnect command now carries an `until` timestamp; the relay inserts a memory-resident deny entry keyed by (iss, pubkey) and denies any subsequent admission attempt for that key until the entry expires. Key additions: - Semantics section rewritten: two-step operation (close sessions + insert deny entry), in-memory-only deny set, MAY-forget-on-restart semantics, fail-closed at capacity (503, not silent drop), until ceiling = now + maximum_assertion_age. - `until` claim added to the command JWT and VerifyCommandJwt procedure (step 4 validates ceiling before principal auth). - Admission at connection gains step 5: deny-set check keyed by (iss, k). - Response table adds 503 deny-set-full row; 400 row covers until > ceiling. - Rejection table: authorization_denied private condition updated from session-only model to active deny-set entry. - Discovery: maximum_residual_upstream_revocation_seconds is now non-null, set to the relay's configured maximum_assertion_age. - Behavioral oracles: FI-TRACE-DENY-SET added. - Security considerations: session-only paragraph replaced with deny-until-TTL analysis, including restart-race residual window. - Non-normative adopted-position note explains the RAM-cache / JWKS-analogy framing and the adapter re-push SHOULD for restart resilience. Closes: no issue. References: block/buzz#7214 (merged spec v2 baseline). Channel: buzz-enterprise-identity-spec-v2 (#a6fe0b1c-987a-43c5-a974-71ee36678d78) Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI.md | 169 +++++++++++++++++++++++++++----------------- 1 file changed, 103 insertions(+), 66 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 08fa5762ee0..2fe574091e2 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -221,7 +221,9 @@ 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. Check deny set for `(iss, k)`; active entry (`now < until`) → deny + `authorization_denied`. [FI-TRACE-DENY-SET] +6. Admit the connection. The session's authority deadline is the minimum of all `authority_deadlines`; see Session policy. ## Session policy @@ -268,51 +270,70 @@ 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) - -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 (session-only vs deny-until-TTL):** +### Semantics (deny-until-TTL) + +A disconnect call causes the relay to: + +1. Close all live WebSocket connections whose proven `k` equals the target + pubkey, synchronously. +2. 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 + supplied in the command body; see Request below). Any subsequent connection + or admission attempt for that pubkey under the same issuer is denied + `authorization_denied` until `now >= until`. + +The deny set is held **in relay memory only** — no durable storage, no schema +changes. A relay restart MAY forget active deny entries. The residual exposure +after a restart is bounded by the remaining assertion TTL +(`max(0, min(exp, iat + maximum_assertion_age) - now)`), which is finite by +the assertion contract. [FI-TRACE-DENY-SET] + +The relay MUST bound the deny set size. When the deny set is at capacity, the +relay MUST NOT silently drop the incoming deny entry; it MUST reject the command +with `503` so the adapter knows the entry was not recorded. Implementations +SHOULD use an LRU or TTL-expiry policy to age out entries before reaching the +hard cap. + +The `until` timestamp MUST NOT exceed the maximum possible remaining assertion +validity for any assertion the issuer could currently mint: the relay MUST +enforce `until <= now + 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. + +> **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 adapter, 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 +> seconds-wide 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 +366,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 + 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,23 +398,28 @@ 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.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) assert target_k == request_body_pubkey or DENY(authorization_denied) - // 6. Atomically reserve jti — final admission step, immediately before side effects. + // 7. 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 + // steps 5 or 6 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) - return CommandResult(target_pubkey=target_k, caller=(claims.iss, claims.sub)) + return CommandResult(target_pubkey=target_k, caller=(claims.iss, claims.sub), until=until) ``` Any failure at any step is fail-closed: no side effects occur and the relay @@ -408,14 +435,17 @@ POST /api/nip-fi/disconnect HTTP/1.1 Nostr-Federated-Identity: Bearer Content-Type: application/json -{"pubkey": ""} +{"pubkey": "", "until": } ``` 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}`. +`CommandResult.target_pubkey` and inserts a deny entry for `(iss, target_pubkey)` +with expiry `CommandResult.until`. An unknown or unprovable pubkey is not an +error; the relay responds `200` with `{"disconnected": 0}`. An `until` value in +the past is not an error; sessions are closed and the deny entry expires +immediately. ### Response @@ -423,7 +453,8 @@ error; the relay responds `200` with `{"disconnected": 0}`. |---|---|---| | Authorized; action taken or no-op | `200` | `{"disconnected": }` where `n` is the count of sessions closed | | 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` | ## Rejection and privacy @@ -435,7 +466,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; 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`. @@ -473,12 +504,19 @@ A relay SHOULD advertise core support in NIP-11 as: "core": "client-attached", "assertion_freshness": { "class": "offline-jwt", - "maximum_residual_upstream_revocation_seconds": null + "maximum_residual_upstream_revocation_seconds": } } } ``` +where `maximum_residual_upstream_revocation_seconds` SHOULD be set to the relay's +configured `maximum_assertion_age` ceiling — the maximum duration between a +successful disconnect call and full denial of any still-live assertion. This +value is non-null because the deny-until-TTL model provides a protocol-level +bound: the issuer supplies an `until` timestamp and the relay enforces the +ceiling at command time. + Discovery MUST NOT state issuer URLs, audiences, claim names, tenant IDs, or deployment-local identifiers. [FI-TRACE-DISCOVERY-PRIVATE] @@ -494,6 +532,7 @@ 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 and does not deny future admissions; a deny-set-full command is rejected `503` without closing sessions. | | `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 +560,23 @@ 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: - -``` -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. -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. +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 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 + maximum_assertion_age`, bounding the maximum residual exposure to the +maximum remaining validity of any assertion the issuer could have already +minted. 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. + +A relay restart clears the in-memory deny set. The adapter, as the durable +system of record for revocations, SHOULD re-push still-active entries on +observed restart. The residual exposure window during the seconds-wide restart +race is bounded by `max(0, min(exp, iat + maximum_assertion_age) - now)` — the +same bound as issuer-stops-issuance without a deny call, and finite by the +assertion contract. **SSRF.** The JWKS fetcher implements SSRF protection: HTTPS-only URI validation, DNS resolution with IP deny-list enforcement, address pinning to From a57f09dc5bcc0cbe6710d470dfa9ee4f4d962665 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 14:12:42 -0400 Subject: [PATCH 02/12] docs(nip-fi): replace 'adapter' with 'issuer' for OSS genericness The OSS artifact must not reference deployment-specific concepts. Replace the three remaining 'adapter' occurrences with 'issuer' to match the genericization applied in #7214. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 2fe574091e2..5f53f5ecd63 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -290,7 +290,7 @@ the assertion contract. [FI-TRACE-DENY-SET] The relay MUST bound the deny set size. When the deny set is at capacity, the relay MUST NOT silently drop the incoming deny entry; it MUST reject the command -with `503` so the adapter knows the entry was not recorded. Implementations +with `503` so the issuer knows the entry was not recorded. Implementations SHOULD use an LRU or TTL-expiry policy to age out entries before reaching the hard cap. @@ -325,7 +325,7 @@ is immediately expired); this is not an error. > the entry expires. > > The deny set is RAM-cache, not a database — the same operational posture as -> the JWKS snapshot. A relay restart clears the set; the adapter, as the +> 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 @@ -571,7 +571,7 @@ minted. 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. -A relay restart clears the in-memory deny set. The adapter, as the durable +A relay restart clears the in-memory deny set. The issuer, as the durable system of record for revocations, SHOULD re-push still-active entries on observed restart. The residual exposure window during the seconds-wide restart race is bounded by `max(0, min(exp, iat + maximum_assertion_age) - now)` — the From 81320a6f21b08c872e8fe4332041ac5fe842be37 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 14:20:06 -0400 Subject: [PATCH 03/12] docs(nip-fi): extend NIP-FI enforcement to HTTP ingress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a normative HTTP ingress section that closes the bypass gap where NIP-98-only protected surfaces would admit any key holding an unexpired assertion even after the principal is offboarded. Pairing rule: a protected HTTP request MUST present both a NIP-98 authorization event (Authorization: Nostr ) and a NIP-FI assertion (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. Per-request: every request re-verifies — no session, no cached admission. Deny-set consulted per request (FI-TRACE-DENY-SET applies to HTTP as it does to WebSocket). Protected surface = deployment-configured set, fail-closed default. Other changes: - Client-attached transport: generalize opening sentence to cover both WebSocket upgrade and protected HTTP request. - Rejection and privacy: add explicit sentence for HTTP request denial path. - Behavioral oracles: add FI-TRACE-HTTP-INGRESS. - Security considerations: add HTTP ingress bypass paragraph. - Sources: add NIP-98 reference. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI.md | 90 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 5f53f5ecd63..7bf459a9617 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -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 @@ -456,6 +457,80 @@ immediately. | 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, an offboarded principal's key +continues to authorize HTTP requests for the full remaining assertion TTL — +bypassing the identity system on every HTTP surface. + +### 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. + +> **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. +`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. +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. + +### 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 Public class is a function only of evidence the requester supplied, never of @@ -470,7 +545,8 @@ exception and reveals only that a required dependency is unreadable. | 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] @@ -533,6 +609,7 @@ deployment-local identifiers. [FI-TRACE-DISCOVERY-PRIVATE] | `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 and does not deny future admissions; a deny-set-full command is rejected `503` without closing sessions. | +| `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. | | `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. | @@ -578,6 +655,14 @@ race is bounded by `max(0, min(exp, iat + maximum_assertion_age) - now)` — the same bound as issuer-stops-issuance without a deny call, and finite by the assertion contract. +**HTTP ingress bypass.** Without the pairing rule, a principal whose key is +still valid for HTTP but whose assertion has been revoked (issuer stops +issuance or a deny-until-TTL entry is active) would retain HTTP access for +the full remaining assertion TTL. 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 prevent DNS rebinding TOCTOU, and redirect denial. The complete IANA @@ -594,6 +679,7 @@ is bounded before any attacker-controlled lookup. ## Sources - NIP-42 authentication: +- NIP-98 HTTP authorization: - JWT BCP: - JWT access-token profile: - DPoP: From c611cf768c6edc1670d52edd442610091121dd66 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 14:27:12 -0400 Subject: [PATCH 04/12] docs(nip-fi): address pass-1 review findings on HTTP ingress section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five IMPORTANT findings from Standard review pass 1/3: 1. Disconnect-endpoint collision: normatively exempt the issuer->relay administrative API from HTTP ingress — it is a distinct transport governed by command-JWT contract only, not the ingress admission procedure. 2. NIP-98 carrier closure: require exactly one Authorization: Nostr field; reject repeated/comma-combined/malformed/ wrong-scheme/fallback-credential variants. Fix base64url -> base64 per NIP-98 spec. FI-TRACE-HTTP-INGRESS extended. 3. Body binding: protected operations classify body relevance server-side, fail-closed. Authorization-relevant bodies require exactly one SHA-256 payload tag over exact consumed bytes; absent/duplicate/mismatched denies evidence_rejected. FI-TRACE-HTTP-INGRESS extended. 4. Dual until: remove until from the request body; signed JWT claim is the sole authority. The body carries pubkey for independent routing validation (signed claim must agree with an independently parsed input); until has no such external routing role. Asymmetry explained in VerifyCommandJwt step 6 comment. 5. Bypass analysis: correct both spec locations (:465-467 and security considerations) — bare NIP-98 permits access for as long as the key remains accepted, not just for assertion TTL; pairing introduces the assertion-lifetime bound; deny entry blocks next request until until. MINOR: add NIP-98 to protocol dependencies (:9); pin NIP-98 source reference to commit ae0fd96907d0767f07fb54ca1de9f197c600cb27. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI.md | 65 +++++++++++++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 7bf459a9617..c516ae1ac06 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 @@ -279,7 +279,7 @@ A disconnect call causes the relay to: pubkey, synchronously. 2. 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 - supplied in the command body; see Request below). Any subsequent connection + carried in the signed command JWT; see Command JWT). Any subsequent connection or admission attempt for that pubkey under the same issuer is denied `authorization_denied` until `now >= until`. @@ -409,6 +409,10 @@ VerifyCommandJwt(token, request_method, request_path, request_body_pubkey): AssertAuthorizedIssuerPrincipal(claims.iss, claims.sub) or DENY(authorization_denied) // 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) // 7. Atomically reserve jti — final admission step, immediately before side effects. @@ -436,14 +440,16 @@ POST /api/nip-fi/disconnect HTTP/1.1 Nostr-Federated-Identity: Bearer Content-Type: application/json -{"pubkey": "", "until": } +{"pubkey": ""} ``` 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` and inserts a deny entry for `(iss, target_pubkey)` -with expiry `CommandResult.until`. An unknown or unprovable pubkey is not an +with expiry `CommandResult.until`. 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": 0}`. An `until` value in the past is not an error; sessions are closed and the deny entry expires immediately. @@ -462,9 +468,11 @@ immediately. 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, an offboarded principal's key -continues to authorize HTTP requests for the full remaining assertion TTL — -bypassing the identity system on every HTTP surface. +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 @@ -476,6 +484,13 @@ 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. @@ -484,11 +499,17 @@ define the set; this spec assigns no normative route names. Each protected HTTP request MUST present both of the following: ```text -Authorization: Nostr +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. @@ -507,12 +528,22 @@ On each protected HTTP request: 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 (any request whose body + constitutes a state-changing operation), 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. +The deployment classifies body relevance server-side. 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 @@ -609,7 +640,7 @@ deployment-local identifiers. [FI-TRACE-DISCOVERY-PRIVATE] | `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 and does not deny future admissions; a deny-set-full command is rejected `503` without closing sessions. | -| `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. | +| `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. | @@ -655,13 +686,13 @@ race is bounded by `max(0, min(exp, iat + maximum_assertion_age) - now)` — the same bound as issuer-stops-issuance without a deny call, and finite by the assertion contract. -**HTTP ingress bypass.** Without the pairing rule, a principal whose key is -still valid for HTTP but whose assertion has been revoked (issuer stops -issuance or a deny-until-TTL entry is active) would retain HTTP access for -the full remaining assertion TTL. 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. +**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 @@ -679,7 +710,7 @@ is bounded before any attacker-controlled lookup. ## Sources - NIP-42 authentication: -- NIP-98 HTTP authorization: +- NIP-98 HTTP authorization: - JWT BCP: - JWT access-token profile: - DPoP: From bdaf199e6aef961a9da62fac9e7024ad0dbd0e14 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 14:34:42 -0400 Subject: [PATCH 05/12] docs(nip-fi): address pass-2 review findings on discovery and body relevance Two IMPORTANT findings from Standard review pass 2/3: 1. Discovery revocation bound: restore maximum_residual_upstream_revocation_seconds to null. The deny-until-TTL mechanism does not constitute an unconditional finite upstream-revocation bound: until is a ceiling on deny duration not a lower bound; past/short until is valid; relay restart MAY forget entries; and issuance need not stop. Replace the incorrect non-null prose with an accurate explanation that null reflects the offline-jwt class properties and that the stop-issuance residual ceiling is an issuer-operational property not advertised unconditionally by this field. 2. Body-relevance definition: expand from state-changing-only to the full scope NIP-98 binding requires. A body is authorization-relevant whenever any body byte influences the authorization decision, target resource, requested capability, effect selector, or state change. Covers body-selected read operations (GraphQL/search targets, capability selectors) that NIP-98 method+URL binding does not otherwise protect. Non-relevant classification requires that none of those properties derive from unbound body fields. Fail-closed rule unchanged. MINOR: update PR body to remove two stale passages (unexpired-assertion framing and base64url encoding). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI.md | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index c516ae1ac06..ea8865a9cdb 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -528,9 +528,8 @@ On each protected HTTP request: 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 (any request whose body - constitutes a state-changing operation), the NIP-98 event MUST contain - exactly one `payload` tag whose value is the lowercase hexadecimal + 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] @@ -540,9 +539,14 @@ On each protected HTTP request: `authorization_denied`. [FI-TRACE-DENY-SET] 6. Admit the request. -The deployment classifies body relevance server-side. 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. +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 @@ -611,18 +615,24 @@ A relay SHOULD advertise core support in NIP-11 as: "core": "client-attached", "assertion_freshness": { "class": "offline-jwt", - "maximum_residual_upstream_revocation_seconds": + "maximum_residual_upstream_revocation_seconds": null } } } ``` -where `maximum_residual_upstream_revocation_seconds` SHOULD be set to the relay's -configured `maximum_assertion_age` ceiling — the maximum duration between a -successful disconnect call and full denial of any still-live assertion. This -value is non-null because the deny-until-TTL model provides a protocol-level -bound: the issuer supplies an `until` timestamp and the relay enforces the -ceiling at command time. +`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] From e7bff7344fb8653196541ba2f105aa3081416d13 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 14:38:22 -0400 Subject: [PATCH 06/12] docs(nip-fi): correct security-considerations deny-until-TTL claims The security considerations section made two claims inconsistent with the corrected discovery contract: 1. The until ceiling (now + maximum_assertion_age) was described as bounding maximum residual exposure. That is incorrect: the ceiling limits how long a deny entry may last; it does not ensure denial outlasts all live assertions. A short or past until is valid; if the issuer continues issuing after the entry expires, access resumes. Rewritten to distinguish the ceiling (deny duration limit) from the operational recommendation (issuer SHOULD set until to outlast the longest live assertion). 2. The restart residual window was described as seconds-wide and unconditionally bounded by max(0, min(exp, iat+maximum_assertion_age)-now). That bound only holds if the issuer stops issuance and re-push completes before any expired-entry reconnect attempt -- neither is required by the protocol. Rewritten to condition the formula explicitly on those issuer behaviors and state that access may continue beyond it otherwise. These corrections make the security considerations consistent with the discovery null explanation added in the previous commit. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI.md | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index ea8865a9cdb..b95d310eea1 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -683,18 +683,22 @@ call with an `until` timestamp), the relay inserts a deny entry for the target pubkey with expiry `until` and 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 + maximum_assertion_age`, bounding the maximum residual exposure to the -maximum remaining validity of any assertion the issuer could have already -minted. 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. - -A relay restart clears the in-memory deny set. The issuer, as the durable -system of record for revocations, SHOULD re-push still-active entries on -observed restart. The residual exposure window during the seconds-wide restart -race is bounded by `max(0, min(exp, iat + maximum_assertion_age) - now)` — the -same bound as issuer-stops-issuance without a deny call, and finite by the -assertion contract. +`now + 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. + +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. **HTTP ingress bypass.** Without the pairing rule, a principal holding an active key can mint fresh NIP-98 events indefinitely and retain HTTP access From b6e7ff1c39e8512cf17195a6d9669dc14a01ce02 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 14:40:51 -0400 Subject: [PATCH 07/12] docs(nip-fi): drop 'seconds-wide' characterization from startup-race note Remove the unqualified 'seconds-wide' description of the restart startup race in the non-normative adopted-position blockquote. No normative mechanism bounds the race to seconds; the characterization was inconsistent with the corrected security-considerations language. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index b95d310eea1..7315a40d284 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -330,7 +330,7 @@ is immediately expired); this is not an error. > 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 -> seconds-wide startup race; this is non-normative. +> startup race; this is non-normative. > > This design was chosen because session-only disconnection must outlast > the live socket to mean anything as a revocation primitive; a self-expiring From db2db8767667199a21f618a80c7369c16017a312 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 15:55:22 -0400 Subject: [PATCH 08/12] docs(nip-fi): apply five Codex security corrections to admin-deny section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex #3 — deny-before-close reorder: insert the deny entry first (step 1), then close live sessions (step 2). A capacity failure at step 1 rejects 503 with no sessions closed and no replay state consumed. Request prose and Security considerations updated to match. Codex #4 — skew in until ceiling: an assertion accepted at the future-skew boundary (iat <= now + skew) remains valid until iat + maximum_assertion_age, so the latest possible authority deadline is now + skew + maximum_assertion_age. Updated in Semantics, VerifyCommandJwt step 4, Command JWT until claim row, and Security considerations. Codex #5 — no LRU eviction of active denies: implementations MUST evict only expired entries. At capacity with all entries active, the relay MUST reject 503 without removing any existing entry. Codex #1 (Will's ruling) — issuer-global deny, no count leak: deny entry explicitly stated as applying across all communities under that issuer; identity revocation is not community-partial. Success response changed from {"disconnected": } to {"disconnected": true}; returning a session count would aggregate activity across communities. Response table, Request prose, and FI-TRACE-DENY-SET oracle updated. Codex #2 (Will picked option b) — cross-replica propagation duty: in a multi-process deployment, the deployment MUST propagate both the session-close and the deny entry to every process serving admissions for the issuer's communities. Mechanism is deployment-defined. Propagation is asynchronous with no protocol-level completion bound. Issuer re-push is the recovery path for lost propagation, exactly as for relay restart. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger Signed-off-by: Duncan --- docs/nips/NIP-FI.md | 62 +++++++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 22 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 7315a40d284..af80b3e487d 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -275,13 +275,15 @@ authenticated `disconnect` call. A disconnect call causes the relay to: -1. Close all live WebSocket connections whose proven `k` equals the target - pubkey, synchronously. -2. Insert a **deny entry** keyed by `(iss, target_pubkey)` into the relay's +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). Any subsequent connection or admission attempt for that pubkey under the same issuer is denied - `authorization_denied` until `now >= until`. + `authorization_denied` until `now >= until`. If the deny set is at capacity + and the 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. The residual exposure @@ -289,19 +291,35 @@ after a restart is bounded by the remaining assertion TTL (`max(0, min(exp, iat + maximum_assertion_age) - now)`), which is finite by the assertion contract. [FI-TRACE-DENY-SET] -The relay MUST bound the deny set size. When the deny set is at capacity, the -relay MUST NOT silently drop the incoming deny entry; it MUST reject the command -with `503` so the issuer knows the entry was not recorded. Implementations -SHOULD use an LRU or TTL-expiry policy to age out entries before reaching the -hard cap. +The relay MUST bound the deny set size. Implementations MUST evict only +expired entries; when the set 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: the relay MUST -enforce `until <= now + maximum_assertion_age` for this issuer policy. An +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 closes existing connections but places no @@ -367,7 +385,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 + maximum_assertion_age` for this issuer policy. The relay validates this ceiling; a value that exceeds it rejects `400`. | +| `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 @@ -402,7 +420,7 @@ VerifyCommandJwt(token, request_method, request_path, request_body_pubkey): until := claims.until or DENY(evidence_rejected) // 4. Validate until ceiling - deny_ceiling := now + policy.maximum_assertion_age + 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) @@ -445,20 +463,20 @@ 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` and inserts a deny entry for `(iss, target_pubkey)` -with expiry `CommandResult.until`. The `until` expiry is taken exclusively +the relay inserts a deny entry for `(iss, target_pubkey)` with expiry +`CommandResult.until`, then 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": 0}`. An `until` value in -the past is not an error; sessions are closed and the deny entry expires +error; the relay responds `200` with `{"disconnected": true}`. An `until` value +in the past is not an error; sessions are closed and the deny entry expires immediately. ### 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 or `until` exceeds ceiling | `400` | `bad request\n` | | Deny set at capacity | `503` | `deny set full\n` | @@ -649,7 +667,7 @@ 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 and does not deny future admissions; a deny-set-full command is rejected `503` without closing sessions. | +| `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 and does not deny future admissions; a deny-set-full command is rejected `503` without closing sessions and without removing any existing entry; 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. | @@ -680,10 +698,10 @@ assertions. If the issuer continues issuing assertions, access continues. 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 closes all matching sessions synchronously. Any +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 + maximum_assertion_age`; this limits how long a deny entry may last — +`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 From 04e3519fd4feed590325ceb54e277d8e52c00bd8 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 16:02:55 -0400 Subject: [PATCH 09/12] docs(nip-fi): fix capacity contract and restart bound (Thufir pass-1 blockers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capacity-503 contract (Codex #3 follow-on): fold jti reservation and deny-entry insertion into one AtomicReserveJtiAndDenyEntry mutation in VerifyCommandJwt step 7. A capacity failure now leaves neither mutation behind — the jti is not burned and the caller may safely retry the same signed command. Previously the jti was reserved inside VerifyCommandJwt (step 7) and the deny entry was inserted separately by the endpoint; a capacity failure at the endpoint burned the jti, making the retry-safe 503 contract stated in Semantics unimplementable. Request prose updated to describe the single atomic admission step and its failure semantics. CommandResult no longer carries until (deny entry is already inserted). Semantics restart bound: condition the finite residual formula on the issuer having stopped issuance and re-push completing before any expired-entry reconnect attempt, and state that continued issuance without an effective re-push has no finite protocol bound. Aligns the last inconsistent copy with the matching language already in Discovery and Security considerations. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger Signed-off-by: Duncan --- docs/nips/NIP-FI.md | 51 ++++++++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index af80b3e487d..4c309bb272b 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -286,10 +286,12 @@ A disconnect call causes the relay to: 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. The residual exposure -after a restart is bounded by the remaining assertion TTL -(`max(0, min(exp, iat + maximum_assertion_age) - now)`), which is finite by -the assertion contract. [FI-TRACE-DENY-SET] +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. Implementations MUST evict only expired entries; when the set is at capacity and all entries are still active, @@ -433,16 +435,21 @@ VerifyCommandJwt(token, request_method, request_path, request_body_pubkey): // the signed claim is the sole authority and is not repeated in the body. assert target_k == request_body_pubkey or DENY(authorization_denied) - // 7. 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 5 or 6 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. 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), until=until) + return CommandResult(target_pubkey=target_k, caller=(claims.iss, claims.sub)) ``` Any failure at any step is fail-closed: no side effects occur and the relay @@ -462,15 +469,17 @@ 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 inserts a deny entry for `(iss, target_pubkey)` with expiry -`CommandResult.until`, then 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 and the deny entry expires -immediately. +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 and the deny entry expires immediately. ### Response From f0be4e84a3ec21e9dbcaad4ed0c08d678647203a Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 17:04:06 -0400 Subject: [PATCH 10/12] docs(nip-fi): add admission TOCTOU ordering rule and per-issuer deny bounds Two Codex-derived corrections to the admin deny-until-TTL section: 1. Admission TOCTOU: add step 5 requiring the session's proven k to be registered in the relay's session table before the deny-set check (now step 6). This ordering ensures any connection straddling a concurrent disconnect is caught by one side or the other -- close scan sees the registered session, or deny-set check sees the inserted entry. FI-TRACE- DENY-SET oracle updated with the straddling termination requirement. 2. Per-issuer deny-set bounds: replace the global bound sentence with a per-issuer bound. One issuer's capacity exhaustion MUST NOT reject another issuer's commands; the 503 check is evaluated against the command's own issuer bound. AtomicReserveJtiAndDenyEntry comment and FI-TRACE-DENY-SET oracle updated to match. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI.md | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 4c309bb272b..b0bf55bffdc 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -222,9 +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. Check deny set for `(iss, k)`; active entry (`now < until`) → deny +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] -6. Admit the connection. The session's authority deadline is the minimum of all +7. Admit the connection. The session's authority deadline is the minimum of all `authority_deadlines`; see Session policy. ## Session policy @@ -293,9 +299,12 @@ attempt, the residual exposure after a restart is bounded by 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. Implementations MUST evict only -expired entries; when the set is at capacity and all entries are still active, -the relay MUST reject the new command `503` without removing any existing entry. +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 @@ -441,7 +450,9 @@ VerifyCommandJwt(token, request_method, request_path, request_body_pubkey): // 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 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. effective_expiry := min(claims.exp, claims.iat + policy.maximum_command_age) AtomicReserveJtiAndDenyEntry( iss=claims.iss, jti=claims.jti, effective_expiry=effective_expiry, @@ -676,7 +687,7 @@ 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 and does not deny future admissions; a deny-set-full command is rejected `503` without closing sessions and without removing any existing entry; 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-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 and does not deny future admissions; 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); 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. | From a32518e6c0dba89605385640b337a3a50cafe400 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 17:06:50 -0400 Subject: [PATCH 11/12] docs(nip-fi): add same-key deny merge rule to prevent revocation shortening A delayed disconnect command with an earlier until could silently overwrite an active deny entry and shorten the revocation window (delivery reordering on one process, no restart or replica machinery needed). Fix: on same-key (iss, target_pubkey) collision, 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. Updated in three locations: - Semantics step 1: normative max-merge rule with past-until behavior - AtomicReserveJtiAndDenyEntry comment: same-key collision semantics - FI-TRACE-DENY-SET oracle: both delivery orders of overlapping commands and past-until-over-active case Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index b0bf55bffdc..5545fba4218 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -283,11 +283,15 @@ A disconnect call causes the relay to: 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). 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 the entry cannot be inserted, the relay MUST reject the command `503`; no - sessions are closed and no replay state is consumed. + 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. @@ -453,6 +457,10 @@ VerifyCommandJwt(token, request_method, request_path, request_body_pubkey): // 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) AtomicReserveJtiAndDenyEntry( iss=claims.iss, jti=claims.jti, effective_expiry=effective_expiry, @@ -687,7 +695,7 @@ 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 and does not deny future admissions; 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); 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-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 and does not deny future admissions; 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. | From af2db3a270c1fe961db6307d577eb2ac03d9a450 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 17:16:32 -0400 Subject: [PATCH 12/12] docs(nip-fi): condition past-until outcomes on same-key entry presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Request prose and FI-TRACE-DENY-SET oracle stated that a past-until command's deny entry 'expires immediately' / 'does not deny future admissions' unconditionally — contradicting the max-merge rule already present in Semantics and the oracle's own later same-key clause. Replace with conditioned language: 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. PR body updated to match. No other text moves. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/nips/NIP-FI.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 5545fba4218..1d37ef78872 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -498,7 +498,9 @@ whose proven `k` equals `CommandResult.target_pubkey`. The `until` expiry is ta 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 and the deny entry expires immediately. +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 @@ -695,7 +697,7 @@ 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 and does not deny future admissions; 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-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. |