docs(nip-fi): adopt deny-until-TTL and extend enforcement to HTTP ingress (#7254)

Spec revision following two decisions: Option B admin deny (2026-09-02)
and the HTTP ingress ruling (2026-09-02). Revises `docs/nips/NIP-FI.md`
only. Follows #7214 (merged spec v2).

## What changed

### Admin disconnect: session-only → deny-until-TTL

The disconnect operation proceeds in two steps, in order:

1. Insert a memory-resident deny entry keyed by `(iss, target_pubkey)`
with absolute expiry `until` — atomically combined with the `(iss, jti)`
replay-identity reservation as one all-or-nothing mutation. If the deny
set is at capacity (per-issuer bound), the relay rejects `503`; neither
the jti nor the deny entry is recorded, and the caller may safely retry
the same signed command.
2. Close all live WebSocket connections for the target pubkey,
synchronously.

The single atomic admission mutation (jti reservation + deny-entry
insertion) lives inside `VerifyCommandJwt` step 7, after all pure
authorization checks. The endpoint only closes sessions on success. This
ordering ensures a capacity failure leaves no state behind and makes the
retry-safe 503 contract implementable.

The deny set is RAM-cache only — no durable storage, no schema changes.
The same operational posture as the JWKS snapshot. A relay restart MAY
forget active entries; the issuer SHOULD re-push still-active deny
entries on observed restart (same publish/cache pattern as JWKS). If the
issuer stops issuing assertions and re-push completes before any
expired-entry reconnection attempt, residual exposure after restart is
bounded by `max(0, min(exp, iat + maximum_assertion_age) - now)`. If the
issuer continues issuing or re-push does not complete in time, that
formula does not apply and access may continue beyond it.

**`until` claim:** Required on the disconnect command JWT. Because an
assertion accepted at the future-skew boundary (`iat <= now + skew`)
remains valid until `iat + maximum_assertion_age`, the latest possible
authority deadline is `now + skew + maximum_assertion_age`. The relay
enforces `until <= now + skew + maximum_assertion_age`. A value above
this ceiling rejects `400`; a past `until` still closes live sessions —
absent an active same-key entry it creates no future denial, while an
active entry remains unchanged under the merge rule.

**Capacity and eviction (per-issuer):** The relay MUST bound the deny
set size **per issuer**. Capacity exhaustion under one issuer MUST NOT
cause rejection of another issuer's commands; the `503` capacity check
is evaluated against the command's own issuer bound. Implementations
MUST evict only expired entries; when an issuer's partition is at
capacity and all entries are still active, the relay MUST reject the new
command `503` without removing any existing entry. There is no LRU
eviction of active denies.

**Issuer-global deny:** The deny entry applies to admission across all
communities served by the relay under that issuer. Identity-level
revocation is intentionally not community-partial.

**Cross-replica propagation:** In a deployment with multiple relay
processes, the deployment MUST propagate both the session-close and the
deny entry to every process serving admissions for the issuer's
communities. The mechanism is deployment-defined (e.g. the existing
inter-process message bus, same posture as JWKS convergence).
Propagation is asynchronous with no protocol-level completion bound. The
issuer re-push duty is the recovery path for lost propagation, exactly
as for relay restart.

**Response shape:** A successful disconnect responds `{"disconnected":
true}` regardless of how many sessions were closed. No session count is
returned; a count would aggregate activity across communities and
constitute an information leak.

**Admission procedure:** Step 5 registers the session's proven `k`
before the deny-set check (new step 6) — ensuring any connection that
straddles a concurrent disconnect is caught by one side or the other.
`FI-TRACE-DENY-SET` oracle covers the per-issuer capacity rule and the
straddling termination requirement.

### HTTP ingress enforcement

Without explicit enforcement, a protected HTTP surface (bridge, invites,
media, git) with NIP-98-only authorization allows a principal holding an
active key to mint fresh NIP-98 events indefinitely — NIP-98 proves key
possession only, not identity. Without assertion verification there is
no expiry bound; the key remains valid for as long as it is accepted.

**Pairing rule:** in enforce mode, a protected HTTP request MUST carry
both:

```
Authorization: Nostr <base64-NIP-98-event>
Nostr-Federated-Identity: Bearer <compact-JWS>
```

The NIP-98 pubkey MUST equal the assertion `nostr_pubkey` claim.
Missing, mismatched, or invalid evidence of either kind denies, fail
closed.

**Verification:** reuses `VerifyAssertion` unchanged — offline, same
JWKS, same claim requirements, same denial classes.

**Per-request:** HTTP is sessionless; every request re-verifies. No
session lifetime, no cached admission. The cumulative residual bound
applies per request.

**Deny-set applicability:** the deny-until-TTL entry introduced above is
consulted per HTTP request identically to WebSocket admission.

**Protected surface:** deployment-configured set of routes, fail-closed
default (unclassifiable routes treated as protected). No normative route
names in the spec.

`FI-TRACE-HTTP-INGRESS` oracle added. Security considerations updated
with HTTP ingress bypass analysis. NIP-98 source reference added.

### Other changes

- `authorization_denied` rejection table row updated to "active deny-set
entry for pubkey".
- Discovery: `maximum_residual_upstream_revocation_seconds` remains
`null` — the deny-until-TTL model is best-effort RAM state and provides
no unconditional finite revocation bound.
- Rejection and privacy: explicit sentence for HTTP denial path.
- Client-attached transport: opening sentence generalized to cover both
WebSocket and HTTP.

## Scope

Single file: `docs/nips/NIP-FI.md`. No code changes.

References #7214. Channel: buzz-enterprise-identity-spec-v2
(#a6fe0b1c-987a-43c5-a974-71ee36678d78).

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-09-02 17:24:14 -04:00
committed by GitHub
parent b6dc533b7c
commit 187df22252

View File

@@ -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 <compact-JWS>
@@ -221,7 +222,15 @@ On WebSocket upgrade:
3. Complete NIP-42 handshake; validate AUTH event, extract `k`.
4. Assert `verified.asserted_key == k`; mismatch → deny `authorization_denied`.
[FI-TRACE-ASSERTION-KEY-MISMATCH]
5. Admit the connection. The session's authority deadline is the minimum of all
5. Register the session's proven `k` in the relay's session table, making it
visible to the disconnect close scan. Registration MUST occur before the
deny-set check in step 6. This ordering ensures any connection that straddles
a concurrent disconnect is caught by one side or the other: either the close
scan sees the registered session, or the deny-set check (step 6) sees the
inserted entry.
6. Check deny set for `(iss, k)`; active entry (`now < until`) → deny
`authorization_denied`. [FI-TRACE-DENY-SET]
7. Admit the connection. The session's authority deadline is the minimum of all
`authority_deadlines`; see Session policy.
## Session policy
@@ -268,51 +277,97 @@ does not imply key revocation or identity loss; that is the issuer's domain.
The assertion issuer can terminate live relay sessions for a specific public key via an
authenticated `disconnect` call.
### Semantics (session-only)
### Semantics (deny-until-TTL)
A disconnect call causes the relay to close all live WebSocket connections
whose proven `k` equals the target pubkey. This is a **session-only**
operation: it closes existing connections but does not prevent the key from
reconnecting. After disconnection, a client holding a still-valid JWT can
reconnect immediately.
A disconnect call causes the relay to:
> **Non-normative note — open product question (session-only vs deny-until-TTL):**
1. Insert a **deny entry** keyed by `(iss, target_pubkey)` into the relay's
in-memory deny set, with an absolute expiry of `until` (a Unix timestamp
carried in the signed command JWT; see Command JWT). If an entry for
`(iss, target_pubkey)` already exists, the relay MUST retain
`max(existing_until, command.until)` — an accepted disconnect MUST NOT
shorten an active deny. A past-`until` command still closes sessions but
MUST NOT clear or shorten an independently active entry. Any subsequent
connection or admission attempt for that pubkey under the same issuer is
denied `authorization_denied` until `now >= until`. If the deny set is at
capacity and a new entry cannot be inserted, the relay MUST reject the command
`503`; no sessions are closed and no replay state is consumed.
2. Close all live WebSocket connections whose proven `k` equals the target
pubkey, synchronously.
The deny set is held **in relay memory only** — no durable storage, no schema
changes. A relay restart MAY forget active deny entries. If the issuer stops
issuing assertions and re-push completes before any expired-entry reconnection
attempt, the residual exposure after a restart is bounded by
`max(0, min(exp, iat + maximum_assertion_age) - now)`. If the issuer continues
issuing or re-push does not complete in time, that formula does not apply and
access may continue beyond it. [FI-TRACE-DENY-SET]
The relay MUST bound the deny set size **per issuer**. Capacity exhaustion under
one issuer MUST NOT cause rejection of another issuer's commands; the `503`
capacity check is evaluated against the command's own issuer bound.
Implementations MUST evict only expired entries; when an issuer's partition is
at capacity and all entries are still active, the relay MUST reject the new
command `503` without removing any existing entry.
The `until` timestamp MUST NOT exceed the maximum possible remaining assertion
validity for any assertion the issuer could currently mint. Because an
assertion accepted at the future-skew boundary (`iat <= now + skew`) remains
valid until `iat + maximum_assertion_age`, the latest possible authority deadline
is `now + skew + maximum_assertion_age`; the relay MUST enforce
`until <= now + skew + maximum_assertion_age` for this issuer policy. An
`until` that exceeds this ceiling is rejected `400`. Supplying an
`until` in the past is a no-op disconnect (sessions are still closed, deny entry
is immediately expired); this is not an error.
The deny entry is keyed `(iss, target_pubkey)` and applies to admission
across **all communities** served by the relay under that issuer.
Identity-level revocation is intentionally not community-partial: a key revoked
by its issuer loses access in every community that issuer governs.
In a deployment with multiple relay processes, the deployment MUST propagate
both the session-close and the deny entry to every process serving admissions
for the issuer's communities. The propagation mechanism is deployment-defined
(for example, the existing inter-process message bus — the same posture as JWKS
snapshot convergence); each process holds its own RAM copy. Propagation is
asynchronous with no protocol-level completion bound. The issuer re-push duty
specified below is the recovery path for lost propagation, exactly as for relay
restart. A success response from the receiving process does not imply
cluster-wide application.
> **Note — adopted position (session-only vs deny-until-TTL):**
>
> The session-only model means a revoked user retains access until their
> assertion's effective authority expires. After a successful disconnect call
> (all matching sessions closed synchronously), there is no surviving
> old-session window. If the issuer also stops issuing new assertions at
> that point, cumulative residual access is bounded by:
> The session-only model closes existing connections but places no
> protocol-level bound on reconnection. For session-only, if the issuer also
> stops issuing new assertions after a disconnect call, cumulative residual
> access is bounded by:
>
> ```
> max(0, min(exp, iat + maximum_assertion_age) - now)
> ```
>
> `max_connection_lifetime_seconds` only partitions that interval into
> individual sessions; it does not shorten the total window. A snapshot
> refresh failure, hard-deadline expiry without key replacement, or signing-key
> removal can terminate access earlier, but these are not reliable protocol-level
> bounds: the JWKS snapshot deadline renews on each refresh even when content is
> unchanged, so it does not cap cumulative access. If the issuer
> continues issuing new assertions after the disconnect call, cumulative
> access extends indefinitely — the session-only protocol places no
> protocol-level bound on that case.
> individual sessions; it does not shorten the total window. If the issuer
> continues issuing new assertions, cumulative access extends indefinitely.
>
> If the disconnect call is asynchronous or best-effort, the spec would need
> to define a completion-bound contract; the current normative text assumes
> synchronous close.
> The **deny-until-TTL** model closes this reconnect window. The relay holds a
> memory-resident deny set keyed by `(iss, pubkey)`, with absolute expiry
> carried by the issuer as `until` in the disconnect command. Any admission
> attempt for that key is denied until the entry expires. The issuer must boot
> the deny TTL to outlast the longest live assertion it may have already
> issued; otherwise an unexpired assertion lets the client back in the moment
> the entry expires.
>
> The alternative is a **deny-until-TTL** model: the relay holds a
> memory-resident deny-list entry for the pubkey keyed to the issuer's stated
> TTL, and any reconnect attempt for that key is denied `authorization_denied`
> until the entry expires. This eliminates the reconnect window at the cost of
> relay in-memory state and a TTL-propagation contract between issuer and relay.
> The deny set is RAM-cache, not a database — the same operational posture as
> the JWKS snapshot. A relay restart clears the set; the issuer, as the
> durable system of record for revocations, SHOULD re-push still-active denies
> when it observes a relay restart (same publish/cache pattern as JWKS). A
> fresh relay MAY consult the issuer before first admissions to close the
> startup race; this is non-normative.
>
> This document intentionally leaves that decision unresolved. The current
> normative text describes session-only. If deny-until-TTL is chosen, Section 6
> must be revised to add: the TTL parameter on the disconnect call, the
> deny-list data structure (keyed by pubkey, value = absolute expiry), the
> deny-list check at admission (step 4), and the expiry/eviction rule.
> This design was chosen because session-only disconnection must outlast
> the live socket to mean anything as a revocation primitive; a self-expiring
> RAM entry preserves the zero-persistence guarantee while closing the window.
### Transport
@@ -345,6 +400,7 @@ The command JWT MUST carry the following claims:
| `path` | Exactly `"/api/nip-fi/disconnect"` (literal string). Binds the command to the endpoint. |
| `cmd` | Exactly `"disconnect"` (literal string). Operation selector. |
| `target_pubkey` | Lowercase hexadecimal encoding of the target 32-byte Nostr public key — the same encoding required for the assertion `nostr_pubkey` claim. |
| `until` | Unix timestamp (NumericDate) at which the deny entry expires. MUST NOT exceed `now + skew + maximum_assertion_age` for this issuer policy. The relay validates this ceiling; a value that exceeds it rejects `400`. |
The `maximum_command_age` policy knob is a required positive finite
configuration per authorized issuer, with a normative upper bound of
@@ -376,21 +432,41 @@ VerifyCommandJwt(token, request_method, request_path, request_body_pubkey):
assert claims.path == request_path or DENY(evidence_rejected)
assert claims.cmd == "disconnect" or DENY(evidence_rejected)
target_k := ParseHexKey(claims.target_pubkey) or DENY(evidence_rejected)
until := claims.until or DENY(evidence_rejected)
// 4. Principal authorization (pure check — no side effects)
// 4. Validate until ceiling
deny_ceiling := now + policy.skew + policy.maximum_assertion_age
assert until <= deny_ceiling or REJECT(400) // out-of-range until, not an auth failure
// 5. Principal authorization (pure check — no side effects)
AssertAuthorizedIssuerPrincipal(claims.iss, claims.sub) or DENY(authorization_denied)
// 5. Signed-target / request-body agreement (pure check — no side effects)
// 6. Signed-target / request-body agreement (pure check — no side effects)
// The body carries the target pubkey so the relay can route the disconnect
// without parsing the JWS first; the signed claim MUST agree with this
// independently parsed input. `until` has no such external routing role —
// the signed claim is the sole authority and is not repeated in the body.
assert target_k == request_body_pubkey or DENY(authorization_denied)
// 6. Atomically reserve jti — final admission step, immediately before side effects.
// The reservation is keyed by (iss, jti) and held until the command's
// effective expiry: min(exp, iat + maximum_command_age). This step MUST
// be the last mutation before disconnect side effects; performing it before
// steps 4 or 5 would burn the signed command identity on failed-authorization
// or mismatched-body requests, violating the fail-closed contract.
// 7. Atomically reserve jti and insert deny entry — single all-or-nothing
// admission mutation, after all pure authorization checks and before any
// session close. Combining both mutations here ensures a capacity failure
// leaves neither behind: the jti is not burned, and the caller may safely
// retry the same signed command. Performing the jti reservation alone
// (without the deny-entry insertion) would burn the command identity on a
// capacity failure, making the new 503 contract unimplementable. Capacity
// is checked against the per-issuer bound for claims.iss; a different
// issuer's capacity exhaustion does not produce a 503 here.
// On same-key collision: retain max(existing_until, until) — never shorten
// an active deny. A past-until command merges as max(existing, past) which
// preserves any active entry; a fresh entry with a past-until inserts with
// an already-expired value (immediately inactive for future admissions).
effective_expiry := min(claims.exp, claims.iat + policy.maximum_command_age)
AtomicReserveJti(claims.iss, claims.jti, effective_expiry) or DENY(authorization_denied)
AtomicReserveJtiAndDenyEntry(
iss=claims.iss, jti=claims.jti, effective_expiry=effective_expiry,
target_pubkey=target_k, until=until
) or DENY(authorization_denied) // replay: jti already reserved
or REJECT(503) // capacity: deny set full; neither mutation applied
return CommandResult(target_pubkey=target_k, caller=(claims.iss, claims.sub))
```
@@ -412,18 +488,131 @@ Content-Type: application/json
```
The relay calls `VerifyCommandJwt` passing the request method, path, and
body `pubkey` field; any failure denies per the rejection table. On success,
the relay closes all live connections whose proven `k` equals
`CommandResult.target_pubkey`. An unknown or unprovable pubkey is not an
error; the relay responds `200` with `{"disconnected": 0}`.
body `pubkey` field; any failure denies per the rejection table. `VerifyCommandJwt`
performs all pure authorization checks and then, as its single atomic admission
mutation (step 7), simultaneously reserves the `(iss, jti)` replay identity and
inserts the deny entry — both or neither. A capacity failure at that step rejects
`503`; neither the jti nor the deny entry is recorded, and the caller may safely
retry the same signed command. On success, the relay closes all live connections
whose proven `k` equals `CommandResult.target_pubkey`. The `until` expiry is taken
exclusively from the signed command JWT claim; the request body carries no `until`
field. An unknown or unprovable pubkey is not an error; the relay responds `200`
with `{"disconnected": true}`. An `until` value in the past is not an error;
sessions are closed. Absent an active same-key deny entry the past-`until`
creates no future denial; if an active entry already exists it remains unchanged
under the merge rule.
### Response
| Condition | Status | Body |
|---|---|---|
| Authorized; action taken or no-op | `200` | `{"disconnected": <n>}` 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 <base64-NIP-98-event>
Nostr-Federated-Identity: Bearer <compact-JWS>
```
`Authorization` carries the NIP-98 authorization event as specified in NIP-98.
The field MUST be present exactly once, MUST use the `Nostr` scheme with a
single base64-encoded event value, and MUST NOT be repeated, comma-combined,
empty, use an alternative scheme, or carry a fallback credential. Missing,
repeated, comma-combined, empty, malformed, non-`Nostr`, or wrong-scheme
`Authorization` values deny.
`Nostr-Federated-Identity` carries the compact-JWS assertion, identical in
format to the WebSocket transport field. The field names are distinct; they
serve different roles and MUST NOT be combined or substituted for each other.
A request presenting only one of the two is denied. The rules for
`Nostr-Federated-Identity` from Client-attached transport apply unchanged:
missing, repeated, comma-combined, empty, malformed, non-Bearer, or
mixed-profile fields deny. [FI-TRACE-TRANSPORT-CLOSED]
### Admission procedure
On each protected HTTP request:
1. Extract `Nostr-Federated-Identity`; missing or malformed → deny
`missing_evidence` or `evidence_rejected`.
2. Call `VerifyAssertion`; any error → deny per the rejection table.
3. Validate the NIP-98 `Authorization` event per NIP-98; extract the proven
pubkey `k` from the event. An absent, malformed, or invalid NIP-98 event
→ deny `missing_evidence` or `evidence_rejected` as appropriate.
For requests with an authorization-relevant body, the NIP-98 event MUST
contain exactly one `payload` tag whose value is the lowercase hexadecimal
SHA-256 hash of the exact consumed request body bytes. An absent,
duplicate, or mismatched `payload` tag on such a request → deny
`evidence_rejected`. [FI-TRACE-HTTP-INGRESS]
4. Assert `verified.asserted_key == k`; mismatch → deny `authorization_denied`.
[FI-TRACE-ASSERTION-KEY-MISMATCH]
5. Check deny set for `(iss, k)`; active entry (`now < until`) → deny
`authorization_denied`. [FI-TRACE-DENY-SET]
6. Admit the request.
A body is **authorization-relevant** whenever any body byte influences the
authorization decision, target resource, requested capability, effect
selector, or state change. Every state-changing body is authorization-relevant.
A body may be classified non-authorization-relevant only if none of those
properties derive from body fields that are not otherwise bound. The
classification MUST be fail-closed: a body that cannot be classified as
non-authorization-relevant MUST be treated as authorization-relevant and a
`payload` tag required.
### Per-request verification
HTTP is sessionless. **Every** protected request re-executes the full
admission procedure above; there is no session lifetime, no cached admission
decision, and no carry-over from a prior request. The cumulative residual
bound `max(0, min(exp, iat + maximum_assertion_age) - now)` applies per
request — there is no per-connection lifetime partition to shorten it
further. Issuers SHOULD configure short assertion TTLs consistent with the
deployment's acceptable revocation latency.
### Denial responses
Denial on a protected HTTP request produces an HTTP response (not a Nostr
text frame). The same public denial classes, status codes, and fixed body
bytes from the Rejection and privacy table apply. The response contains no
free text, reason code, issuer, subject, key, claim, or timing hint.
[FI-TRACE-DENIAL-ORACLE]
## Rejection and privacy
@@ -435,11 +624,12 @@ exception and reveals only that a required dependency is unreadable.
|---|---|---|---|
| assertion or proof absent | `missing_evidence` | `auth-required: authentication required` | `401`; `WWW-Authenticate: Nostr`; `Content-Type: text/plain; charset=utf-8`; body `authentication required\n` |
| malformed, invalid, or expired evidence | `evidence_rejected` | `restricted: evidence rejected` | `403`; `Content-Type: text/plain; charset=utf-8`; body `evidence rejected\n` |
| assertion–key mismatch; local policy denial; issuer-initiated disconnect (session-only model) | `authorization_denied` | `restricted: authorization denied` | `403`; `Content-Type: text/plain; charset=utf-8`; body `authorization denied\n` |
| assertion–key mismatch; local policy denial; active deny-set entry for pubkey | `authorization_denied` | `restricted: authorization denied` | `403`; `Content-Type: text/plain; charset=utf-8`; body `authorization denied\n` |
| required JWKS snapshot unreadable | `authorization_unavailable` | `restricted: authorization unavailable` | `503`; `Content-Type: text/plain; charset=utf-8`; body `authorization unavailable\n` |
A denial decided on a WebSocket upgrade is the HTTP response in place of `101`.
A denial decided after the connection is established is the Nostr text.
A denial decided on a protected HTTP request is the HTTP response.
A denial decided after a WebSocket connection is established is the Nostr text.
Responses contain no free text, reason code, issuer, subject, key, claim, or
timing hint. [FI-TRACE-DENIAL-ORACLE]
@@ -479,6 +669,19 @@ A relay SHOULD advertise core support in NIP-11 as:
}
```
`maximum_residual_upstream_revocation_seconds` is `null` for the
`offline-jwt` class because this spec provides no unconditional finite
upstream-revocation bound. The deny-until-TTL mechanism closes the
reconnect window when the issuer issues a well-formed disconnect command
and the relay retains the entry, but neither condition is guaranteed by
the protocol: `until` is an upper ceiling on deny duration, not a lower
bound ensuring denial outlasts all live assertions; a past or short
`until` is valid; and a relay restart MAY forget active entries. If the
issuer stops issuance after a successful deny, the residual ceiling is
`max(0, min(exp, iat + maximum_assertion_age) - now)` — but this is an
issuer-operational property, not a protocol invariant this field can
advertise unconditionally.
Discovery MUST NOT state issuer URLs, audiences, claim names, tenant IDs, or
deployment-local identifiers. [FI-TRACE-DISCOVERY-PRIVATE]
@@ -494,6 +697,8 @@ deployment-local identifiers. [FI-TRACE-DISCOVERY-PRIVATE]
| `FI-TRACE-JWKS-REMOVE` | Connections verified under a removed key deny on next revalidation or reconnect. |
| `FI-TRACE-DEPENDENCY-FAIL-CLOSED` | An unreadable JWKS snapshot denies `authorization_unavailable`; no degraded Nostr-only access. |
| `FI-TRACE-LEASE-BOUND` | A session closes at its earliest deadline; equality at any deadline is expired. |
| `FI-TRACE-DENY-SET` | A pubkey in the deny set is denied `authorization_denied` on admission until `now >= until`; an expired or absent entry does not deny; a past-`until` command closes sessions — absent an active same-key entry it creates no future denial, while an active entry remains unchanged under the merge rule; a deny-set-full command is rejected `503` without closing sessions and without removing any existing entry; capacity is evaluated per issuer — one issuer's capacity exhaustion MUST NOT reject another issuer's command; a connection that passes the deny-set check before a concurrent deny-entry insertion but completes admission after MUST still be terminated (the session's proven `k` is registered before the deny-set check, ensuring the close scan catches it); two overlapping commands for the same `(iss, pubkey)` in either delivery order result in `until = max(until_A, until_B)` — delivery order does not shorten the longer deny; a past-`until` command arriving over an active entry leaves the active entry's `until` unchanged; a successful disconnect responds `{"disconnected": true}` regardless of how many sessions were closed; the deny entry applies across all communities served by the relay under that issuer. |
| `FI-TRACE-HTTP-INGRESS` | A protected HTTP request with both valid headers and matching pubkeys is admitted; absent, mismatched, or invalid assertion or NIP-98 event denies; a request presenting only one of the two denies; an active deny-set entry denies; a route that cannot be classified as exempt is treated as protected; repeated, comma-combined, wrong-scheme, or alternative-credential `Authorization` fields deny; an authorization-relevant body without exactly one matching `payload` tag denies; the NIP-FI administrative API is not a protected surface. |
| `FI-TRACE-DENIAL-ORACLE` | Each public-class row produces its exact fixed bytes; all private-state rows compare byte-identical. |
| `FI-TRACE-DISCOVERY-PRIVATE` | Complete discovery bytes do not expose issuer, audience, or deployment-private state. |
| `FI-TRACE-CROSS-DOMAIN-COLLISION` | Equal `sub` values under different `iss` values remain distinct identities. |
@@ -521,25 +726,35 @@ key replacement, or signing-key removal). Stopping issuance prevents minting
assertions that extend this window; it does not invalidate already-issued
assertions. If the issuer continues issuing assertions, access continues.
For the session-only disconnect model (issuer issues a successful disconnect
call that closes all matching sessions synchronously), there is no surviving
old-session window. If the issuer also stops issuing new assertions at that
point, cumulative residual access is bounded by:
For the deny-until-TTL disconnect model (issuer issues a successful disconnect
call with an `until` timestamp), the relay inserts a deny entry for the target
pubkey with expiry `until` and then closes all matching sessions synchronously. Any
subsequent admission attempt for that pubkey is denied `authorization_denied`
until `now >= until`. The `until` ceiling enforced by the relay is
`now + skew + maximum_assertion_age`; this limits how long a deny entry may last —
it does not ensure denial outlasts all live assertions. A short or past
`until` is valid, and if the issuer continues issuing after the entry
expires, access resumes. The issuer SHOULD set `until` to outlast the
longest still-live assertion it has issued to ensure no unexpired assertion
slides through the expiry boundary; this is an operational recommendation,
not a protocol invariant.
```
max(0, min(exp, iat + maximum_assertion_age) - now)
```
A relay restart clears the in-memory deny set. The issuer SHOULD re-push
still-active entries on observed restart, but re-push is not required and
carries no specified completion bound. If the issuer stops issuing
assertions and re-push completes before any expired-entry reconnection
attempt, the residual exposure after restart is bounded by
`max(0, min(exp, iat + maximum_assertion_age) - now)`. If the issuer
continues issuing or re-push does not complete in time, that formula does
not apply and access may continue beyond it.
`max_connection_lifetime_seconds` only partitions that interval into individual
sessions; it does not shorten the total window. A snapshot refresh failure,
hard-deadline expiry without key replacement, or signing-key removal can
terminate access earlier, but these are not reliable protocol-level bounds: the
JWKS snapshot deadline renews on each refresh even when content is unchanged.
If the issuer continues issuing new assertions after the disconnect call,
cumulative access extends indefinitely — the session-only protocol places no
protocol-level bound on that case. See the non-normative note in the Admin
disconnect section for the open product question on the deny-until-TTL
alternative.
**HTTP ingress bypass.** Without the pairing rule, a principal holding an
active key can mint fresh NIP-98 events indefinitely and retain HTTP access
for as long as the key remains accepted — the assertion TTL provides no
bound when the assertion is never examined. The pairing rule closes this
gap by requiring assertion verification on every protected HTTP request.
The per-request re-verification model means there is no cached admission
window; a deny-set entry takes effect on the very next request.
**SSRF.** The JWKS fetcher implements SSRF protection: HTTPS-only URI
validation, DNS resolution with IP deny-list enforcement, address pinning to
@@ -557,6 +772,7 @@ is bounded before any attacker-controlled lookup.
## Sources
- NIP-42 authentication: <https://github.com/nostr-protocol/nips/blob/6d2979b3f503a8539c983efbcdcf901bbcf9ed23/42.md>
- NIP-98 HTTP authorization: <https://github.com/nostr-protocol/nips/blob/ae0fd96907d0767f07fb54ca1de9f197c600cb27/98.md>
- JWT BCP: <https://www.rfc-editor.org/rfc/rfc8725>
- JWT access-token profile: <https://www.rfc-editor.org/rfc/rfc9068>
- DPoP: <https://www.rfc-editor.org/rfc/rfc9449>