From 2eec4460225e718872b7cfd8ede8489aab3eef83 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:20:29 -0500 Subject: [PATCH 1/5] docs(nips): enforce NIP-FI v1 semantics Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- docs/nips/NIP-FI-MODEL.md | 144 ++++++++++++++++++++++++++++---------- docs/nips/NIP-FI.md | 66 ++++++++++++----- 2 files changed, 154 insertions(+), 56 deletions(-) diff --git a/docs/nips/NIP-FI-MODEL.md b/docs/nips/NIP-FI-MODEL.md index c47d2dad29..046f7cf3e0 100644 --- a/docs/nips/NIP-FI-MODEL.md +++ b/docs/nips/NIP-FI-MODEL.md @@ -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. +Identity disablement and key revocation may occur before enrollment and are independent of pair retirement: + +```text +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. -Key rotation is not an authorization side effect: +Rotation or recovery is a separate privileged transition, not an authorization side effect: ```text -Rotate(D, i, k_old, k_new): +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. diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 948386831c..17ee7c35e6 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -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: + +- **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`. -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. +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. From 18507ce9f790f1c647854eada853abd72db94078 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:20:38 -0500 Subject: [PATCH 2/5] docs(nips): add NIP-FI conformance matrix Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- docs/nips/NIP-FI-CONFORMANCE.md | 61 +++++++++++++++++++++++++++++++++ docs/nips/NIP-FI-MODEL.md | 30 +++------------- docs/nips/NIP-FI.md | 2 +- 3 files changed, 66 insertions(+), 27 deletions(-) create mode 100644 docs/nips/NIP-FI-CONFORMANCE.md diff --git a/docs/nips/NIP-FI-CONFORMANCE.md b/docs/nips/NIP-FI-CONFORMANCE.md new file mode 100644 index 0000000000..0e2ba882d1 --- /dev/null +++ b/docs/nips/NIP-FI-CONFORMANCE.md @@ -0,0 +1,61 @@ +# NIP-FI conformance matrix + +This companion matrix makes the normative [NIP-FI specification](NIP-FI.md) and its [formal model](NIP-FI-MODEL.md) reviewable as executable traces. The specification remains normative; the model and this matrix must agree with it. + +Use synthetic issuers, subjects, keys, assertions, and domains. A denial with “no mutation” means no binding, lifecycle, enrollment, membership, publication, or last-seen mutation; a redacted security audit record is allowed. Internal decision names below need not be exposed on the wire: public failures use only the redacted `auth-required:` or `restricted:` classes defined by the specification. + +## State shorthand + +- `B_i`, `B_k`: active binding lookups for the presented identity and proven key. +- `P`: retired exact-pair selector for `(i, k)`. +- `X`: disabled-identity selector for `i`. +- `Y`: revoked-key selector for `k`. +- `Q`: pending explicit replacement for `i`. +- “Lifecycle reads” means `B_i`, `B_k`, `P`, `X`, `Y`, and `Q` are read in the same linearizable transition. + +## Required traces + +| ID | Pre-state / configuration | Presented evidence | Atomic reads | Decision | Mutation | Lease / session effect | Public result | Specification / model anchors | +|---|---|---|---|---|---|---|---|---| +| TR-1 | Trusted-proxy profile; direct verifier ingress is reachable or inbound assertion copies are not stripped | Client-controlled proxy header and otherwise valid proof | None | Reject non-conforming ingress | None | None | `restricted:` or connection rejection without claim detail | [Transport](NIP-FI.md#assertion-transport), [Trust](NIP-FI-MODEL.md#trust-assumptions) | +| TR-2 | Browser WebSocket client without a trusted proxy | Assertion in URL/query string | None | Reject forbidden transport | None | None | No authenticated session; token never logged | [Transport](NIP-FI.md#assertion-transport), [Privacy](NIP-FI.md#privacy) | +| TR-3 | Client-attached HTTP profile | Valid assertion and valid NIP-98 proof on different requests, or malformed/multiple bearer values | None | Reject evidence that is not co-located and unambiguous | None | None | HTTP `401`/`403` with redacted body | [Transport](NIP-FI.md#assertion-transport), [Rejection](NIP-FI.md#rejection-semantics) | +| AS-1 | Accepted issuer configured | Missing assertion, or missing Nostr proof | None | Deny missing independent evidence | None | None | `auth-required:` / HTTP `401` | [Proof](NIP-FI.md#nostr-proof), [Proof model](NIP-FI-MODEL.md#nostr-proof-validity) | +| AS-2 | Accepted issuer configured | Bad signature; symmetric/`none` algorithm; wrong issuer or audience | None | Reject assertion | None | None | `restricted:` / HTTP `403` without validation detail | [Validation](NIP-FI.md#assertion-validation), [Validation model](NIP-FI-MODEL.md#assertion-validity) | +| AS-3 | Accepted issuer configured | Missing/expired `exp`, future `nbf` or `iat` beyond skew | None | Reject assertion | None | None | `restricted:` / HTTP `403` | [Validation](NIP-FI.md#assertion-validation), [Validation model](NIP-FI-MODEL.md#assertion-validity) | +| AS-4 | Accepted issuer configured | Missing, empty, ambiguous, or wrong-type `sub`; malformed or ambiguous key claim | None | Reject assertion | None | None | `restricted:` / HTTP `403` | [Validation](NIP-FI.md#assertion-validation), [Validation model](NIP-FI-MODEL.md#assertion-validity) | +| AS-5 | Key set unavailable, stale beyond policy, or unknown `kid` after a fresh bounded fetch | Otherwise plausible JWT | None | Fail closed; do not drive unbounded refreshes | None | None | `restricted:` / HTTP `403` | [Validation](NIP-FI.md#assertion-validation), [Validation model](NIP-FI-MODEL.md#assertion-validity) | +| AS-6 | Issuers `A` and `B` use the same `sub` | Valid assertion from each issuer | Issuer-qualified lifecycle reads | Treat `(A, sub)` and `(B, sub)` as different identities | Only the selected identity may enroll | Independent leases | Normal result for each qualified identity | [Definitions](NIP-FI.md#definitions), [Domain terms](NIP-FI-MODEL.md#terms-and-domains) | +| PR-1 | Any binding state | Invalid, stale, replayed, wrong-target, or wrong-payload NIP-42/NIP-98 proof | None | Reject proof before authorization | None | None | `restricted:` / HTTP `403` | [Proof](NIP-FI.md#nostr-proof), [Proof model](NIP-FI-MODEL.md#nostr-proof-validity) | +| AU-1 | Any binding state | Valid assertion with `k_a != k` | None | Deny `key_mismatch` before state access | None | None | `restricted:` without either key | [Authorization](NIP-FI.md#authorization), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | +| AU-2 | `B_i = B_k = (i, k)` and no lifecycle gate | Matching valid assertion and proof | Lifecycle reads | Allow existing binding | None | Create lease bounded by all applicable limits | Success | [Authorization](NIP-FI.md#authorization), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | +| AU-3 | `B_i = (i, k_old)` | Valid assertion for `i`; proof by `k_new` | Lifecycle reads | Deny `binding_conflict` | None | None | `restricted:` without conflict detail | [Authorization](NIP-FI.md#authorization), [Binding invariant](NIP-FI.md#binding-invariant) | +| AU-4 | `B_k = (i_other, k)` | Valid assertion for `i`; proof by `k` | Lifecycle reads | Deny `binding_conflict` | None | None | `restricted:` without conflict detail | [Authorization](NIP-FI.md#authorization), [Binding invariant](NIP-FI-MODEL.md#binding-invariant) | +| EN-1 | No binding or lifecycle gate; `attested-key` mode | Valid assertion with `k_a = k`; valid proof | Lifecycle reads | Allow created binding | Create `(i, k)` with `attested-key` provenance | Create bounded lease | Success | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | +| EN-2 | No binding or lifecycle gate; `attested-key` mode | Valid assertion without key claim; valid proof | Lifecycle reads | Deny `key_attestation_required` | None | None | `restricted:` | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | +| EN-3 | No active binding; `provisioned` mode | Valid assertion and proof | Lifecycle reads | Deny `binding_required` | None | None | `restricted:` | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | +| EN-4 | No binding or lifecycle gate; `tofu` mode | Valid assertion without key claim; valid proof | Lifecycle reads | Allow created binding with accepted TOFU risk | Create `(i, k)` with `tofu` provenance | Create bounded lease | Success | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | +| EN-5 | No binding or lifecycle gate; `tofu` mode | Valid assertion with `k_a = k`; valid proof | Lifecycle reads | Allow created binding | Create `(i, k)` with `attested-key` provenance; later requests cannot downgrade it | Create bounded lease | Success | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | +| CC-1 | No binding or lifecycle gate | Concurrent identical first use of `(i, k)` | Serialized lifecycle reads | One create; all others observe exact match and allow existing | Exactly one insertion | Equivalent bounded leases | Success | [Authorization](NIP-FI.md#authorization), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | +| CC-2 | No binding or lifecycle gate | Concurrent `(i, k1)` / `(i, k2)` or `(i1, k)` / `(i2, k)` | Serialized lifecycle reads | At most one create; conflicting attempt denies | At most one insertion | Lease only for committed pair | Conflicting request receives `restricted:` | [Authorization](NIP-FI.md#authorization), [Binding invariant](NIP-FI-MODEL.md#binding-invariant) | +| CC-3 | Any prospective enrollment | Lifecycle lookup, insertion, or committed-result reread fails | Required state is unavailable | Fail closed | None beyond a transaction proven committed | No new lease from an unreadable result | `restricted:` | [Authorization](NIP-FI.md#authorization), [Safety](NIP-FI-MODEL.md#safety-properties) | +| LC-1 | `(i, k) in P`; no active binding | Still-valid matching assertion and proof, with or without matching key claim | Lifecycle reads | Deny `pair_retired` | None | No lease | `restricted:` without tombstone detail | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-2 | `i in X`, including before first enrollment | Valid assertion for `i`; proof by any key | Lifecycle reads | Deny `identity_disabled` | None | Invalidate/deny direct and dependent delegated leases | `restricted:` | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-3 | `k in Y`, including without an active binding | Valid assertion for any identity; proof by `k` | Lifecycle reads | Deny `key_revoked` | None | Invalidate/deny every lease depending on `k` | `restricted:` | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-4 | `Q(i) = k_old`; no active binding | Valid assertion for `i`; proof by fresh `k_new` | Lifecycle reads | Deny `explicit_replacement_required` | None | No lease | `restricted:` | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-5 | Active old pair or `Q(i) = k_old`; replacement is lifecycle-eligible | Separately authorized rotation/recovery; fresh `k_a = k_new` when required | Old/new active and lifecycle state | Allow privileged replacement | Atomically retire old pair/key, create new pair, clear `Q`, retain history | Invalidate old direct/delegated leases; new pair may obtain a bounded lease | Operator transition; not an `Authorize` response | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-6 | Authorization and retire/disable/revoke/replace race | Valid evidence plus concurrent lifecycle transition | Serialized active and lifecycle state | Serialization decides; completed lifecycle state cannot be violated | No post-transition resurrection or partial replacement | Matching leases invalidated when transition is observed | Success only for the serialization that precedes the lifecycle transition | [Authorization](NIP-FI.md#authorization), [Safety](NIP-FI-MODEL.md#safety-properties) | +| SE-1 | HTTP endpoint | Valid assertion and NIP-98 proof | Lifecycle reads | Apply decision to this request only | Enrollment only when allowed | No reusable HTTP lease implied | Per-request result | [Sessions](NIP-FI.md#session-semantics), [Session model](NIP-FI-MODEL.md#session-behavior) | +| SE-2 | Active WebSocket lease reaches its earliest bound | No new upgrade assertion | Current time and observed dependencies | Reject protected operations or close | None | Lease expires; renewal requires a new connection, fresh assertion, and fresh NIP-42 proof | `restricted:` / closure without private detail | [Sessions](NIP-FI.md#session-semantics), [Session model](NIP-FI-MODEL.md#session-behavior) | +| SE-3 | One WebSocket authenticates `k1` and `k2`; only `k1` is authorized | Operations attributed to each key | Per-key lease lookup | Allow only operations attributed to `k1` | None | `k1` lease never authorizes `k2` | Independent per-key result | [Sessions](NIP-FI.md#session-semantics), [Session model](NIP-FI-MODEL.md#session-behavior) | +| SE-4 | Direct or delegated lease depends on binding/policy/delegation state that becomes invalid | Previously valid session | Observed dependency state | Deny after the documented detection bound | None | Invalidate every dependent lease; do not claim immediate revocation when polling | Redacted denial/closure | [Sessions](NIP-FI.md#session-semantics), [Safety](NIP-FI-MODEL.md#safety-properties) | +| DG-1 | Owner has an eligible active binding; delegation is valid and narrower | Delegate proves its own key; no delegate assertion | Owner binding/lease plus delegation conditions | Allow only intersected delegated operations | Never create owner's federated binding for delegate | Lease bounded by owner, delegation, policy, and implementation limits | Success for allowed delegated operation | [Delegation](NIP-FI.md#delegation), [Delegation model](NIP-FI-MODEL.md#delegation) | +| DG-2 | Owner binding is retired/revoked, or delegation expired/revoked | Delegate presents formerly valid proof | Owner and delegation dependencies | Deny | None | Invalidate dependent delegated lease on documented detection schedule | `restricted:` | [Delegation](NIP-FI.md#delegation), [Delegation model](NIP-FI-MODEL.md#delegation) | +| BD-1 | Two server-resolved authorization domains | Client attempts to choose another domain or reuse evidence across domains | State only for trusted resolved `D` | Ignore/reject client domain choice; no cross-domain authority | None outside selected domain | Domain-scoped lease only | Redacted denial when no selected-domain authority exists | [Definitions](NIP-FI.md#definitions), [Safety](NIP-FI-MODEL.md#safety-properties) | +| OP-1 | Conflict, validation failure, or lifecycle denial | Any denied request | As required by the decision | Deny without disclosing the conflicting identity, key, claims, or selector | None | None or matching lease invalidation | Only `auth-required:` / `restricted:` or HTTP `401`/`403` | [Rejection](NIP-FI.md#rejection-semantics), [Privacy](NIP-FI.md#privacy) | +| OP-2 | Relay supports a subset of transports, enrollment modes, or delegation | NIP-11 query | None | Advertise only supported behavior | None | None | No issuer-internal tenant, claim, or audience detail | [Discovery](NIP-FI.md#discovery), [Conformance hooks](NIP-FI-MODEL.md#conformance-hooks-for-the-nip) | +| OP-3 | Separate opt-in relay-signed public projection exists | Approved label, or a forged/public projection presented as auth evidence | Normal NIP-FI evidence and lifecycle state only | Publish only approved non-private label; never accept projection as authorization evidence | Projection is separate from binding state | No authorization lease from projection | No `iss`, `sub`, bearer material, or private claim disclosure | [Privacy](NIP-FI.md#privacy), [Safety](NIP-FI-MODEL.md#safety-properties) | + +## Conformance claim + +An implementation may claim NIP-FI conformance only for the transport profiles, enrollment modes, delegation behavior, and optional projection behavior it advertises and tests. Every applicable trace above must pass against the same reviewed implementation revision. Unsupported optional behavior must be omitted from discovery rather than represented as implemented. diff --git a/docs/nips/NIP-FI-MODEL.md b/docs/nips/NIP-FI-MODEL.md index 046f7cf3e0..ed643f04a7 100644 --- a/docs/nips/NIP-FI-MODEL.md +++ b/docs/nips/NIP-FI-MODEL.md @@ -239,32 +239,9 @@ Assuming the issuer, key source, binding store, and network are available: 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 - -| Trace | Required result | -|---|---| -| Valid assertion for `i`, attacker proves unbound `k_x`, `i` already bound to `k_v` | Deny `binding_conflict` | -| Valid assertion with key claim `k_v`, attacker proves `k_x` | Deny `key_mismatch` before mutation | -| Stolen assertion for never-enrolled `i`, attacker proves `k_x` | Deny in `attested-key`/`provisioned`; TOFU can bind and explicitly accepts this risk | -| Client injects trusted-proxy header while bypassing proxy | Deployment is non-conforming; verifier must reject direct/untrusted ingress | -| Assertion for issuer `A`, same `sub` as issuer `B` | Distinct identities; never collide or inherit binding | -| 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 | -| 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 | -| JWT or corporate identifier is accidentally published as event/tag | Non-conforming privacy failure; assertion transport must not enter relay event history | +# Conformance traces + +The companion [NIP-FI conformance matrix](NIP-FI-CONFORMANCE.md) defines stable-ID success, denial, concurrency, lifecycle, session, delegation, disclosure, and privacy traces. In particular, its revoked-pair replay trace requires a stable denial with no mutation even when the still-valid assertion carries a matching key claim; only the separately authorized replacement transition can restore authority. # Conformance hooks for the NIP @@ -287,4 +264,5 @@ 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) +- Companion conformance matrix: [`NIP-FI-CONFORMANCE.md`](NIP-FI-CONFORMANCE.md) - 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. diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 17ee7c35e6..783557c562 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -182,7 +182,7 @@ A separate, opt-in relay-signed projection protocol such as NIP-85 MAY publish a - **Availability vs. safety**: issuer, key-set, and storage outages deny. Availability MUST NOT override identity safety. - **Cross-issuer collision**: identical `sub` values under different issuers are distinct identities and MUST never collide or inherit each other's bindings. -A companion formal model of this protocol — state machine, safety and liveness properties, and attack traces — accompanies this specification. +The companion [formal model](NIP-FI-MODEL.md) defines the state machine and safety/liveness properties. The [conformance matrix](NIP-FI-CONFORMANCE.md) supplies stable, reviewable success, denial, concurrency, lifecycle, session, disclosure, and privacy traces. ## Implementation relationship From 674440ffcfa8976fd10e3efd883c173ad7ffa6b3 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:20:47 -0500 Subject: [PATCH 3/5] docs(nips): resolve NIP-FI review findings Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- docs/nips/NIP-FI-CONFORMANCE.md | 63 ++++++++--- docs/nips/NIP-FI-MODEL.md | 180 ++++++++++++++++++++++++-------- docs/nips/NIP-FI.md | 77 +++++++++----- 3 files changed, 236 insertions(+), 84 deletions(-) diff --git a/docs/nips/NIP-FI-CONFORMANCE.md b/docs/nips/NIP-FI-CONFORMANCE.md index 0e2ba882d1..283987dcea 100644 --- a/docs/nips/NIP-FI-CONFORMANCE.md +++ b/docs/nips/NIP-FI-CONFORMANCE.md @@ -2,7 +2,15 @@ This companion matrix makes the normative [NIP-FI specification](NIP-FI.md) and its [formal model](NIP-FI-MODEL.md) reviewable as executable traces. The specification remains normative; the model and this matrix must agree with it. -Use synthetic issuers, subjects, keys, assertions, and domains. A denial with “no mutation” means no binding, lifecycle, enrollment, membership, publication, or last-seen mutation; a redacted security audit record is allowed. Internal decision names below need not be exposed on the wire: public failures use only the redacted `auth-required:` or `restricted:` classes defined by the specification. +Use synthetic issuers, subjects, keys, assertions, and domains. A denial with “no mutation” means no binding, lifecycle, enrollment, publication, or last-seen mutation; a redacted security audit record is allowed. Internal decision names below need not be exposed on the wire: public failures use only the redacted `auth-required:` or `restricted:` classes defined by the specification. + +Review LC-13 together with the model's `RevokeKey` transition: its active, inactive, already-revoked, lineage-drift, post-commit-reread, and `Q+Y` branches intentionally form one regression surface. + +Stable subcase labels for allocation and mechanical regression checks are `LC-13.active`, `LC-13.inactive`, `LC-13.already-revoked`, `LC-13.lineage-drift`, `LC-13.post-commit-reread`, and `LC-13.q-plus-y`. They are parts of the single public LC-13 trace rather than additional trace IDs. + +OP-3 has two separately evaluated subcases under one public trace ID: `OP-3.absent` is always required and proves that an unimplemented/default-off projection has no surface or advertisement; `OP-3.implemented` applies only when a separate privacy-approved projection exists and proves its publication and non-authority constraints. A conformance report records `OP-3.implemented` as `not applicable` — never blank or failed — when no such projection exists; when it exists, the subcase must pass. + +Lifecycle lineage checks also expose stable subcases without adding public trace IDs: `LC-17.valid` and `LC-17.lineage-drift` cover re-enablement, while `LC-20.valid` and `LC-20.lineage-drift` cover recovery. Each drift subcase includes missing, mismatched, and unreadable retired-pair state. ## State shorthand @@ -11,6 +19,7 @@ Use synthetic issuers, subjects, keys, assertions, and domains. A denial with - `X`: disabled-identity selector for `i`. - `Y`: revoked-key selector for `k`. - `Q`: pending explicit replacement for `i`. +- `Q(i) = k` and `k in Y` may coexist after key revocation: `Q` preserves retired-pair lineage while `Y` independently denies the key throughout the authorization domain. - “Lifecycle reads” means `B_i`, `B_k`, `P`, `X`, `Y`, and `Q` are read in the same linearizable transition. ## Required traces @@ -19,23 +28,28 @@ Use synthetic issuers, subjects, keys, assertions, and domains. A denial with |---|---|---|---|---|---|---|---|---| | TR-1 | Trusted-proxy profile; direct verifier ingress is reachable or inbound assertion copies are not stripped | Client-controlled proxy header and otherwise valid proof | None | Reject non-conforming ingress | None | None | `restricted:` or connection rejection without claim detail | [Transport](NIP-FI.md#assertion-transport), [Trust](NIP-FI-MODEL.md#trust-assumptions) | | TR-2 | Browser WebSocket client without a trusted proxy | Assertion in URL/query string | None | Reject forbidden transport | None | None | No authenticated session; token never logged | [Transport](NIP-FI.md#assertion-transport), [Privacy](NIP-FI.md#privacy) | -| TR-3 | Client-attached HTTP profile | Valid assertion and valid NIP-98 proof on different requests, or malformed/multiple bearer values | None | Reject evidence that is not co-located and unambiguous | None | None | HTTP `401`/`403` with redacted body | [Transport](NIP-FI.md#assertion-transport), [Rejection](NIP-FI.md#rejection-semantics) | -| AS-1 | Accepted issuer configured | Missing assertion, or missing Nostr proof | None | Deny missing independent evidence | None | None | `auth-required:` / HTTP `401` | [Proof](NIP-FI.md#nostr-proof), [Proof model](NIP-FI-MODEL.md#nostr-proof-validity) | +| TR-3 | Client-attached HTTP profile | Valid assertion and valid NIP-98 proof on different requests | None | Reject evidence that is not co-located | None | None | HTTP `401` with redacted body | [Transport](NIP-FI.md#assertion-transport), [Rejection](NIP-FI.md#rejection-semantics) | +| TR-4 | Client-attached profile | Malformed, non-Bearer, or multiple bearer assertion values | None | Reject presented-invalid evidence | None | None | `restricted:` / HTTP `403` with redacted body | [Transport](NIP-FI.md#assertion-transport), [Rejection](NIP-FI.md#rejection-semantics) | +| AS-1 | Accepted issuer configured | Missing assertion, or missing Nostr proof | None | Deny missing independent evidence | None | None | `auth-required:` / HTTP `401` | [Transport](NIP-FI.md#assertion-transport), [Proof](NIP-FI.md#nostr-proof), [Rejection](NIP-FI.md#rejection-semantics), [Proof model](NIP-FI-MODEL.md#nostr-proof-validity) | | AS-2 | Accepted issuer configured | Bad signature; symmetric/`none` algorithm; wrong issuer or audience | None | Reject assertion | None | None | `restricted:` / HTTP `403` without validation detail | [Validation](NIP-FI.md#assertion-validation), [Validation model](NIP-FI-MODEL.md#assertion-validity) | | AS-3 | Accepted issuer configured | Missing/expired `exp`, future `nbf` or `iat` beyond skew | None | Reject assertion | None | None | `restricted:` / HTTP `403` | [Validation](NIP-FI.md#assertion-validation), [Validation model](NIP-FI-MODEL.md#assertion-validity) | -| AS-4 | Accepted issuer configured | Missing, empty, ambiguous, or wrong-type `sub`; malformed or ambiguous key claim | None | Reject assertion | None | None | `restricted:` / HTTP `403` | [Validation](NIP-FI.md#assertion-validation), [Validation model](NIP-FI-MODEL.md#assertion-validity) | +| AS-4 | Accepted issuer configured; exercise the optional `npub` normalization when advertised | Missing, empty, ambiguous, or wrong-type `sub`; malformed or ambiguous key claim; valid all-lowercase and all-uppercase bare NIP-19 Bech32 `npub` encodings of the same key; invalid Bech32m/mixed-case/checksum/prefix/TLV/length variants | None | Accept only valid configured encodings that resolve to one 32-byte key and canonicalize both permitted `npub` case forms to the same lowercase hex; reject every malformed, Bech32m, or ambiguous variant | None | None | Normal processing for the valid canonical key; otherwise `restricted:` / HTTP `403` | [Validation](NIP-FI.md#assertion-validation), [Validation model](NIP-FI-MODEL.md#assertion-validity) | | AS-5 | Key set unavailable, stale beyond policy, or unknown `kid` after a fresh bounded fetch | Otherwise plausible JWT | None | Fail closed; do not drive unbounded refreshes | None | None | `restricted:` / HTTP `403` | [Validation](NIP-FI.md#assertion-validation), [Validation model](NIP-FI-MODEL.md#assertion-validity) | | AS-6 | Issuers `A` and `B` use the same `sub` | Valid assertion from each issuer | Issuer-qualified lifecycle reads | Treat `(A, sub)` and `(B, sub)` as different identities | Only the selected identity may enroll | Independent leases | Normal result for each qualified identity | [Definitions](NIP-FI.md#definitions), [Domain terms](NIP-FI-MODEL.md#terms-and-domains) | | PR-1 | Any binding state | Invalid, stale, replayed, wrong-target, or wrong-payload NIP-42/NIP-98 proof | None | Reject proof before authorization | None | None | `restricted:` / HTTP `403` | [Proof](NIP-FI.md#nostr-proof), [Proof model](NIP-FI-MODEL.md#nostr-proof-validity) | | AU-1 | Any binding state | Valid assertion with `k_a != k` | None | Deny `key_mismatch` before state access | None | None | `restricted:` without either key | [Authorization](NIP-FI.md#authorization), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | -| AU-2 | `B_i = B_k = (i, k)` and no lifecycle gate | Matching valid assertion and proof | Lifecycle reads | Allow existing binding | None | Create lease bounded by all applicable limits | Success | [Authorization](NIP-FI.md#authorization), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | +| AU-2 | `B_i = B_k = (i, k)` and no lifecycle gate | Matching valid assertion and proof | Lifecycle reads and known expiry bounds | Allow existing binding | None | Create direct lease bounded by the earliest assertion, binding, policy, and implementation limit | Success | [Authorization](NIP-FI.md#authorization), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | | AU-3 | `B_i = (i, k_old)` | Valid assertion for `i`; proof by `k_new` | Lifecycle reads | Deny `binding_conflict` | None | None | `restricted:` without conflict detail | [Authorization](NIP-FI.md#authorization), [Binding invariant](NIP-FI.md#binding-invariant) | | AU-4 | `B_k = (i_other, k)` | Valid assertion for `i`; proof by `k` | Lifecycle reads | Deny `binding_conflict` | None | None | `restricted:` without conflict detail | [Authorization](NIP-FI.md#authorization), [Binding invariant](NIP-FI-MODEL.md#binding-invariant) | +| AU-5 | `B_i = B_k = (i, k)` and no lifecycle gate | Matching assertion and proof; display-name, email, or similar metadata changed | Lifecycle reads | Same allow decision as unchanged metadata | No binding or authorization mutation from metadata | Equivalent bounded lease | Success without exposing profile values | [Validation](NIP-FI.md#assertion-validation), [Terms](NIP-FI-MODEL.md#terms-and-domains) | | EN-1 | No binding or lifecycle gate; `attested-key` mode | Valid assertion with `k_a = k`; valid proof | Lifecycle reads | Allow created binding | Create `(i, k)` with `attested-key` provenance | Create bounded lease | Success | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | | EN-2 | No binding or lifecycle gate; `attested-key` mode | Valid assertion without key claim; valid proof | Lifecycle reads | Deny `key_attestation_required` | None | None | `restricted:` | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | -| EN-3 | No active binding; `provisioned` mode | Valid assertion and proof | Lifecycle reads | Deny `binding_required` | None | None | `restricted:` | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | +| EN-3 | No active binding or lifecycle gate; `provisioned` mode | Valid assertion and proof | Lifecycle reads | Deny `binding_required` | None | None | `restricted:` | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | | EN-4 | No binding or lifecycle gate; `tofu` mode | Valid assertion without key claim; valid proof | Lifecycle reads | Allow created binding with accepted TOFU risk | Create `(i, k)` with `tofu` provenance | Create bounded lease | Success | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | -| EN-5 | No binding or lifecycle gate; `tofu` mode | Valid assertion with `k_a = k`; valid proof | Lifecycle reads | Allow created binding | Create `(i, k)` with `attested-key` provenance; later requests cannot downgrade it | Create bounded lease | Success | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | +| EN-5 | No binding or lifecycle gate; `tofu` mode | Valid assertion with `k_a = k`; valid proof | Lifecycle reads | Allow created binding | Create `(i, k)` with `attested-key` provenance | Create bounded lease | Success | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | +| EN-6 | Existing `(i, k)` has `attested-key` provenance | Later valid assertion omits the key claim; valid proof by `k` | Lifecycle reads and stored provenance | Allow existing binding without downgrade | Preserve `attested-key` provenance exactly | Create bounded direct lease | Success | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | +| EN-7 | `provisioned` mode; no active binding for `i` or `k`; `i` not disabled or pending; `k` not revoked; exact `(i, k)` not retired | Separately authorized provisioning | Enrollment mode, active, and lifecycle state | Allow privileged provisioning | Atomically create `(i, k)` with `provisioned` provenance and append provision history | No direct lease; later direct authority still requires valid assertion and fresh proof by `k` | Operator transition; not an `Authorize` response | [Enrollment](NIP-FI.md#enrollment-modes), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| EN-8 | Non-`provisioned` mode; active binding for `i` or `k`; `i` disabled or pending; `k` revoked; exact `(i, k)` retired; a required mode, active-binding, or lifecycle-selector read is unreadable; or a concurrent competing provision wins | Separately authorized provisioning | Serialized attempts to read mode, active, lifecycle, and history state | Deny ineligible, unreadable, or losing provision | None; never overwrite or clear active/lifecycle state or append provision history | No lease | Privileged transition failure without selector detail | [Enrollment](NIP-FI.md#enrollment-modes), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | | CC-1 | No binding or lifecycle gate | Concurrent identical first use of `(i, k)` | Serialized lifecycle reads | One create; all others observe exact match and allow existing | Exactly one insertion | Equivalent bounded leases | Success | [Authorization](NIP-FI.md#authorization), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | | CC-2 | No binding or lifecycle gate | Concurrent `(i, k1)` / `(i, k2)` or `(i1, k)` / `(i2, k)` | Serialized lifecycle reads | At most one create; conflicting attempt denies | At most one insertion | Lease only for committed pair | Conflicting request receives `restricted:` | [Authorization](NIP-FI.md#authorization), [Binding invariant](NIP-FI-MODEL.md#binding-invariant) | | CC-3 | Any prospective enrollment | Lifecycle lookup, insertion, or committed-result reread fails | Required state is unavailable | Fail closed | None beyond a transaction proven committed | No new lease from an unreadable result | `restricted:` | [Authorization](NIP-FI.md#authorization), [Safety](NIP-FI-MODEL.md#safety-properties) | @@ -43,19 +57,40 @@ Use synthetic issuers, subjects, keys, assertions, and domains. A denial with | LC-2 | `i in X`, including before first enrollment | Valid assertion for `i`; proof by any key | Lifecycle reads | Deny `identity_disabled` | None | Invalidate/deny direct and dependent delegated leases | `restricted:` | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | | LC-3 | `k in Y`, including without an active binding | Valid assertion for any identity; proof by `k` | Lifecycle reads | Deny `key_revoked` | None | Invalidate/deny every lease depending on `k` | `restricted:` | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | | LC-4 | `Q(i) = k_old`; no active binding | Valid assertion for `i`; proof by fresh `k_new` | Lifecycle reads | Deny `explicit_replacement_required` | None | No lease | `restricted:` | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-5 | Active old pair or `Q(i) = k_old`; replacement is lifecycle-eligible | Separately authorized rotation/recovery; fresh `k_a = k_new` when required | Old/new active and lifecycle state | Allow privileged replacement | Atomically retire old pair/key, create new pair, clear `Q`, retain history | Invalidate old direct/delegated leases; new pair may obtain a bounded lease | Operator transition; not an `Authorize` response | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-6 | Authorization and retire/disable/revoke/replace race | Valid evidence plus concurrent lifecycle transition | Serialized active and lifecycle state | Serialization decides; completed lifecycle state cannot be violated | No post-transition resurrection or partial replacement | Matching leases invalidated when transition is observed | Success only for the serialization that precedes the lifecycle transition | [Authorization](NIP-FI.md#authorization), [Safety](NIP-FI-MODEL.md#safety-properties) | +| LC-5 | Active `(i, k_old)`; `Q(i)` is absent; eligible unbound `k_new` | Separately authorized rotation; fresh target-bound proof by `k_new`; current assertion for `i` with `k_a = k_new` when attestation is required | Old/new active, `Q`, lifecycle, proof, and optional attestation state | Allow privileged rotation | Atomically remove `(i, k_old)` from `B`, add that exact pair to `P`, create `(i, k_new)` with deterministic provenance, preserve `Q(i)` as absent, and append distinct rotation history; do not add `k_old` to domain-scoped key revocation | After commit, invalidate old direct and dependent delegated leases; new pair may later obtain a bounded lease | Operator transition; not an `Authorize` response | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-6 | `i in X`, `Q(i) = k_old`, and `(i, k_old) in P` | Separately authorized recovery with otherwise eligible `k_new` | Identity, pending-replacement, retired-pair, and new-key state | Deny `identity_disabled` | None, including no selector clearing or history append | No new lease | Privileged transition failure without private detail | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-7 | Eligible active old pair or pending replacement; `k_new` is active, revoked, or `(i, k_new)` is retired | Separately authorized rotation or recovery with otherwise valid proof | Old/new active and lifecycle state | Deny ineligible replacement-key state | None; do not retire the old pair or disturb the conflicting binding | No new lease | Privileged transition failure without selector detail | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-8 | One eligible active old pair; two distinct eligible replacement keys | Concurrent separately authorized rotations with valid replacement proofs | Serialized old/new active and lifecycle state | At most one complete rotation succeeds | Exactly one rotation and history record; loser makes no mutation | Old leases invalidated once; lease possible only for committed replacement | One operator transition succeeds at most | [Revocation](NIP-FI.md#revocation-and-rotation), [Safety](NIP-FI-MODEL.md#safety-properties) | +| LC-9 | Any eligible provision, retire, disable, revoke, rotate, recover, or re-enablement transition | Injected failure while writing active state, selectors, or lifecycle history | Transactional active, lifecycle, and history state | Fail closed | Entire transition rolls back; prior state and history remain unchanged | No new lease or partial invalidation claim | Privileged transition failure | [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation), [Safety](NIP-FI-MODEL.md#safety-properties) | +| LC-10 | Authorization and provision/retire/disable/revoke/rotate/recover/re-enable race | Valid evidence plus concurrent lifecycle transition | Serialized active and lifecycle state | Serialization decides; completed lifecycle state cannot be violated | No post-transition resurrection or partial replacement | Matching leases invalidated when transition is observed | Result matches the serialized state; no stale allow or private detail | [Authorization](NIP-FI.md#authorization), [Safety](NIP-FI-MODEL.md#safety-properties) | +| LC-11 | Active `(i, k)` with absent `Q(i)` | Separately authorized pair retirement; inject present or unreadable `Q(i)` | Active pair, `Q`, other selectors, and lifecycle history | Commit valid retirement; deny lineage drift without mutation | Atomically recheck absent `Q(i)`, remove `(i, k)`, add it to `P`, set `Q(i) = k`, and append one history record. Present or unreadable `Q(i)` rolls back with no selector or history mutation | Invalidate matching direct and dependent delegated leases only after a successful commit | Operator transition or redacted failure; not an `Authorize` response | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-12 | Identity `i` may be active with absent `Q(i)`, pending replacement without an active binding, or never enrolled | Separately authorized identity disablement; inject present or unreadable `Q(i)` for the active branch | Active pair, `X`, `P`, `Q`, and lifecycle history | Commit valid disablement; deny active-branch lineage drift without mutation | Atomically recheck absent `Q(i)` when active, add `i` to `X`, remove and retire any active pair, set `Q(i)` for that pair, and append one history record. Present or unreadable `Q(i)` in the active branch rolls back with no mutation; without an active pair, preserve any existing `Q(i)` | Invalidate direct and dependent delegated leases for `i` only after a successful commit | Operator transition or redacted failure; not an `Authorize` response | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-13 | `LC-13.active`: `k` has a unique active pair and absent `Q(i)`. `LC-13.inactive`: `k` is inactive or never bound. `LC-13.already-revoked`: inactive `k` is already in `Y`. `LC-13.lineage-drift`: the active pair has present or unreadable `Q(i)`. `LC-13.post-commit-reread` and `LC-13.q-plus-y` evaluate the committed branch states | Separately authorized key revocation, including a repeated invocation; execute every labeled subcase | Active pair, `Y`, `P`, `Q`, and lifecycle history before commit; durable `Y` and any affected `P`/`Q` state after commit | Commit valid revocation; deny active-branch lineage drift without mutation | Atomically add or preserve `k` in `Y` and append exactly one history record per successfully committed separately authorized invocation. If the unique active pair exists, first recheck absent `Q(i)`, then retire the pair and set `Q(i) = k`; present or unreadable `Q(i)` rolls back the invocation with no history append. If no active pair exists, make no `P` or `Q` mutation and preserve every pre-existing `Q`. Reread the committed state: the active-pair result with simultaneous `Q(i) = k` and `k in Y` is expected and neither selector is cleared or normalized away; the inactive or already-revoked result retains `k in Y` without `P` or `Q` mutation | Invalidate every direct or delegated lease depending on `k` only after a successful commit | Operator transition or redacted failure; not an `Authorize` response | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-14 | Successful LC-5 rotation or LC-20 recovery has committed `(i, k_new)` and retired the exact old pair | Fresh valid assertion and proof for `k_new` | Lifecycle reads | Allow existing replacement binding | None | Create a bounded direct lease for `k_new`; the old pair remains denied by LC-1 and the old key is denied across the authorization domain only after LC-13 | Success only for `k_new` | [Revocation](NIP-FI.md#revocation-and-rotation), [Liveness](NIP-FI-MODEL.md#liveness-properties) | +| LC-15 | `(i, k)` is pair-retired and pending replacement; `k` is not in `Y` and has no active binding | Eligible first enrollment for distinct identity `j` with proof by `k` | Lifecycle reads for `(j, k)` | Apply normal enrollment policy; pair retirement alone does not revoke `k` across the authorization domain | Create `(j, k)` only when the configured enrollment mode permits | Bounded direct lease only for `(j, k)` | Normal result for `j`; no authority for `i` | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-16 | `Q(i) = k_old` and `(j, k_old)` is active; eligible distinct `k_new`; `i` is enabled for recovery or disabled for re-enablement | Separately authorized matching transition for `i` with valid replacement proof | Old/replacement active and lifecycle state | Apply recovery only when `i` is enabled, or re-enablement only when `i` is disabled, without treating pair retirement as domain-scoped key revocation | Create only `(i, k_new)`, preserve retired `(i, k_old)`, and do not disturb `(j, k_old)` or add `k_old` to `Y` | Invalidate only dependencies of the retired pair; new binding may later obtain a lease | Operator result without disclosing `j` | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-17 | `LC-17.valid`: `i in X`, with or without `Q(i) = k_old`; when `Q` is present, `(i, k_old) in P`; eligible `k_new`. `LC-17.lineage-drift`: `Q(i) = k_old` but the exact retired pair is missing, mismatched, or unreadable | Separately authorized enablement with fresh target-bound proof by `k_new` and current matching assertion when required; execute each lineage subcase | Identity, old/new key, proof, readable pending/retired-pair selectors, and history state inside the atomic transition | Valid: allow privileged enablement. Drift or stale compare: deny fail closed before mutation | Valid: atomically create `(i, k_new)` with deterministic provenance, clear `X`, preserve absent `Q` or conditionally compare-and-clear present `Q(i) = k_old`, append enablement history, and preserve exact old-pair retirement without domain-scoped key revocation. Drift or stale compare: no binding, selector, or history mutation | Valid: after commit, invalidate prior-pair dependencies; new binding may later obtain a bounded direct lease. Drift or stale compare: no lease or invalidation | Operator transition or redacted failure; ordinary enrollment never sees an ungated re-enabled identity | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-18 | Eligible rotation, recovery, or re-enablement; replacement proof is valid and issuer attestation is optional | Supplied current assertion for `i` has `k_a != k_new` | Old/new active, lifecycle, proof, and assertion state | Deny mismatched supplied attestation | None; do not create a binding or record attested provenance | No new lease | Privileged transition failure without claim detail | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-19 | Two privileged state transitions affect the same identity or key, including two recoveries from one `Q(i) = k_old` to distinct eligible replacement keys or recovery racing disablement/re-enablement | Concurrent provision, retire, disable, revoke, recover, rotate, or re-enable requests with valid transition-specific evidence for their observed pre-state | Serialized active, selector, and history state with precondition and conditional-comparison recheck | At most one incompatible transition commits; a still-eligible serialized successor may commit only from the complete new state, and a recovery cannot commit after disablement wins | Each committed transition is atomic with one typed history append; a stale compare-and-clear loser makes no mutation | Invalidation follows only committed transitions; a loser triggers none | Deterministic operator results without private selector detail | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-20 | `LC-20.valid`: `Q(i) = k_old`, `(i, k_old) in P`, no active binding for `i`, and eligible unbound `k_new`. `LC-20.lineage-drift`: `Q(i) = k_old` but the exact retired pair is missing, mismatched, or unreadable | Separately authorized recovery with fresh target-bound proof by `k_new` and current matching assertion when required; execute each lineage subcase | Old/new active, readable pending/retired-pair selectors, proof, and optional attestation state inside the atomic transition | Valid: allow privileged recovery. Drift or stale compare: deny fail closed before mutation | Valid: atomically preserve retired `(i, k_old)`, create `(i, k_new)` with deterministic provenance, conditionally compare-and-clear `Q(i)` from `k_old` to absent, and append distinct recovery history without revoking `k_old` across the domain. Drift or stale compare: no binding, selector, or history mutation | Valid: after commit, invalidate old-pair dependencies; new pair may later obtain a bounded lease. Drift or stale compare: no lease or invalidation | Operator transition or redacted failure; not an `Authorize` response | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-21 | Otherwise eligible rotation, recovery, or re-enablement, including when issuer attestation is optional | Supplied key-attestation assertion is stale, resolves to an identity other than `i`, or has no key claim | Assertion policy, assertion, proof, active, and lifecycle state | Deny invalid supplied replacement attestation; invalid evidence cannot degrade to absent optional evidence | None | No new lease | Privileged transition failure without identity or claim detail | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-22 | Otherwise eligible rotation, recovery, or re-enablement | Missing, stale, replayed, wrong-target, or wrong-key Nostr proof for `k_new` | Proof, active, and lifecycle state | Deny replacement-key control failure | None | No new lease | Privileged transition failure without proof detail | [Proof](NIP-FI.md#nostr-proof), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-23 | Otherwise eligible provision, retire, disable, revoke, rotate, recover, or re-enablement | Missing or invalid lifecycle authority, or authority bound to another operation or domain | Lifecycle-authority scope plus active and lifecycle state | Deny unauthorized privileged transition | None | No lease or invalidation | Privileged transition failure without authority detail | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | +| LC-24 | Otherwise eligible rotation, recovery, or re-enablement in a domain that requires issuer attestation | Attestation evidence is absent, or the supplied current assertion for `i` has no key claim | Assertion policy, proof, active, and lifecycle state | Deny missing required replacement attestation | None | No new lease | Privileged transition failure without claim detail | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | | SE-1 | HTTP endpoint | Valid assertion and NIP-98 proof | Lifecycle reads | Apply decision to this request only | Enrollment only when allowed | No reusable HTTP lease implied | Per-request result | [Sessions](NIP-FI.md#session-semantics), [Session model](NIP-FI-MODEL.md#session-behavior) | -| SE-2 | Active WebSocket lease reaches its earliest bound | No new upgrade assertion | Current time and observed dependencies | Reject protected operations or close | None | Lease expires; renewal requires a new connection, fresh assertion, and fresh NIP-42 proof | `restricted:` / closure without private detail | [Sessions](NIP-FI.md#session-semantics), [Session model](NIP-FI-MODEL.md#session-behavior) | +| SE-2 | Active direct WebSocket lease reaches its earliest assertion, binding, policy, or implementation bound | No new upgrade assertion | Current time and observed dependencies | Reject protected operations or close | None | Direct lease expires; renewal requires a new connection, fresh assertion, and fresh NIP-42 proof | `restricted:` / closure without private detail | [Sessions](NIP-FI.md#session-semantics), [Session model](NIP-FI-MODEL.md#session-behavior) | | SE-3 | One WebSocket authenticates `k1` and `k2`; only `k1` is authorized | Operations attributed to each key | Per-key lease lookup | Allow only operations attributed to `k1` | None | `k1` lease never authorizes `k2` | Independent per-key result | [Sessions](NIP-FI.md#session-semantics), [Session model](NIP-FI-MODEL.md#session-behavior) | | SE-4 | Direct or delegated lease depends on binding/policy/delegation state that becomes invalid | Previously valid session | Observed dependency state | Deny after the documented detection bound | None | Invalidate every dependent lease; do not claim immediate revocation when polling | Redacted denial/closure | [Sessions](NIP-FI.md#session-semantics), [Safety](NIP-FI-MODEL.md#safety-properties) | -| DG-1 | Owner has an eligible active binding; delegation is valid and narrower | Delegate proves its own key; no delegate assertion | Owner binding/lease plus delegation conditions | Allow only intersected delegated operations | Never create owner's federated binding for delegate | Lease bounded by owner, delegation, policy, and implementation limits | Success for allowed delegated operation | [Delegation](NIP-FI.md#delegation), [Delegation model](NIP-FI-MODEL.md#delegation) | +| SE-5 | Active delegated WebSocket lease reaches its earliest owner-binding, delegation, policy, or mandatory finite implementation bound | No newly validated delegated decision | Current time and observed dependencies | Reject protected operations or close | None | Lease expires; a new delegated decision requires fresh delegate proof, an active owner binding, and revalidated delegation, but no delegate assertion | `restricted:` / closure without private detail | [Delegation](NIP-FI.md#delegation), [Delegation model](NIP-FI-MODEL.md#delegation) | +| DG-1 | Owner has an eligible active binding; delegation is valid and narrower; finite delegated-lease maximum configured | Delegate proves its own key; no delegate assertion | Active owner binding plus delegation conditions; cached owner lease alone is insufficient | Allow only intersected delegated operations | No binding or lifecycle-selector mutation for the owner or delegate | Delegated lease is bounded by the mandatory finite implementation maximum and shorter owner-binding, delegation, or policy limits; no independent assertion bound | Success for allowed delegated operation | [Delegation](NIP-FI.md#delegation), [Delegation model](NIP-FI-MODEL.md#delegation) | | DG-2 | Owner binding is retired/revoked, or delegation expired/revoked | Delegate presents formerly valid proof | Owner and delegation dependencies | Deny | None | Invalidate dependent delegated lease on documented detection schedule | `restricted:` | [Delegation](NIP-FI.md#delegation), [Delegation model](NIP-FI-MODEL.md#delegation) | +| DG-3 | No finite delegated-lease maximum is configured | Otherwise valid owner binding, delegation, and delegate proof | Delegation configuration | Deny delegated authorization; delegation is unsupported | None | No delegated lease | `restricted:`; discovery must omit/disable delegation | [Delegation](NIP-FI.md#delegation), [Discovery](NIP-FI.md#discovery) | | BD-1 | Two server-resolved authorization domains | Client attempts to choose another domain or reuse evidence across domains | State only for trusted resolved `D` | Ignore/reject client domain choice; no cross-domain authority | None outside selected domain | Domain-scoped lease only | Redacted denial when no selected-domain authority exists | [Definitions](NIP-FI.md#definitions), [Safety](NIP-FI-MODEL.md#safety-properties) | | OP-1 | Conflict, validation failure, or lifecycle denial | Any denied request | As required by the decision | Deny without disclosing the conflicting identity, key, claims, or selector | None | None or matching lease invalidation | Only `auth-required:` / `restricted:` or HTTP `401`/`403` | [Rejection](NIP-FI.md#rejection-semantics), [Privacy](NIP-FI.md#privacy) | -| OP-2 | Relay supports a subset of transports, enrollment modes, or delegation | NIP-11 query | None | Advertise only supported behavior | None | None | No issuer-internal tenant, claim, or audience detail | [Discovery](NIP-FI.md#discovery), [Conformance hooks](NIP-FI-MODEL.md#conformance-hooks-for-the-nip) | -| OP-3 | Separate opt-in relay-signed public projection exists | Approved label, or a forged/public projection presented as auth evidence | Normal NIP-FI evidence and lifecycle state only | Publish only approved non-private label; never accept projection as authorization evidence | Projection is separate from binding state | No authorization lease from projection | No `iss`, `sub`, bearer material, or private claim disclosure | [Privacy](NIP-FI.md#privacy), [Safety](NIP-FI-MODEL.md#safety-properties) | +| OP-2 | Relay supports a subset of transports, enrollment modes, or delegation | NIP-11 query | Supported configuration, including the finite delegated-lease maximum when delegation is enabled | Advertise only supported behavior | None | None | When delegation is enabled, publish a positive `delegated_lease_max_seconds`; expose no issuer-internal tenant, claim, or audience detail | [Discovery](NIP-FI.md#discovery), [Conformance hooks](NIP-FI-MODEL.md#conformance-hooks-for-the-nip) | +| OP-3 | `OP-3.absent`: projection unimplemented/default-off; `OP-3.implemented`: separate projection privacy-approved and implemented | For each applicable subcase, query discovery/publication and present any available projection as auth evidence; in the implemented subcase also publish an approved label | Projection availability plus normal NIP-FI evidence and lifecycle state only | `OP-3.absent`: pass only with no projection surface or advertisement. `OP-3.implemented`: report `not applicable` when no separate projection exists; otherwise pass only when approved non-private labels publish. Both applicable decisions: never accept projection as authorization evidence | Both keep projection separate from binding state; the absent subcase creates no projection state | No authorization lease from either subcase | Absent: no projection surface. Implemented: N/A or no `iss`, `sub`, bearer material, or private claim disclosure | [Privacy](NIP-FI.md#privacy), [Safety](NIP-FI-MODEL.md#safety-properties) | +| OP-4 | NIP-FI authorization succeeds or fails | Assertion, `iss`, `sub`, and mutable profile claims are available internally | Protocol output, publication, history, and operational log/metric capture | Authorization result is independent of publication; private material remains access-controlled | No assertion/private-claim insertion into events, tags, filters, or relay history; no raw bearer or unredacted identity/profile claim in operational logs or metrics | Normal lease effect only | No private material in protocol output | [Transport](NIP-FI.md#assertion-transport), [Privacy](NIP-FI.md#privacy) | ## Conformance claim -An implementation may claim NIP-FI conformance only for the transport profiles, enrollment modes, delegation behavior, and optional projection behavior it advertises and tests. Every applicable trace above must pass against the same reviewed implementation revision. Unsupported optional behavior must be omitted from discovery rather than represented as implemented. +An implementation may claim NIP-FI conformance only for the transport profiles, enrollment mode, and delegation behavior it advertises and tests. Every applicable trace above must pass against the same reviewed implementation revision. Trusted-proxy traces such as TR-1 additionally require deployment evidence for both properties: origin isolation, proving that untrusted clients cannot reach verifier ingress, and inbound assertion-header stripping. That evidence names the enforced isolation control and records negative bypass and header-injection tests; code-only tests cannot establish full trusted-proxy conformance. Unsupported optional NIP-FI behavior must be omitted from discovery rather than represented as implemented. OP-3's absent/default-off branch applies to every implementation; its implemented branch is reported `not applicable` when no separate projection protocol exists and must pass when one does. That protocol is not part of NIP-FI conformance or discovery. diff --git a/docs/nips/NIP-FI-MODEL.md b/docs/nips/NIP-FI-MODEL.md index ed643f04a7..a9df417741 100644 --- a/docs/nips/NIP-FI-MODEL.md +++ b/docs/nips/NIP-FI-MODEL.md @@ -1,6 +1,6 @@ # Scope -This model specifies a relay or HTTP service authorizing a Nostr principal only when a valid federated identity assertion and a valid Nostr proof resolve to the same active identity-to-key binding. It models authorization, enrollment, revocation, and key rotation. It does not publish the federated identity on Nostr and does not make the identity provider a Nostr signing authority. +This model specifies a relay or HTTP service authorizing a Nostr principal only when a valid federated identity assertion and a valid Nostr proof resolve to the same active identity-to-key binding. It models authorization, enrollment, provisioning, retirement, disablement, revocation, rotation, recovery, and re-enablement. It does not publish the federated identity on Nostr and does not make the identity provider a Nostr signing authority. The model is transport-independent. A concrete NIP must separately define how an assertion reaches a verifier and how support is advertised. NIP-42 and NIP-98 remain the mechanisms for proving control of a Nostr key; a bearer assertion alone is never a Nostr proof. @@ -17,13 +17,13 @@ The model is transport-independent. A concrete NIP must separately define how an - `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. +- `H_D`: immutable lifecycle-state history required for transition correctness and replacement lineage; it is not an authorization input or the complete operational audit contract. - `mode(D)`: enrollment policy, either `attested-key`, `provisioned`, or `tofu`. A binding record is: ```text -Binding = (domain, identity, key, source, created_at) +Binding = (domain, identity, key, source, created_at, expires_at?) source = attested-key | provisioned | tofu ``` @@ -37,7 +37,7 @@ source = attested-key | provisioned | tofu 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. -5. Binding-state transactions are serializable with respect to the same domain, identity, or key. The implementation may realize this with locks and unique constraints. +5. Binding-state transactions, including `Authorize` and every lifecycle transition, are serializable with respect to the same domain, identity, or key. The implementation may realize this with locks and unique constraints. 6. The verifier's clock is sufficiently accurate for assertion and proof freshness checks. Compromise of an accepted issuer or trusted ingress can impersonate federated principals. It still cannot satisfy Nostr proof for an already-bound uncompromised key, and in `attested-key` mode it cannot bind an arbitrary key unless the compromised issuer also attests that key. Theft of an assertion alone cannot authorize an already-bound identity without control of the bound Nostr key. @@ -51,11 +51,11 @@ It succeeds only if all of the following hold: 1. the signature validates under a currently trusted key and an explicitly allowed asymmetric algorithm; 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; +4. `exp` exists and `now < exp`; 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). +8. if a configured Nostr-key claim is present, it parses to exactly one 32-byte key `k_a`; canonical lowercase hex is the wire form. An explicitly documented optional decoder accepts only a bare NIP-19 Bech32 — not Bech32m — `npub` that is all lowercase or all uppercase, whose lowercased prefix is exactly `npub`, with a valid checksum and exactly 32 decoded bytes. It rejects mixed case, Bech32m, another prefix, a TLV form, invalid checksum, or another payload length, and converts either permitted case to the same canonical lowercase hex before comparison, so one claim has only one key interpretation. 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. @@ -81,6 +81,8 @@ Equivalently, an active identity has at most one key and an active key has at mo 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. +Base V1 treats `B_D`, `P_D`, `X_D`, `Y_D`, and `Q_D` as the verifier's authoritative state. A signed external binding authority is a future extension requiring explicit claim, conflict, rotation, revocation, and migration semantics; an assertion in this model does not transfer that authority. + Active bindings also satisfy the lifecycle invariants: ```text @@ -89,6 +91,7 @@ Active bindings also satisfy the lifecycle invariants: (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 +Q_D(i) = k ⇒ (i, k) ∈ P_D ``` # Authorization and enrollment transition @@ -109,6 +112,7 @@ Authorize(D, i, k_a?, k): q := i ∈ dom(Q_D) if b_i = (i, k) and b_k = (i, k) and not (p or x or y or q): + preserve the binding's recorded source ALLOW(existing) if b_i exists or b_k exists: @@ -137,7 +141,7 @@ The resulting authorization lease is: ```text L = (D, i, k, expires_at) -expires_at <= min(assertion.exp, policy_expiry?, delegation_expiry?, implementation_limit?) +expires_at <= min(assertion.exp, binding_expiry?, policy_expiry?, implementation_limit?) ``` 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. @@ -152,17 +156,43 @@ If multiple keys authenticate on one NIP-42 connection, authorization is tracked # Revocation and rotation +Every lifecycle transition below is linearizable with `Authorize` and with every other lifecycle transition affecting the same domain, identity, or key. Preconditions are rechecked inside the atomic transition; a losing concurrent transition makes no partial mutation. + +`LifecycleAuthorization` is abstract authenticated authority for one named transition in one domain. Missing or invalid authority, or authority bound to a different operation or domain, denies before mutation. This model does not standardize an operator transport, approval policy, retry protocol, or operational audit schema. An implementation may wrap these state transitions in an idempotent operation-ID interface with actor, reason, correlation, and durable audit evidence without changing their state semantics. + +For replacement transitions, optional `A_new` is key-attestation evidence only when current validation returns `ValidateAssertion(A_new, C, now) = (i, k_new, exp)`. A stale assertion, a different identity, an absent key claim, or a different key is not valid attestation evidence. `P_new` is a fresh, target-bound Nostr proof for the privileged request and must satisfy `ValidateProof(P_new, target, now) = k_new`. + +Every `require` and conditional invariant recheck in `Rotate`, `Recover`, and `EnableIdentity` executes inside the same atomic transition as the corresponding state mutation. A failed or unreadable precondition denies before any mutation, so none of these transitions has a check-then-act window. + +Operator provisioning is a distinct privileged transition: + +```text +ProvisionBinding(D, i, k): + atomically: + require LifecycleAuthorization(D, provision, i, k) + require mode(D) = provisioned + require no active binding for i or k + require i ∉ X_D and i ∉ dom(Q_D) + require k ∉ Y_D and (i, k) ∉ P_D + create (i, k, source = provisioned) + append a provision transition to H_D +``` + +Provisioning is available only in `provisioned` enrollment mode and creates no direct lease. Every mode, active-binding, `X_D`, `Q_D`, `Y_D`, and `P_D` eligibility read is fail-closed: an unreadable precondition denies and rolls back the entire transition with no binding or history mutation. A later direct `Authorize` call still requires a valid assertion for `i` and fresh Nostr proof by `k`; delegated authorization remains subject to the separate active-owner, delegation-proof, and deployment-admission rules below. + Pair retirement is an explicit administrative transition: ```text RetirePair(D, i, k): - require (i, k) ∈ B_D atomically: + require LifecycleAuthorization(D, retire-pair, i, k) + require (i, k) ∈ B_D + require Q_D(i) is absent 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 + append a pair-retirement transition to H_D + after commit, invalidate direct and dependent delegated leases for the binding as soon as observed ``` Identity disablement and key revocation may occur before enrollment and are independent of pair retirement: @@ -170,45 +200,110 @@ Identity disablement and key revocation may occur before enrollment and are inde ```text DisableIdentity(D, i): atomically: + require LifecycleAuthorization(D, disable-identity, i) + let active_pair := the unique active binding for i, if any + if active_pair exists, require Q_D(i) is absent 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 + if active_pair exists: + remove active_pair from B_D, add active_pair to P_D, and set Q_D(i) = active_pair.key + append an identity-disablement transition to H_D + after commit, invalidate direct and dependent delegated leases for i RevokeKey(D, k): atomically: + require LifecycleAuthorization(D, revoke-key, k) + let active_pair := the unique active binding for k, if any + if active_pair exists, require Q_D(active_pair.identity) is absent 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 + if active_pair exists: + remove active_pair from B_D, add active_pair to P_D, and set Q_D(active_pair.identity) = k + append a key-revocation transition to H_D + after commit, reread durable Y_D and any affected P_D/Q_D state, verify the expected inactive-key or active-pair result, and invalidate every direct or delegated lease that depends on k ``` +The active-pair branches of `RetirePair`, `DisableIdentity`, and `RevokeKey` each recheck absent `Q_D(i)` inside the same atomic transition; a present or unreadable selector denies and rolls back the entire invocation instead of overwriting earlier lineage. Non-active `DisableIdentity` preserves an existing `Q_D(i)`. After a successful active-pair key revocation, the post-commit reread confirms that `Q_D(i)` records the retired old key while that key also remains in `Y_D`. This durable `Q+Y` state is valid: the pending-replacement selector preserves lineage, while the key-revocation selector independently prevents reuse of that key anywhere in the authorization domain. When no active pair existed, the reread instead confirms `k ∈ Y_D` with no `P_D` or `Q_D` mutation, preserving every pre-existing `Q_D` entry. Repeating `RevokeKey` for an already-revoked inactive key is selector-idempotent: it preserves `k ∈ Y_D`, makes no `P_D` or `Q_D` mutation, and appends exactly one new history record for the successfully committed separately authorized invocation; retry deduplication, if desired, uses the optional operation-ID wrapper rather than suppressing an otherwise authorized invocation. + 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: +Rotation and recovery are distinct privileged transitions, never authorization side effects: ```text -RotateOrRecover(D, i, k_old, k_new): - require explicit recovery/admin authorization - 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 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 +Rotate(D, i, k_old, k_new, A_new?, P_new): + atomically: + require LifecycleAuthorization(D, rotate, i, k_old, k_new) + require (i, k_old) ∈ B_D + require Q_D(i) is absent + require i ∉ X_D + require k_new ∉ Y_D + require (i, k_new) ∉ P_D + require no active binding for k_new + require ValidateProof(P_new, target, now) = k_new + if A_new exists, require ValidateAssertion(A_new, C, now) = (i, k_new, exp) + if issuer-attested rotation is required, require A_new exists + remove (i, k_old) from B_D + add (i, k_old) to P_D + create (i, k_new, source = A_new exists ? attested-key : provisioned) + preserve Q_D(i) as absent + append a rotation transition to H_D + after commit, invalidate direct and dependent delegated leases for (i, k_old) + +Recover(D, i, k_old, k_new, A_new?, P_new): + atomically: + require LifecycleAuthorization(D, recover, i, k_old, k_new) + require Q_D(i) = k_old + # Recheck the implied pair invariant explicitly and fail closed on storage drift. + require (i, k_old) ∈ P_D + require i ∉ X_D and no active binding for i + require k_new ∉ Y_D + require (i, k_new) ∉ P_D + require no active binding for k_new + require ValidateProof(P_new, target, now) = k_new + if A_new exists, require ValidateAssertion(A_new, C, now) = (i, k_new, exp) + if issuer-attested recovery is required, require A_new exists + compare-and-clear Q_D(i) from k_old to absent; require the comparison succeeds + preserve (i, k_old) ∈ P_D + create (i, k_new, source = A_new exists ? attested-key : provisioned) + append a recovery transition to H_D + after commit, invalidate any direct and dependent delegated leases for (i, k_old) +``` + +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 requires a replacement key with no active binding, `k_new ∉ Y_D`, and `(i, k_new) ∉ P_D`. Pair retirement, rotation, and recovery do not add `k_old` to `Y_D`; domain-scoped key revocation remains the distinct `RevokeKey` transition. A retired old key may therefore remain active for another identity without being disturbed by recovery. Same-pair reactivation requires an extension with an equivalently explicit privileged transition and retained lifecycle history. + +The explicit `(i, k_old) ∈ P_D` guard in `Recover` deliberately rechecks the invariant implied by `Q_D(i) = k_old`; an invariant-violating or unreadable retired-pair record fails closed rather than being treated as redundant state. The conditional compare-and-clear is part of the same atomic transition: after one recovery consumes `Q_D(i) = k_old`, a concurrent recovery whose stale read observed that value cannot commit. + +Every successful rotation, recovery, or re-enablement requires fresh Nostr proof by `k_new`. Absence of `A_new` is allowed only where issuer attestation is optional and records `provisioned` provenance; it never waives key-control proof. `ProvisionBinding` is the only base transition that may select a key before proof; it grants no direct lease, and a later direct `Authorize` still requires a valid assertion and proof. Delegated authorization remains separately constrained as described below. + +`DisableIdentity` preserves an existing `Q_D(i)` selector or creates one when it removes an active pair. + +Base V1 re-enablement is also a privileged binding transition, never a standalone removal from `X_D`: + +```text +EnableIdentity(D, i, k_new, A_new?, P_new): + atomically: + require LifecycleAuthorization(D, enable-identity, i, k_new) + require i ∈ X_D + read q := Q_D(i); require q is readable + if q is present, let k_old := q; require (i, k_old) ∈ P_D + else require q is absent + require no active binding for i or k_new + require k_new ∉ Y_D and (i, k_new) ∉ P_D + require ValidateProof(P_new, target, now) = k_new + if A_new exists, require ValidateAssertion(A_new, C, now) = (i, k_new, exp) + if issuer-attested enablement is required, require A_new exists + if q is present, compare-and-clear Q_D(i) from q to absent; require the comparison succeeds + create (i, k_new, source = A_new exists ? attested-key : provisioned) + remove i from X_D + append an identity-enablement transition to H_D + after commit, invalidate any direct and dependent delegated leases for a prior retired pair ``` -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. +For a never-enrolled identity, `Q_D(i)` is absent and there is no prior pair. The new binding is still created in the same transaction that removes `i` from `X_D`, so ordinary enrollment never observes an enabled identity with neither a binding nor a lifecycle gate. If `Q_D(i) = k_old`, the exact old pair remains retired; revocation of `k_old` across the authorization domain is a separate `RevokeKey` transition. + +The conditional retired-pair guard in `EnableIdentity` deliberately rechecks the same `Q_D`-to-`P_D` invariant as `Recover`; an invariant-violating or unreadable retired-pair record fails closed before re-enablement. When a pending selector exists, its compare-and-clear is part of the same atomic transition, so a concurrent lifecycle change prevents the stale enablement from committing; an absent selector remains absent. # 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. +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; a cached owner lease is not alternative authority when that binding is absent. This path creates or modifies no federated identity binding or lifecycle selector for either the owner or delegate key. The delegated decision retains the active owner-binding dependency, intersects the delegation's operations and conditions, and expires no later than a mandatory finite configured implementation limit and every shorter known owner-binding, delegation, or policy bound. A service without that finite maximum cannot issue delegated leases or advertise delegation support. The decision is invalidated when the owner binding is retired or revoked. The delegate presents no assertion, so its lease has no independent assertion-expiry bound; a deployment that additionally requires a current owner assertion or direct lease includes that bound too. A deployment may add a stronger current-provider admission requirement for the owner without changing this base primitive. # Safety properties @@ -220,28 +315,28 @@ Under the trust assumptions, for direct (non-delegated) authorization: 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. **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. +7. **Lifecycle closure:** a disabled identity cannot authorize any key, a revoked key cannot authorize or bind to any identity, and disablement cannot be cleared without atomically creating an explicitly authorized binding. 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. +9. **Lifecycle-transition atomicity:** observers see either the valid prior state or the completed transition, never a partial transition; lifecycle history is retained. +10. **Linearizable lifecycle:** authorization and competing lifecycle transitions cannot commit a binding or selector state that violates a 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. +12. **Lease boundedness:** no cached authorization survives its earliest applicable assertion, binding, policy, delegation, or implementation bound; every delegated lease has a mandatory finite configured 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. +14. **Privacy:** NIP-FI protocol behavior never publishes `iss`, `sub`, JWTs, or mutable profile claims in Nostr events or relay-visible event history, and operational logs never retain their unredacted values. Access-controlled binding and lifecycle state may retain identifiers required for enforcement and audit. A separate opt-in relay-signed projection may publish an approved label, but never `iss`, `sub`, bearer material, or another unapproved private claim, 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 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. +2. an eligible never-retired pair is created at most once under concurrent first use when the configured enrollment mode permits it, and a valid assertion with matching proof eventually authorizes the committed binding; and +3. after `Rotate` or `Recover` commits and bounded cache invalidation completes, a replacement binding that remains active and lifecycle-eligible is eventually authorized when presented with a valid assertion for `i` and fresh matching proof by `k_new`. The exact old pair remains denied; the old key remains denied across the authorization domain only when `RevokeKey` has committed. 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. # Conformance traces -The companion [NIP-FI conformance matrix](NIP-FI-CONFORMANCE.md) defines stable-ID success, denial, concurrency, lifecycle, session, delegation, disclosure, and privacy traces. In particular, its revoked-pair replay trace requires a stable denial with no mutation even when the still-valid assertion carries a matching key claim; only the separately authorized replacement transition can restore authority. +The companion [NIP-FI conformance matrix](NIP-FI-CONFORMANCE.md) defines stable-ID success, denial, concurrency, lifecycle, session, delegation, disclosure, and privacy traces. In particular, its retired-pair replay trace requires a stable denial with no mutation even when the still-valid assertion carries a matching key claim; only a separately authorized replacement transition can restore authority. # Conformance hooks for the NIP @@ -252,6 +347,7 @@ The normative NIP should expose enough information for clients and operators to - whether a key claim is required; - enrollment mode (`attested-key`, `provisioned`, or explicitly risk-labeled `tofu`); - authorization lease/re-authentication behavior; +- the finite delegated-lease maximum when delegation is supported; - machine-readable rejection classes using existing NIP-42 `auth-required:` and `restricted:` prefixes where applicable; - privacy requirements and trusted-proxy deployment requirements. @@ -265,4 +361,4 @@ It should not standardize database schema, lock mechanism, Okta-specific claims, - 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) - Companion conformance matrix: [`NIP-FI-CONFORMANCE.md`](NIP-FI-CONFORMANCE.md) -- 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. +- Buzz PR #1476 at reviewed revision `1e9822de8dbe0ae91c00c0ce0ed8ff583915692f` is a disabled partial foundation, not a complete NIP-FI implementation. Its default identity-claim selection is `sub`, but that revision trims string claims; preserving the exact literal `sub` value, future-`iat`, discovery, lifecycle, and lease conformance remain additive work. Its combined replacement helper and domain-scoped old-key revocation also do not implement this model's distinct rotation, recovery, and pair-retirement semantics. A configured non-`sub` mapping is an extension that cannot claim Base V1 identity conformance. diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 783557c562..3b1abb1d25 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -6,11 +6,11 @@ Federated Identity Authorization `draft` `optional` `relay` -**Depends on**: NIP-01 (basic event format), NIP-42 (Authentication of Clients to Relays). **Composes with**: NIP-98 (HTTP Auth), NIP-11 (Relay Information Document), NIP-OA (Owner Attestation). +**Depends on**: NIP-01 (basic event format), NIP-42 (Authentication of Clients to Relays). **Composes with**: NIP-98 (HTTP Auth), NIP-11 (Relay Information Document), and optionally the draft NIP-OA (Owner Attestation) as one delegation-proof format. ## Abstract -This NIP defines how a relay or Nostr-adjacent HTTP service authorizes an already-authenticated Nostr key only when a valid federated identity assertion resolves to the same principal and key. It specifies assertion transport, validation, an identity-to-key binding lifecycle (enroll, conflict, revoke, rotate), session semantics, and failure behavior. A separately validated delegation MAY derive narrower authority from a bound owner as described below; that exception does not turn the delegate into the federated principal. +This NIP defines how a relay or Nostr-adjacent HTTP service authorizes an already-authenticated Nostr key only when a valid federated identity assertion resolves to the same principal and key. It specifies assertion transport, validation, an identity-to-key binding lifecycle (enroll, conflict, retire, disable, revoke, recover, rotate, and re-enable), session semantics, and failure behavior. A separately validated delegation MAY derive narrower authority from a bound owner as described below; that exception does not turn the delegate into the federated principal. The identity provider never becomes a Nostr signing authority, and the assertion never substitutes for Nostr proof of key control. This NIP is an authorization layer above NIP-42 and NIP-98, not a replacement for either. @@ -29,14 +29,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, 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. +- **binding**: an active record associating exactly one federated identity with exactly one 32-byte Nostr public key within a domain. A binding MAY carry an authoritative expiry. - **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. +- **pending replacement**: lifecycle state recording that an identity whose prior key was retired MUST use a separately authorized recovery transition, or re-enablement when disabled, 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 and every shorter authoritative policy, delegation, or implementation limit. +- **direct lease**: a cached direct authorization decision for one `(domain, identity, key)`, bounded by the assertion's expiry and every shorter known binding, policy, or implementation limit. +- **delegated lease**: a cached delegated authorization decision for a delegate key, dependent on an active owner binding and bounded by a mandatory finite configured implementation limit and every shorter known owner-binding, delegation, or policy limit. It has no independent assertion-expiry bound unless a stronger deployment policy requires a current owner assertion or direct lease. ## Assertion transport @@ -60,11 +61,11 @@ The verifier is configured, per accepted issuer, with: the issuer identifier, a 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 no later than verifier time plus a bounded, configured clock skew. +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. An `iat` beyond that allowed skew is future-dated and fails validation. 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. +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; a bare NIP-19 `npub` using Bech32 (not Bech32m) MAY be accepted as a documented input normalization. That optional decoder accepts only a bare NIP-19 Bech32 — not Bech32m — `npub` that is all lowercase or all uppercase, whose lowercased prefix is exactly `npub`, with a valid checksum and exactly 32 decoded bytes. It rejects mixed case, Bech32m, another prefix, a TLV form, invalid checksum, or another payload length, and converts either permitted case to the same canonical lowercase hex before comparison, so one claim has only one key interpretation. -A display-name claim MAY be extracted as mutable metadata. It MUST NOT participate in any authorization decision. +Display-name, email, and similar profile claims MAY be extracted as mutable metadata. They MUST NOT participate in any authorization decision. Signing-key retrieval failures MUST fail closed. Verifiers SHOULD cache the key set with a bounded lifetime and SHOULD NOT refetch it in response to an unknown `kid` that was absent from a freshly fetched set, so that forged tokens cannot drive request floods to the identity provider. @@ -89,7 +90,7 @@ Authorize(D, i, k_a?, k): q := whether i is pending explicit replacement in D if b_i = (i, k) and b_k = (i, k) - and not (p or x or y or q): ALLOW (existing binding) + and not (p or x or y or q): preserve source; ALLOW (existing binding) if b_i exists or b_k exists: DENY (binding conflict) if x: DENY (identity disabled) if y: DENY (key revoked) @@ -97,12 +98,12 @@ Authorize(D, i, k_a?, k): if q: DENY (explicit replacement required) # no active binding or applicable lifecycle gate: first enrollment - attested-key: k_a required, else DENY; create (i, k); ALLOW + attested-key: k_a required, else DENY; create (i, k, source=attested-key); ALLOW provisioned: DENY (binding must be pre-created by an operator) - tofu: create (i, k); ALLOW + tofu: create (i, k, source=(k_a exists ? attested-key : tofu)); 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. +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 provisioning and every lifecycle transition affecting those selectors, including pair retirement, identity disablement, key revocation, rotation, recovery, and re-enablement. 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 @@ -110,44 +111,62 @@ The active-binding and lifecycle-gate reads, and any insertion, MUST be one line - **`provisioned`**: bindings are created only through an out-of-band administrative process; requests never create bindings. - **`tofu`** (trust on first use): first use of an unbound identity with an unbound key creates the binding. A stolen assertion for a never-enrolled identity can bind an attacker's key in this mode; services offering it MUST document this risk. When an assertion in `tofu` mode carries a valid key claim, the binding SHOULD record the stronger `attested-key` provenance, and a binding's recorded provenance MUST NOT be downgraded by later requests. +In `provisioned` mode, creation requires a separately authorized `ProvisionBinding` transition. That transition uses the operation- and domain-bound lifecycle authority described in [Revocation and rotation](#revocation-and-rotation) and formalized as `LifecycleAuthorization(D, provision, i, k)` in the model. It MUST verify the configured mode, that no active binding exists for `i` or `k`, that `i` is neither disabled nor pending replacement, that `k` is not revoked, and that the exact pair `(i, k)` is not retired. Its transition shape is `ProvisionBinding(D, i, k): atomically(require LifecycleAuthorization(D, provision, i, k); require provisioned mode and every eligibility condition above; create (i, k, provisioned); append provision history)`, followed by no direct lease. A failed requirement, including an unreadable mode, active-binding, or lifecycle-selector read, rolls back the entire shape. A later direct `Authorize` still requires a valid assertion for `i` and fresh Nostr proof by `k`. Delegated authorization remains subject to the separate active-owner, delegation-proof, and deployment-admission rules below. + ### 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. 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. +Pending replacement is identity-scoped and blocks every active binding for that identity until an explicit recovery transition—or re-enablement when the identity is disabled—succeeds. Exact-pair retirement remains pair-scoped: by itself it does not revoke the retired key for a different identity in the same authorization domain. + 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. +Base V1 also treats the verifier's binding and lifecycle state as authoritative. A signed external binding authority is a future extension and requires explicit claim, conflict, rotation, revocation, and migration semantics; a normal assertion under this NIP does not transfer that authority. + ## 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. 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. +For a NIP-42 WebSocket connection, the relay MAY cache the decision as a direct lease. Its expiry MUST be no later than the assertion's `exp` and every shorter known binding-expiry, policy, or configured implementation bound. 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 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: +Provisioning and lifecycle changes are explicit administrative or policy transitions, never side effects of `Authorize`. Each requires authenticated authority for the named operation in the selected domain. This NIP defines state semantics, not an operator transport, approval policy, retry protocol, or complete audit schema; an implementation MAY wrap the transitions in an idempotent operation-ID interface with actor, reason, correlation, and durable audit evidence. + +The storage representation is implementation-defined, but each transition's denial selectors, active-binding changes, and lifecycle-state-history append MUST commit atomically. That history preserves state and replacement lineage; it is not by itself the complete operational audit contract. After a successful commit, the implementation MUST trigger invalidation of affected direct and dependent delegated leases within its documented detection bound; cache invalidation need not share the state transaction: + +- **Retire pair**: recheck within the atomic transition that the active pair's identity has no pending-replacement selector; a present or unreadable selector denies without mutation instead of overwriting lineage. Then remove `(i, k)`, retain an exact-pair tombstone, mark `i` pending explicit replacement, and invalidate matching direct and dependent delegated leases after commit. +- **Disable identity**: record the identity selector even when `i` has never enrolled. If `i` has an active binding, recheck within the atomic transition that its pending-replacement selector is absent; a present or unreadable selector denies and rolls back the entire invocation. Then remove the active pair, add an exact-pair tombstone, and mark `i` pending explicit replacement. If no active binding exists and `i` was already pending replacement, preserve that state. After commit, invalidate direct and dependent delegated leases for `i`. +- **Revoke key**: record the key selector even when `k` is not active. If `k` has an active binding, recheck within the atomic transition that its identity has no pending-replacement selector; a present or unreadable selector denies without mutation instead of overwriting lineage. Then remove the active pair, retire it, and mark its identity pending explicit replacement. If no active binding exists, preserve every pending-replacement selector unchanged. After commit, invalidate every direct or delegated lease that depends on `k`. -- **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`. +Every privileged lifecycle transition affecting the same domain, identity, or key MUST be linearizable with every other such transition and with `Authorize`. Its preconditions MUST be rechecked within the atomic transition, so concurrent provision, retire, disable, revoke, rotate, recover, or re-enable operations cannot partially commit or overwrite one another. 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. +Rotation and recovery are distinct privileged transitions. `Rotate` requires an active `(i, k_old)` binding and an absent pending-replacement selector for `i`; `Recover` requires both `Q(i) = k_old` and the exact retired pair `(i, k_old)`, plus no active binding for `i`. `Rotate` reads and rechecks the selector's absence inside the same atomic transition as its mutation; a present or unreadable selector denies without mutation. `Recover` likewise reads and rechecks both pending lineage and the exact retired-pair selector inside the same atomic transition; missing, mismatched, or unreadable state denies without mutation. It conditionally compare-and-clears `Q(i)` from `k_old` to absent inside that atomic transition; if the comparison loses to a concurrent lifecycle change, the entire invocation denies and rolls back with no binding, selector, or history mutation, lease, or invalidation. Both transitions require that `i` is not disabled, no active binding exists for `k_new`, `k_new` is not revoked, the exact pair `(i, k_new)` is not retired, and fresh target-bound Nostr proof by `k_new` is supplied for the privileged request. A retired pair containing `k_new` and a different identity does not make `k_new` ineligible. Any assertion supplied as key-attestation evidence to either transition MUST currently validate to identity `i` with key claim `k_new`; a stale assertion, different identity, absent key claim, or different key is invalid evidence and cannot be treated as if no attestation was supplied. Where the domain requires issuer attestation, valid matching evidence MUST be supplied. -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. +`Rotate` atomically removes the active old pair from `B`, adds the exact `(i, k_old)` pair to retired-pair selector `P`, and creates `(i, k_new)`. `Recover` preserves the already-retired old pair, clears pending replacement, and creates `(i, k_new)`. Each records `attested-key` provenance when matching attestation evidence is present and `provisioned` provenance otherwise, and appends a distinct rotation or recovery history entry. After commit it invalidates direct and dependent delegated leases for the old pair. Neither transition revokes `k_old` across the authorization domain; key revocation remains the separate `RevokeKey` transition. A routine request presenting a new key is either a binding conflict or `explicit replacement required` and MUST be denied without mutation, including no binding, lifecycle, enrollment, publication, or last-seen update; a redacted security audit record is allowed. + +Evidence carried by ordinary `Authorize`, including its Nostr proof, cannot satisfy lifecycle authorization or invoke `Rotate`, `Recover`, or `EnableIdentity`. Replacement-key proof is validated only inside the separately authorized lifecycle transition. + +Base V1 recovery uses a replacement key that has no active binding, is not revoked in the authorization domain, and has never formed a retired pair with `i`. A retired pair with another identity does not by itself revoke the key across that domain; only the distinct domain-scoped key-revocation selector does. Same-pair reactivation is an extension and MUST provide an equivalently explicit privileged transition while retaining the original lifecycle history; ordinary `Authorize` can never perform it. + +Base V1 has no standalone operation that merely clears a disabled-identity selector. Re-enablement requires a privileged `EnableIdentity` transition that selects an eligible replacement or first key, requires fresh target-bound Nostr proof by that key, and atomically creates its binding, clears the disabled selector, conditionally clears a present pending-replacement selector, and appends identity-enablement history. The transition reads `Q(i)` inside that atomic transition and fails closed if it is unreadable: an absent selector remains absent; when `Q(i) = k_old` is present, the transition rechecks the exact retired pair `(i, k_old)` and conditionally compare-and-clears that exact selector to absent. Missing, mismatched, or unreadable retired-pair state denies without mutation instead of clearing lineage. If the conditional comparison loses to a concurrent lifecycle change, the entire invocation denies and rolls back with no binding, selector, or history mutation, lease, or invalidation. Any assertion supplied as key-attestation evidence to the transition MUST currently validate to identity `i` with a key claim equal to the selected key; a stale assertion, different identity, absent key claim, or different key is invalid evidence and cannot be treated as if no attestation was supplied. Where the domain requires issuer attestation, valid matching evidence MUST be supplied. Matching attestation records `attested-key` provenance; otherwise the transition records `provisioned`. After commit, invalidate any direct and dependent delegated leases for a prior retired pair. For a never-enrolled identity there is no prior pair, but the new binding is still created in the same transaction that clears disablement. A prior key remains pair-retired; revoking it across the authorization domain requires the separate `RevokeKey` transition. Ordinary enrollment therefore never observes a re-enabled identity with neither an active binding nor a lifecycle gate. ## 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 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. +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. A cached owner lease MUST NOT substitute for that active binding. This delegation path MUST NOT create or modify any federated identity binding or lifecycle selector for either the owner or delegate key. The delegated decision retains an explicit dependency on the active owner binding, intersects the delegated operations and conditions, and expires no later than a mandatory finite configured implementation limit and every shorter known owner-binding, delegation, or policy bound. A service without that finite maximum MUST NOT issue delegated leases or advertise delegation support. Because the delegate presents no assertion, its lease has no independent assertion-expiry bound; if a deployment additionally requires a current owner assertion or direct lease, that bound is included too. 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 -Machine-readable rejections reuse NIP-01/NIP-42 prefixes on `OK` and `CLOSED` messages: +Machine-readable rejection classes and their transport mapping reuse NIP-01/NIP-42 prefixes on `OK` and `CLOSED` messages: -- `auth-required: ` — no assertion was presented, or no NIP-42 proof has been performed. +- `auth-required: ` — no assertion was presented, or no applicable Nostr proof has been performed or presented for the protected operation: NIP-42 for WebSocket or NIP-98 for HTTP. - `restricted: ` — the assertion or proof was presented but failed validation, mismatched, conflicted with an active binding, or the identity's enrollment/binding state does not permit the operation. +- Transport mapping (not a new prefix) — a protected operation on an established WebSocket connection by a key without applicable NIP-42 proof uses `auth-required`; an HTTP protected request without applicable NIP-98 proof uses `auth-required` and status `401`. + HTTP endpoints respond `401` where `auth-required` applies and `403` where `restricted` applies. Rejection bodies MUST NOT echo assertion contents, claim values, or the conflicting party's identity or key. @@ -160,16 +179,18 @@ A relay SHOULD advertise support in its NIP-11 document under `limitation` as `" "federated_identity": { "transports": ["trusted-proxy", "client-attached"], "enrollment": "attested-key", - "delegation": false + "delegation": true, "delegated_lease_max_seconds": 300 } } ``` -`transports` contains the supported profile names from this NIP, `enrollment` is exactly one enrollment mode, and `delegation` states whether separately validated delegation may be honored. Unknown fields MUST be ignored. A relay MUST NOT publish issuer-internal detail (tenant URLs, claim names, audiences) that is not already public. +The value `300` is illustrative; an implementation publishes its actual configured finite maximum. + +`transports` contains the supported profile names from this NIP, `enrollment` is exactly one enrollment mode, and `delegation` states whether separately validated delegation may be honored. When `delegation` is `true`, the object MUST also include `delegated_lease_max_seconds` as a positive integer advertising the configured finite upper bound; when `delegation` is `false`, that field SHOULD be omitted. A relay MUST NOT advertise delegation without that finite bound. Unknown fields MUST be ignored. A relay MUST NOT publish issuer-internal detail (tenant URLs, claim names, audiences) that is not already public. ## Privacy -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. +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. Access-controlled binding and lifecycle records are service-internal and MAY retain the identifiers needed to enforce and audit the state machine. Operational logs and metrics MUST NOT record raw bearer assertions or unredacted `iss`, `sub`, display-name, email, or other private assertion claims; redacted or pseudonymous security records are allowed. 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. @@ -177,13 +198,13 @@ A separate, opt-in relay-signed projection protocol such as NIP-85 MAY publish a - **Issuer or proxy compromise** impersonates federated principals, but cannot satisfy Nostr proof for an already-bound uncompromised key, and in `attested-key` mode cannot bind an arbitrary key without also forging the key claim. - **Assertion theft** cannot authorize an already-bound identity without control of the bound key. Its remaining power — enrolling a never-bound identity — exists only in `tofu` mode, which is why that mode is risk-labeled. -- **Header injection**: the trusted-proxy profile is void if clients can reach the verifier directly or the proxy forwards inbound copies of the assertion header. Deployments MUST verify both properties. +- **Header injection**: the trusted-proxy profile is void if clients can reach the verifier directly or the proxy forwards inbound copies of the assertion header. Deployments MUST verify both properties. Verification evidence identifies the enforced origin-isolation control — for example a network ACL, mutually authenticated proxy-to-verifier channel, or local socket boundary — and records negative tests showing that bypass ingress is unreachable and a client-supplied assertion header is stripped or replaced before trusted injection. - **Algorithm confusion** is excluded by rejecting symmetric algorithms before key selection. - **Availability vs. safety**: issuer, key-set, and storage outages deny. Availability MUST NOT override identity safety. -- **Cross-issuer collision**: identical `sub` values under different issuers are distinct identities and MUST never collide or inherit each other's bindings. +- **Cross-issuer collision**: the same exact literal `sub` value under different issuers denotes distinct identities, which MUST never collide or inherit each other's bindings. The companion [formal model](NIP-FI-MODEL.md) defines the state machine and safety/liveness properties. The [conformance matrix](NIP-FI-CONFORMANCE.md) supplies stable, reviewable success, denial, concurrency, lifecycle, session, disclosure, and privacy traces. ## Implementation relationship -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. +Buzz PR [#1476](https://github.com/block/buzz/pull/1476), reviewed at revision `1e9822de8dbe0ae91c00c0ce0ed8ff583915692f`, is a disabled partial foundation from which this provider-neutral contract was generalized. Its default identity-claim selection is `sub`, but that revision trims string claims; preserving the exact literal `sub` value, future-`iat` rejection, NIP-11 discovery, and additional lifecycle and lease conformance remain additive implementation work. Its combined replacement helper and domain-scoped old-key revocation also do not implement this draft's distinct rotation, recovery, and pair-retirement semantics. A configured non-`sub` mapping is a deployment extension and cannot claim Base V1 identity conformance. This draft does not require modifying the reviewed revision. From c9efaf61a8e5100ae0f6c0c8c1583d10a3cdeafa Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:00:30 -0500 Subject: [PATCH 4/5] docs(nip-fi): rebuild public contract guidance Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- docs/CORPORATE_IDENTITY.md | 54 +++++ docs/NIP_FI_CONTRIBUTING.md | 151 ++++++++++++ docs/NIP_FI_DEPLOYMENT.md | 143 ++++++++++++ docs/NIP_FI_INTEGRATION.md | 166 +++++++++++++ docs/NIP_FI_RUNTIME_OPERATIONS.md | 150 ++++++++++++ docs/NIP_FI_THREAT_MODEL.md | 116 ++++++++++ .../nip-fi-conformance-report.json.example | 162 +++++++++++++ .../nip-fi-recovery-plan.json.example | 44 ++++ .../examples/nip-fi-stock-domain.json.example | 115 +++++++++ docs/nips/NIP-FI-CONFORMANCE.md | 218 ++++++++++-------- 10 files changed, 1223 insertions(+), 96 deletions(-) create mode 100644 docs/CORPORATE_IDENTITY.md create mode 100644 docs/NIP_FI_CONTRIBUTING.md create mode 100644 docs/NIP_FI_DEPLOYMENT.md create mode 100644 docs/NIP_FI_INTEGRATION.md create mode 100644 docs/NIP_FI_RUNTIME_OPERATIONS.md create mode 100644 docs/NIP_FI_THREAT_MODEL.md create mode 100644 docs/examples/nip-fi-conformance-report.json.example create mode 100644 docs/examples/nip-fi-recovery-plan.json.example create mode 100644 docs/examples/nip-fi-stock-domain.json.example diff --git a/docs/CORPORATE_IDENTITY.md b/docs/CORPORATE_IDENTITY.md new file mode 100644 index 0000000000..4c418a78d8 --- /dev/null +++ b/docs/CORPORATE_IDENTITY.md @@ -0,0 +1,54 @@ +# Corporate identity migration + +Historical corporate identity approaches based on provider-specific middleware, unsigned forwarded headers, or a parallel authorization authority are retired guidance. Buzz's provider-neutral contract is [NIP-FI](nips/NIP-FI.md). + +## Current status + +This documentation revision does not include a NIP-FI runtime adapter, activate identity enforcement, or establish conformance. Do not deploy a legacy identity sidecar or trust an identity header as a temporary substitute. A later exact-head implementation and deployment must pass the [behavioral evidence matrix](nips/NIP-FI-CONFORMANCE.md) before discovery or enforcement is enabled. + +## Supported boundary + +NIP-FI combines a valid issuer assertion with independent fresh Nostr key proof, current durable binding and lifecycle state, server-owned request context, and final application admission. For the trusted-proxy profile, the stock Buzz contract requires cryptographic HMAC provenance bound to the complete canonical request. Header presence and network location alone are insufficient. + +NIP-FI defines no public corporate directory or identity projection. Issuer-qualified identity and profile claims remain access-controlled enforcement data. + +## No parallel authority + +An enforcing domain has one current NIP-FI authority and policy lineage for every protected ingress. Do not: + +- accept a legacy corporate header when NIP-FI denies; +- keep a provider-specific identity path for selected routes; +- infer identity from email or subject without the configured issuer; +- copy assertion expiry into durable binding expiry; +- import revoked, disabled, or retired state as an active binding; or +- expose corporate claims in Nostr events, discovery, logs, metrics, or traces. + +NIP-42 and NIP-98 continue to prove control of a Nostr key. They do not replace the additional NIP-FI authority for a protected operation. + +## Migration plan + +1. **Inventory:** enumerate every protected WebSocket and HTTP ingress, existing identity source, forwarded field, policy, binding store, lifecycle action, and fallback. +2. **Freeze legacy expansion:** add no routes, providers, claims, or identities to the legacy authority while migration is in progress. +3. **Define domains and policies:** map each server-owned domain to exact issuer-qualified identities `(iss, sub)`, accepted semantics, enrollment mode, and transport profile. +4. **Normalize state:** represent active durable bindings, immutable provenance, retired pairs, disabled identities, revoked keys, pending replacement lineage, typed history, and versions. +5. **Verify imports:** require independent evidence for imported identity/key pairs. Do not treat a forwarded header, email match, or expired assertion as proof of key control. +6. **Install without activation:** deploy the later exact implementation with discovery and enforcement off, one canonical verifier, and no legacy fallback. +7. **Run behavior:** execute all applicable `FI-TRACE-*` adapters at the exact artifact, deployment, and policy digests, including route inventory and deployed-boundary proxy negatives. +8. **Cut over atomically:** enable one authority across the complete protected-ingress set and remove the legacy path. Canary isolated domains or deployments, not individual routes under competing authorities. +9. **Verify and retain:** test old headers, old keys, tombstones, conflicts, denial privacy, dependency outages, restore, and rollback; retain privacy-safe evidence. + +If historical data lacks proof or unambiguous issuer qualification, keep it non-authoritative until a separately authorized provisioning or recovery transition establishes current state. + +## Rollback and repair + +Rollback returns to a previously conformant artifact with compatible current state, or disables discovery and fails protected operations closed. It never restores unsigned forwarding, a removed verification key, a parallel provider runtime, or an older authority database. + +Correct an imported binding or lifecycle fact with a reviewed privileged compensating transition. Preserve typed history and incident evidence; do not delete the record to make ordinary enrollment available again. + +## Operator references + +- [Integration contract](NIP_FI_INTEGRATION.md) +- [Threat model](NIP_FI_THREAT_MODEL.md) +- [Stock deployment](NIP_FI_DEPLOYMENT.md) +- [Runtime operations](NIP_FI_RUNTIME_OPERATIONS.md) +- [Contributor guide](NIP_FI_CONTRIBUTING.md) diff --git a/docs/NIP_FI_CONTRIBUTING.md b/docs/NIP_FI_CONTRIBUTING.md new file mode 100644 index 0000000000..ece56ff1ce --- /dev/null +++ b/docs/NIP_FI_CONTRIBUTING.md @@ -0,0 +1,151 @@ +# Contributing to NIP-FI + +This guide applies to public NIP-FI protocol, implementation, deployment, and operations changes in Buzz. Repository-wide rules in [CONTRIBUTING.md](../CONTRIBUTING.md) still apply. + +## Start with the contract + +Read these together: + +1. [NIP-FI](nips/NIP-FI.md) defines normative behavior. +2. [The formal model](nips/NIP-FI-MODEL.md) defines state, invariants, and transitions. +3. [The conformance evidence matrix](nips/NIP-FI-CONFORMANCE.md) defines the behavioral release gate. +4. [The threat model](NIP_FI_THREAT_MODEL.md) records assets, trust boundaries, and residual risk. + +Non-normative implementation or operator prose cannot weaken those documents. + +## Change ownership + +Keep changes in the layer that owns them: + +- normative semantics belong in the specification and model; +- stable behavioral oracles belong in the conformance matrix; +- runtime adapters, state, routes, and tests belong in the implementation stack; +- network isolation and secret delivery belong in deployment artifacts; +- lifecycle procedures and evidence retention belong in operations guidance; and +- public entry points link to accepted behavior without claiming unexecuted support. + +Do not move later runtime behavior into an earlier reviewed commit to make a checklist appear complete. Preserve protected ancestry and append reviewable, signed-off commits. + +## Stable labels + +`FI-INV-01` through `FI-INV-16` and the 24 `FI-TRACE-*` identifiers are public review interfaces. Do not renumber or reuse them for different semantics. + +When behavior changes: + +1. update the normative text and model together; +2. decide whether an existing trace still represents the same oracle; +3. add a new stable trace only when no existing trace can express the behavior; +4. update the matrix and example report in the same series; and +5. hand the label to the implementation stack for executable adapter coverage. + +A wording cleanup that does not change behavior should not churn labels. + +## Behavioral adapters + +An adapter maps a stable trace to executable behavior at one exact implementation revision. It records the command, test IDs, fixtures, expected oracle, artifacts, and cleanup. + +Adapters should exercise production or production-equivalent entry points. Internal helpers may inspect state or inject an outage. They cannot replace the protected operation under test. + +Examples: + +- verifier parity runs one assertion corpus through every transport adapter; +- uniform authority executes the protected-route inventory and observes policy identity at each ingress; +- tombstone replay creates selector state in the database, presents fresh evidence through a real ingress, and verifies no mutation; +- final-denial tests inspect replay, receipt, binding, audit, and application stores; and +- trusted-proxy spoof tests run against the deployed listener boundary. + +The following are not behavioral adapters: + +- a regular expression over source or generated schemas; +- a test that checks only that a function or route exists; +- a documentation-link check; +- a mocked proxy-isolation test; or +- a passing result copied from another revision. + +## Pull request evidence + +A runtime or deployment change includes: + +- exact parent and head revisions; +- owned behavior and affected stable labels; +- implementation and adapter commands; +- services, fixtures, and fault injection used; +- evidence artifact digests; +- migrations and rollback behavior when state changes; +- route and discovery impact; +- privacy and observability impact; and +- explicit unsupported behavior. + +Do not describe a feature as conforming because it compiles, parses configuration, or has a green source-string test. State whether the full exact-head matrix ran and link its immutable report. + +## Review checklist + +### Protocol and model + +- Definitions, pseudocode, state, and traces agree. +- Stable policy identity excludes rotating key material. +- Assertion expiry bounds authority but not durable binding lifetime. +- Lifecycle selectors are checked before ordinary enrollment. +- Preparation remains read-only. +- Final admission rereads only applicable direct or delegated witnesses. +- Public denials remain many-to-one and privacy safe. + +### Runtime + +- Every protected ingress uses one current authority and policy lineage. +- The canonical verifier produces identical decisions across transports. +- HMAC provenance covers the exact canonical request and retains replay state long enough. +- Binding and lifecycle decisions are serialized under concurrency. +- JWKS addition, removal, hard expiry, and outage behavior are exercised. +- Lease reuse checks current dependencies. +- Application denial leaves no authority or application mutation. + +### Lifecycle + +- Provisioning requires target-key proof. +- Retirement, disablement, and revocation preserve lineage. +- Rotation starts from one exact active binding and leaves no pending replacement. +- Recovery consumes one exact pending lineage for an enabled identity. +- Re-enablement handles disabled identities and consumes present lineage once. +- Administrative expiry does not create a tombstone or free coordinates. +- Rollback uses a compensating privileged transition instead of database rewind. + +### Deployment and operations + +- Direct origin access to trusted-proxy ingress is blocked and tested. +- Secret references contain no secret values in repository files. +- Startup denies uncovered routes or competing authorities. +- Backup and restore include lifecycle selectors, policy generations, receipts, and audit state. +- Dependency outages and audit-capacity limits fail closed and alert. +- A rollback cannot restore a removed verification key or bypass current tombstones. + +### Evidence + +- All 24 traces appear exactly once in the report. +- Every required trace passes at the claim tuple. +- Every `not-applicable` trace has executable absence evidence. +- Artifacts and digests resolve. +- Privacy canaries are absent from every public and operational sink. + +## Documentation checks + +Documentation-only changes run, at minimum: + +```sh +git diff --check +jq empty docs/examples/nip-fi-*.json.example +``` + +They also validate local Markdown links, code fences, table structure, example-to-normative label equality, and matrix-to-normative label equality. These checks validate documentation consistency. They do not prove runtime conformance. + +Runtime and deployment changes additionally run the repository's normal gates and every applicable exact-head behavioral adapter. + +## Security-sensitive changes + +Treat verifier rules, proxy canonicalization, secret rotation, replay retention, lifecycle transitions, privacy filters, denial mapping, delegation, lease invalidation, restore, and rollback as security-sensitive. Update the [threat model](NIP_FI_THREAT_MODEL.md) when a trust boundary, asset, attacker capability, or residual risk changes. + +Never place real assertions, subjects, issuer-private values, HMAC secrets, operator credentials, or production evidence in examples or test fixtures. + +## Commit and handoff + +Use focused conventional commits with DCO sign-off. Preserve human-authored history and public review text. Before handoff, report exact commit, tree, and parent identities; changed paths; checks; stable labels affected; and remaining implementation or deployment dependencies. diff --git a/docs/NIP_FI_DEPLOYMENT.md b/docs/NIP_FI_DEPLOYMENT.md new file mode 100644 index 0000000000..98f07d7380 --- /dev/null +++ b/docs/NIP_FI_DEPLOYMENT.md @@ -0,0 +1,143 @@ +# NIP-FI stock deployment + +This guide describes a provider-neutral stock deployment for the normative [NIP-FI contract](nips/NIP-FI.md). It does not define runtime configuration keys or activate support. + +## Current status + +This documentation revision ships no NIP-FI runtime adapter, configuration schema, image capability, or conformance report. The current Compose and Helm bundles must not advertise or enforce NIP-FI. A later implementation release must document its exact adapter and configuration surface and pass the [behavioral evidence matrix](nips/NIP-FI-CONFORMANCE.md) at the deployed revision before activation. + +The files under [`docs/examples`](examples/) are review templates. They are not accepted runtime schemas and contain no deployable secrets. + +## Stock topology + +```text +external client + -> TLS listener or trusted edge + -> Buzz verifier ingress + -> one canonical NIP-FI verifier and final-admission authority + -> binding/lifecycle, replay, policy, receipt, audit, and application stores + | + -> configured issuer metadata and JWKS over authenticated TLS +``` + +The stock profile is provider free. It identifies issuers by exact configured values and retrieves keys under bounded policy. It does not require a provider-specific sidecar, claim dialect, SDK, or forwarded-identity convention. + +## Activation prerequisites + +Do not publish NIP-FI discovery or enable enforcement until all of these are true for one immutable claim tuple: + +1. The running artifact supplies a canonical verifier, final-admission authority, lifecycle state, and executable adapter manifest. +2. Every protected WebSocket and HTTP ingress appears in the executed route inventory. +3. One current domain policy and policy lineage covers every protected ingress. +4. Issuer, audience, algorithm, claim, time, size, JWKS, and enrollment policy is explicit for each domain. +5. Binding, lifecycle, replay, receipt, audit, invalidation, and application storage meets serialization and durability requirements. +6. Secrets arrive from an access-controlled secret store and never from repository examples. +7. Backup, restore, key rotation, dependency outage, recovery, and rollback exercises pass. +8. All applicable `FI-TRACE-*` rows pass at the exact implementation, adapter, artifact, deployment, and policy digests. + +A source scan, config parse, rendered manifest, healthy process, or documentation link closes none of these gates. + +## Domain policy + +Use one reviewed policy object per server-selected domain. The review-only [stock domain example](examples/nip-fi-stock-domain.json.example) records the required decisions without claiming a runtime schema. + +At minimum, the later runtime schema must represent: + +- exact domain identity and trusted listener or route mapping; +- accepted issuer and audience values; +- allowed compact-JWS algorithms and compatible key types; +- subject and optional Nostr-key claim rules; +- assertion, clock-skew, header, and body bounds; +- JWKS refresh, stale-on-error, and hard-validity bounds; +- exactly one enrollment mode; +- accepted assertion transports; +- delegation support and positive finite maximum, when enabled; +- stable public denial mapping; and +- current policy identity and lineage. + +Rotating verification keys changes the JWKS generation, not the stable verifier-policy identity. Changing accepted assertion semantics creates a new policy identity. + +## Client-attached profile + +Expose `client-attached` only where the client can send exactly one `Nostr-Federated-Identity: Bearer ` field and fresh Nostr proof through one protected request flow. Use that assertion field for both WebSocket upgrades and HTTP requests. Never accept the federated assertion in `Authorization`. Reject assertion-provenance fields on this profile and never fall back to it after trusted-proxy evidence is missing, mixed, or invalid. + +Treat assertions as confidential bearer material. Do not log headers, echo them in errors, retain them in public history, or forward them beyond the canonical verifier. + +## Trusted-proxy profile + +The Buzz stock trusted-proxy profile is `trusted-proxy-hmac-v1`. The edge: + +1. removes every inbound assertion and provenance field, including attacker-supplied duplicates; +2. completes trusted routing and canonical path rewriting; +3. computes the assertion digest and request body digest; +4. creates a fresh timestamp and nonce; +5. MACs the exact timestamp, nonce, assertion digest, method, authority, path/query, and body digest; and +6. sends exactly one `Nostr-Federated-Identity: Bearer ` field and one `Nostr-Federated-Identity-Provenance` field to verifier ingress. + +The HMAC secret is random, access controlled, versioned, and delivered independently of application configuration. Rotation uses a short, explicit overlap. Replay uniqueness is scoped to the trusted-proxy domain, profile, and nonce and is independent of which active secret verified the MAC. A nonce re-signed with another active secret remains a replay. The matched secret version may appear only in private audit metadata. Remove the old version after all requests and replay windows expire. + +Network controls ensure that only the trusted edge can reach verifier ingress. A separate health or administrative listener cannot proxy protected operations. The deployment test runs direct-origin, bypass, client-header injection, mixed-profile, replay, and cross-request mutations from both sides of the boundary. A mocked listener test cannot satisfy `FI-TRACE-PROXY-SPOOF`. + +## Storage and transactions + +Production-equivalent storage must provide: + +- serialized uniqueness for both sides of each domain's active binding relation; +- durable lifecycle selectors and immutable typed history; +- monotonic binding and lifecycle versions; +- atomic enrollment, replay claim, request-bound receipt, and required audit evidence; +- idempotent application consumption when the application effect uses another transaction; +- bounded replay retention that outlives the accepted provenance window; +- current policy, JWKS generation, resource, and relationship witnesses; and +- backup and restore consistency across authority state. + +Caching may improve reads but cannot authorize from state older than its witnessed invalidation bound. An unavailable cache origin, database, replay store, receipt store, or audit sink denies. + +## Rollout + +### 1. Inventory + +List every protected ingress and its current authorization path. Include WebSocket operations, HTTP event/query/count, media, Git, audio, invite, moderation, and operator paths. Remove or fail startup on uncovered and competing authorities. + +### 2. Install without discovery + +Deploy the exact implementation artifact with NIP-FI discovery and enforcement off. Load reviewed policy and secret references. Validate issuer connectivity, JWKS authenticity, state migrations, backup, observability redaction, and route inventory without creating production bindings. + +### 3. Run isolated behavior + +Run every applicable adapter in an isolated namespace using synthetic issuers, identities, keys, and domains. Retain commands, results, state snapshots, wire captures, sink scans, and artifact digests. + +### 4. Exercise the deployed boundary + +Run production-equivalent trusted-edge negative tests, dependency fault injection, restore, key rotation, concurrency, lifecycle, recovery, and rollback exercises. Do not infer these outcomes from isolated unit tests. + +### 5. Activate atomically + +Only after the exact claim tuple passes, publish discovery and enable one canonical authority for the complete protected-ingress set. Do not canary by leaving some protected routes under an older identity authority. Canary domains or isolated deployments instead. + +### 6. Observe without identity leakage + +Monitor aggregate allow/deny classes, dependency readiness, refresh age, replay pressure, final-admission conflicts, lease invalidations, and audit capacity. Private reason details stay access controlled. Raw assertions and identity claims are never metric labels or trace attributes. + +## Rollback + +Rollback means returning to a previously conformant artifact and compatible policy/state lineage, or disabling NIP-FI discovery and failing the protected operation closed. It does not mean accepting unsigned identity, restoring a removed verification key, deleting tombstones, rewinding lifecycle tables, bypassing audit, or running a legacy authority beside NIP-FI. + +Before rollout, record compatible artifact, policy, migration, and storage checkpoints. If a schema or semantic change is not backward compatible, use a reviewed forward repair or compensating privileged transition. Follow the [runtime operations guide](NIP_FI_RUNTIME_OPERATIONS.md) for authority-state recovery. + +## Compose and Helm + +The current [Compose](../deploy/compose/README.md) and [Helm](../deploy/charts/buzz/README.md) bundles contain no NIP-FI adapter wiring. Their NIP-FI sections are readiness statements, not activation instructions. + +A later bundle must: + +- pin the exact implementation artifact and expose its adapter/config version; +- mount domain policy and secret references without putting secrets in values or examples; +- isolate trusted edge and verifier ingress when the proxy profile is enabled; +- provide readiness that covers every fail-closed dependency; +- name the backup, restore, migration, and rollback procedures; and +- link the matching immutable conformance report. + +## Deployment record + +For each enforcing environment, retain the immutable claim tuple, policy and artifact digests, route inventory, accepted profiles, enrollment mode, delegation posture, secret versions without secret values, storage topology, executed adapter report, restore exercise, activation time, and rollback target. diff --git a/docs/NIP_FI_INTEGRATION.md b/docs/NIP_FI_INTEGRATION.md new file mode 100644 index 0000000000..921abd8335 --- /dev/null +++ b/docs/NIP_FI_INTEGRATION.md @@ -0,0 +1,166 @@ +# NIP-FI integration contract + +This guide describes how a Buzz implementation integrates the normative [NIP-FI specification](nips/NIP-FI.md), [formal model](nips/NIP-FI-MODEL.md), and [behavioral evidence matrix](nips/NIP-FI-CONFORMANCE.md). It is non-normative and does not weaken those documents. + +## Current status + +This documentation revision does not add a runtime adapter, activate enforcement, or establish conformance. A later implementation stack must supply exact-head behavioral adapters for all applicable `FI-TRACE-*` identifiers. NIP-FI discovery and enforcement remain off until one immutable implementation and deployment tuple passes the release gate. + +Source review, type presence, configuration parsing, route registration, and prose checks are useful review inputs. None proves runtime behavior. + +## Integration boundary + +NIP-FI is an additional admission authority above NIP-42 or NIP-98 proof of key control. It does not replace Nostr signatures or application authorization. A protected operation proceeds only when all applicable gates agree: + +```text +trusted route and domain + + accepted assertion transport or delegated evidence + + fresh Nostr proof + + current binding and lifecycle state + + current verifier, policy, and resource state + + final application admission + = committed authorization +``` + +A configured deployment uses one canonical current NIP-FI authority for every protected ingress in a domain. It cannot run a legacy identity authority beside NIP-FI, select an older policy lineage for one route, or leave a protected route Nostr-only. + +## Protected-ingress inventory + +The implementation stack owns an executable inventory of every protected operation. At minimum, reviewers classify and exercise: + +- WebSocket connection authentication and each protected message or event path; +- HTTP event submission, query, and count operations; +- media reads, writes, and metadata operations; +- Git smart HTTP and policy hooks; +- audio and real-time session operations; +- invite and moderation operations; and +- lifecycle or operator operations. + +The inventory records the server-selected domain, operation, resource, transport profile, policy identity, and final-admission adapter for each route. Unknown protected routes and incompatible policy lineages fail closed. `FI-TRACE-AUTHORITY-UNIFORM` executes the inventory rather than inspecting route source. + +## Server-owned context + +Trusted listener and route configuration resolves the target context before identity evidence can influence a decision. The Nostr proof then seals the actor key into the request context. + +The implementation must not accept a client domain, forwarded authority, assertion claim, tag, or query parameter as a domain selector. If a trusted edge rewrites authority or path, it completes the rewrite before computing HMAC provenance and the relay reconstructs the same canonical post-routing values. + +Multi-domain deployments use the same authenticated domain lookup for authorization and discovery. An unconfigured domain receives no inherited NIP-FI profile. + +## Canonical assertion verifier + +Every transport adapter calls one provider-neutral assertion verifier contract. The verifier owns compact-JWS parsing, issuer and audience selection, algorithm and key compatibility, subject rules, optional key attestation, time bounds, size bounds, JWKS refresh, and normalized evidence. + +The stable verifier policy identity contains accepted semantics but excludes rotating keys and transport. A separate JWKS generation identifies the effective key snapshot. Prepared and direct-lease evidence retains a confidential handle to the exact assertion and the key snapshot's hard-validity deadline. + +On a generation change, final admission and lease reuse revalidate the original assertion against the current snapshot. A retained key can continue to authorize. A removed key, unreadable current snapshot, or hard-expired snapshot denies. `FI-TRACE-VERIFIER-PARITY`, `FI-TRACE-JWKS-ADD`, and `FI-TRACE-JWKS-REMOVE` prove these behaviors through every transport. + +## Assertion transport + +The selected profile is part of trusted target context. The implementation supports only complete profiles and never falls back after mixed, missing, or rejected evidence. + +### Client attached + +The `client-attached` adapter accepts exactly one `Nostr-Federated-Identity: Bearer ` field and rejects assertion-provenance fields. The same assertion field is used for WebSocket upgrades and HTTP requests. `Authorization` remains reserved for Nostr proof where required and never carries the federated assertion. HTTP evidence and NIP-98 proof arrive on the same request. WebSocket assertion evidence arrives on the authenticated upgrade and is retained confidentially only for the resulting admission or lease. + +### Trusted proxy + +The `trusted-proxy-hmac-v1` adapter implements the exact envelope, canonical request bytes, time bounds, nonce size, HMAC, and replay retention in NIP-FI. The trusted edge removes inbound assertion and provenance fields before setting its own. + +The deployment also proves: + +- untrusted clients cannot reach verifier ingress; +- direct origin requests deny; +- mixed profiles deny without fallback; +- client header injection denies; +- nonce replay commits at most once; and +- changing any request-bound field denies. + +Local unit tests cannot prove network isolation. The deployment bundle must retain live negative evidence for `FI-TRACE-PROXY-SPOOF`. + +## Binding and lifecycle state + +Storage represents the formal model's effective state: + +- `B`: active durable bindings and immutable provenance; +- `T`: retired exact identity/key pairs; +- `X`: disabled identities; +- `Y`: domain-scoped revoked keys; +- `Q`: pending-replacement lineage; +- `H`: immutable typed lifecycle history; and +- `V`: monotonic binding and lifecycle versions. + +An implementation may use different names or tables, but the behavioral selectors remain distinct. A history timestamp is not an active selector. Assertion expiry never becomes binding expiry. Optional administrative expiry is separately authorized and versioned. + +Hot-path authorization reads both sides of the active relation and every applicable selector. It does not rely only on writer invariants. Unreadable or contradictory state denies. + +## Prepared and committed authorization + +Preparation is read-only. It creates no binding, lifecycle fact, replay claim, receipt, audit observation, lease, publication, last-seen value, or application mutation. + +Prepared evidence uses one of two dependency sets: + +- `DirectPrepared`: assertion, optional proxy provenance, actor binding and lifecycle witnesses, and enrollment-mode witness; or +- `DelegatedPrepared`: delegation, current owner binding and lifecycle witnesses, and relationship witness. + +Both include exact request context, fresh Nostr proof, local policy and resource witnesses, deadlines, and invalidation dependencies. + +Final admission rereads only applicable witnesses inside the authorization transaction. Unreadable state denies. Changed state requires a complete recomputation and may commit only a semantically equivalent current result. Identical concurrent enrollment may converge on the same binding version; a conflicting result denies. + +Replay claims, eligible enrollment, request-bound receipt, and required authorization audit evidence commit together. If the application effect uses another transaction, it consumes a request-bound idempotent receipt so retry cannot duplicate the effect. + +## Enrollment modes + +- `attested-key` creates a first binding only when the assertion's key claim equals the proven key. +- `provisioned` never creates a binding from ordinary authorization. A privileged transition still requires fresh target-key proof and any configured issuer attestation. +- `tofu` is risk-labelled. It may bind an attacker's key when the attacker has a stolen assertion for a never-enrolled identity. + +Mode changes affect future creation only. They do not rewrite existing bindings or downgrade provenance. A matching key claim in TOFU records `attested-key`, not `tofu`. + +## Lifecycle operations + +Provision, retire, disable, revoke, rotate, recover, re-enable, and administrative-expiry changes are separately authorized transitions. Each binds authority to the domain, operation, request, identity, old version when present, and target key when present. + +Every new target key proves control. Replacement provenance reflects the evidence used for that key. A privileged transition without matching issuer key attestation records provisioned provenance; it never inherits TOFU or attested provenance from another key. + +Rotation starts from an active binding and leaves no pending lineage. Recovery consumes exact pending lineage for an enabled identity. A disabled identity uses re-enablement. Retired pairs and revoked keys remain durable. + +See [runtime operations](NIP_FI_RUNTIME_OPERATIONS.md) for preconditions, postconditions, recovery, and rollback rules. + +## Sessions and delegation + +HTTP authorization applies to one request. A WebSocket lease is per key, domain, capability, resource, and exact dependency set. Its deadline is the earliest applicable assertion, key-snapshot, proxy, proof, binding, delegation, policy, and implementation bound. + +Direct lease reuse rechecks current binding and lifecycle versions, the key-snapshot hard deadline, and JWKS generation. Delegated lease reuse rechecks the current exact owner binding and relationship revision. A lease for one key never covers another key on the connection. + +Delegation is optional and separate from federated assertion transport. The delegate supplies its own fresh Nostr proof and no assertion or assertion-provenance field. The owner must remain current and eligible at preparation and final admission. Rotation does not transfer delegation to the new owner key. + +## Discovery and privacy + +Discovery is a claim, not a feature flag. The service advertises only profiles and optional behavior that passed the evidence matrix at the running implementation and deployment revision. An implementation without a complete adapter omits NIP-FI discovery. + +NIP-FI defines no public identity projection. Assertions, issuer-qualified identities, profile claims, HMAC correlation values, and private policy state stay out of protocol output and public history. Access-controlled enforcement state retains only what lifecycle, audit, and incident response need. + +Public denials use the stable classes from NIP-FI. They do not reveal whether an identity, key, binding, tombstone, claim, enrollment mode, or private policy exists. + +## Implementation-stack handoff + +The later implementation stack must deliver, at one exact head: + +1. a route adapter manifest for every protected ingress; +2. one canonical assertion verifier and transport-neutral assertion corpus; +3. serialized lifecycle storage with selector-conflict fixtures; +4. read-only preparation and atomic final admission; +5. direct and delegated lease dependency revalidation; +6. deployed trusted-proxy negative evidence when that profile is supported; +7. an executable adapter mapping for all applicable `FI-TRACE-*` labels; and +8. a conformance report whose revisions and artifact digests match the deployment. + +The implementation handoff also lists exact commands, required services, fault-injection controls, and cleanup steps. A source grep, documentation link, or claim that code paths are wired is not a substitute. + +## Related guidance + +- [Threat model](NIP_FI_THREAT_MODEL.md) +- [Stock deployment](NIP_FI_DEPLOYMENT.md) +- [Runtime operations](NIP_FI_RUNTIME_OPERATIONS.md) +- [Contributor guide](NIP_FI_CONTRIBUTING.md) +- [Legacy corporate identity migration](CORPORATE_IDENTITY.md) diff --git a/docs/NIP_FI_RUNTIME_OPERATIONS.md b/docs/NIP_FI_RUNTIME_OPERATIONS.md new file mode 100644 index 0000000000..0a0eedf30f --- /dev/null +++ b/docs/NIP_FI_RUNTIME_OPERATIONS.md @@ -0,0 +1,150 @@ +# NIP-FI runtime operations + +This guide covers lifecycle, recovery, restore, and rollback for a conforming Buzz NIP-FI implementation. The normative [specification](nips/NIP-FI.md) and [formal model](nips/NIP-FI-MODEL.md) control when this guide differs. + +## Current status + +This documentation revision supplies no runtime adapter or operator command. Command names and request schemas must come from the later exact-head implementation stack. The [recovery-plan example](examples/nip-fi-recovery-plan.json.example) is a review template, not a runtime request. + +Do not simulate an operator transition with direct database edits. Every authority change uses a separately authorized, audited transition with fresh target-key proof when a new key is introduced. + +## Operating principles + +- Keep ordinary authorization separate from privileged lifecycle authority. +- Resolve the domain, operation, identity, current version, and target key from one sealed operator request. +- Check both sides of the active binding and every lifecycle selector under serialization. +- Preserve retired pairs, revoked keys, disabled identities, pending lineage, typed history, and immutable provenance. +- Treat preparation and denial as read-only. +- Commit final authority state, receipt, replay claim, and required audit evidence atomically. +- Atomically commit current authority versions and a durable invalidation intent. Fence new and reused authority on those versions immediately, then close affected cached leases after commit within the documented detection bound. +- Use compensating transitions for repair; never rewind authority tables. +- Fail closed when current policy, state, receipt, replay, audit, or invalidation dependencies are unreadable. + +## Routine readiness + +At startup and continuously, verify: + +- one current policy lineage covers every protected ingress; +- the canonical verifier can read an authentic current key snapshot within its hard bound; +- binding and lifecycle stores are readable and constraints are healthy; +- replay, receipt, audit, relationship, policy, resource, and invalidation stores are available; +- clock synchronization stays inside configured bounds; +- trusted-proxy ingress is isolated and current HMAC versions are known; +- queues, replay retention, audit capacity, and JWKS refresh age remain inside limits; and +- the deployed artifact, adapter, policy, and route inventory still match the recorded conformance tuple. + +Readiness for protected operations fails when a required dependency fails. Liveness may remain available for diagnosis but cannot become a bypass route. + +## Lifecycle transition table + +| Transition | Required current state and authority | Committed result | Required evidence | +|---|---|---|---| +| Provision | Provisioned enrollment mode; no conflicting active pair or pending lineage; eligible identity and key; privileged authority; fresh target-key proof; configured issuer attestation when required | Fresh active binding with provisioned provenance and new version | Sealed request, target proof, policy and state witnesses, receipt, typed history, and lease invalidation | +| Retire pair | Exact active identity/key pair and expected version | Binding removed, exact pair added to retired state, exact pending lineage recorded | Old and new versions, selector snapshots, receipt, history, and affected lease closure | +| Disable identity | Current enabled identity; exact active version when bound | Identity disabled; active pair retired and pending lineage recorded when present | Identity selector, optional pair/version, receipt, history, and direct/delegated invalidation | +| Revoke key | Domain-scoped key, active or inactive | Key remains revoked; active pair is removed, retired, and recorded as pending when present | Key selector, idempotent-repeat result, optional pair/version, receipt, history, and invalidation | +| Rotate | Exact active old binding/version, eligible new key, no pending lineage, fresh new-key proof | Old pair retired; new binding created at a fresh version; no pending lineage remains | Privileged transition authority, exact old binding/version, fresh new-key proof, selectors, provenance decision, receipt, history, and owner/delegate lease effects | +| Recover | Enabled identity with one exact pending lineage and no active binding; eligible new key; fresh new-key proof | Pending lineage consumed once; new binding created; old retired pair preserved | Pending version, target proof, selector snapshots, receipt, history, and invalidation | +| Re-enable identity | Disabled identity and either no prior lineage or one exact pending lineage; eligible key; fresh key proof | Disabled state cleared; present lineage consumed once; new binding created | Disabled and lineage selectors, target proof, receipt, history, and invalidation | +| Set or clear administrative expiry | Exact active binding/version and privileged authority | Administrative bound and version updated without retiring the pair or freeing either coordinate | Old/new bound, controlled-clock checks, receipt, history, and lease deadline change | + +Repeated authorized revocation may be idempotent but cannot erase lineage. A retry of any transition uses its request-bound receipt and must not create a second history fact or binding version. + +## Recovery decision + +Use the lifecycle state, not the operator's desired outcome, to choose the transition: + +- An enabled identity with exact pending replacement lineage uses **Recover**. +- A disabled identity uses **Re-enable identity**, whether or not pending lineage exists. +- An active binding moving directly to a new key uses **Rotate**. +- An identity with no active binding and no lineage uses **Provision** when policy allows it. +- A binding blocked only by administrative expiry uses the separately authorized administrative-expiry transition. + +If selectors are contradictory, versions are unknown, or history and active state disagree, stop. Preserve the evidence, fail closed, and investigate before authorizing a forward repair. + +## Recovery procedure + +1. Open an incident or change record and identify the server-selected domain. +2. Read the active binding from both identity and key directions plus `T`, `X`, `Y`, `Q`, `H`, and `V`. +3. Select Recover, Re-enable, Rotate, Provision, or administrative-expiry change using the decision above. +4. Obtain separately authorized operator approval and fresh proof from every new target key. +5. Prepare the transition without mutation and record its exact versions and policy witnesses. +6. At final admission, reread the applicable selectors and atomically commit the transition, receipt, history, audit evidence, current authority versions, and durable invalidation intent. +7. Fence authorization on the committed versions immediately and close affected cached leases after commit within the documented detection bound. +8. Verify the intended postconditions and that old pairs, revoked keys, and disabled or pending selectors changed only as defined. +9. Exercise old-key, old-delegation, replay, cross-domain, and new-key cases through production-equivalent ingress. +10. Retain privacy-safe evidence and close the change only after state and lease checks pass. + +The review-only [recovery-plan example](examples/nip-fi-recovery-plan.json.example) helps record this plan without containing real identity or secret values. + +## JWKS rotation and outage + +On a new key generation: + +1. authenticate and validate the complete snapshot; +2. retain the stable verifier-policy identity when accepted semantics did not change; +3. publish the new generation witness atomically; +4. revalidate prepared evidence and direct leases that observe the generation change; and +5. invalidate evidence whose signing key was removed. + +A retained key may continue after successful revalidation. A removed key cannot. Once a snapshot reaches its hard-validity deadline, unreadable current state denies even if a cache contains the old key. + +Alert on refresh failure, increasing snapshot age, key-set rollback, incompatible algorithms, or invalid metadata. Never fix an outage by extending hard validity without a reviewed policy change and a new exact-head claim. + +## Trusted-proxy secret rotation + +Create a new random HMAC version in the secret store and distribute it to the edge and verifier through authenticated channels. If overlap is necessary, bound it explicitly. Keep one replay namespace for the trusted-proxy domain and profile regardless of which active secret verified a nonce; retain the matched version only as private audit metadata. Exercise baseline, old-version, new-version, the same nonce re-signed across versions, cross-request, and direct-origin cases. + +Retire the old version only after its maximum timestamp skew, request lifetime, replay retention, and in-flight processing bounds have passed. Remove it from both edge and verifier and retain no secret value in logs or evidence. + +## Dependency incident + +When verifier, JWKS, binding, lifecycle, replay, policy, receipt, audit, relationship, resource, or invalidation state is unavailable or contradictory: + +1. keep the affected protected operations fail closed; +2. stop new leases and close leases whose current dependencies cannot be checked; +3. preserve aggregate availability signals without recording raw identity material; +4. restore the dependency from a consistent known-good point; +5. reconcile versions, receipts, replay retention, and application effects before reopening; and +6. rerun `FI-TRACE-DEPENDENCY-FAIL-CLOSED` plus affected state and privacy traces. + +Do not queue an authorization for later implicit approval. A client retry starts a new request with fresh evidence. + +## Backup and restore + +Back up a consistent authority set: + +- active bindings and immutable provenance; +- retired pairs, disabled identities, revoked keys, and pending lineage; +- typed history and monotonic versions; +- verifier policies, JWKS generation metadata, and domain-policy lineage; +- delegation and relationship revisions; +- request-bound receipts and required authorization audit evidence; +- replay state for every still-acceptable request window; and +- application idempotency state needed to reconcile receipts and effects. + +Keep secret material in the secret system's own protected backup process. Record secret versions, not values, with the authority backup. + +After restore, hold protected traffic closed. Verify referential and selector invariants, policy and key-generation freshness, replay coverage, receipt/application reconciliation, audit continuity, lease invalidation, and route inventory. Rerun the complete exact-head conformance matrix before advertising enforcement from a materially changed restore topology. + +Restoring an older database snapshot must not resurrect removed keys, deleted policy generations, retired pairs, disabled identities, revoked keys, or consumed pending lineage. Apply verified forward records or a privileged compensating transition before reopening. + +## Rollback + +An application rollback is safe only when the prior artifact understands the current authority schema, policy lineage, stable labels, and transition semantics. Pin the compatible artifact and verify its digest before rollout. + +If no compatible conformant artifact exists, disable discovery and fail the protected operation closed while repairing forward. Do not restore unsigned forwarded identity, a legacy corporate authority, Nostr-only access to an NIP-FI-protected route, an old JWKS key, or a prior database snapshot as an authorization shortcut. + +Any authority correction uses a new privileged transition with new receipt and history. Preserve the erroneous record and incident evidence. + +## Monitoring and privacy + +Monitor dependency readiness, public denial class, final-admission conflict, replay pressure, JWKS age, lease invalidation lag, lifecycle transition rate, receipt/application reconciliation, audit capacity, and route-inventory drift. + +Never use raw assertions, issuer-qualified identities, Nostr keys linked to private identity, email, display name, HMAC value, or private decision reason as a metric label or public trace field. Access-controlled investigation records retain only the minimum needed under a documented retention period. + +Run privacy canaries through allowed and denied flows after adding an observability sink or changing redaction. Finding a canary in protocol output, public history, logs, metrics, or traces is an incident and a conformance failure. + +## Periodic exercises + +Rerun the full matrix for every immutable release tuple and after a material deployment, issuer, policy, proxy, storage, restore, or observability change. Periodically exercise concurrency, JWKS add/remove, proxy bypass/replay, dependency outage, recovery, restore, and rollback even when application code did not change. diff --git a/docs/NIP_FI_THREAT_MODEL.md b/docs/NIP_FI_THREAT_MODEL.md new file mode 100644 index 0000000000..4b3cabb38b --- /dev/null +++ b/docs/NIP_FI_THREAT_MODEL.md @@ -0,0 +1,116 @@ +# NIP-FI threat model + +This document records the security model for Buzz implementations of the normative [NIP-FI specification](nips/NIP-FI.md) and [formal model](nips/NIP-FI-MODEL.md). It is non-normative. The [conformance evidence matrix](nips/NIP-FI-CONFORMANCE.md) defines the behavioral release gate. + +## Current status + +This documentation revision contains no NIP-FI runtime adapter and establishes no conformance claim. The threats and mitigations below become implementation claims only when a later exact-head implementation and deployment tuple passes every applicable `FI-TRACE-*` behavior. + +## Security objectives + +NIP-FI adds issuer-qualified identity to fresh Nostr key proof without making either one sufficient by itself. Its security objectives are: + +- bind authority to one server-selected domain, route, operation, resource, and actor key; +- accept an assertion only under one provider-neutral verifier policy and current key snapshot; +- make trusted-proxy identity cryptographically request bound and replay resistant; +- keep durable identity/key bindings independent of assertion lifetime; +- preserve retirement, disablement, revocation, replacement, and provenance across ordinary authorization; +- keep preparation read-only and make final admission the only authority commit point; +- bound sessions and delegation by every current dependency; +- deny on unreadable, ambiguous, stale, or contradictory state; and +- prevent identity material and detailed decision reasons from becoming public or observable. + +## Protected assets + +| Asset | Required protection | +|---|---| +| Assertions and confidential revalidation handles | Confidentiality, integrity, bounded retention, and no public projection | +| Fresh Nostr proofs and proven actor keys | Request binding, freshness, and separation between keys on one connection | +| Trusted-proxy HMAC keys, timestamps, and nonces | Secret delivery, canonical request binding, replay retention, and rotation | +| Server-selected domain and request context | Integrity against host, forwarded-field, path, query, and body substitution | +| Verifier policy and JWKS generations | Stable semantic identity, authentic refresh, hard expiry, and current-key revalidation | +| Binding and lifecycle state `B`, `T`, `X`, `Y`, `Q`, `H`, and `V` | Serialization, durability, monotonic lineage, and backup consistency | +| Prepared evidence, receipts, replay claims, and audit evidence | Integrity, idempotence, privacy, and atomic final admission | +| Leases, delegation, and invalidation state | Finite bounds and current owner, key, policy, and resource dependencies | +| Conformance reports and artifacts | Exact-revision provenance, digest integrity, completeness, and reproducibility | + +## Actors and trust boundaries + +- **Client:** controls all request fields and may hold a valid assertion, a Nostr key, both, or neither. +- **Trusted edge:** for `trusted-proxy-hmac-v1`, terminates the external request, strips inbound identity fields, and creates the request-bound HMAC envelope. +- **Buzz ingress:** selects the domain and route from trusted configuration, verifies transport evidence, and invokes one final-admission authority. +- **Issuer and JWKS endpoint:** assert identity and publish verification keys. They are trusted only within configured policy and freshness bounds. +- **State services:** store binding, lifecycle, replay, receipt, audit, policy, relationship, and application state. +- **Operator:** controls deployment policy and privileged lifecycle transitions. Operator authority is not ordinary user authority. +- **Evidence runner:** drives production-equivalent behavior and retains privacy-safe conformance artifacts for one immutable claim tuple. + +The external client-to-edge, edge-to-origin, origin-to-issuer, process-to-storage, operator-to-lifecycle, and test-runner-to-deployment boundaries are independent. Trust at one boundary does not replace authentication at another. + +## Attacker capabilities + +The model assumes an attacker can: + +- send arbitrary HTTP and WebSocket traffic, headers, authority values, paths, query strings, bodies, events, and Nostr keys; +- reach any listener exposed by deployment, replay captured traffic, race enrollment and lifecycle operations, and hold connections across rotations; +- steal a bearer assertion without stealing its user's Nostr key, or steal a Nostr key without obtaining a valid assertion; +- choose equal subjects across issuers and collide inputs across domains; +- trigger issuer, JWKS, database, replay, policy, receipt, audit, and network outages; and +- observe public responses and externally exported logs, metrics, and traces. + +The model does not assume that source IP, header presence, a private subnet, or a function name proves trusted provenance or authorization. + +## Threats and required evidence + +| Threat | Security effect | Required control | Behavioral evidence | +|---|---|---|---| +| Forged or unsigned forwarded identity | Assertion accepted without a trusted edge | Accept only the complete request-bound HMAC profile; reject direct, missing, mixed, and client-injected provenance | `FI-TRACE-PROXY-SPOOF` | +| Proxy nonce replay | Duplicate authorization or application effect | Retain nonce state through final admission and commit replay claim with receipt and authority state | `FI-TRACE-PROXY-REPLAY`, `FI-TRACE-FINAL-DENIAL-NO-MUTATION` | +| Cross-request HMAC reuse | Identity transplanted to another operation | MAC the timestamp, nonce, assertion digest, method, authority, path/query, and body digest using exact canonical bytes | `FI-TRACE-PROXY-CROSS-REQUEST` | +| Domain or route confusion | Authority crosses tenants or bypasses policy | Resolve context from trusted listener and route state; reject uncovered or different-lineage authorities | `FI-TRACE-DOMAIN-SPOOF`, `FI-TRACE-AUTHORITY-UNIFORM`, `FI-TRACE-CROSS-DOMAIN-COLLISION` | +| Transport-specific verifier drift | A weaker ingress accepts a rejected assertion | Use one canonical verifier and run one corpus through every transport adapter | `FI-TRACE-VERIFIER-PARITY` | +| JWKS rotation race or stale key use | Removed keys continue to authorize, or retained keys fail unpredictably | Separate policy identity from JWKS generation and revalidate prepared evidence and leases on generation change | `FI-TRACE-JWKS-ADD`, `FI-TRACE-JWKS-REMOVE` | +| Assertion/key substitution | Issuer identity attaches to an unproven key | Require fresh Nostr proof and equality with any asserted key claim | `FI-TRACE-ASSERTION-KEY-MISMATCH` | +| Binding takeover or concurrent first use | One identity or key silently replaces another | Serialize both sides of the partial bijection and commit at most one conflicting enrollment | `FI-TRACE-BINDING-CONFLICT`, `FI-TRACE-CONCURRENT-ENROLLMENT` | +| Tombstone bypass | Retired, disabled, revoked, or pending lineage reappears | Check every active lifecycle selector before enrollment and preserve lineage durably | `FI-TRACE-TOMBSTONE-REPLAY` | +| Assertion expiry confused with binding expiry | A durable binding disappears or is recreated with different provenance | Keep binding lifetime independent; treat administrative expiry as a separate privileged field | `FI-TRACE-ASSERTION-REFRESH`, `FI-TRACE-ADMIN-EXPIRY` | +| Stale prepared decision or time-of-check/time-of-use race | Changed request or authority state commits under old evidence | Keep preparation read-only; seal witnesses; reread applicable dependencies at final admission | `FI-TRACE-PREPARED-STALE`, `FI-TRACE-FINAL-DENIAL-NO-MUTATION` | +| Stolen assertion first use | Attacker enrolls its key for a new identity | Prefer attested or provisioned enrollment; expose TOFU only as explicit risk-labelled policy | `FI-TRACE-TOFU-THEFT` | +| Delegation survives owner change or expiry | Former or expired owner authority persists | Bind delegation to exact current owner version and a positive finite deadline | `FI-TRACE-DELEGATE-OWNER-ROTATED`, `FI-TRACE-DELEGATION-EXPIRED` | +| Cross-key session confusion | One key's lease authorizes another key | Key leases by actor, domain, capability, resource, and dependency set | `FI-TRACE-MULTI-KEY-SESSION` | +| Public decision oracle | Identity or private policy can be enumerated | Map private reasons to stable many-to-one public denial classes | `FI-TRACE-DENIAL-ORACLE` | +| Identity leakage | Assertions or claims enter public events or observability | Minimize private state and scan protocol output plus every configured sink with canaries | `FI-TRACE-PRIVACY-NONPUBLIC` | +| Dependency outage or contradictory state | Availability failure becomes an authorization bypass | Bound work, alert, and fail closed at preparation, final admission, and lease reuse | `FI-TRACE-DEPENDENCY-FAIL-CLOSED` | +| Evidence substitution | A report from another revision is used to activate enforcement | Bind reports and artifact digests to one immutable claim tuple and fail on missing or duplicate traces | Complete exact-head matrix validation | + +## Enrollment risk + +Attested enrollment resists bearer-assertion theft only when the issuer's key claim is itself trustworthy and equals the independently proven key. Provisioned enrollment moves creation to an explicit privileged transition but still requires target-key proof. TOFU accepts the first proven key for a never-enrolled identity and therefore cannot prevent first-use theft by an attacker holding a valid bearer assertion. + +Changing enrollment mode does not repair or reclassify existing bindings. Operators must use an authorized lifecycle transition and preserve the original provenance history. + +## Availability and resource exhaustion + +Fail-closed behavior deliberately trades availability for authorization safety. Attackers may amplify issuer refresh, signature verification, replay lookup, audit writes, or policy reads. Implementations bound assertion and header sizes, canonicalization work, clock skew, JWKS refresh, replay retention, concurrency, queues, and observability work. A full or unavailable audit or replay store denies instead of silently dropping evidence. + +Rate limits cannot replace cryptographic verification, lifecycle selectors, or final-admission serialization. + +## Residual risk + +The protocol cannot eliminate: + +- compromise or malicious behavior by an accepted issuer within its configured claims; +- compromise of the trusted edge, an active HMAC key, the Buzz process, storage credentials, or privileged operator authority; +- first-use theft in risk-labelled TOFU mode; +- denial of service caused by required dependencies failing closed; +- correlation visible to systems that legitimately process private identity state; or +- an implementation defect that the exact behavioral matrix does not exercise. + +Use short-lived and narrowly scoped secrets, separation of duties, access-controlled audit, independent artifact retention, and periodic reruns to reduce these risks. + +## Out of scope + +NIP-FI does not define provider setup, account recovery at an issuer, public identity projection, a public profile event, human-resources policy, or a mechanism for trusting unsigned corporate headers. Those concerns cannot add an alternate authorization path. + +## Review triggers + +Review this threat model and rerun affected traces when a change adds an ingress, transport, issuer rule, accepted algorithm, claim, enrollment mode, lifecycle transition, delegation capability, state dependency, cache, lease, proxy hop, observability sink, restore process, or rollback path. diff --git a/docs/examples/nip-fi-conformance-report.json.example b/docs/examples/nip-fi-conformance-report.json.example new file mode 100644 index 0000000000..956c9ae1e8 --- /dev/null +++ b/docs/examples/nip-fi-conformance-report.json.example @@ -0,0 +1,162 @@ +{ + "schema": "nip-fi-conformance-report-v1", + "document_role": "shape-example-not-conformance-claim", + "claim": { + "implementation_revision": "REPLACE_WITH_40_HEX_COMMIT", + "adapter_revision": "REPLACE_WITH_40_HEX_COMMIT", + "build_artifact_sha256": "REPLACE_WITH_64_HEX_DIGEST", + "deployment_revision": "REPLACE_WITH_IMMUTABLE_DEPLOYMENT_ID", + "domain_policy_sha256": "REPLACE_WITH_64_HEX_DIGEST", + "transport_profiles": [ + "trusted-proxy-hmac-v1" + ], + "enrollment_mode": "attested-key", + "delegation": false + }, + "results": [ + { + "trace_id": "FI-TRACE-PROXY-SPOOF", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-PROXY-REPLAY", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-PROXY-CROSS-REQUEST", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-AUTHORITY-UNIFORM", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-VERIFIER-PARITY", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-DOMAIN-SPOOF", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-ASSERTION-KEY-MISMATCH", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-BINDING-CONFLICT", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-TOMBSTONE-REPLAY", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-ASSERTION-REFRESH", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-ADMIN-EXPIRY", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-JWKS-ADD", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-JWKS-REMOVE", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-PREPARED-STALE", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-FINAL-DENIAL-NO-MUTATION", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-CONCURRENT-ENROLLMENT", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-TOFU-THEFT", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-DELEGATE-OWNER-ROTATED", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-DELEGATION-EXPIRED", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-DENIAL-ORACLE", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-DEPENDENCY-FAIL-CLOSED", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-MULTI-KEY-SESSION", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-CROSS-DOMAIN-COLLISION", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + }, + { + "trace_id": "FI-TRACE-PRIVACY-NONPUBLIC", + "status": "not-run", + "adapter_test_ids": [], + "evidence": [] + } + ] +} diff --git a/docs/examples/nip-fi-recovery-plan.json.example b/docs/examples/nip-fi-recovery-plan.json.example new file mode 100644 index 0000000000..38e5bf9a14 --- /dev/null +++ b/docs/examples/nip-fi-recovery-plan.json.example @@ -0,0 +1,44 @@ +{ + "schema": "nip-fi-recovery-plan-v1", + "document_role": "operator-review-template-not-runtime-request", + "domain": "community.example.test", + "operation": "recover", + "authorization": { + "operator_authority_ref": "evidence://operator-approval/REPLACE_ME", + "request_digest": "REPLACE_WITH_64_HEX_DIGEST", + "expires_at": "2030-01-01T00:00:00Z" + }, + "identity_ref": "private://identity/REPLACE_ME", + "expected_lineage": { + "old_binding_version": "REPLACE_WITH_VERSION", + "retired_pair_ref": "private://retired-pair/REPLACE_ME", + "pending_replacement_ref": "private://pending-replacement/REPLACE_ME" + }, + "target": { + "nostr_key": "REPLACE_WITH_64_HEX_PUBLIC_KEY", + "fresh_proof_ref": "evidence://nostr-proof/REPLACE_ME", + "issuer_attestation_required": true, + "issuer_attestation_ref": "evidence://assertion/REPLACE_ME" + }, + "preconditions": [ + "identity-is-not-disabled", + "identity-has-no-active-binding", + "pending-lineage-matches", + "retired-pair-matches", + "target-key-is-unbound", + "target-key-is-not-revoked", + "target-pair-is-not-retired" + ], + "expected_postconditions": [ + "old-pair-remains-retired", + "pending-lineage-is-consumed-once", + "new-binding-has-new-version", + "replacement-provenance-matches-evidence", + "dependent-leases-are-invalidated" + ], + "rollback": { + "database_rewind_allowed": false, + "ordinary_authorization_repair_allowed": false, + "compensating_privileged_transition_required": true + } +} diff --git a/docs/examples/nip-fi-stock-domain.json.example b/docs/examples/nip-fi-stock-domain.json.example new file mode 100644 index 0000000000..945ac6e1f8 --- /dev/null +++ b/docs/examples/nip-fi-stock-domain.json.example @@ -0,0 +1,115 @@ +{ + "schema": "nip-fi-stock-domain-review-v1", + "document_role": "review-template-not-runtime-schema", + "domain": { + "id": "community.example.test", + "authority": "community.example.test:443", + "protected_ingress_classes": [ + "websocket", + "http-event-query-count", + "media-read-write", + "git-smart-http", + "audio", + "invite-moderation", + "operator-lifecycle" + ] + }, + "policy_control": { + "current_policy_identity": "REPLACE_WITH_STABLE_POLICY_DIGEST", + "current_policy_lineage": "REPLACE_WITH_MONOTONIC_LINEAGE_ID" + }, + "transport": { + "profile": "trusted-proxy-hmac-v1", + "assertion_field": "Nostr-Federated-Identity", + "assertion_scheme": "Bearer", + "assertion_field_cardinality": "exactly-one", + "federated_assertion_in_authorization_field": false, + "provenance_field": "Nostr-Federated-Identity-Provenance", + "hmac_secret_ref": "secret://nip-fi/community.example.test/proxy-hmac", + "hmac_algorithm": "HMAC-SHA-256", + "minimum_hmac_secret_bits": 256, + "maximum_provenance_age_seconds": 30, + "future_skew_seconds": 5, + "maximum_provenance_field_bytes": 512, + "minimum_nonce_bytes": 16, + "maximum_nonce_bytes": 64, + "nonce_retention_minimum_seconds": 35, + "nonce_replay_scope": "domain-and-profile-independent-of-secret-version", + "direct_origin_allowed": false, + "fallback_profile": null + }, + "request_context": { + "method_source": "matched-server-route-uppercase-ascii", + "authority_source": "server-configured-host-and-explicit-port", + "path_and_query_source": "exact-post-routing-origin-form", + "body_digest": "SHA-256-over-exact-request-body", + "maximum_body_bytes": 10485760, + "unknown_operation": "deny", + "unknown_resource": "deny" + }, + "assertion_policy": { + "issuer": "https://issuer.example.test", + "audiences": [ + "community.example.test" + ], + "allowed_asymmetric_algorithms": [ + "RS256" + ], + "authenticated_key_source": "https://issuer.example.test/keys.json", + "subject_claim": "sub", + "nostr_key_claim": "nostr_pubkey", + "maximum_assertion_age_seconds": 300, + "clock_skew_seconds": 30, + "maximum_assertion_bytes": 16384, + "maximum_protected_header_bytes": 4096, + "maximum_claims_bytes": 8192, + "maximum_subject_bytes": 512, + "maximum_key_identifier_bytes": 256, + "maximum_configured_keys": 32, + "jwks_soft_refresh_seconds": 300, + "jwks_stale_known_key_seconds": 0, + "jwks_hard_maximum_age_seconds": 3600, + "unknown_key_refresh_is_per_request": false + }, + "nostr_proof": { + "nip42_maximum_age_seconds": 60, + "nip98_maximum_age_seconds": 60, + "replay_retention_covers_acceptance_window": true + }, + "enrollment": { + "mode": "attested-key" + }, + "lifecycle_policy": { + "ordinary_enrollment_sets_binding_not_after": false, + "administrative_expiry_requires_privileged_transition": true + }, + "leases": { + "direct_maximum_seconds": 300, + "delegation_enabled": false, + "delegated_maximum_seconds": null + }, + "safety": { + "unknown_protected_route": "deny", + "dependency_unavailable": "deny", + "competing_authority": "startup-deny", + "public_identity_projection": false, + "public_denial_mapping": { + "missing_evidence": { + "http_status": 401, + "nostr_prefix": "auth-required:" + }, + "evidence_rejected": { + "http_status": 403, + "nostr_prefix": "restricted:" + }, + "authorization_denied": { + "http_status": 403, + "nostr_prefix": "restricted:" + }, + "authorization_unavailable": { + "http_status": 503, + "nostr_prefix": "restricted:" + } + } + } +} diff --git a/docs/nips/NIP-FI-CONFORMANCE.md b/docs/nips/NIP-FI-CONFORMANCE.md index 283987dcea..0876545d6b 100644 --- a/docs/nips/NIP-FI-CONFORMANCE.md +++ b/docs/nips/NIP-FI-CONFORMANCE.md @@ -1,96 +1,122 @@ -# NIP-FI conformance matrix - -This companion matrix makes the normative [NIP-FI specification](NIP-FI.md) and its [formal model](NIP-FI-MODEL.md) reviewable as executable traces. The specification remains normative; the model and this matrix must agree with it. - -Use synthetic issuers, subjects, keys, assertions, and domains. A denial with “no mutation” means no binding, lifecycle, enrollment, publication, or last-seen mutation; a redacted security audit record is allowed. Internal decision names below need not be exposed on the wire: public failures use only the redacted `auth-required:` or `restricted:` classes defined by the specification. - -Review LC-13 together with the model's `RevokeKey` transition: its active, inactive, already-revoked, lineage-drift, post-commit-reread, and `Q+Y` branches intentionally form one regression surface. - -Stable subcase labels for allocation and mechanical regression checks are `LC-13.active`, `LC-13.inactive`, `LC-13.already-revoked`, `LC-13.lineage-drift`, `LC-13.post-commit-reread`, and `LC-13.q-plus-y`. They are parts of the single public LC-13 trace rather than additional trace IDs. - -OP-3 has two separately evaluated subcases under one public trace ID: `OP-3.absent` is always required and proves that an unimplemented/default-off projection has no surface or advertisement; `OP-3.implemented` applies only when a separate privacy-approved projection exists and proves its publication and non-authority constraints. A conformance report records `OP-3.implemented` as `not applicable` — never blank or failed — when no such projection exists; when it exists, the subcase must pass. - -Lifecycle lineage checks also expose stable subcases without adding public trace IDs: `LC-17.valid` and `LC-17.lineage-drift` cover re-enablement, while `LC-20.valid` and `LC-20.lineage-drift` cover recovery. Each drift subcase includes missing, mismatched, and unreadable retired-pair state. - -## State shorthand - -- `B_i`, `B_k`: active binding lookups for the presented identity and proven key. -- `P`: retired exact-pair selector for `(i, k)`. -- `X`: disabled-identity selector for `i`. -- `Y`: revoked-key selector for `k`. -- `Q`: pending explicit replacement for `i`. -- `Q(i) = k` and `k in Y` may coexist after key revocation: `Q` preserves retired-pair lineage while `Y` independently denies the key throughout the authorization domain. -- “Lifecycle reads” means `B_i`, `B_k`, `P`, `X`, `Y`, and `Q` are read in the same linearizable transition. - -## Required traces - -| ID | Pre-state / configuration | Presented evidence | Atomic reads | Decision | Mutation | Lease / session effect | Public result | Specification / model anchors | -|---|---|---|---|---|---|---|---|---| -| TR-1 | Trusted-proxy profile; direct verifier ingress is reachable or inbound assertion copies are not stripped | Client-controlled proxy header and otherwise valid proof | None | Reject non-conforming ingress | None | None | `restricted:` or connection rejection without claim detail | [Transport](NIP-FI.md#assertion-transport), [Trust](NIP-FI-MODEL.md#trust-assumptions) | -| TR-2 | Browser WebSocket client without a trusted proxy | Assertion in URL/query string | None | Reject forbidden transport | None | None | No authenticated session; token never logged | [Transport](NIP-FI.md#assertion-transport), [Privacy](NIP-FI.md#privacy) | -| TR-3 | Client-attached HTTP profile | Valid assertion and valid NIP-98 proof on different requests | None | Reject evidence that is not co-located | None | None | HTTP `401` with redacted body | [Transport](NIP-FI.md#assertion-transport), [Rejection](NIP-FI.md#rejection-semantics) | -| TR-4 | Client-attached profile | Malformed, non-Bearer, or multiple bearer assertion values | None | Reject presented-invalid evidence | None | None | `restricted:` / HTTP `403` with redacted body | [Transport](NIP-FI.md#assertion-transport), [Rejection](NIP-FI.md#rejection-semantics) | -| AS-1 | Accepted issuer configured | Missing assertion, or missing Nostr proof | None | Deny missing independent evidence | None | None | `auth-required:` / HTTP `401` | [Transport](NIP-FI.md#assertion-transport), [Proof](NIP-FI.md#nostr-proof), [Rejection](NIP-FI.md#rejection-semantics), [Proof model](NIP-FI-MODEL.md#nostr-proof-validity) | -| AS-2 | Accepted issuer configured | Bad signature; symmetric/`none` algorithm; wrong issuer or audience | None | Reject assertion | None | None | `restricted:` / HTTP `403` without validation detail | [Validation](NIP-FI.md#assertion-validation), [Validation model](NIP-FI-MODEL.md#assertion-validity) | -| AS-3 | Accepted issuer configured | Missing/expired `exp`, future `nbf` or `iat` beyond skew | None | Reject assertion | None | None | `restricted:` / HTTP `403` | [Validation](NIP-FI.md#assertion-validation), [Validation model](NIP-FI-MODEL.md#assertion-validity) | -| AS-4 | Accepted issuer configured; exercise the optional `npub` normalization when advertised | Missing, empty, ambiguous, or wrong-type `sub`; malformed or ambiguous key claim; valid all-lowercase and all-uppercase bare NIP-19 Bech32 `npub` encodings of the same key; invalid Bech32m/mixed-case/checksum/prefix/TLV/length variants | None | Accept only valid configured encodings that resolve to one 32-byte key and canonicalize both permitted `npub` case forms to the same lowercase hex; reject every malformed, Bech32m, or ambiguous variant | None | None | Normal processing for the valid canonical key; otherwise `restricted:` / HTTP `403` | [Validation](NIP-FI.md#assertion-validation), [Validation model](NIP-FI-MODEL.md#assertion-validity) | -| AS-5 | Key set unavailable, stale beyond policy, or unknown `kid` after a fresh bounded fetch | Otherwise plausible JWT | None | Fail closed; do not drive unbounded refreshes | None | None | `restricted:` / HTTP `403` | [Validation](NIP-FI.md#assertion-validation), [Validation model](NIP-FI-MODEL.md#assertion-validity) | -| AS-6 | Issuers `A` and `B` use the same `sub` | Valid assertion from each issuer | Issuer-qualified lifecycle reads | Treat `(A, sub)` and `(B, sub)` as different identities | Only the selected identity may enroll | Independent leases | Normal result for each qualified identity | [Definitions](NIP-FI.md#definitions), [Domain terms](NIP-FI-MODEL.md#terms-and-domains) | -| PR-1 | Any binding state | Invalid, stale, replayed, wrong-target, or wrong-payload NIP-42/NIP-98 proof | None | Reject proof before authorization | None | None | `restricted:` / HTTP `403` | [Proof](NIP-FI.md#nostr-proof), [Proof model](NIP-FI-MODEL.md#nostr-proof-validity) | -| AU-1 | Any binding state | Valid assertion with `k_a != k` | None | Deny `key_mismatch` before state access | None | None | `restricted:` without either key | [Authorization](NIP-FI.md#authorization), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | -| AU-2 | `B_i = B_k = (i, k)` and no lifecycle gate | Matching valid assertion and proof | Lifecycle reads and known expiry bounds | Allow existing binding | None | Create direct lease bounded by the earliest assertion, binding, policy, and implementation limit | Success | [Authorization](NIP-FI.md#authorization), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | -| AU-3 | `B_i = (i, k_old)` | Valid assertion for `i`; proof by `k_new` | Lifecycle reads | Deny `binding_conflict` | None | None | `restricted:` without conflict detail | [Authorization](NIP-FI.md#authorization), [Binding invariant](NIP-FI.md#binding-invariant) | -| AU-4 | `B_k = (i_other, k)` | Valid assertion for `i`; proof by `k` | Lifecycle reads | Deny `binding_conflict` | None | None | `restricted:` without conflict detail | [Authorization](NIP-FI.md#authorization), [Binding invariant](NIP-FI-MODEL.md#binding-invariant) | -| AU-5 | `B_i = B_k = (i, k)` and no lifecycle gate | Matching assertion and proof; display-name, email, or similar metadata changed | Lifecycle reads | Same allow decision as unchanged metadata | No binding or authorization mutation from metadata | Equivalent bounded lease | Success without exposing profile values | [Validation](NIP-FI.md#assertion-validation), [Terms](NIP-FI-MODEL.md#terms-and-domains) | -| EN-1 | No binding or lifecycle gate; `attested-key` mode | Valid assertion with `k_a = k`; valid proof | Lifecycle reads | Allow created binding | Create `(i, k)` with `attested-key` provenance | Create bounded lease | Success | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | -| EN-2 | No binding or lifecycle gate; `attested-key` mode | Valid assertion without key claim; valid proof | Lifecycle reads | Deny `key_attestation_required` | None | None | `restricted:` | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | -| EN-3 | No active binding or lifecycle gate; `provisioned` mode | Valid assertion and proof | Lifecycle reads | Deny `binding_required` | None | None | `restricted:` | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | -| EN-4 | No binding or lifecycle gate; `tofu` mode | Valid assertion without key claim; valid proof | Lifecycle reads | Allow created binding with accepted TOFU risk | Create `(i, k)` with `tofu` provenance | Create bounded lease | Success | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | -| EN-5 | No binding or lifecycle gate; `tofu` mode | Valid assertion with `k_a = k`; valid proof | Lifecycle reads | Allow created binding | Create `(i, k)` with `attested-key` provenance | Create bounded lease | Success | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | -| EN-6 | Existing `(i, k)` has `attested-key` provenance | Later valid assertion omits the key claim; valid proof by `k` | Lifecycle reads and stored provenance | Allow existing binding without downgrade | Preserve `attested-key` provenance exactly | Create bounded direct lease | Success | [Enrollment](NIP-FI.md#enrollment-modes), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | -| EN-7 | `provisioned` mode; no active binding for `i` or `k`; `i` not disabled or pending; `k` not revoked; exact `(i, k)` not retired | Separately authorized provisioning | Enrollment mode, active, and lifecycle state | Allow privileged provisioning | Atomically create `(i, k)` with `provisioned` provenance and append provision history | No direct lease; later direct authority still requires valid assertion and fresh proof by `k` | Operator transition; not an `Authorize` response | [Enrollment](NIP-FI.md#enrollment-modes), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| EN-8 | Non-`provisioned` mode; active binding for `i` or `k`; `i` disabled or pending; `k` revoked; exact `(i, k)` retired; a required mode, active-binding, or lifecycle-selector read is unreadable; or a concurrent competing provision wins | Separately authorized provisioning | Serialized attempts to read mode, active, lifecycle, and history state | Deny ineligible, unreadable, or losing provision | None; never overwrite or clear active/lifecycle state or append provision history | No lease | Privileged transition failure without selector detail | [Enrollment](NIP-FI.md#enrollment-modes), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| CC-1 | No binding or lifecycle gate | Concurrent identical first use of `(i, k)` | Serialized lifecycle reads | One create; all others observe exact match and allow existing | Exactly one insertion | Equivalent bounded leases | Success | [Authorization](NIP-FI.md#authorization), [Transition](NIP-FI-MODEL.md#authorization-and-enrollment-transition) | -| CC-2 | No binding or lifecycle gate | Concurrent `(i, k1)` / `(i, k2)` or `(i1, k)` / `(i2, k)` | Serialized lifecycle reads | At most one create; conflicting attempt denies | At most one insertion | Lease only for committed pair | Conflicting request receives `restricted:` | [Authorization](NIP-FI.md#authorization), [Binding invariant](NIP-FI-MODEL.md#binding-invariant) | -| CC-3 | Any prospective enrollment | Lifecycle lookup, insertion, or committed-result reread fails | Required state is unavailable | Fail closed | None beyond a transaction proven committed | No new lease from an unreadable result | `restricted:` | [Authorization](NIP-FI.md#authorization), [Safety](NIP-FI-MODEL.md#safety-properties) | -| LC-1 | `(i, k) in P`; no active binding | Still-valid matching assertion and proof, with or without matching key claim | Lifecycle reads | Deny `pair_retired` | None | No lease | `restricted:` without tombstone detail | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-2 | `i in X`, including before first enrollment | Valid assertion for `i`; proof by any key | Lifecycle reads | Deny `identity_disabled` | None | Invalidate/deny direct and dependent delegated leases | `restricted:` | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-3 | `k in Y`, including without an active binding | Valid assertion for any identity; proof by `k` | Lifecycle reads | Deny `key_revoked` | None | Invalidate/deny every lease depending on `k` | `restricted:` | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-4 | `Q(i) = k_old`; no active binding | Valid assertion for `i`; proof by fresh `k_new` | Lifecycle reads | Deny `explicit_replacement_required` | None | No lease | `restricted:` | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-5 | Active `(i, k_old)`; `Q(i)` is absent; eligible unbound `k_new` | Separately authorized rotation; fresh target-bound proof by `k_new`; current assertion for `i` with `k_a = k_new` when attestation is required | Old/new active, `Q`, lifecycle, proof, and optional attestation state | Allow privileged rotation | Atomically remove `(i, k_old)` from `B`, add that exact pair to `P`, create `(i, k_new)` with deterministic provenance, preserve `Q(i)` as absent, and append distinct rotation history; do not add `k_old` to domain-scoped key revocation | After commit, invalidate old direct and dependent delegated leases; new pair may later obtain a bounded lease | Operator transition; not an `Authorize` response | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-6 | `i in X`, `Q(i) = k_old`, and `(i, k_old) in P` | Separately authorized recovery with otherwise eligible `k_new` | Identity, pending-replacement, retired-pair, and new-key state | Deny `identity_disabled` | None, including no selector clearing or history append | No new lease | Privileged transition failure without private detail | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-7 | Eligible active old pair or pending replacement; `k_new` is active, revoked, or `(i, k_new)` is retired | Separately authorized rotation or recovery with otherwise valid proof | Old/new active and lifecycle state | Deny ineligible replacement-key state | None; do not retire the old pair or disturb the conflicting binding | No new lease | Privileged transition failure without selector detail | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-8 | One eligible active old pair; two distinct eligible replacement keys | Concurrent separately authorized rotations with valid replacement proofs | Serialized old/new active and lifecycle state | At most one complete rotation succeeds | Exactly one rotation and history record; loser makes no mutation | Old leases invalidated once; lease possible only for committed replacement | One operator transition succeeds at most | [Revocation](NIP-FI.md#revocation-and-rotation), [Safety](NIP-FI-MODEL.md#safety-properties) | -| LC-9 | Any eligible provision, retire, disable, revoke, rotate, recover, or re-enablement transition | Injected failure while writing active state, selectors, or lifecycle history | Transactional active, lifecycle, and history state | Fail closed | Entire transition rolls back; prior state and history remain unchanged | No new lease or partial invalidation claim | Privileged transition failure | [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation), [Safety](NIP-FI-MODEL.md#safety-properties) | -| LC-10 | Authorization and provision/retire/disable/revoke/rotate/recover/re-enable race | Valid evidence plus concurrent lifecycle transition | Serialized active and lifecycle state | Serialization decides; completed lifecycle state cannot be violated | No post-transition resurrection or partial replacement | Matching leases invalidated when transition is observed | Result matches the serialized state; no stale allow or private detail | [Authorization](NIP-FI.md#authorization), [Safety](NIP-FI-MODEL.md#safety-properties) | -| LC-11 | Active `(i, k)` with absent `Q(i)` | Separately authorized pair retirement; inject present or unreadable `Q(i)` | Active pair, `Q`, other selectors, and lifecycle history | Commit valid retirement; deny lineage drift without mutation | Atomically recheck absent `Q(i)`, remove `(i, k)`, add it to `P`, set `Q(i) = k`, and append one history record. Present or unreadable `Q(i)` rolls back with no selector or history mutation | Invalidate matching direct and dependent delegated leases only after a successful commit | Operator transition or redacted failure; not an `Authorize` response | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-12 | Identity `i` may be active with absent `Q(i)`, pending replacement without an active binding, or never enrolled | Separately authorized identity disablement; inject present or unreadable `Q(i)` for the active branch | Active pair, `X`, `P`, `Q`, and lifecycle history | Commit valid disablement; deny active-branch lineage drift without mutation | Atomically recheck absent `Q(i)` when active, add `i` to `X`, remove and retire any active pair, set `Q(i)` for that pair, and append one history record. Present or unreadable `Q(i)` in the active branch rolls back with no mutation; without an active pair, preserve any existing `Q(i)` | Invalidate direct and dependent delegated leases for `i` only after a successful commit | Operator transition or redacted failure; not an `Authorize` response | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-13 | `LC-13.active`: `k` has a unique active pair and absent `Q(i)`. `LC-13.inactive`: `k` is inactive or never bound. `LC-13.already-revoked`: inactive `k` is already in `Y`. `LC-13.lineage-drift`: the active pair has present or unreadable `Q(i)`. `LC-13.post-commit-reread` and `LC-13.q-plus-y` evaluate the committed branch states | Separately authorized key revocation, including a repeated invocation; execute every labeled subcase | Active pair, `Y`, `P`, `Q`, and lifecycle history before commit; durable `Y` and any affected `P`/`Q` state after commit | Commit valid revocation; deny active-branch lineage drift without mutation | Atomically add or preserve `k` in `Y` and append exactly one history record per successfully committed separately authorized invocation. If the unique active pair exists, first recheck absent `Q(i)`, then retire the pair and set `Q(i) = k`; present or unreadable `Q(i)` rolls back the invocation with no history append. If no active pair exists, make no `P` or `Q` mutation and preserve every pre-existing `Q`. Reread the committed state: the active-pair result with simultaneous `Q(i) = k` and `k in Y` is expected and neither selector is cleared or normalized away; the inactive or already-revoked result retains `k in Y` without `P` or `Q` mutation | Invalidate every direct or delegated lease depending on `k` only after a successful commit | Operator transition or redacted failure; not an `Authorize` response | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-14 | Successful LC-5 rotation or LC-20 recovery has committed `(i, k_new)` and retired the exact old pair | Fresh valid assertion and proof for `k_new` | Lifecycle reads | Allow existing replacement binding | None | Create a bounded direct lease for `k_new`; the old pair remains denied by LC-1 and the old key is denied across the authorization domain only after LC-13 | Success only for `k_new` | [Revocation](NIP-FI.md#revocation-and-rotation), [Liveness](NIP-FI-MODEL.md#liveness-properties) | -| LC-15 | `(i, k)` is pair-retired and pending replacement; `k` is not in `Y` and has no active binding | Eligible first enrollment for distinct identity `j` with proof by `k` | Lifecycle reads for `(j, k)` | Apply normal enrollment policy; pair retirement alone does not revoke `k` across the authorization domain | Create `(j, k)` only when the configured enrollment mode permits | Bounded direct lease only for `(j, k)` | Normal result for `j`; no authority for `i` | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-16 | `Q(i) = k_old` and `(j, k_old)` is active; eligible distinct `k_new`; `i` is enabled for recovery or disabled for re-enablement | Separately authorized matching transition for `i` with valid replacement proof | Old/replacement active and lifecycle state | Apply recovery only when `i` is enabled, or re-enablement only when `i` is disabled, without treating pair retirement as domain-scoped key revocation | Create only `(i, k_new)`, preserve retired `(i, k_old)`, and do not disturb `(j, k_old)` or add `k_old` to `Y` | Invalidate only dependencies of the retired pair; new binding may later obtain a lease | Operator result without disclosing `j` | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-17 | `LC-17.valid`: `i in X`, with or without `Q(i) = k_old`; when `Q` is present, `(i, k_old) in P`; eligible `k_new`. `LC-17.lineage-drift`: `Q(i) = k_old` but the exact retired pair is missing, mismatched, or unreadable | Separately authorized enablement with fresh target-bound proof by `k_new` and current matching assertion when required; execute each lineage subcase | Identity, old/new key, proof, readable pending/retired-pair selectors, and history state inside the atomic transition | Valid: allow privileged enablement. Drift or stale compare: deny fail closed before mutation | Valid: atomically create `(i, k_new)` with deterministic provenance, clear `X`, preserve absent `Q` or conditionally compare-and-clear present `Q(i) = k_old`, append enablement history, and preserve exact old-pair retirement without domain-scoped key revocation. Drift or stale compare: no binding, selector, or history mutation | Valid: after commit, invalidate prior-pair dependencies; new binding may later obtain a bounded direct lease. Drift or stale compare: no lease or invalidation | Operator transition or redacted failure; ordinary enrollment never sees an ungated re-enabled identity | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-18 | Eligible rotation, recovery, or re-enablement; replacement proof is valid and issuer attestation is optional | Supplied current assertion for `i` has `k_a != k_new` | Old/new active, lifecycle, proof, and assertion state | Deny mismatched supplied attestation | None; do not create a binding or record attested provenance | No new lease | Privileged transition failure without claim detail | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-19 | Two privileged state transitions affect the same identity or key, including two recoveries from one `Q(i) = k_old` to distinct eligible replacement keys or recovery racing disablement/re-enablement | Concurrent provision, retire, disable, revoke, recover, rotate, or re-enable requests with valid transition-specific evidence for their observed pre-state | Serialized active, selector, and history state with precondition and conditional-comparison recheck | At most one incompatible transition commits; a still-eligible serialized successor may commit only from the complete new state, and a recovery cannot commit after disablement wins | Each committed transition is atomic with one typed history append; a stale compare-and-clear loser makes no mutation | Invalidation follows only committed transitions; a loser triggers none | Deterministic operator results without private selector detail | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-20 | `LC-20.valid`: `Q(i) = k_old`, `(i, k_old) in P`, no active binding for `i`, and eligible unbound `k_new`. `LC-20.lineage-drift`: `Q(i) = k_old` but the exact retired pair is missing, mismatched, or unreadable | Separately authorized recovery with fresh target-bound proof by `k_new` and current matching assertion when required; execute each lineage subcase | Old/new active, readable pending/retired-pair selectors, proof, and optional attestation state inside the atomic transition | Valid: allow privileged recovery. Drift or stale compare: deny fail closed before mutation | Valid: atomically preserve retired `(i, k_old)`, create `(i, k_new)` with deterministic provenance, conditionally compare-and-clear `Q(i)` from `k_old` to absent, and append distinct recovery history without revoking `k_old` across the domain. Drift or stale compare: no binding, selector, or history mutation | Valid: after commit, invalidate old-pair dependencies; new pair may later obtain a bounded lease. Drift or stale compare: no lease or invalidation | Operator transition or redacted failure; not an `Authorize` response | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-21 | Otherwise eligible rotation, recovery, or re-enablement, including when issuer attestation is optional | Supplied key-attestation assertion is stale, resolves to an identity other than `i`, or has no key claim | Assertion policy, assertion, proof, active, and lifecycle state | Deny invalid supplied replacement attestation; invalid evidence cannot degrade to absent optional evidence | None | No new lease | Privileged transition failure without identity or claim detail | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-22 | Otherwise eligible rotation, recovery, or re-enablement | Missing, stale, replayed, wrong-target, or wrong-key Nostr proof for `k_new` | Proof, active, and lifecycle state | Deny replacement-key control failure | None | No new lease | Privileged transition failure without proof detail | [Proof](NIP-FI.md#nostr-proof), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-23 | Otherwise eligible provision, retire, disable, revoke, rotate, recover, or re-enablement | Missing or invalid lifecycle authority, or authority bound to another operation or domain | Lifecycle-authority scope plus active and lifecycle state | Deny unauthorized privileged transition | None | No lease or invalidation | Privileged transition failure without authority detail | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| LC-24 | Otherwise eligible rotation, recovery, or re-enablement in a domain that requires issuer attestation | Attestation evidence is absent, or the supplied current assertion for `i` has no key claim | Assertion policy, proof, active, and lifecycle state | Deny missing required replacement attestation | None | No new lease | Privileged transition failure without claim detail | [Revocation](NIP-FI.md#revocation-and-rotation), [Lifecycle model](NIP-FI-MODEL.md#revocation-and-rotation) | -| SE-1 | HTTP endpoint | Valid assertion and NIP-98 proof | Lifecycle reads | Apply decision to this request only | Enrollment only when allowed | No reusable HTTP lease implied | Per-request result | [Sessions](NIP-FI.md#session-semantics), [Session model](NIP-FI-MODEL.md#session-behavior) | -| SE-2 | Active direct WebSocket lease reaches its earliest assertion, binding, policy, or implementation bound | No new upgrade assertion | Current time and observed dependencies | Reject protected operations or close | None | Direct lease expires; renewal requires a new connection, fresh assertion, and fresh NIP-42 proof | `restricted:` / closure without private detail | [Sessions](NIP-FI.md#session-semantics), [Session model](NIP-FI-MODEL.md#session-behavior) | -| SE-3 | One WebSocket authenticates `k1` and `k2`; only `k1` is authorized | Operations attributed to each key | Per-key lease lookup | Allow only operations attributed to `k1` | None | `k1` lease never authorizes `k2` | Independent per-key result | [Sessions](NIP-FI.md#session-semantics), [Session model](NIP-FI-MODEL.md#session-behavior) | -| SE-4 | Direct or delegated lease depends on binding/policy/delegation state that becomes invalid | Previously valid session | Observed dependency state | Deny after the documented detection bound | None | Invalidate every dependent lease; do not claim immediate revocation when polling | Redacted denial/closure | [Sessions](NIP-FI.md#session-semantics), [Safety](NIP-FI-MODEL.md#safety-properties) | -| SE-5 | Active delegated WebSocket lease reaches its earliest owner-binding, delegation, policy, or mandatory finite implementation bound | No newly validated delegated decision | Current time and observed dependencies | Reject protected operations or close | None | Lease expires; a new delegated decision requires fresh delegate proof, an active owner binding, and revalidated delegation, but no delegate assertion | `restricted:` / closure without private detail | [Delegation](NIP-FI.md#delegation), [Delegation model](NIP-FI-MODEL.md#delegation) | -| DG-1 | Owner has an eligible active binding; delegation is valid and narrower; finite delegated-lease maximum configured | Delegate proves its own key; no delegate assertion | Active owner binding plus delegation conditions; cached owner lease alone is insufficient | Allow only intersected delegated operations | No binding or lifecycle-selector mutation for the owner or delegate | Delegated lease is bounded by the mandatory finite implementation maximum and shorter owner-binding, delegation, or policy limits; no independent assertion bound | Success for allowed delegated operation | [Delegation](NIP-FI.md#delegation), [Delegation model](NIP-FI-MODEL.md#delegation) | -| DG-2 | Owner binding is retired/revoked, or delegation expired/revoked | Delegate presents formerly valid proof | Owner and delegation dependencies | Deny | None | Invalidate dependent delegated lease on documented detection schedule | `restricted:` | [Delegation](NIP-FI.md#delegation), [Delegation model](NIP-FI-MODEL.md#delegation) | -| DG-3 | No finite delegated-lease maximum is configured | Otherwise valid owner binding, delegation, and delegate proof | Delegation configuration | Deny delegated authorization; delegation is unsupported | None | No delegated lease | `restricted:`; discovery must omit/disable delegation | [Delegation](NIP-FI.md#delegation), [Discovery](NIP-FI.md#discovery) | -| BD-1 | Two server-resolved authorization domains | Client attempts to choose another domain or reuse evidence across domains | State only for trusted resolved `D` | Ignore/reject client domain choice; no cross-domain authority | None outside selected domain | Domain-scoped lease only | Redacted denial when no selected-domain authority exists | [Definitions](NIP-FI.md#definitions), [Safety](NIP-FI-MODEL.md#safety-properties) | -| OP-1 | Conflict, validation failure, or lifecycle denial | Any denied request | As required by the decision | Deny without disclosing the conflicting identity, key, claims, or selector | None | None or matching lease invalidation | Only `auth-required:` / `restricted:` or HTTP `401`/`403` | [Rejection](NIP-FI.md#rejection-semantics), [Privacy](NIP-FI.md#privacy) | -| OP-2 | Relay supports a subset of transports, enrollment modes, or delegation | NIP-11 query | Supported configuration, including the finite delegated-lease maximum when delegation is enabled | Advertise only supported behavior | None | None | When delegation is enabled, publish a positive `delegated_lease_max_seconds`; expose no issuer-internal tenant, claim, or audience detail | [Discovery](NIP-FI.md#discovery), [Conformance hooks](NIP-FI-MODEL.md#conformance-hooks-for-the-nip) | -| OP-3 | `OP-3.absent`: projection unimplemented/default-off; `OP-3.implemented`: separate projection privacy-approved and implemented | For each applicable subcase, query discovery/publication and present any available projection as auth evidence; in the implemented subcase also publish an approved label | Projection availability plus normal NIP-FI evidence and lifecycle state only | `OP-3.absent`: pass only with no projection surface or advertisement. `OP-3.implemented`: report `not applicable` when no separate projection exists; otherwise pass only when approved non-private labels publish. Both applicable decisions: never accept projection as authorization evidence | Both keep projection separate from binding state; the absent subcase creates no projection state | No authorization lease from either subcase | Absent: no projection surface. Implemented: N/A or no `iss`, `sub`, bearer material, or private claim disclosure | [Privacy](NIP-FI.md#privacy), [Safety](NIP-FI-MODEL.md#safety-properties) | -| OP-4 | NIP-FI authorization succeeds or fails | Assertion, `iss`, `sub`, and mutable profile claims are available internally | Protocol output, publication, history, and operational log/metric capture | Authorization result is independent of publication; private material remains access-controlled | No assertion/private-claim insertion into events, tags, filters, or relay history; no raw bearer or unredacted identity/profile claim in operational logs or metrics | Normal lease effect only | No private material in protocol output | [Transport](NIP-FI.md#assertion-transport), [Privacy](NIP-FI.md#privacy) | - -## Conformance claim - -An implementation may claim NIP-FI conformance only for the transport profiles, enrollment mode, and delegation behavior it advertises and tests. Every applicable trace above must pass against the same reviewed implementation revision. Trusted-proxy traces such as TR-1 additionally require deployment evidence for both properties: origin isolation, proving that untrusted clients cannot reach verifier ingress, and inbound assertion-header stripping. That evidence names the enforced isolation control and records negative bypass and header-injection tests; code-only tests cannot establish full trusted-proxy conformance. Unsupported optional NIP-FI behavior must be omitted from discovery rather than represented as implemented. OP-3's absent/default-off branch applies to every implementation; its implemented branch is reported `not applicable` when no separate projection protocol exists and must pass when one does. That protocol is not part of NIP-FI conformance or discovery. +# NIP-FI conformance evidence + +This document turns the stable trace identifiers in the normative [NIP-FI specification](NIP-FI.md#stable-conformance-labels) and [formal model](NIP-FI-MODEL.md#stable-conformance-traces) into a behavioral evidence contract. The specification and model remain normative. + +A document, source scan, compiled symbol, configuration key, or passing prose review does not prove conformance. A claim is valid only when executable adapters exercise the behavior at the exact implementation revision and preserve the evidence required below. + +## Claim unit + +A conformance claim names one immutable tuple: + +```text +(implementation revision, + adapter revision, + build artifact digest, + deployment revision, + domain-policy digest, + advertised transport profiles, + enrollment mode, + delegation support) +``` + +Changing any element creates a new claim. Results from another tuple cannot be carried forward without rerunning the affected traces. A report includes all 24 `FI-TRACE-*` identifiers exactly once. + +The later implementation stack owns the executable adapters and exact commands. This documentation revision supplies no runtime adapter and closes no behavioral gate. + +## Evidence rules + +Each passing trace records: + +- the exact claim tuple; +- a stable behavioral test ID and adapter entry point; +- the command, start and end times, exit status, and random seed when used; +- synthetic input or a privacy-safe digest of it; +- relevant before-and-after state, wire results, and lease or lifecycle versions; +- the expected oracle and observed result; +- artifact locations and SHA-256 digests; and +- cleanup status for synthetic identities, keys, and domains. + +Stateful tests use an isolated database or namespace. They inspect committed state after the operation instead of inferring state from a response. Concurrency tests record every contender and the single serialized outcome. Time-boundary tests use a controlled clock. Privacy tests inspect protocol output and the configured log, metric, and trace sinks. + +When an adapter evaluates denial timing, it freezes the sampling method, production-equivalent environment, warm-up, sample count, statistic, noise treatment, and acceptance threshold before execution. The report retains those values and the raw privacy-safe measurements. An undefined or post-selected threshold cannot pass a trace. + +The following do not satisfy a trace: + +- searching source, documentation, schemas, or binaries for a token; +- asserting that a route calls a named function; +- recording only a unit-test name without its execution result; +- using a mock to prove deployed network isolation; +- citing a pull request check from another revision; or +- marking a required trace as passed because the feature is configured. + +## Trace matrix + +| Trace ID | Required behavioral oracle | Minimum evidence | Normative references | +|---|---|---|---| +| `FI-TRACE-PROXY-SPOOF` | Direct ingress, missing or repeated provenance, mixed profiles, client-supplied provenance, malformed canonical encoding, and every clock or size violation deny before authority. No fallback to `client-attached` occurs. | Deployed negatives from inside and outside the edge; missing/repeated/comma/extra components; non-canonical base64url; nonce lengths 15, 16, configured maximum, and maximum plus one; MAC lengths 31, 32, and 33; field maximum and maximum plus one; future-skew equality and excess; age-bound just-before and equality; overflow cases; listener topology; responses; and no-mutation state diffs. | `FI-INV-04`, `FI-INV-05`, `FI-INV-14` | +| `FI-TRACE-PROXY-REPLAY` | Final admissions using one live HMAC nonce commit at most once, including when the nonce is re-signed by another concurrently active secret. Preparation consumes nothing. | Parallel same-secret and cross-secret overlap transcripts, domain/profile replay namespace, replay-store before/after state, committed receipt count, matched secret version as private audit metadata only, and retention deadline. | `FI-INV-08`, `FI-INV-09`, `FI-INV-14` | +| `FI-TRACE-PROXY-CROSS-REQUEST` | Changing assertion, method, authority, path/query, or body denies under the original MAC. | One valid baseline plus one mutation for every bound field, exact canonical byte fixtures, and wire results. | `FI-INV-04`, `FI-INV-05` | +| `FI-TRACE-AUTHORITY-UNIFORM` | Every protected ingress uses one current domain policy and final-admission authority. Uncovered, competing, and different-lineage paths fail closed. | Executed route inventory covering WebSocket and every protected HTTP class, policy identity observed per route, negative unknown-route case, and startup result for incompatible policy lineage. | `FI-INV-15` | +| `FI-TRACE-VERIFIER-PARITY` | After valid profile-specific transport handling, the same assertion, policy, time, and JWKS snapshot produce the same normalized assertion-verifier decision on every transport. WebSocket and HTTP accept the assertion only as exactly one `Nostr-Federated-Identity: Bearer ` field. | Shared assertion corpus through every adapter with byte-for-byte normalized verifier decisions; valid single-field cases; and transport rejection of `Authorization` bearer assertions, missing/repeated/comma-combined fields, provenance on `client-attached`, and mixed profiles on both WebSocket and HTTP. | `FI-INV-06`, `FI-INV-16` | +| `FI-TRACE-DOMAIN-SPOOF` | Client domain, host, or forwarded authority cannot replace the server-selected domain. | Multi-domain requests over each ingress, trusted-route observation, state diff proving no cross-domain mutation, and redacted denial. | `FI-INV-04`, `FI-INV-14` | +| `FI-TRACE-ASSERTION-KEY-MISMATCH` | An asserted key different from the proven key denies before mutation. | Valid assertion and proof fixture with unequal keys, decision capture, and complete authority-state diff. | `FI-INV-05`, `FI-INV-08` | +| `FI-TRACE-BINDING-CONFLICT` | A conflict on either side of the active partial bijection denies without replacement or provenance change. | Identity-side and key-side conflict cases, before/after binding rows, lifecycle selectors, and receipts. | `FI-INV-01`, `FI-INV-02`, `FI-INV-09` | +| `FI-TRACE-TOMBSTONE-REPLAY` | Fresh evidence cannot cross a retired pair, disabled identity, revoked key, or pending replacement. | Four selector cases plus selector-conflict fixtures, fresh evidence, redacted responses, and unchanged state. | `FI-INV-03`, `FI-INV-10`, `FI-INV-14` | +| `FI-TRACE-ASSERTION-REFRESH` | A fresh assertion authorizes the same eligible durable binding after the enrollment assertion expires. | Controlled clock before and after original expiry, unchanged binding version and provenance, new bounded lease, and original assertion rejection. | `FI-INV-02`, `FI-INV-11` | +| `FI-TRACE-ADMIN-EXPIRY` | Before administrative expiry may allow; equality and after deny. Time alone creates no tombstone or free coordinate. | Controlled-clock before/equal/after cases, binding and selector state, lease deadline, and explicit privileged restoration case. | `FI-INV-02`, `FI-INV-10`, `FI-INV-11` | +| `FI-TRACE-JWKS-ADD` | A new generation that retains the signing key revalidates prepared evidence and may authorize the unchanged binding. | Two key snapshots, generation witnesses, retained signing-key identity, exact assertion revalidation, and final result. | `FI-INV-06`, `FI-INV-07` | +| `FI-TRACE-JWKS-REMOVE` | Removing the signing key denies prepared evidence and active direct leases. Hard snapshot expiry also denies when current state is unreadable. | Prepared and leased cases across removal, hard-deadline boundary, refresh outage, lease closure, and unchanged binding. | `FI-INV-07`, `FI-INV-11`, `FI-INV-14` | +| `FI-TRACE-PREPARED-STALE` | A changed request or applicable decision witness cannot authorize from stale preparation. Equivalent concurrent enrollment may recompute as existing. | Mutations for request, binding, lifecycle, mode, policy, resource, delegation, relationship, replay, and invalidation witnesses at final admission; final state and recomputation evidence. | `FI-INV-08`, `FI-INV-09`, `FI-INV-14` | +| `FI-TRACE-FINAL-DENIAL-NO-MUTATION` | Preparation, local-policy denial, and final-admission denial create no authority, audit observation, replay claim, receipt, lease, or application mutation. | Complete before/after snapshots for each denial layer, including application storage and replay/audit stores. | `FI-INV-08`, `FI-INV-09` | +| `FI-TRACE-CONCURRENT-ENROLLMENT` | Identical first uses converge on one binding version. Conflicting first uses commit at most one winner. | Barrier-synchronized identical and conflicting races, all results, proof of the serialized single-winner outcome, history count, and final binding. | `FI-INV-01`, `FI-INV-09` | +| `FI-TRACE-TOFU-THEFT` | Stolen-assertion first use denies in attested and provisioned modes. Only explicitly configured risk-labelled TOFU may create the attacker's proven key. | Same synthetic theft fixture under all three modes, discovery/config witness, provenance result, and no-mutation denials. | `FI-INV-05`, `FI-INV-10` | +| `FI-TRACE-DELEGATE-OWNER-ROTATED` | Rotation, retirement, disablement, key revocation, owner-binding version change, or relationship revision makes old delegation non-current. No authority transfers to a replacement key. | Delegated allow baseline; each dependency mutation between preparation and final admission and during lease reuse; exact owner and relationship versions; replacement-key non-inheritance; denial; and bounded closure time. | `FI-INV-10`, `FI-INV-12` | +| `FI-TRACE-DELEGATION-EXPIRED` | Missing finite configuration, delegation-expiry equality, owner administrative-expiry equality, and use after either bound deny. | Controlled-clock just-before/equal/after cases for both bounds, configuration omission case, delegate proof, exact owner version, and lease deadline. | `FI-INV-11`, `FI-INV-12`, `FI-INV-14` | +| `FI-TRACE-DENIAL-ORACLE` | Unknown identity, conflicts, tombstones, enrollment posture, and private-policy denials are not distinguishable on the public wire. | Public response corpus normalized by transport, status/prefix comparison, predeclared timing method and threshold with raw measurements, and private-detail scan. | `FI-INV-13` | +| `FI-TRACE-DEPENDENCY-FAIL-CLOSED` | Unreadable verifier, JWKS, binding, lifecycle, replay, policy, receipt, audit, or invalidation state never allows. | One injected outage per dependency at preparation, final admission, and lease reuse where applicable; results and state diffs. | `FI-INV-14` | +| `FI-TRACE-MULTI-KEY-SESSION` | A lease for one authenticated key cannot authorize another key on the same connection. | One connection with two keys, per-key operations, lease lookup evidence, and wire results before and after invalidation. | `FI-INV-05`, `FI-INV-11` | +| `FI-TRACE-CROSS-DOMAIN-COLLISION` | Equal subjects across issuers and equal pairs across domains remain distinct and inherit no authority. | Two issuers and two domains with controlled collisions, four state snapshots, and cross-use denials. | `FI-INV-01`, `FI-INV-04` | +| `FI-TRACE-PRIVACY-NONPUBLIC` | Assertions and private identity material never enter protocol output, public history, discovery, logs, metrics, or traces. | Seeded canary claims, successful and denied flows, scans of every configured sink, and access-control evidence for retained private state. | `FI-INV-13`; [NIP-FI privacy](NIP-FI.md#privacy); [model privacy](NIP-FI-MODEL.md#denial-and-privacy-model) | + +## Applicability + +Every report contains every trace ID. `pass` and `not-applicable` are the only claimable statuses. A blank, skipped, expected failure, or `not-run` result cannot support a claim. + +`not-applicable` needs a machine-readable reason and behavioral proof that the optional surface is absent: + +- proxy traces may be not applicable only when `trusted-proxy-hmac-v1` is neither accepted nor advertised and executable absence cases show rejection; +- TOFU may be not applicable only when risk-labelled TOFU is neither configurable nor advertised and executable absence cases show rejection; +- delegation traces may be not applicable only when delegation is disabled, omitted from discovery, and denied on every ingress; and +- every other trace is required for an enforcing deployment. + +An implementation that supports an optional surface must run its traces even when one deployed domain does not activate that surface. + +## Adapter contract + +The implementation stack must supply an adapter manifest at its exact head. The manifest maps each applicable trace to executable test IDs and commands. It also identifies required services, fixtures, fault injection, and deployed-boundary steps. + +Adapters must drive public or production-equivalent entry points. Storage helpers may inspect state and inject a dependency outage, but they cannot replace the operation under test. A route test that calls an internal authorization function without traversing the protected ingress does not satisfy route coverage. + +The adapter exits nonzero when: + +- a trace is absent or duplicated; +- the implementation, adapter, artifact, deployment, or policy digest differs from the claim tuple; +- a required result is not `pass`; +- a `not-applicable` result lacks absence evidence; +- an evidence artifact is missing or its digest differs; or +- cleanup or privacy inspection is incomplete. + +The example [conformance report](../examples/nip-fi-conformance-report.json.example) contains every stable trace with `not-run` status. It is a shape example, not a conformance claim. + +## Release gate + +Before NIP-FI discovery or enforcement is activated, reviewers verify: + +1. The implementation stack supplies exact-head behavioral adapters for every applicable trace. +2. One immutable claim tuple passes the complete matrix. +3. The protected-ingress inventory has no uncovered or competing authority. +4. Trusted-proxy deployments include live bypass, mixed-profile, header-injection, replay, and cross-request evidence. +5. Restore, rollback, lifecycle, and dependency-outage exercises have completed against production-equivalent storage. +6. Public and operational sinks pass the privacy canary inspection. + +Documentation review, source review, and static scans remain useful review inputs. They do not close any item in this release gate. From f1fb5df9360276497213c3c7b6c685623b9812b9 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:00:36 -0500 Subject: [PATCH 5/5] docs(nip-fi): reconcile deployment entry points Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .env.example | 10 ++++++++++ README.md | 1 + SECURITY.md | 27 ++++++++++++++++++++++----- deploy/charts/buzz/README.md | 15 +++++++++++++++ deploy/compose/README.md | 15 +++++++++++++++ 5 files changed, 63 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 696d3a0617..0a4450aa3a 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,16 @@ RELAY_URL=ws://localhost:3000 # (use `just web` for Vite HMR instead). # BUZZ_WEB_DIR=./web/dist +# ----------------------------------------------------------------------------- +# Federated identity (NIP-FI) +# ----------------------------------------------------------------------------- +# This revision contains contract documentation only. It defines no supported +# NIP-FI runtime environment variables and does not activate enforcement. +# Review-only policy shape: docs/examples/nip-fi-stock-domain.json.example +# Do not convert that example into environment variables or trust a legacy +# corporate identity header. A later implementation release must document its +# exact adapter/configuration surface and matching behavioral evidence. + # ----------------------------------------------------------------------------- # Git (NIP-34 bare repositories) # ----------------------------------------------------------------------------- diff --git a/README.md b/README.md index 07523f1a9b..dce180c52b 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,7 @@ A Rust workspace of focused crates. Single source of truth: the relay. See [ARCH - **[VISION.md](VISION.md)** · **[VISION_SOVEREIGN.md](VISION_SOVEREIGN.md)** · **[VISION_PROJECTS.md](VISION_PROJECTS.md)** · **[VISION_AGENT.md](VISION_AGENT.md)** — the four vision docs - **[ARCHITECTURE.md](ARCHITECTURE.md)** — system design, kind ranges, subsystem boundaries +- **[NIP-FI](docs/nips/NIP-FI.md)** · **[integration](docs/NIP_FI_INTEGRATION.md)** · **[threat model](docs/NIP_FI_THREAT_MODEL.md)** · **[deployment](docs/NIP_FI_DEPLOYMENT.md)** · **[operations](docs/NIP_FI_RUNTIME_OPERATIONS.md)** — federated-identity contract and public guidance; this documentation revision does not include or activate a runtime adapter - **[TESTING.md](TESTING.md)** — multi-agent E2E test suite - **[CONTRIBUTING.md](CONTRIBUTING.md)** · **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)** · **[SECURITY.md](SECURITY.md)** · **[GOVERNANCE.md](GOVERNANCE.md)** diff --git a/SECURITY.md b/SECURITY.md index 96222e029d..1e07e9b946 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -53,17 +53,34 @@ REST endpoints authenticate via the client signs a `kind:27235` event containing the request URL and method. The relay verifies the Schnorr signature and extracts the pubkey. -### Authorization — Channel Membership as the Gate +### Authorization and admission -Channel membership is the **only** access control mechanism. There are no -separate ACL lists or capability taxonomies. If a principal (human or agent) -is a member of a channel, they can read and write to it. If they are not a -member, the relay rejects their requests — even if they are authenticated. +Channel membership is Buzz's application-level access control mechanism. If a +principal (human or agent) is a member of a channel, application policy may +allow them to read and write it. If they are not a member, the relay rejects +their requests even if they are authenticated. Private channels are invisible to non-members: they do not appear in channel listings, and subscription filters for private channel events return nothing unless the subscriber is a member. +[NIP-FI](docs/nips/NIP-FI.md) defines an optional additional admission +authority that combines provider-neutral issuer assertions with fresh Nostr +key proof, current durable binding and lifecycle state, and final application +admission. When enabled for a domain, one current NIP-FI authority covers every +protected ingress atomically, and both NIP-FI and application authorization +must allow each operation. NIP-FI does not replace NIP-42, NIP-98, Nostr +signatures, channel membership, or resource policy. + +This documentation revision does not include or activate a NIP-FI runtime +adapter. A later exact-head implementation and deployment must pass the +[behavioral evidence matrix](docs/nips/NIP-FI-CONFORMANCE.md) before publishing +discovery or enforcing the contract. Source scans, prose, and configuration +presence do not establish conformance. See the [threat +model](docs/NIP_FI_THREAT_MODEL.md), [stock +deployment](docs/NIP_FI_DEPLOYMENT.md), and [runtime +operations](docs/NIP_FI_RUNTIME_OPERATIONS.md). + ### Append-Only Audit Log All events are written to a tamper-evident audit log (`buzz-audit`). Each diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 010b3dffde..da1385a760 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -78,6 +78,21 @@ Save these. Losing any of them is data loss. See NOTES.txt printed by `helm inst 4. Git PVC — repo on-disk state served by the relay's git endpoint. 5. Owner private key — held by the operator, not by this chart. Restore by re-installing with the same `ownerPubkey`. +## NIP-FI readiness + +This chart does not expose a NIP-FI runtime adapter, policy schema, trusted-edge +topology, secret keys, or conformance runner. Do not advertise or enforce +NIP-FI from this chart, and do not use an ingress identity header or +provider-specific sidecar as a fallback authority. + +A later chart release must pin an implementation with an exact adapter and +configuration version, keep policy separate from secret values, isolate +verifier ingress for `trusted-proxy-hmac-v1`, include every fail-closed +dependency in readiness, and link an immutable exact-head behavioral report. +A rendered chart and healthy pod do not prove those behaviors. See the +[provider-neutral deployment guide](../../../docs/NIP_FI_DEPLOYMENT.md) and +[runtime operations guide](../../../docs/NIP_FI_RUNTIME_OPERATIONS.md). + ## Honest limitations (v1) - **Bundled MinIO is eval-only.** The quickstart profile runs an in-cluster diff --git a/deploy/compose/README.md b/deploy/compose/README.md index 0de524fb5b..292dc59ad7 100644 --- a/deploy/compose/README.md +++ b/deploy/compose/README.md @@ -41,6 +41,21 @@ keypair. Run `./run.sh backup-hint` for the backup checklist. +## NIP-FI readiness + +This Compose bundle does not wire a NIP-FI runtime adapter, policy schema, +trusted edge, or conformance runner. Do not advertise or enforce NIP-FI from +this bundle, and do not add a provider-specific sidecar or unsigned corporate +identity header as a substitute. + +A later implementation release must pin an exact image, document its supported +configuration and adapter version, isolate verifier ingress when +`trusted-proxy-hmac-v1` is enabled, deliver HMAC keys through a secret store, +and pass the complete exact-head behavioral matrix before activation. A valid +Compose render or healthy relay does not close those gates. See the +[provider-neutral deployment guide](../../docs/NIP_FI_DEPLOYMENT.md) and +[runtime operations guide](../../docs/NIP_FI_RUNTIME_OPERATIONS.md). + ## Validation Before sharing an install link publicly, verify a fresh install with: