mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
docs(nips): enforce NIP-FI v1 semantics
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
This commit is contained in:
+108
-38
@@ -6,28 +6,34 @@ The model is transport-independent. A concrete NIP must separately define how an
|
||||
|
||||
# Terms and domains
|
||||
|
||||
- `D`: authorization domain chosen by the service (for example one relay tenant). Bindings never cross domains implicitly.
|
||||
- `D`: authorization domain resolved by the verifier from authenticated server routing or configuration (for example one relay tenant). An assertion, proof, header, or other untrusted request input cannot select or rewrite it, and bindings never cross domains implicitly.
|
||||
- `I`: federated principal, the tuple `(iss, sub)`. `iss` is the assertion's exact validated issuer identifier and `sub` is its exact non-empty subject string. A username, email, display name, or bare `sub` is not an identity key.
|
||||
- `K`: 32-byte Nostr public key.
|
||||
- `A`: federated assertion.
|
||||
- `P`: Nostr proof authenticating key `k`, such as a valid NIP-42 AUTH event or NIP-98 event.
|
||||
- `now`: verifier time.
|
||||
- `B_D`: active binding relation in domain `D`, a partial bijection between `I` and `K`.
|
||||
- `R_D`: durable history of revoked bindings.
|
||||
- `P_D`: durable set of retired exact pairs `(i, k)`.
|
||||
- `X_D`: durable set of disabled identities `i`.
|
||||
- `Y_D`: durable set of revoked keys `k`.
|
||||
- `Q_D`: pending explicit replacements, mapping an identity `i` to its retired key `k_old`.
|
||||
- `H_D`: immutable lifecycle audit history; it is not an authorization input by itself.
|
||||
- `mode(D)`: enrollment policy, either `attested-key`, `provisioned`, or `tofu`.
|
||||
|
||||
A binding record is:
|
||||
|
||||
```text
|
||||
Binding = (domain, identity, key, source, created_at, revoked_at?)
|
||||
Binding = (domain, identity, key, source, created_at)
|
||||
source = attested-key | provisioned | tofu
|
||||
```
|
||||
|
||||
`P_D`, `X_D`, `Y_D`, and `Q_D` are semantic authorization state, not a required database schema. A conforming implementation may derive them from immutable lifecycle records as long as `Authorize` can read their effective values atomically with `B_D`.
|
||||
|
||||
`display_name`, email, and similar values may be stored as mutable metadata but are never part of binding identity or an authorization decision.
|
||||
|
||||
# Trust assumptions
|
||||
|
||||
1. The verifier has an authenticated configuration for each accepted issuer: issuer identifier, allowed signing algorithms, key source, accepted audience(s), and claim mapping.
|
||||
1. The verifier has an authenticated configuration for each accepted issuer: issuer identifier, allowed signing algorithms, key source, accepted audience(s), and optional Nostr-key and display-name claim mappings.
|
||||
2. TLS and/or a trusted ingress boundary prevents attackers from injecting or replacing assertions. A reverse-proxy assertion header is trusted only when untrusted clients cannot reach the verifier directly and all inbound copies of that header are stripped before the trusted proxy sets it.
|
||||
3. The issuer protects its signing keys and assigns stable, non-reassignable `sub` values within an issuer. If an issuer reassigns a subject, the model cannot distinguish the people.
|
||||
4. The Nostr signature primitive is unforgeable and the concrete Nostr proof is fresh and bound to the target relay or HTTP request.
|
||||
@@ -46,9 +52,9 @@ It succeeds only if all of the following hold:
|
||||
2. `A.iss` exactly equals the configured issuer identifier used to select that key;
|
||||
3. at least one `A.aud` value exactly equals an audience configured for this service;
|
||||
4. `exp` exists and `now < exp`, allowing only a bounded configured clock skew;
|
||||
5. if present, `nbf <= now` and `iat` is not unreasonably in the future;
|
||||
6. the configured subject claim is a non-empty string;
|
||||
7. `i = (A.iss, A.subject)`; and
|
||||
5. if present, `nbf <= now + configured_skew` and `iat <= now + configured_skew`;
|
||||
6. `A.sub` is an unambiguous non-empty string;
|
||||
7. `i = (A.iss, A.sub)`; and
|
||||
8. if a configured Nostr-key claim is present, it parses to exactly one 32-byte key `k_a` (hex on the wire; bech32 may be accepted only as an explicitly documented input normalization).
|
||||
|
||||
Unknown issuers, key IDs, algorithms, claims, and validation failures fail closed. Key retrieval failure also fails closed. A verifier must bound key-cache lifetime and refresh behavior; it must not accept a token merely because parsing succeeded.
|
||||
@@ -73,24 +79,46 @@ For every domain `D`, active bindings are one-to-one:
|
||||
|
||||
Equivalently, an active identity has at most one key and an active key has at most one identity in a domain.
|
||||
|
||||
Base V1 therefore represents one active principal key per domain. Multiple devices share that key or use bounded delegation; a simultaneous active key set requires a future protocol extension.
|
||||
|
||||
Active bindings also satisfy the lifecycle invariants:
|
||||
|
||||
```text
|
||||
(i, k) ∈ B_D ⇒ (i, k) ∉ P_D
|
||||
(i, k) ∈ B_D ⇒ i ∉ X_D
|
||||
(i, k) ∈ B_D ⇒ k ∉ Y_D
|
||||
(i, k) ∈ B_D ⇒ i ∉ dom(Q_D)
|
||||
i ∈ dom(Q_D) ⇒ no active binding exists for i
|
||||
```
|
||||
|
||||
# Authorization and enrollment transition
|
||||
|
||||
Given domain `D`, assertion result `(i, k_a?, exp)`, and proof result `k`, evaluate one atomic transaction:
|
||||
Given trusted server-resolved domain `D`, assertion result `(i, k_a?, exp)`, and proof result `k`, evaluate one atomic transaction:
|
||||
|
||||
```text
|
||||
Authorize(D, i, k_a?, k):
|
||||
if k_a exists and k_a != k:
|
||||
DENY(key_mismatch)
|
||||
|
||||
b_i := active binding in B_D for i, if any
|
||||
b_k := active binding in B_D for k, if any
|
||||
atomically read:
|
||||
b_i := active binding in B_D for i, if any
|
||||
b_k := active binding in B_D for k, if any
|
||||
p := (i, k) ∈ P_D
|
||||
x := i ∈ X_D
|
||||
y := k ∈ Y_D
|
||||
q := i ∈ dom(Q_D)
|
||||
|
||||
if b_i = (i, k) and b_k = (i, k):
|
||||
if b_i = (i, k) and b_k = (i, k) and not (p or x or y or q):
|
||||
ALLOW(existing)
|
||||
|
||||
if b_i exists or b_k exists:
|
||||
DENY(binding_conflict)
|
||||
|
||||
if x: DENY(identity_disabled)
|
||||
if y: DENY(key_revoked)
|
||||
if p: DENY(pair_retired)
|
||||
if q: DENY(explicit_replacement_required)
|
||||
|
||||
switch mode(D):
|
||||
attested-key:
|
||||
if k_a is absent: DENY(key_attestation_required)
|
||||
@@ -103,56 +131,84 @@ Authorize(D, i, k_a?, k):
|
||||
ALLOW(created)
|
||||
```
|
||||
|
||||
If a concurrent attempt finds the identical committed binding, it allows as `existing`; if the committed outcome cannot be read or storage is unavailable, deny — never fall back to an unchecked allow. The check and possible insertion must be linearizable for `(D, i, k)`.
|
||||
If a concurrent attempt finds the identical committed binding, it allows as `existing`; if the committed outcome cannot be read or active or lifecycle storage is unavailable, deny — never fall back to an unchecked allow. The active-binding and lifecycle-gate reads and possible insertion must be linearizable for `(D, i)` and `(D, k)` and serialize with every lifecycle transition affecting them.
|
||||
|
||||
The resulting authorization lease is:
|
||||
|
||||
```text
|
||||
L = (D, i, k, binding_version, expires_at)
|
||||
expires_at <= assertion.exp
|
||||
L = (D, i, k, expires_at)
|
||||
expires_at <= min(assertion.exp, policy_expiry?, delegation_expiry?, implementation_limit?)
|
||||
```
|
||||
|
||||
An implementation may impose a shorter maximum lease. A lease authorizes only policy-selected operations in `D`; it does not authorize signing and does not imply that event authors may differ from `k`.
|
||||
Unknown optional bounds are omitted from the minimum. A lease authorizes only policy-selected operations in `D`; it does not authorize signing and does not imply that event authors may differ from `k`. Its continued eligibility also depends on every binding and lifecycle selector read by the decision.
|
||||
|
||||
# Session behavior
|
||||
|
||||
For a single HTTP request, the assertion, Nostr proof, and authorization decision apply only to that request.
|
||||
|
||||
For a NIP-42 WebSocket connection, a relay may cache `L`, but it must not use the lease after `expires_at`. It must reject protected operations or terminate the connection; obtaining a fresh assertion and proof requires a new connection under this transport profile. A relay that learns that the binding or federated session was revoked must invalidate matching leases. Implementations must document their maximum revocation-detection latency; they cannot claim immediate revocation if they only poll.
|
||||
For a NIP-42 WebSocket connection, a relay may cache `L`, but it must not use the lease after `expires_at`. It must reject protected operations or terminate the connection. Renewal requires a new WebSocket connection carrying a fresh assertion on its upgrade request, followed by fresh NIP-42 proof; base V1 has no in-connection renewal transition. A relay that learns that a binding, identity, key, policy decision, or delegation dependency is no longer valid must invalidate every matching direct and delegated lease. Implementations must document their maximum revocation-detection latency; they cannot claim immediate revocation if they only poll.
|
||||
|
||||
If multiple keys authenticate on one NIP-42 connection, authorization is tracked independently per key. A lease for one `(i, k)` must not authorize another authenticated key.
|
||||
|
||||
# Revocation and rotation
|
||||
|
||||
Revocation is an explicit administrative transition:
|
||||
Pair retirement is an explicit administrative transition:
|
||||
|
||||
```text
|
||||
Revoke(D, i, k):
|
||||
RetirePair(D, i, k):
|
||||
require (i, k) ∈ B_D
|
||||
atomically remove (i, k) from B_D
|
||||
append immutable revocation record to R_D
|
||||
atomically:
|
||||
remove (i, k) from B_D
|
||||
add (i, k) to P_D
|
||||
set Q_D(i) = k
|
||||
append the transition to H_D
|
||||
invalidate cached leases for the binding as soon as observed
|
||||
```
|
||||
|
||||
An assertion, including one with `k_a = k`, must not silently reactivate the same revoked binding unless the domain's explicit recovery policy authorizes that transition. This prevents replay of a still-valid assertion from undoing revocation.
|
||||
|
||||
Key rotation is not an authorization side effect:
|
||||
Identity disablement and key revocation may occur before enrollment and are independent of pair retirement:
|
||||
|
||||
```text
|
||||
Rotate(D, i, k_old, k_new):
|
||||
DisableIdentity(D, i):
|
||||
atomically:
|
||||
add i to X_D
|
||||
if (i, k) ∈ B_D:
|
||||
remove (i, k), add (i, k) to P_D, and clear Q_D(i)
|
||||
append the transition to H_D
|
||||
invalidate direct and dependent delegated leases for i
|
||||
|
||||
RevokeKey(D, k):
|
||||
atomically:
|
||||
add k to Y_D
|
||||
if (i, k) ∈ B_D:
|
||||
remove (i, k), add (i, k) to P_D, and set Q_D(i) = k
|
||||
append the transition to H_D
|
||||
invalidate every direct or delegated lease that depends on k
|
||||
```
|
||||
|
||||
An assertion, including one with `k_a = k`, cannot clear `P_D`, `X_D`, `Y_D`, or `Q_D` and cannot invoke a recovery transition. This prevents replay of a still-valid assertion and presentation of an unbound replacement key from undoing revocation.
|
||||
|
||||
Rotation or recovery is a separate privileged transition, not an authorization side effect:
|
||||
|
||||
```text
|
||||
RotateOrRecover(D, i, k_old, k_new):
|
||||
require explicit recovery/admin authorization
|
||||
require (i, k_old) ∈ B_D
|
||||
require (i, k_old) ∈ B_D or Q_D(i) = k_old
|
||||
require i ∉ X_D
|
||||
require k_new ∉ Y_D
|
||||
require (i, k_new) ∉ P_D
|
||||
require no active binding for k_new
|
||||
if issuer-attested rotation is required, require fresh k_a = k_new
|
||||
atomically revoke (i, k_old) and create (i, k_new)
|
||||
atomically remove any active (i, k_old), add (i, k_old) to P_D,
|
||||
add k_old to Y_D, create (i, k_new), and clear Q_D(i)
|
||||
append the transition to H_D
|
||||
invalidate leases for k_old
|
||||
```
|
||||
|
||||
A normal request that presents `i` with `k_new` while `k_old` is active is a conflict and must not rotate automatically.
|
||||
A normal request that presents `i` with `k_new` while `k_old` is active is a conflict. If `i` is pending replacement, it denies `explicit_replacement_required`. Neither path rotates automatically. Base V1 recovery uses a fresh, non-retired key; same-key reactivation requires an extension with an equivalently explicit privileged transition and retained lifecycle history.
|
||||
|
||||
# Delegation
|
||||
|
||||
Delegation is outside the base identity-binding primitive. A separate delegation standard may allow a bound owner key to authorize a delegate key. If supported, the verifier must first validate the delegation proof and derive the owner key, then require an active, unexpired authorization lease or binding for that owner. It must not create a federated identity binding for the delegate unless explicitly specified. Delegation expiry/revocation and allowed operations remain bounded by both the owner identity authorization and the delegation.
|
||||
Delegation is outside the base identity-binding primitive. A separate delegation standard may allow a bound owner key to authorize a delegate key. If supported, the verifier must first validate the delegation proof and derive the owner key, then require an active owner binding or unexpired owner authorization lease. It must not create the owner's federated identity binding for the delegate. The delegated decision retains the owner dependency, intersects the delegation's operations and conditions, expires at the earliest owner, delegation, policy, or implementation bound, and is invalidated when the owner binding is retired or revoked. A deployment may add a stronger current-provider admission requirement for the owner without changing this base primitive.
|
||||
|
||||
# Safety properties
|
||||
|
||||
@@ -163,20 +219,25 @@ Under the trust assumptions, for direct (non-delegated) authorization:
|
||||
3. **Agreement:** if the issuer supplies a key claim, the asserted key, proven key, and bound key are equal.
|
||||
4. **Binding consistency:** no two active identities share a key and no identity has two active keys in one domain.
|
||||
5. **No implicit rotation:** conflicting assertions or proofs cannot replace an active binding.
|
||||
6. **Domain separation:** authorization in one domain does not imply authorization in another.
|
||||
7. **Lease boundedness:** no cached authorization survives assertion expiry; after revocation is observed, no matching cached authorization remains valid.
|
||||
8. **Fail-closed storage and verification:** validation, key retrieval, or binding-state failures never produce allow.
|
||||
9. **Privacy:** conforming protocol behavior need not publish `iss`, `sub`, JWTs, email, or display names in Nostr events or relay-visible event history.
|
||||
6. **No replayed resurrection:** ordinary authorization cannot recreate a retired pair or replace a key for an identity pending explicit replacement.
|
||||
7. **Lifecycle closure:** a disabled identity cannot authorize any key, and a revoked key cannot authorize or bind to any identity.
|
||||
8. **Lifecycle consistency:** active bindings satisfy the partial-bijection and lifecycle invariants above.
|
||||
9. **Rotation atomicity:** observers see either the valid old state or the completed replacement, never a partial transition; lifecycle history is retained.
|
||||
10. **Linearizable lifecycle:** authorization racing a lifecycle transition cannot commit a binding that violates the completed transition.
|
||||
11. **Domain separation:** authorization in one domain does not imply authorization in another.
|
||||
12. **Lease boundedness:** no cached authorization survives its earliest assertion, policy, delegation, or implementation bound; after a dependency change is observed, no matching direct or delegated lease remains valid.
|
||||
13. **Fail-closed storage and verification:** validation, key retrieval, or binding-state failures never produce allow.
|
||||
14. **Privacy:** NIP-FI protocol behavior never publishes `iss`, `sub`, JWTs, email, or display names in Nostr events or relay-visible event history. A separate opt-in relay-signed projection may publish an approved label, but never those private values and never as authorization evidence.
|
||||
|
||||
# Liveness properties
|
||||
|
||||
Assuming the issuer, key source, binding store, and network are available:
|
||||
|
||||
1. a valid assertion and matching proof for an existing active binding are eventually authorized;
|
||||
2. an unbound pair is eventually authorized exactly once when the configured enrollment mode permits it;
|
||||
3. after an authorized revocation/rotation and bounded cache invalidation, the old key is denied and the new valid binding can be authorized.
|
||||
1. a valid assertion and matching proof for an eligible existing active binding are eventually authorized;
|
||||
2. a never-retired pair with no applicable identity, key, or pending-replacement gate is eventually authorized exactly once when the configured enrollment mode permits it;
|
||||
3. after `RotateOrRecover` commits and bounded cache invalidation completes, the replacement binding is eventually authorized and the old pair and key remain denied.
|
||||
|
||||
Liveness is intentionally not guaranteed during issuer/JWKS/storage outage; availability must not override identity safety.
|
||||
No authorization liveness is promised while identity disablement, key revocation, pair retirement, or pending replacement blocks a request. Liveness is also intentionally not guaranteed during issuer/JWKS/storage outage; availability must not override identity safety.
|
||||
|
||||
# Representative attack traces
|
||||
|
||||
@@ -190,7 +251,16 @@ Liveness is intentionally not guaranteed during issuer/JWKS/storage outage; avai
|
||||
| Assertion has wrong audience, expired `exp`, unknown algorithm/key, malformed subject/key | Deny without binding mutation |
|
||||
| Concurrent first use of `(i,k1)` and `(i,k2)` | At most one commits; the other denies conflict |
|
||||
| Reuse of valid WebSocket authorization after assertion expiry | Deny protected operation or reauthenticate/close |
|
||||
| Fresh assertion for a revoked pair | Deny unless explicit recovery transition authorizes reactivation |
|
||||
| Retire `(i,k)`, then replay a matching assertion and proof in TOFU | Deny `pair_retired` without mutation |
|
||||
| Retire `(i,k)`, then replay an issuer key claim matching `k` | Deny `pair_retired` without mutation |
|
||||
| Disable never-enrolled `i`, then present any valid assertion and proof | Deny `identity_disabled` without mutation |
|
||||
| Revoke `k`, then present it for another identity | Deny `key_revoked` without mutation |
|
||||
| Revoke active `k`, then present fresh `k_new` for the same identity | Deny `explicit_replacement_required`; require privileged replacement |
|
||||
| `Authorize` races pair retirement or key revocation | Serialize; no binding that violates the completed transition survives |
|
||||
| Rotate to an active, revoked, or previously retired replacement | Deny without partial mutation |
|
||||
| Two concurrent replacements for one identity | At most one commits; the other denies after observing committed state |
|
||||
| Successful explicit replacement | Old pair and key remain denied; new active pair authorizes |
|
||||
| Lifecycle-state lookup fails | Deny without enrollment mutation |
|
||||
| New key presented for bound identity | Deny; require explicit rotation |
|
||||
| Display name/email changes while `(iss,sub)` is stable | May update metadata; binding identity is unchanged |
|
||||
| One NIP-42 connection authenticates `k1` and `k2`, only `k1` is bound | Only operations attributed to `k1` receive its lease |
|
||||
@@ -217,4 +287,4 @@ It should not standardize database schema, lock mechanism, Okta-specific claims,
|
||||
- NIP-05 issuer-controlled identifier mapping precedent: https://github.com/nostr-protocol/nips/blob/8f8444d05a8842c40211ded5d10af3521541f865/05.md
|
||||
- NIP-46 external auth challenge precedent: https://github.com/nostr-protocol/nips/blob/8f8444d05a8842c40211ded5d10af3521541f865/46.md
|
||||
- Companion protocol specification: [`NIP-FI.md`](NIP-FI.md)
|
||||
- Buzz implementation semantics reviewed at `bd822f3ea8fc04b449501fd4738097c32d3da950` (PR #1476)
|
||||
- Buzz PR #1476 at `1e9822de8dbe0ae91c00c0ce0ed8ff583915692f` is a disabled partial foundation, not a complete NIP-FI implementation; future-`iat`, discovery, lifecycle, and lease conformance remain additive work.
|
||||
|
||||
+47
-19
@@ -28,11 +28,15 @@ Without a standard, each deployment invents an incompatible binding scheme, and
|
||||
|
||||
- **assertion**: a JWT issued by a configured identity provider, presented alongside (never instead of) Nostr authentication.
|
||||
- **federated identity** (`i`): the tuple `(iss, sub)` from a validated assertion. The `iss` value MUST be the exact validated issuer identifier and `sub` the exact non-empty subject string. A username, email, display name, or bare `sub` MUST NOT be used as a federated identity.
|
||||
- **authorization domain** (`D`): the scope within which bindings apply, chosen by the service (an entire relay, or one tenant of a multi-tenant relay). Bindings MUST NOT cross domains implicitly.
|
||||
- **authorization domain** (`D`): the scope within which bindings apply, resolved by the verifier from authenticated server routing or configuration (an entire relay, or one tenant of a multi-tenant relay). An assertion, proof, header value, or other untrusted request input MUST NOT select or rewrite `D`, and bindings MUST NOT cross domains implicitly.
|
||||
- **binding**: an active record associating exactly one federated identity with exactly one 32-byte Nostr public key within a domain.
|
||||
- **retired pair**: a durable denial selector recording that one exact `(identity, key)` pair MUST NOT be recreated by ordinary authorization.
|
||||
- **disabled identity**: a durable denial selector preventing an identity from authorizing or enrolling any key.
|
||||
- **revoked key**: a durable denial selector preventing a key from authorizing or binding to any identity.
|
||||
- **pending replacement**: lifecycle state recording that an identity whose prior key was retired MUST use a separately authorized recovery or rotation transition before another key can become active.
|
||||
- **enrollment mode**: the domain's policy for creating bindings — `attested-key`, `provisioned`, or `tofu` (defined below).
|
||||
- **Nostr proof**: a valid NIP-42 AUTH event (WebSocket) or NIP-98 event (HTTP) proving control of a key on the current connection or request.
|
||||
- **lease**: a cached authorization decision for one `(domain, identity, key)`, bounded by the assertion's expiry.
|
||||
- **lease**: a cached authorization decision for one `(domain, identity, key)`, bounded by the assertion's expiry and every shorter authoritative policy, delegation, or implementation limit.
|
||||
|
||||
## Assertion transport
|
||||
|
||||
@@ -43,19 +47,21 @@ An assertion reaches the verifier in an HTTP header on the request being authori
|
||||
|
||||
Assertion acquisition and interactive OIDC login are outside this NIP. A client-attached assertion value MUST use the `Bearer` scheme; after removing that scheme, the value MUST contain exactly one JWT and no comma-separated alternatives.
|
||||
|
||||
Normal browser WebSocket APIs cannot attach the client-attached header. Browser deployments therefore require the trusted-proxy profile or a separately standardized assertion transport. Bearer assertions MUST NOT be placed in WebSocket URLs or query strings.
|
||||
|
||||
On a WebSocket connection, the assertion captured at upgrade is evaluated when a key performs NIP-42 AUTH — each authenticating key is authorized against that assertion independently. On HTTP, the assertion and the NIP-98 proof MUST arrive on the same request they authorize.
|
||||
|
||||
Assertions MUST NOT be carried inside Nostr events, event tags, or subscription filters, and MUST NOT be written to relay-visible event history.
|
||||
|
||||
## Assertion validation
|
||||
|
||||
The verifier is configured, per accepted issuer, with: the issuer identifier, a signing-key source (a JWKS endpoint, discoverable via OIDC `/.well-known/openid-configuration`), accepted audience values, and a claim mapping. Validation MUST enforce all of the following; any failure MUST reject the assertion:
|
||||
The verifier is configured, per accepted issuer, with: the issuer identifier, a signing-key source (a JWKS endpoint, discoverable via OIDC `/.well-known/openid-configuration`), accepted audience values, and optional Nostr-key and display-name claim mappings. Validation MUST enforce all of the following; any failure MUST reject the assertion:
|
||||
|
||||
1. The JWT signature verifies under a currently trusted key for an explicitly allowed **asymmetric** algorithm. Symmetric (HS*) and `none` algorithms MUST be rejected before any key lookup.
|
||||
2. `iss` exactly equals the configured issuer identifier used to select the verification key.
|
||||
3. At least one `aud` value exactly equals a configured audience.
|
||||
4. `exp` is present and in the future; `nbf` and `iat`, when present, are not in the future — each within a bounded, configured clock skew.
|
||||
5. The configured subject claim is present and a non-empty string. A configured claim that is absent when required, not of its expected type, or not unambiguously a single value MUST be rejected.
|
||||
4. `exp` is present and in the future; `nbf` and `iat`, when present, are no later than verifier time plus a bounded, configured clock skew.
|
||||
5. The JWT `sub` claim is present, a non-empty string, and unambiguously a single value. Base V1 always defines `i = (iss, sub)`; mapping another claim into a local principal is a deployment extension and MUST NOT be advertised as base V1 conformance.
|
||||
6. If a key claim is configured and present, it parses to exactly one 32-byte Nostr public key. Lowercase hex is the canonical encoding; `npub` bech32 MAY be accepted as a documented input normalization.
|
||||
|
||||
A display-name claim MAY be extracted as mutable metadata. It MUST NOT participate in any authorization decision.
|
||||
@@ -68,25 +74,35 @@ The key being authorized is always the key returned by Nostr proof validation
|
||||
|
||||
## Authorization
|
||||
|
||||
Given a validated assertion yielding identity `i`, optional asserted key `k_a`, and expiry `exp`, and a Nostr proof yielding key `k`, the verifier evaluates one atomic decision in domain `D`:
|
||||
Given a validated assertion yielding identity `i`, optional asserted key `k_a`, and expiry `exp`, and a Nostr proof yielding key `k`, the verifier evaluates one atomic decision in the trusted server-resolved domain `D`:
|
||||
|
||||
```text
|
||||
Authorize(D, i, k_a?, k):
|
||||
if k_a exists and k_a != k: DENY (key mismatch)
|
||||
|
||||
b_i := active binding for i in D, if any
|
||||
b_k := active binding for k in D, if any
|
||||
atomically read:
|
||||
b_i := active binding for i in D, if any
|
||||
b_k := active binding for k in D, if any
|
||||
p := whether (i, k) is a retired pair in D
|
||||
x := whether i is disabled in D
|
||||
y := whether k is revoked in D
|
||||
q := whether i is pending explicit replacement in D
|
||||
|
||||
if b_i = (i, k) and b_k = (i, k): ALLOW (existing binding)
|
||||
if b_i = (i, k) and b_k = (i, k)
|
||||
and not (p or x or y or q): ALLOW (existing binding)
|
||||
if b_i exists or b_k exists: DENY (binding conflict)
|
||||
if x: DENY (identity disabled)
|
||||
if y: DENY (key revoked)
|
||||
if p: DENY (pair retired)
|
||||
if q: DENY (explicit replacement required)
|
||||
|
||||
# no active binding on either side: enrollment
|
||||
# no active binding or applicable lifecycle gate: first enrollment
|
||||
attested-key: k_a required, else DENY; create (i, k); ALLOW
|
||||
provisioned: DENY (binding must be pre-created by an operator)
|
||||
tofu: create (i, k); ALLOW
|
||||
```
|
||||
|
||||
The check and any insertion MUST be atomic for `(D, i, k)`: under concurrent first use of the same identity or key, at most one binding is created and every other attempt observes it (allow on exact match, deny on conflict). Storage failure or a race whose committed result cannot be read MUST deny — never fall back to an unchecked allow.
|
||||
The active-binding and lifecycle-gate reads, and any insertion, MUST be one linearizable transition for `(D, i)` and `(D, k)`. They MUST serialize with pair retirement, identity disablement, key revocation, recovery, and rotation affecting those selectors. Under concurrent first use of the same identity or key, at most one binding is created and every other attempt observes it (allow on exact match, deny on conflict). Missing lifecycle state, storage failure, or a race whose committed result cannot be read MUST deny — never fall back to an unchecked allow.
|
||||
|
||||
### Enrollment modes
|
||||
|
||||
@@ -96,25 +112,35 @@ The check and any insertion MUST be atomic for `(D, i, k)`: under concurrent fir
|
||||
|
||||
### Binding invariant
|
||||
|
||||
Within a domain, active bindings form a partial bijection: an identity has at most one active key and a key has at most one active identity. Every state transition in this NIP preserves this invariant.
|
||||
Within a domain, active bindings form a partial bijection: an identity has at most one active key and a key has at most one active identity. An active binding MUST NOT overlap a retired pair, disabled identity, revoked key, or pending-replacement identity. Every state transition in this NIP preserves these invariants.
|
||||
|
||||
Base V1 therefore has one active principal key per domain. Multiple devices either share that principal key or use bounded delegation. Supporting multiple simultaneously active principal keys requires a future protocol extension.
|
||||
|
||||
## Session semantics
|
||||
|
||||
For HTTP requests, the decision applies to that request only.
|
||||
|
||||
For a NIP-42 WebSocket connection, the relay MAY cache the decision as a lease. A lease MUST NOT be honored past the assertion's `exp` (implementations MAY enforce a shorter maximum). At expiry the relay MUST reject protected operations or close the connection; a fresh assertion arrives only on a new connection's upgrade request. When a relay learns a binding was revoked, it MUST invalidate matching leases; a relay that detects revocation by polling MUST NOT claim immediate revocation and SHOULD document its detection latency.
|
||||
For a NIP-42 WebSocket connection, the relay MAY cache the decision as a lease. Its expiry MUST be no later than the assertion's `exp` and every shorter policy, delegation, or configured implementation bound known to the verifier. At expiry the relay MUST reject protected operations or close the connection. Renewal requires a new WebSocket connection carrying a fresh assertion on its upgrade request, followed by fresh NIP-42 proof; base V1 defines no in-connection renewal message. When a relay learns that a binding, identity, key, policy decision, or delegation on which a lease depends is no longer valid, it MUST invalidate every matching direct and delegated lease. A relay that detects revocation by polling MUST NOT claim immediate revocation and SHOULD document its maximum detection latency.
|
||||
|
||||
When multiple keys authenticate on one connection (NIP-42 permits this), authorization is tracked per key. A lease for one key MUST NOT authorize operations attributed to another.
|
||||
|
||||
## Revocation and rotation
|
||||
|
||||
Revocation is an explicit administrative or policy transition: the binding is removed from the active set and a durable revocation record is retained. A subsequent valid assertion — including one whose key claim matches the revoked key — MUST NOT reactivate a revoked binding unless the domain's documented recovery policy explicitly authorizes that transition. This prevents a replayed, still-valid assertion from silently undoing revocation.
|
||||
Revocation and recovery are explicit administrative or policy transitions, never side effects of `Authorize`. Their storage representation is implementation-defined, but their denial selectors and active-binding changes MUST be atomic and durable:
|
||||
|
||||
Key rotation is likewise explicit, never a side effect of authorization: rotating `i` from `k_old` to `k_new` requires administrative or documented recovery authorization, an active `(i, k_old)` binding, no active binding for `k_new`, and — where the domain requires issuer attestation — a fresh assertion whose key claim equals `k_new`. The old binding is revoked and the new one created atomically, and leases for `k_old` are invalidated. A routine request presenting `i` with a new key while `(i, k_old)` is active is a binding conflict and MUST be denied.
|
||||
- **Retire pair**: remove an active `(i, k)`, retain an exact-pair tombstone, mark `i` pending explicit replacement, and invalidate matching leases.
|
||||
- **Disable identity**: record the identity selector even when `i` has never enrolled. If `i` has an active binding, remove it, retire the pair, and invalidate direct and dependent delegated leases.
|
||||
- **Revoke key**: record the key selector even when `k` is not active. If `k` has an active binding, remove it, retire the pair, mark its identity pending explicit replacement, and invalidate every direct or delegated lease that depends on `k`.
|
||||
|
||||
A subsequent valid assertion — including one whose key claim matches a retired key — cannot clear these selectors or create a replacement binding. This prevents a replayed, still-valid assertion and a routine login with a different key from silently undoing revocation.
|
||||
|
||||
Rotation or recovery requires a separate privileged transition. Replacing `k_old` with `k_new` requires explicit administrative or documented recovery authorization, an active `(i, k_old)` binding or pending-replacement record for that pair, no active binding or lifecycle gate for `k_new`, and — where the domain requires issuer attestation — a fresh assertion whose key claim equals `k_new`. The transition atomically retires the old pair and key, creates `(i, k_new)`, clears the pending-replacement state, records durable lifecycle history, and invalidates leases for `k_old`. A routine request presenting a new key is either a binding conflict or `explicit replacement required` and MUST be denied without mutation.
|
||||
|
||||
Base V1 recovery uses a fresh, non-retired key. A deployment that permits same-key reactivation is an extension and MUST provide an equivalently explicit privileged transition while retaining the original lifecycle history; ordinary `Authorize` can never perform it.
|
||||
|
||||
## Delegation
|
||||
|
||||
Delegation is outside the base primitive but composes with it. A service MAY admit a key that presents no assertion when a separately validated delegation proof (for example a NIP-OA `auth` tag) establishes an owner key that holds an active binding in the domain. The delegate key MUST NOT acquire a federated identity binding of its own through this path, and the delegate's authorization is bounded by both the owner's binding state and the delegation's own conditions. Revoking the owner's binding revokes the delegate's admission on the same schedule as the owner's own leases.
|
||||
Delegation is outside the base primitive but composes with it. A service MAY admit a key that presents no assertion when a separately validated delegation proof (for example a NIP-OA `auth` tag) establishes an owner key that holds an active binding in the domain. The delegate key MUST NOT acquire the owner's federated identity binding through this path. Its authorization retains an explicit dependency on the owner binding, intersects the delegated operations and conditions, and expires at the earliest owner, delegation, policy, or implementation bound. Revoking or retiring the owner binding invalidates dependent delegated leases on the same detection schedule as the owner's own leases. A deployment MAY require a stronger current-provider admission decision for the owner, but that is an additional authorization layer rather than part of this base binding primitive.
|
||||
|
||||
## Rejection semantics
|
||||
|
||||
@@ -143,7 +169,9 @@ A relay SHOULD advertise support in its NIP-11 document under `limitation` as `"
|
||||
|
||||
## Privacy
|
||||
|
||||
Federated identities are typically personal data (employee identifiers). A conforming service MUST NOT publish `iss`, `sub`, assertion contents, or display-name claims in Nostr events or tags, and MUST NOT expose another user's binding state through rejection messages. Binding records, audit logs, and metrics are service-internal, and logs MUST NOT record raw bearer assertions.
|
||||
Federated identities are typically personal data (employee identifiers). NIP-FI itself MUST NOT publish `iss`, `sub`, assertion contents, or display-name claims in Nostr events or tags, and a conforming service MUST NOT expose another user's binding state through rejection messages. Binding records, audit logs, and metrics are service-internal, and logs MUST NOT record raw bearer assertions.
|
||||
|
||||
A separate, opt-in relay-signed projection protocol such as NIP-85 MAY publish an approved label. Such a projection MUST NOT contain `iss`, `sub`, bearer material, or other unapproved private claims, and it MUST NOT be accepted as NIP-FI authorization evidence.
|
||||
|
||||
## Security considerations
|
||||
|
||||
@@ -156,6 +184,6 @@ Federated identities are typically personal data (employee identifiers). A confo
|
||||
|
||||
A companion formal model of this protocol — state machine, safety and liveness properties, and attack traces — accompanies this specification.
|
||||
|
||||
## Reference implementation
|
||||
## Implementation relationship
|
||||
|
||||
Buzz relay: corporate identity enforcement layered above NIP-42/NIP-98/media/git/audio ingress, with JWKS validation, TOFU and attested-key enrollment, atomic binding with conflict detection, and NIP-OA delegation composition.
|
||||
Buzz PR [#1476](https://github.com/block/buzz/pull/1476), reviewed at `1e9822de8dbe0ae91c00c0ce0ed8ff583915692f`, is a disabled partial foundation from which this provider-neutral contract was generalized. It is not a complete NIP-FI implementation: future-`iat` rejection, NIP-11 discovery, and additional lifecycle and lease conformance remain additive implementation work. NIP-FI compatibility does not require changing that frozen PR.
|
||||
|
||||
Reference in New Issue
Block a user