diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 08fa5762ee0..1d37ef78872 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) - -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. 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: - -``` -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 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. + +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 +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: