From 19dce1ade69fb4b483ddae34afd465ef11a01cd7 Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Sat, 15 Aug 2026 07:13:59 -0400 Subject: [PATCH 01/20] docs(nips): skeleton for comprehensive NIP-FI restructure (core + 4 profiles) Pins the file layout for the approved 9/9/9/9 blueprint: a ~350-line normative core plus separately claimable EDGE / LIFECYCLE / DELEG / CONF profiles and a non-normative model companion. Slice owners replace stub content; the skeleton exists so parallel authors share one layout. Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- docs/nips/NIP-FI-CONF.md | 13 +++++++++++++ docs/nips/NIP-FI-DELEG.md | 10 ++++++++++ docs/nips/NIP-FI-EDGE.md | 14 ++++++++++++++ docs/nips/NIP-FI-LIFECYCLE.md | 12 ++++++++++++ docs/nips/NIP-FI-MODEL.md | 5 +++++ docs/nips/NIP-FI.md | 19 +++++++++++++++++++ 6 files changed, 73 insertions(+) create mode 100644 docs/nips/NIP-FI-CONF.md create mode 100644 docs/nips/NIP-FI-DELEG.md create mode 100644 docs/nips/NIP-FI-EDGE.md create mode 100644 docs/nips/NIP-FI-LIFECYCLE.md create mode 100644 docs/nips/NIP-FI-MODEL.md create mode 100644 docs/nips/NIP-FI.md diff --git a/docs/nips/NIP-FI-CONF.md b/docs/nips/NIP-FI-CONF.md new file mode 100644 index 00000000000..9ea85005012 --- /dev/null +++ b/docs/nips/NIP-FI-CONF.md @@ -0,0 +1,13 @@ +# NIP-FI-CONF: Conformance Profile + +`draft` `optional` + +> SKELETON — text owner: Dawn. Source: PLANS/NIP_FI_9999_CORRECTNESS_KIT.md. + +## Scope + +Claim tuple + evidence rules; per-profile trace subsets (core traces live in +core); 12-row denial reason→class mapping with closed public-response +vocabulary + byte-identical-response traces; mutation adequacy (one mutant per +core MUST, caught by its named trace); interop exit test (byte-exact request + +denial per class derivable from core doc alone). diff --git a/docs/nips/NIP-FI-DELEG.md b/docs/nips/NIP-FI-DELEG.md new file mode 100644 index 00000000000..33caa489bc7 --- /dev/null +++ b/docs/nips/NIP-FI-DELEG.md @@ -0,0 +1,10 @@ +# NIP-FI-DELEG: Delegated Agent Profile + +`draft` `optional` + +> SKELETON — text owner: Wren. Semantics unchanged from a383fd50a; own traces. + +## Scope + +Delegation attached to a bound owner; owner revocation invalidates delegates on +the same schedule; delegation traces. diff --git a/docs/nips/NIP-FI-EDGE.md b/docs/nips/NIP-FI-EDGE.md new file mode 100644 index 00000000000..bee9be15c98 --- /dev/null +++ b/docs/nips/NIP-FI-EDGE.md @@ -0,0 +1,14 @@ +# NIP-FI-EDGE: Trusted Edge Profile + +`draft` `optional` + +> SKELETON — text owner: Perci. Source: RESEARCH/NIP_FI_9999_TRANSPORT_WIRE_DESIGN.md. + +## Scope + +Registered edge adapters; trusted-proxy-hmac-v2 envelope + canonicalization; +authorization_domain_id derivation (exact 16 RFC 9562 UUID bytes, network order); +proof_transport_code registry + extension procedure; key rotation; nonce replay; +body acquisition bounds (deny-before-hashing, EOF-complete); normative test-vector +suite with full intermediates; proxy provenance traces. Header-trust-without- +provenance is nonconformant. diff --git a/docs/nips/NIP-FI-LIFECYCLE.md b/docs/nips/NIP-FI-LIFECYCLE.md new file mode 100644 index 00000000000..b76e48c8cf2 --- /dev/null +++ b/docs/nips/NIP-FI-LIFECYCLE.md @@ -0,0 +1,12 @@ +# NIP-FI-LIFECYCLE: Binding Lifecycle Profile + +`draft` `optional` + +> SKELETON — text owner: Max. Source: PLANS/NIP_FI_9999_DESIGN.md (lifecycle rows +> of Wren's disposition table) + prior spec text at a383fd50a. + +## Scope + +Recover, re-enable, provisioned mode, binding_not_after, pending-replacement +lineage, dual-control/admin transitions; one conformance trace per privileged +transition including one-shot Q_D consumption. diff --git a/docs/nips/NIP-FI-MODEL.md b/docs/nips/NIP-FI-MODEL.md new file mode 100644 index 00000000000..4c3b191bcc8 --- /dev/null +++ b/docs/nips/NIP-FI-MODEL.md @@ -0,0 +1,5 @@ +# NIP-FI-MODEL: Formal Model (Non-Normative Companion) + +> SKELETON — owner: Wren. Demoted from normative ONLY after FI-INV-01..16 move +> into NIP-FI.md as normative text (ordering constraint per Dawn's flag). +> This document defines nothing normative; it illustrates and cross-checks. diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md new file mode 100644 index 00000000000..69f5e1552d9 --- /dev/null +++ b/docs/nips/NIP-FI.md @@ -0,0 +1,19 @@ +# NIP-FI: Federated Identity Authorization (Core) + +`draft` `optional` + +> SKELETON — text owner: Wren (core boundary + NIP idiom), integrating Max's +> cross-cutting sections (freshness classes, token typ rules, two contract +> identities). Source designs: RESEARCH/NIP_FI_9999_CORE_BOUNDARY_DESIGN.md, +> RESEARCH/NIP_FI_9999_SECURITY_SEMANTICS_DESIGN.md, PLANS/NIP_FI_9999_DESIGN.md. +> Target: ~350 normative lines. One normative source: FI-INV-01..16 live HERE. + +## Scope + +Issuer-qualified identity (iss, sub); independent Nostr proof; client-attached +assertion transport; partial bijection with durable tombstones; atomic final +admission; bounded leases; private denials with closed response vocabulary; +retire/revoke/rotate transitions; two contract identities +(assertion_policy_id, transport_contract_id); declared freshness class +(offline-jwt | current-status); server-declared body authorization relevance +(NIP-98 payload binding fix); BCP 14; "equivalent" defined; worked wire example. From 9f2cb82ff3434088fd823f70131a2c74974b52c8 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 15 Aug 2026 07:30:53 -0400 Subject: [PATCH 02/20] docs(nips): specify FI binding lifecycle profile Co-authored-by: Max Signed-off-by: Max --- docs/nips/NIP-FI-LIFECYCLE.md | 241 +++++++++++++++++++++++++++++++++- 1 file changed, 235 insertions(+), 6 deletions(-) diff --git a/docs/nips/NIP-FI-LIFECYCLE.md b/docs/nips/NIP-FI-LIFECYCLE.md index b76e48c8cf2..acb15b3e36f 100644 --- a/docs/nips/NIP-FI-LIFECYCLE.md +++ b/docs/nips/NIP-FI-LIFECYCLE.md @@ -2,11 +2,240 @@ `draft` `optional` -> SKELETON — text owner: Max. Source: PLANS/NIP_FI_9999_DESIGN.md (lifecycle rows -> of Wren's disposition table) + prior spec text at a383fd50a. +## Abstract -## Scope +This profile extends NIP-FI with provisioned enrollment, identity disablement, +recovery, re-enablement, and an administrative binding-expiry gate. It is for +deployments whose binding changes require separately authorized operator or +enterprise workflows. It does not change NIP-FI assertion validation, Nostr +proof, final admission, or public denial semantics. -Recover, re-enable, provisioned mode, binding_not_after, pending-replacement -lineage, dual-control/admin transitions; one conformance trace per privileged -transition including one-shot Q_D consumption. +The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, +**SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this +document are to be interpreted as described in BCP 14 when, and only when, they +appear in all capitals as shown here. + +## Dependencies and claim + +An implementation of this profile implements NIP-FI Core and advertises only +the boolean `"lifecycle": true` inside its NIP-11 `federated_identity` object. +This boolean claims support for this profile; it deliberately reveals neither an +enrollment mode nor lifecycle state. For a fixed set of claimed profiles, the +complete discovery output MUST be byte-identical whether enrollment is +attested-key, TOFU, or provisioned and whether lifecycle facts exist. A server +MUST NOT advertise the claim until every protected ingress in the advertised +authorization domain applies this profile through the same final-admission +authority (`FI-LC-CLAIM`). + +This profile contributes lifecycle dependencies and deadlines to the core +prepared decision and lease. They compose with core dependencies by set union; +the earliest applicable deadline wins. This profile cannot weaken, replace, or +bypass a core check. + +## Additional state + +For authorization domain `D`, this profile adds: + +```text +X_D : set of disabled identities +Q_D : identity -> pending lineage + +PendingLineage = ( + identity, + old_key, + old_binding_version +) +``` + +It also permits a core binding to carry `binding_not_after`, an optional +administrative deadline. The pending lineage names one exact retired pair and +binding version. There is at most one pending lineage per identity. + +`X_D`, `Q_D`, and `binding_not_after` are deployment-local state. Their versions +are revalidation dependencies, not contract identities. A change invalidates a +prepared decision and every dependent lease unless complete final-admission +recomputation produces the required current result. + +Ordinary authorization MUST deny when its identity is disabled, when pending +lineage exists for that identity, or when `now >= binding_not_after`; it MUST +NOT clear, consume, or alter any of those facts (`FI-LC-ORDINARY-GATES`). An +absent `binding_not_after` has no administrative expiry. Assertion `exp`, +`iat`, refresh, or maximum age never creates, renews, extends, or clears it. +Time passage alone creates no tombstone, lineage, or history. + +## Common transition contract + +Each transition below requires privileged authority distinct from an ordinary +federated assertion and Nostr proof. That authority MUST be bound to the exact +`D`, transition name, identity, request, old binding version when present, and +target key when present (`FI-LC-AUTHORITY`). The deployment defines how that +authority is obtained; role names, approval count, and operator APIs are out of +scope. + +A transition MUST, in one atomic commit: + +1. validate that privileged authority and fresh target-key evidence; +2. read and recheck the applicable core binding relation, retired pairs, + revoked keys, `X_D`, `Q_D`, policy, and dependency versions; +3. apply exactly the state changes specified below; +4. append immutable lifecycle history identifying the transition and versions; + and +5. advance lifecycle state so dependent prepared decisions and leases cannot + authorize after commit. + +A stale precondition, denied transition, unreadable dependency, or failed commit +MUST leave all authoritative state unchanged (`FI-LC-ATOMIC`). Lease +invalidation MAY be delivered asynchronously, but authorization use after the +commit MUST recheck the advanced dependency before allowing an operation. + +`TargetEligible(i, k, allow_disabled)` means that `k` is not revoked, `(i, k)` +is not retired, neither `i` nor `k` has an active binding, and `i` is not +disabled unless `allow_disabled` is true. Every new target key requires fresh, +request-bound Nostr proof by that key. If domain policy requires issuer key +attestation, the transition also requires a current assertion for `i` whose key +claim equals `k`. Supplied stale, absent, wrong-identity, or mismatched required +attestation denies; it is never ignored as optional evidence +(`FI-LC-TARGET-PROOF`). + +A replacement binding records `attested-key` provenance only when current +matching issuer attestation was validated; otherwise it records `provisioned`. +TOFU provenance can arise only from the core ordinary first-use extension and +is never inherited by a replacement. + +## Privileged transitions + +### Provision binding + +```text +ProvisionBinding(i, k): + require domain enrollment policy = provisioned + require TargetEligible(i, k, false) + require Q_D(i) is absent + require fresh target-key evidence + create Binding(i, k, new_version, provisioned) +``` + +The transition creates no authorization lease. Later use requires a current +assertion, fresh Nostr proof, and ordinary final admission. Ordinary +request-time authorization under `provisioned` policy MUST NOT create a binding +(`FI-LC-PROVISION`). + +### Disable identity + +```text +DisableIdentity(i): + add i to X_D + if Binding(i, k, old_version) exists: + remove Binding(i, k, old_version) + add (i, k) to the core retired-pair set + set Q_D(i) = (i, k, old_version) +``` + +Applying an authorized disablement repeatedly is idempotent. It MUST NOT erase +or replace existing lineage. If `i` has no active binding, disablement creates +no lineage (`FI-LC-DISABLE`). + +### Recover + +```text +Recover(i, old_binding_version, k_new): + require i is not in X_D + require Q_D(i) = (i, k_old, old_binding_version) + require TargetEligible(i, k_new, false) + require fresh target-key evidence + consume that exact Q_D(i) + create Binding(i, k_new, new_version, ReplacementProvenance(evidence)) +``` + +Recovery preserves the old retired pair. It cannot consume absent, stale, or +different lineage and cannot recover a disabled identity +(`FI-LC-RECOVER`). + +### Re-enable identity + +```text +ReenableIdentity(i, expected_lineage?, k_new): + require i is in X_D + require Q_D(i) is absent when expected_lineage is absent, + otherwise require Q_D(i) = expected_lineage + require TargetEligible(i, k_new, true) + require fresh target-key evidence + remove i from X_D + consume expected_lineage when present + create Binding(i, k_new, new_version, ReplacementProvenance(evidence)) +``` + +Clearing disabled state and creating the target binding are inseparable. There +is no clear-only transition: it would permit a later ordinary enrollment to +capture the identity. An operator that intends to provision later leaves the +identity disabled until the target and fresh proof are available +(`FI-LC-REENABLE`). + +### Set administrative expiry + +```text +SetAdministrativeExpiry(i, k, old_version, binding_not_after?): + require exact current Binding(i, k, old_version) + require separate privileged expiry authority + replace it with Binding(i, k, new_version, + same_provenance, binding_not_after?) +``` + +This transition changes neither side of the pair nor its provenance. Setting, +replacing, or clearing the bound advances the binding version. At equality the +binding is ineligible but remains durable and occupies both sides of the core +partial bijection. Only this or another applicable privileged transition can +restore access; ordinary authorization cannot renew the bound +(`FI-LC-ADMIN-EXPIRY`). + +## One-shot lineage and concurrency + +Consumption of `Q_D` and creation of its replacement binding MUST be one +compare-and-commit operation over the exact pending lineage. Of two concurrent +recoveries or re-enablings presenting the same lineage, at most one can commit. +The loser observes changed state and denies without creating a binding, +consuming another lineage, or changing history (`FI-LC-QD-ONCE`). + +A lifecycle transition racing ordinary final admission is ordered by the same +authoritative state transaction or dependency check. If the lifecycle commit +wins, the ordinary operation denies; if final admission wins first, the +lifecycle transition still invalidates subsequent lease use. No ordering +permits authority from a disabled identity, consumed lineage, or expired +binding after the corresponding state change is observed. + +## Behavioral oracles + +Each oracle is normative. A conforming implementation produces the stated +result at final admission and retains no partial authoritative mutation from a +denied case. + +| ID | Setup and required result | +|---|---| +| `FI-LC-CLAIM` | For a fixed profile set, compare complete discovery bytes across attested-key, TOFU, and provisioned configurations and across lifecycle states: they are identical. If one protected ingress omits lifecycle gates or uses a different lifecycle lineage, the domain cannot advertise the profile and the uncovered ingress fails closed. | +| `FI-LC-ORDINARY-GATES` | Fresh assertion and proof for a disabled identity, an identity with pending lineage, and a binding at administrative-expiry equality each deny without changing lifecycle state. | +| `FI-LC-AUTHORITY` | An ordinary assertion plus valid Nostr proof, but no transition-specific authority, cannot perform any transition; mutation of any authority-bound field denies. | +| `FI-LC-ATOMIC` | Inject failure at each transition write boundary; no binding, tombstone, disabled fact, lineage, history entry, or dependency version is partially committed. | +| `FI-LC-TARGET-PROOF` | Missing, stale, wrong-key, wrong-request, or mismatched required attestation for a new target denies without mutation. | +| `FI-LC-PROVISION` | Ordinary first use in provisioned mode denies; authorized provisioning creates one binding and no lease; later current ordinary admission may use it. | +| `FI-LC-DISABLE` | Disabling an active identity atomically disables it, retires its exact pair, records exact lineage, and closes subsequent lease use; replay is idempotent and preserves lineage. | +| `FI-LC-RECOVER` | Exact pending lineage plus an eligible proven target creates one replacement and consumes that lineage; disabled, absent, stale, or mismatched lineage denies. | +| `FI-LC-REENABLE` | Re-enablement creates an eligible proven binding in the same commit that clears disabled state; a clear-only attempt and wrong lineage deny. | +| `FI-LC-ADMIN-EXPIRY` | Before the bound the binding may authorize; at equality it denies while still occupying the relation; only an authorized version-checked update changes the bound. | +| `FI-LC-QD-ONCE` | Two concurrent transitions consume the same `Q_D` lineage; exactly one commits and the loser leaves every authoritative store unchanged. | +| `FI-LC-RACE` | Race each transition against prepared ordinary admission and lease use; no operation authorizes after observing the advanced lifecycle or binding dependency. | + +## Security considerations + +Privileged authority compromise can provision or replace enterprise bindings; +deployments should apply controls proportionate to that authority. This profile +makes the authority request-bound and transitions atomic, but does not define +approval UX or key custody. + +Disabled identities, retired pairs, revoked keys, and pending lineage serve +different purposes. Re-enablement removes only the exact disabled fact and +optional exact lineage named by its transition. Recovery consumes lineage but +never removes a retired pair. No transition in this profile removes a core +revoked-key or retired-pair fact. + +Administrative expiry is local policy, not upstream revocation freshness. It +cannot extend an assertion, status witness, Nostr proof, or lease deadline. From 0c283a558dca19ffe40659d2bef50eb34e80091b Mon Sep 17 00:00:00 2001 From: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Date: Sat, 15 Aug 2026 07:31:58 -0400 Subject: [PATCH 03/20] docs(nips): specify trusted edge profile Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> --- docs/nips/NIP-FI-EDGE.md | 379 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 371 insertions(+), 8 deletions(-) diff --git a/docs/nips/NIP-FI-EDGE.md b/docs/nips/NIP-FI-EDGE.md index bee9be15c98..e0d116f1e44 100644 --- a/docs/nips/NIP-FI-EDGE.md +++ b/docs/nips/NIP-FI-EDGE.md @@ -2,13 +2,376 @@ `draft` `optional` -> SKELETON — text owner: Perci. Source: RESEARCH/NIP_FI_9999_TRANSPORT_WIRE_DESIGN.md. - ## Scope -Registered edge adapters; trusted-proxy-hmac-v2 envelope + canonicalization; -authorization_domain_id derivation (exact 16 RFC 9562 UUID bytes, network order); -proof_transport_code registry + extension procedure; key rotation; nonce replay; -body acquisition bounds (deny-before-hashing, EOF-complete); normative test-vector -suite with full intermediates; proxy provenance traces. Header-trust-without- -provenance is nonconformant. +This profile lets a trusted enterprise edge deliver federated assertion evidence to +a NIP-FI verifier. It defines two constructions: + +- `trusted-proxy-hmac-v2`, a portable request-bound HMAC envelope; and +- a private authenticated-edge assertion adapter, for platforms that provide an + equivalent closed trust boundary without the stock envelope. + +NIP-FI-EDGE is optional. A deployment can implement NIP-FI core using only +`client-attached`. Claiming this profile does not weaken core assertion validation, +independent Nostr proof, binding, lifecycle, policy, final-admission, or lease rules. +The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, +**SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **NOT RECOMMENDED**, **MAY**, and +**OPTIONAL** are to be interpreted as described in BCP 14 when, and only when, +they appear in all capitals. + +`transport_contract_id` and every identifier serialized by this document are +interoperability-critical. Local adapter revision identifiers and key identifiers +are deployment-local and MUST NOT appear in public discovery. + +## Common trusted-edge requirements + +Server-owned listener, route, and authorization-domain configuration selects exactly +one edge profile before protected traffic is accepted. Request evidence cannot +select, negotiate, or downgrade that profile. Missing, repeated, comma-combined, +malformed, oversized, mixed-profile, or profile-inconsistent evidence denies without +fallback to `client-attached` or another edge profile. + +Every trusted edge MUST: + +1. strip every inbound copy of each assertion, identity, authorization, capability, + provenance, and client-peer field before inserting its own fields; +2. cryptographically authenticate the immediate edge to the accepting origin and + isolate the origin from direct or alternate ingress; +3. integrity-protect every request component used by authorization; +4. apply a positive finite provenance deadline that is included in final admission + and every resulting lease; +5. validate a closed upstream identity and authorization claim set and produce the + same normalized assertion result required by core; +6. preserve the server-resolved domain, operation, resource, method, authority, + path/query, body semantics, proof transport, and Nostr actor key through final + admission; and +7. keep assertions, credentials, signatures, MACs, raw client addresses, and private + claims out of URLs, public protocol output, logs, metrics, and traces. + +Header presence, source address, private-network location, hostname, or reachability +alone is not provenance. Accepting unsigned identity or capability headers, or +accepting signed headers without authenticating and isolating the immediate caller, +is nonconformant. + +An adapter's reviewed contract MUST identify its accepting origins, direct-origin +controls, field-stripping point, immediate-caller authentication, protected request +components, upstream assertion and policy validation, freshness bounds, independent +Nostr-proof path, compromise impact, and conformance evidence. It MUST deny when any +part of this boundary is absent or unreadable. + +### Authenticated-edge assertion adapters + +A deployment MAY install a private authenticated-edge adapter instead of HMAC-v2. +The adapter MUST satisfy all common requirements and demonstrate together: +origin isolation, cryptographically authenticated immediate caller, inbound-field +stripping, integrity of the complete authorization-relevant request, bounded +assertion and policy freshness, no direct-origin fallback, and the core final- +admission path with independent Nostr proof. + +The adapter maps only its closed, validated claim set into the normalized result. +An opaque edge token is acceptable only inside this complete contract; opacity does +not make an unchecked header authoritative. Vendor names, issuer details, caller +identities, private field names, capability semantics, and adapter identifiers MUST +NOT appear in NIP-11 or portable examples. + +## `trusted-proxy-hmac-v2` + +The stock profile identifier is `trusted-proxy-hmac-v2`. Core computes the +`transport_contract_id` from a canonical contract that includes this profile's exact +wire format, protected components, replay rules, deadline rules, configured code +meanings, and adapter semantics. Changing any of those inputs produces a different +contract identity; the profile identifier itself remains stable. The proxy strips +all inbound assertion, provenance, and client-peer fields and inserts exactly one of +each: + +```text +Nostr-Federated-Identity: Bearer +Nostr-Federated-Identity-Provenance: v2... +Nostr-Federated-Identity-Client-Peer: +``` + +The assertion field follows core's compact-JWS and size rules. `timestamp` is +canonical unsigned decimal without leading zeroes, except zero is `0`. `nonce` and +`mac` are canonical unpadded base64url. Padding, the standard base64 alphabet, +ignored whitespace, or another encoding denies. The proxy generates a fresh nonce +containing at least 128 bits from a cryptographically secure random source. The +decoded MAC is exactly 32 octets. Finite field and decoded-nonce maxima are applied +before decoding, replay lookup, hashing, or allocation. + +`client-peer` is at most 64 ASCII octets. IPv4 uses dotted decimal with no leading +zeroes. IPv6 uses lowercase RFC 5952 text. The edge converts an observed IPv4-mapped +IPv6 address to canonical IPv4 before constructing the field; a textual mapped IPv6 +field is noncanonical. Empty, repeated, comma-combined, whitespace-padded, non-IP, +or noncanonical values deny. After verification, the verifier MAY retain only a +domain-separated keyed digest of this value in bounded private state. + +The profile uses HMAC-SHA-256 with a deployment secret containing at least 256 bits. +Let `LP(x) = uint64be(len(x)) || x`, where length is in octets. The literal prefix is +14 ASCII octets and is not length-prefixed. The pre-MAC input is exactly: + +```text +"NIP-FI-PROXY-2" || +LP(timestamp_u64be) || LP(nonce_bytes) || LP(SHA256(jwt_ascii)) || +LP(authorization_domain_id) || +LP(method_ascii) || LP(authority_ascii) || LP(path_and_query_ascii) || +LP(SHA256(payload_octets)) || LP(proof_transport_octet) || LP(client_peer_ascii) +``` + +`mac = HMAC-SHA-256(secret, pre_mac_input)`. The transmitted `mac` is canonical +unpadded base64url of the raw 32-octet result. The verifier compares it in constant +time. + +### Canonical components + +- **Timestamp:** Parse canonical decimal into an unsigned 64-bit integer, rejecting + overflow, then serialize it as exactly eight-byte big-endian. Freshness checks are + separate from serialization. +- **Nonce:** Decode the exact canonical base64url field before serialization. +- **Assertion:** Hash the exact ASCII compact-JWS octets after the one space in + `Bearer `. No whitespace, Unicode, JSON, or base64 normalization is allowed. +- **Authorization domain:** Configuration contains a canonical lowercase, + hyphenated RFC 9562 UUID named `authorization_domain_uuid`. Parse its 32 displayed + hexadecimal digits into the exact 16 UUID octets in display/network order. For + example, `00112233-4455-6677-8899-aabbccddeeff` becomes + `00112233445566778899aabbccddeeff`. UTF-8 UUID text, hashing, truncation, + namespace derivation, mixed-endian GUID encoding, uppercase, and unhyphenated + configuration are forbidden. The UUID is generated once, is immutable for the + domain's lifetime, and is shared through authenticated proxy/verifier + configuration. Duplicate UUIDs among active domains MUST fail startup. +- **Method:** Use the exact uppercase ASCII method token after trusted route + resolution. Lowercase or noncanonical input denies; the verifier does not repair it. +- **Authority:** Use server-configured lowercase ASCII host plus explicit effective + decimal port. IPv6 uses brackets and RFC 5952. Userinfo, a trailing dot, an omitted + port, percent encoding, or an authority derived solely from `Host`, `Forwarded`, or + `X-Forwarded-Host` denies. +- **Path and query:** Use the exact post-rewrite ASCII origin-form. Empty path becomes + `/`; a present query includes `?`. Percent octets and hex case, an empty query, + repeated names, and parameter order are preserved. No decoding, sorting, + dot-segment removal, or re-encoding may occur after the edge snapshot. An + unaccounted rewrite denies. +- **Payload:** Hash the complete HTTP payload octets after transfer-coding removal and + before content-coding decompression. These are exactly the octets forwarded by the + edge and exposed to verification. HTTP framing, chunk delimiters, and trailers are + excluded; `Content-Encoding` is not decoded. A WebSocket upgrade uses the empty + payload. Any transformation after the protected snapshot is forbidden. +- **Proof transport:** Serialize exactly one assigned octet from the registry below. +- **Client peer:** Serialize the exact canonical ASCII field value. + +### Freshness, replay, and key rotation + +The deployment configures a positive finite `maximum_provenance_age` and a +non-negative finite `future_skew`. Evidence is live exactly when, using overflow-safe +comparisons: + +```text +timestamp <= now + future_skew +now < timestamp + maximum_provenance_age +``` + +Equality at the age bound is expired. A direct lease deadline is no later than +`timestamp + maximum_provenance_age` and every core assertion, proof, policy, and +state deadline. + +Absent, malformed, stale, future-dated, wrong-key, or mismatched provenance denies. +A v1 envelope denies. A verifier MAY try only a configured finite set of active +secrets. Rotation does not change nonce identity: replay uniqueness is scoped to +`(authorization_domain_id, trusted-proxy-hmac-v2, nonce)` and is independent of the +secret that verifies the MAC. A committed nonce is retained through at least +`timestamp + maximum_provenance_age`. + +Preparation consumes neither nonce nor Nostr-proof replay identity. Final admission +atomically consumes both with any enrollment, receipt, and authorization decision. +A failed or rolled-back admission consumes neither. Two concurrent admissions with +the same nonce commit at most one authorization. The proxy-to-verifier hop still +requires confidentiality and integrity. + +## Proof-transport code registry + +| Code | Meaning and allocation policy | +|---|---| +| `0x00` | Invalid; MUST deny. | +| `0x01` | NIP-42 connection proof. | +| `0x02` | NIP-98 HTTP proof. | +| `0x03` | Git smart-HTTP session proof profile. | +| `0x04` | Blossom proof profile. | +| `0x05`–`0x7f` | Unassigned; allocation requires a published stable specification. | +| `0x80`–`0xfe` | Private use under an explicit shared proxy/verifier contract only. | +| `0xff` | Reserved for a future extended encoding; invalid in HMAC-v2. | + +An allocation MUST define exact proof validation, request binding, freshness, replay +identity and window, and conformance vectors. Assigned semantics never change; an +incompatible meaning receives a new code. Unknown, unconfigured, or private-use +codes without the same configured contract at proxy and verifier deny. Private-use +codes MUST NOT be advertised as portable NIP-FI-EDGE interoperability. + +## Bounded payload acquisition + +Every protected `(authorization_domain_id, route, proof_transport_code)` tuple MUST +configure a finite `maximum_payload_octets` and finite per-request +`maximum_spool_octets >= maximum_payload_octets`. Zero is allowed only for a route +that requires an empty payload. Proxy and verifier configuration MUST agree and is +part of the transport contract. + +If trusted `Content-Length` exceeds the route limit, the edge denies before reading, +hashing, JWT verification, replay lookup, or authoritative mutation. For absent, +unknown, or streamed length, acquisition uses a bounded counter and spool and stops +on octet `limit + 1`. Incremental SHA-256 is allowed, but no digest or prefix can +authorize until EOF proves completeness. + +Spooling uses memory or access-controlled temporary storage with finite per-request +and aggregate quotas, cleanup on every outcome, no public or log output, and no reuse +across requests. Quota exhaustion fails closed and creates no nonce claim, proof +claim, receipt, lease, or application mutation. At or below the limit, the exact +captured payload is replayed unchanged. HMAC verification and core final admission +complete before application effects. Forwarding to a rollback-safe private spool is +not an application effect; forwarding to a parser, decoder, handler, or origin that +can act is. + +A content decoder, multipart parser, Git/Blossom handler, framework, or intermediary +that cannot expose and replay the exact stage defined above before effects cannot +claim HMAC-v2 for that route. It MUST use core `client-attached` or another specified +edge profile, never a partial-body MAC. + +## Normative HMAC-v2 vectors + +All vector integers and lengths are big-endian. Common values are: + +```text +secret_hex = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f +nonce_hex = 000102030405060708090a0b0c0d0e0f +nonce_base64url = AAECAwQFBgcICQoLDA0ODw +authorization_domain_uuid = 00112233-4455-6677-8899-aabbccddeeff +authorization_domain_id_hex = 00112233445566778899aabbccddeeff +jwt_ascii = eyJhbGciOiJFUzI1NiIsInR5cCI6Im5pcC1maStqd3QifQ.eyJpc3MiOiJodHRwczovL2lkLmV4YW1wbGUiLCJzdWIiOiIxMjMifQ.c2ln +assertion_digest_hex = 6103b52a52730bc065d65673247603a63c9810488c90d0ada3d8d227eee5285f +``` + +The fixture JWT represents a separately minted `nip-fi+jwt` assertion and is opaque +test input; its deliberately synthetic signature is not an assertion-validation +vector. Implementations MUST reproduce each field, complete pre-MAC input, +diagnostic input digest, raw MAC, and wire MAC exactly. + +### Vector 1: HTTP / NIP-98 / non-empty payload + +```text +timestamp_decimal = 1700000000 +timestamp_u64be_hex = 000000006553f100 +method_ascii = POST +authority_ascii = api.example:443 +path_and_query_ascii = /upload?part=1&part=2&x=%2F +payload_hex = 68656c6c6f0a +body_digest_hex = 5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03 +proof_transport_hex = 02 +client_peer_ascii = 203.0.113.9 +pre_mac_input_hex = 4e49502d46492d50524f58592d320000000000000008000000006553f1000000000000000010000102030405060708090a0b0c0d0e0f00000000000000206103b52a52730bc065d65673247603a63c9810488c90d0ada3d8d227eee5285f000000000000001000112233445566778899aabbccddeeff0000000000000004504f5354000000000000000f6170692e6578616d706c653a343433000000000000001b2f75706c6f61643f706172743d3126706172743d3226783d25324600000000000000205891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03000000000000000102000000000000000b3230332e302e3131332e39 +pre_mac_input_sha256 = df2870230d2170595dccd17d9e61a82282d8cd8b978ac18bff07419ed59091d5 +mac_hex = 761d3ecbf609f0f558b4a02a1a18a25070f3dbe89fce9cac59a80bce4436ade5 +mac_base64url = dh0-y_YJ8PVYtKAqGhiiUHDz2-ifzpysWagLzkQ2reU +provenance = v2.1700000000.AAECAwQFBgcICQoLDA0ODw.dh0-y_YJ8PVYtKAqGhiiUHDz2-ifzpysWagLzkQ2reU +``` + +### Vector 2: WebSocket / NIP-42 / empty payload / mapped peer + +The edge observed `::ffff:192.0.2.128` and emitted canonical `192.0.2.128`. + +```text +timestamp_decimal = 1 +timestamp_u64be_hex = 0000000000000001 +method_ascii = GET +authority_ascii = relay.example:443 +path_and_query_ascii = / +payload_hex = +body_digest_hex = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +proof_transport_hex = 01 +client_peer_ascii = 192.0.2.128 +pre_mac_input_hex = 4e49502d46492d50524f58592d32000000000000000800000000000000010000000000000010000102030405060708090a0b0c0d0e0f00000000000000206103b52a52730bc065d65673247603a63c9810488c90d0ada3d8d227eee5285f000000000000001000112233445566778899aabbccddeeff0000000000000003474554000000000000001172656c61792e6578616d706c653a34343300000000000000012f0000000000000020e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855000000000000000101000000000000000b3139322e302e322e313238 +pre_mac_input_sha256 = 67564d241499491b3ea53b31d6111fbc9efac37a294f6ce591519e4bf21b53e9 +mac_hex = f71a179a018637a0582cf3de39ccb7b976216c18ada312127d4c983c14af4b20 +mac_base64url = 9xoXmgGGN6BYLPPeOcy3uXYhbBitoxISfUyYPBSvSyA +``` + +### Vector 3: IPv6 authority and path/query byte preservation + +```text +timestamp_decimal = 1700000000 +timestamp_u64be_hex = 000000006553f100 +method_ascii = GET +authority_ascii = [2001:db8::1]:443 +path_and_query_ascii = /a%2Fb?b=2&a=1&a=0 +payload_hex = +body_digest_hex = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +proof_transport_hex = 02 +client_peer_ascii = 2001:db8::2 +pre_mac_input_hex = 4e49502d46492d50524f58592d320000000000000008000000006553f1000000000000000010000102030405060708090a0b0c0d0e0f00000000000000206103b52a52730bc065d65673247603a63c9810488c90d0ada3d8d227eee5285f000000000000001000112233445566778899aabbccddeeff000000000000000347455400000000000000115b323030313a6462383a3a315d3a34343300000000000000122f61253246623f623d3226613d3126613d300000000000000020e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855000000000000000102000000000000000b323030313a6462383a3a32 +pre_mac_input_sha256 = 8a93a29c4ac30b0f2551d346d0636040b639bb1f109d287e93ce44ddaed73e33 +mac_hex = df2936f81d752f3d6bac2a36d3381c38db2c9abc3570236cb121274ad34a6161 +mac_base64url = 3yk2-B11Lz1rrCo20zgcONssmrw1cCNssSEnStNKYWE +``` + +### Serialization and negative matrix + +The following timestamp values MUST serialize as shown before freshness evaluation: + +| Decimal | `uint64be` hex | +|---:|---| +| `0` | `0000000000000000` | +| `1` | `0000000000000001` | +| `255` | `00000000000000ff` | +| `256` | `0000000000000100` | +| `18446744073709551615` | `ffffffffffffffff` | + +`00`, `01`, `+1`, surrounding whitespace, negative values, and +`18446744073709551616` deny before MAC comparison. The maximum value above is an +encoding vector; ordinary freshness policy will reject it. + +Every implementation MUST run these normative negative cases: + +| Class | Required cases and result | +|---|---| +| Envelope | Absent/repeated/comma-combined fields, `v1`, missing/extra component, padding, alternate alphabet, nonce below 16 octets or above configured max, and MAC lengths 31 or 33 all deny. | +| Domain | Uppercase/nonhyphenated UUID config fails configuration; mixed-endian UUID bytes or any one-bit domain transplant fails the baseline MAC; duplicate active UUID fails startup. | +| Request | Mutating assertion, method, authority, path/query, body, proof code, or peer while retaining Vector 1's MAC denies. | +| Path | `%2F`→`%2f`, decoding to `/`, reordering repeated query values, or adding/removing an empty `?` fails the baseline MAC. | +| Authority | Unbracketed or non-RFC-5952 IPv6, uppercase host, trailing dot, or missing port denies before MAC comparison. | +| Peer | Textual `::ffff:192.0.2.128`, padded IPv4, uppercase/noncanonical IPv6, or whitespace denies before MAC comparison. | +| Proof | `0x00`, `0xff`, unknown stock code, or private code without a shared configured contract denies. | +| Body | Known and unknown lengths `0`, `limit-1`, and `limit` may proceed only after EOF; `limit+1`, disconnect before EOF, aggregate-quota exhaustion, or any post-snapshot transform denies with no replay or authoritative mutation. | +| Replay | Concurrent final admissions of one valid envelope commit at most one; preparation and failed final admission consume none; secret rotation does not create a new nonce namespace. | +| Fallback | Direct ingress, mixed evidence, and failed HMAC never retry as `client-attached` or another adapter. | + +## Discovery and conformance + +A relay that completely implements the stock profile MAY list +`trusted-proxy-hmac-v2` in NIP-11's NIP-FI transport discovery. It MUST NOT advertise +private adapters, keys, domains, field names, or code contracts. Claiming FI-EDGE +requires every configured edge profile to pass the applicable core conformance suite +and these profile traces: + +| Trace | Required oracle | +|---|---| +| `FI-TRACE-PROXY-SPOOF` | Direct ingress, unsigned/header-only identity, unauthenticated caller, or invalid provenance denies without fallback. | +| `FI-TRACE-PROXY-REPLAY` | Two HMAC-v2 final admissions using one nonce commit at most one; preparation consumes neither. A private adapter proves its declared replay semantics. | +| `FI-TRACE-PROXY-CROSS-REQUEST` | Each protected component mutation denies. HMAC-v2 covers assertion, domain, method, authority, path/query, complete body, proof transport, and peer. | +| `FI-TRACE-EDGE-BODY-BOUNDS` | Known and streamed boundary cases prove bounded work/storage, EOF completeness, cleanup, and no pre-authorization effect. | +| `FI-TRACE-EDGE-KEY-ROTATION` | A finite active-key set accepts an intended overlap without allowing nonce reuse or an unknown key. | + +The conformance record binds the exact implementation, adapter, deployment, +assertion policy, transport contract, configured code meanings, and vector revision. +Two HMAC-v2 implementations interoperate only when they reproduce all valid vector +bytes exactly, reject every negative, agree on UUID and code configuration, and +preserve atomic replay and bounded complete-body behavior. + +## Security considerations + +HMAC-v2 limits header spoofing, replay, and cross-request transplantation only when +its secret remains confidential, the edge snapshots the final routed request, the +origin authenticates that edge, and final admission atomically consumes replay state. +It does not replace TLS or independent Nostr proof. A compromised edge or shared +secret can forge federated evidence within its configured domains; use distinct +secrets and UUIDs to limit blast radius. + +Authenticated-edge adapters intentionally shift more proof to deployment controls. +A hostname, private network, or opaque token is not an equivalent construction unless +the complete boundary obligations above are demonstrated. Body buffering and replay +state are attacker-controlled resource surfaces, so all field, payload, spool, +aggregate, key-set, and retention bounds fail closed. From 8f6b5c7685d3353a80d4c3a7d53e3c82476f5d37 Mon Sep 17 00:00:00 2001 From: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Date: Sat, 15 Aug 2026 07:35:14 -0400 Subject: [PATCH 04/20] docs(nips): define federated identity core Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> --- docs/nips/NIP-FI-DELEG.md | 165 ++++++++++++- docs/nips/NIP-FI-MODEL.md | 99 +++++++- docs/nips/NIP-FI.md | 503 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 743 insertions(+), 24 deletions(-) diff --git a/docs/nips/NIP-FI-DELEG.md b/docs/nips/NIP-FI-DELEG.md index 33caa489bc7..5ba8421efff 100644 --- a/docs/nips/NIP-FI-DELEG.md +++ b/docs/nips/NIP-FI-DELEG.md @@ -1,10 +1,165 @@ -# NIP-FI-DELEG: Delegated Agent Profile +NIP-FI-DELEG +============ -`draft` `optional` +Delegated agent authorization profile +-------------------------------------- -> SKELETON — text owner: Wren. Semantics unchanged from a383fd50a; own traces. +`draft` `optional` `relay` + +**Protocol dependency**: NIP-FI core. + +The key words "MUST", "MUST NOT", "REQUIRED", "SHOULD", "SHOULD NOT", and +"MAY" in this document are to be interpreted as described in BCP 14 (RFC 2119 +and RFC 8174) when, and only when, they appear in all capitals. ## Scope -Delegation attached to a bound owner; owner revocation invalidates delegates on -the same schedule; delegation traces. +This profile authorizes a delegate key from separately validated delegation +evidence rooted in a currently eligible NIP-FI owner binding. The delegate +proves its own key. It does not present a federated assertion and never receives +or inherits the owner's binding. + +This profile defines the normalized delegation result and its additional +preparation, final-admission, and lease witnesses. It does not define a wire +format for creating delegation relationships; NIP-OA or another protocol may +supply the evidence if it satisfies this contract. + +## Delegation evidence + +A validator returns this closed result: + +```text +DelegationEvidence = ( + domain, + owner_key, + delegate_key, + relationship_id, + relationship_revision, + audience, + operations, + resource_or_target, + not_before?, + mandatory_expiry +) +``` + +`relationship_id` and `relationship_revision` are deployment-local dependency +identifiers. All other fields are interoperability-critical in meaning even +when their concrete encoding belongs to the supplying delegation protocol. + +The evidence authenticates every field, has one unambiguous owner and delegate, +matches the server-owned domain and exact request or target, and has a finite +expiry. If present, `not_before` is live. Equality at either temporal boundary +is expired. The proven actor equals `delegate_key`. [FI-DELEG-EVIDENCE-CLOSED] + +A delegated request carries fresh request-appropriate Nostr proof and no +`Nostr-Federated-Identity` or profile provenance field. Mixed direct and +delegated evidence denies rather than selecting a path. [FI-DELEG-PATH-SEPARATION] + +## Preparation + +Preparation resolves the exact server-owned domain, target context, operation, +resource, and proven delegate actor before validating delegation evidence. It +then atomically reads: + +- the active owner binding and exact binding version; +- every owner tombstone, key-revocation, administrative, and profile lifecycle + gate applicable to that binding; +- the exact relationship identifier and revision; +- current local policy and resource versions; and +- every invalidation dependency and deadline. + +The owner binding is current and authorization-eligible at preparation. A +cached owner lease is not authority. The requested capability is the +intersection of the delegation's operation, audience, conditions, and target +with current local policy; an unsupported operation or empty intersection +denies. [FI-DELEG-OWNER-CURRENT] + +Preparation remains read-only under `FI-INV-08`. It cannot create or change an +owner or delegate binding, identity, provenance, lifecycle fact, relationship, +last-seen value, replay claim, receipt, lease, or application effect. +[FI-DELEG-NO-BINDING] + +## Final admission + +Core final admission additionally requires: + +1. the exact delegation evidence and delegate proof remain live; +2. domain, actor, target, audience, operation, resource, and relationship match + the prepared value; +3. the exact current owner binding and binding version remain eligible; +4. relationship identity and revision remain current; +5. current capability intersection equals the prepared intersection; and +6. changed dependencies are reread and the complete delegated decision is + recomputed before atomic commit. + +Any mismatch, expiry, owner retirement, owner key revocation, owner binding +version change, relationship change, unreadable dependency, or unsupported +capability denies. Rotation makes the former owner key non-current; its +relationships do not transfer to the new key. [FI-DELEG-OWNER-CURRENT] + +The delegated path creates no owner or delegate binding and cannot consume an +enrollment opportunity. Its receipt identifies the delegate actor and exact +owner-binding and relationship dependencies without publishing identity +material. [FI-DELEG-NO-BINDING] + +## Delegated leases + +A deployment configures a positive finite delegated maximum. The lease deadline +is no later than the minimum of: + +- delegation expiry; +- delegate proof or connection bound; +- owner binding administrative bound, when applicable; +- current relationship bound; +- local policy bound; +- the configured delegated maximum; and +- any stronger owner-assertion bound the deployment requires. + +Missing finite configuration denies. Equality is expired and arithmetic is +overflow-safe. [FI-DELEG-LEASE-BOUND] + +Before each protected use, the service checks the delegate actor, owner binding +and version, relationship and revision, capability intersection, target, +resource, local policy, deadline, and invalidation state. It closes or rejects +the lease within the deployment's tested revocation-detection bound after any +owner or relationship dependency becomes ineligible. The claimed bound is no +smaller than measured worst-case detection plus enforcement delay. +[FI-DELEG-INVALIDATION-BOUND] + +Owner retirement, revocation, rotation, disablement under NIP-FI-LIFECYCLE, or +binding replacement invalidates dependent delegates on the same effective +schedule as owner authority. A delegate lease never authorizes another delegate +or owner key on the same connection. [FI-DELEG-OWNER-CURRENT] + +## Discovery + +A relay claiming this profile MAY add `"delegation": true` to the NIP-FI +object in NIP-11 only when owner-current resolution, the positive finite +maximum, uniform final admission, and all profile oracles are active. It does +not advertise relationship IDs, owner keys, private delegation protocol names, +or policy detail. [FI-DELEG-DISCOVERY] + +## Behavioral oracles + +| ID | Required outcome | +|---|---| +| `FI-DELEG-EVIDENCE-CLOSED` | Valid closed evidence passes; unauthenticated, ambiguous, wrong-domain/actor/target/audience, not-yet-valid, and expiry-equality variants deny. | +| `FI-DELEG-PATH-SEPARATION` | Delegation plus any direct assertion/provenance field denies; neither path falls back to the other. | +| `FI-DELEG-OWNER-CURRENT` | Exact current owner succeeds; retirement, revocation, rotation, replacement, stale owner version, stale relationship, and unreadable owner state deny without inheritance. | +| `FI-DELEG-NO-BINDING` | Successful, denied, and concurrent delegated requests create or change no owner/delegate binding or lifecycle state. | +| `FI-DELEG-LEASE-BOUND` | Every authority bound and equality boundary closes the lease; absent finite maximum denies. | +| `FI-DELEG-INVALIDATION-BOUND` | Measured owner/relationship revocation closes prepared evidence and live leases within the claimed detection bound. | +| `FI-DELEG-DISCOVERY` | Discovery is false/absent until the complete active profile passes; public output contains no relationship or owner detail. | + +NIP-FI-CONF defines evidence packaging and mutation adequacy. Each uppercase +requirement above names the oracle that detects its violation. + +## Security considerations + +Delegation expands authority only by intersection and never by copying owner +capabilities. A stolen delegation still requires the delegate key. A stolen +delegate key is bounded by the relationship and finite lease. Owner rotation +cannot silently transfer delegation because the exact owner key and binding +version are dependencies. Implementations should invalidate by dependency index +rather than wait for incidental delegate traffic. diff --git a/docs/nips/NIP-FI-MODEL.md b/docs/nips/NIP-FI-MODEL.md index 4c3b191bcc8..95c0a073d29 100644 --- a/docs/nips/NIP-FI-MODEL.md +++ b/docs/nips/NIP-FI-MODEL.md @@ -1,5 +1,96 @@ -# NIP-FI-MODEL: Formal Model (Non-Normative Companion) +NIP-FI-MODEL +============ -> SKELETON — owner: Wren. Demoted from normative ONLY after FI-INV-01..16 move -> into NIP-FI.md as normative text (ordering constraint per Dawn's flag). -> This document defines nothing normative; it illustrates and cross-checks. +Composed authorization model (non-normative) +-------------------------------------------- + +This companion is explanatory. It defines no requirement, invariant, wire +value, denial mapping, or conformance claim. Normative requirements live in +[NIP-FI](NIP-FI.md) and the claimed profile documents. In particular, +`FI-INV-01` through `FI-INV-16` are defined only by NIP-FI core. + +## State sketch + +One useful implementation model keeps these authoritative relations per domain: + +```text +B_D : active identity-to-key relation +T_D : retired identity/key pairs +Y_D : revoked keys +H_D : immutable lifecycle history +V_D : binding and lifecycle versions +``` + +NIP-FI-LIFECYCLE adds disabled identities and pending replacement lineage. +NIP-FI-DELEG adds relationship state but no delegate binding. NIP-FI-EDGE adds +transport-provenance and replay witnesses. Implementations may use different +storage as long as their observable behavior satisfies the owning normative +documents. + +## Composed direct decision + +The core decision can be read as this equation: + +```text +validated issuer-qualified identity ++ fresh request/connection-bound Nostr proof ++ current durable partial-bijection state ++ current local policy ++ atomic final admission += authority for exactly the proven key and operation +``` + +Preparation gathers immutable evidence and snapshots every dependency without +mutation. Final admission compares exact context and stable contract identities, +checks all deadlines, revalidates changed snapshots, recomputes from current +binding and policy state, then commits replay claims, optional enrollment, and a +receipt atomically. The special concurrent-enrollment normalization is narrow: +an `enroll(i,k)` proposal may become the same eligible `existing(i,k)` result; +a different winner is not equivalent. + +## Profile composition + +Profiles contribute witnesses, never alternate final authority: + +```text +core witnesses +∪ EDGE provenance/replay witnesses +∪ LIFECYCLE eligibility/lineage witnesses +∪ DELEG owner/relationship witnesses +``` + +The lease deadline is the minimum of every bound in the resulting set. A missing +or unreadable required witness denies. A profile cannot remove a core witness, +extend a core deadline, replace the proven actor, or create a second admission +lineage. + +For direct authorization, the path dependency is the normalized assertion and +its current snapshot/status witnesses. For delegated authorization, it is the +exact eligible owner binding plus relationship evidence; direct assertion fields +are absent. Both paths share context resolution, Nostr-proof validation, local +policy, read-only preparation, and atomic final admission. + +## Lifecycle intuition + +Bindings are durable; leases are ephemeral. Retirement makes one exact pair +permanently ineligible for ordinary recreation. Revocation makes a key +ineligible throughout the domain. Rotation retires the old pair and creates a +new binding version but does not globally revoke the old key. Extended lifecycle +operations may add disabled identity and one-shot pending-lineage state, as +specified by NIP-FI-LIFECYCLE. + +## Privacy intuition + +Private reasons collapse to fixed public bytes. In particular, binding +conflicts, tombstones, lifecycle gates, key mismatch, enrollment requirements, +and local-policy decisions are indistinguishable. Operational diagnostics may +retain bounded private reason codes, but such records are not protocol objects +and never become authorization witnesses. + +## Reading order + +1. NIP-FI for core state, wire behavior, invariants, and direct admission. +2. NIP-FI-EDGE for a trusted-enterprise edge. +3. NIP-FI-LIFECYCLE for provisioning, disablement, recovery, and re-enablement. +4. NIP-FI-DELEG for delegated agents. +5. NIP-FI-CONF for claim and evidence rules. diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 69f5e1552d9..2ae530be757 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -1,19 +1,492 @@ -# NIP-FI: Federated Identity Authorization (Core) +NIP-FI +====== -`draft` `optional` +Federated identity authorization — core +---------------------------------------- -> SKELETON — text owner: Wren (core boundary + NIP idiom), integrating Max's -> cross-cutting sections (freshness classes, token typ rules, two contract -> identities). Source designs: RESEARCH/NIP_FI_9999_CORE_BOUNDARY_DESIGN.md, -> RESEARCH/NIP_FI_9999_SECURITY_SEMANTICS_DESIGN.md, PLANS/NIP_FI_9999_DESIGN.md. -> Target: ~350 normative lines. One normative source: FI-INV-01..16 live HERE. +`draft` `optional` `relay` -## Scope +**Protocol dependencies**: NIP-01 and either NIP-42 or NIP-98. Optional +profiles are defined by NIP-FI-EDGE, NIP-FI-LIFECYCLE, NIP-FI-DELEG, and +NIP-FI-CONF. -Issuer-qualified identity (iss, sub); independent Nostr proof; client-attached -assertion transport; partial bijection with durable tombstones; atomic final -admission; bounded leases; private denials with closed response vocabulary; -retire/revoke/rotate transitions; two contract identities -(assertion_policy_id, transport_contract_id); declared freshness class -(offline-jwt | current-status); server-declared body authorization relevance -(NIP-98 payload binding fix); BCP 14; "equivalent" defined; worked wire example. +The key words "MUST", "MUST NOT", "REQUIRED", "SHOULD", "SHOULD NOT", and +"MAY" in this document are to be interpreted as described in BCP 14 (RFC 2119 +and RFC 8174) when, and only when, they appear in all capitals. + +## Abstract + +NIP-FI authorizes a Nostr key only when four independent facts agree: a valid +issuer-qualified identity assertion, fresh proof of that Nostr key, current +identity-to-key binding state, and current local policy for the exact operation. +The identity provider never signs Nostr events, and an assertion never replaces +Nostr proof. + +Bindings outlive individual assertions. Assertions and authorization leases do +not outlive their evidence. This core defines the portable client-attached +assertion transport, direct enrollment, atomic final admission, bounded +sessions, privacy-preserving denial responses, and the smallest useful binding +lifecycle. Companion profiles add trusted edges, extended lifecycle operations, +and delegation without changing the core admission rule. + +This NIP does not define an identity provider, database schema, operator API, +public identity projection, application membership policy, or user interface. + +## Terms and identifier classes + +- **domain** (`D`): an authorization boundary selected only by authenticated + server routing and configuration. +- **identity** (`i`): the exact tuple `(iss, sub)` returned by assertion + validation. Email, display name, employee number, and a bare `sub` are not + identities. +- **target context** (`R_t`): the server-resolved method, authority, path and + query, body semantics, transport, operation, and resource. +- **actor** (`k`): the 32-byte public key returned by Nostr-proof validation. +- **request context** (`R`): `R_t` sealed with `k`. +- **binding**: a durable, versioned association `(D, i, k)` with immutable + provenance `attested-key` or `tofu`. +- **retired pair**: a durable denial fact for an exact `(D, i, k)`. +- **revoked key**: a durable denial fact for `(D, k)`. +- **prepared authorization**: immutable, read-only evidence and witnesses for a + possible admission. +- **committed authorization**: authority returned only after final revalidation + and atomic commit. +- **lease**: a cached committed decision for one actor and bounded operation + set. A lease is not a binding. + +Every identifier is either **interoperability-critical** or +**deployment-local**. Header names, public response bytes, token type values, +and trace identifiers are interoperability-critical and fixed here. +`assertion_policy_id`, `transport_contract_id`, domain IDs, snapshot versions, +binding versions, policy versions, and correlation IDs are deployment-local; +their values are opaque outside a deployment, while their stability and +invalidation behavior are normative. + +## Core security invariants + +These labels are the normative home of the NIP-FI invariants. Companion +profiles may add witnesses and bounds but cannot weaken them. + +1. **`FI-INV-01 — partial bijection.`** Active bindings are one-to-one within a + domain: one identity has at most one active key and one key has at most one + active identity. [FI-TRACE-BINDING-CONFLICT] +2. **`FI-INV-02 — durable binding.`** Assertion expiry removes neither a + binding nor its provenance. Fresh eligible evidence may authorize the same + binding later. [FI-TRACE-ASSERTION-REFRESH] +3. **`FI-INV-03 — tombstone monotonicity.`** Ordinary authorization never + removes a retired-pair or revoked-key fact and never recreates a retired + pair. [FI-TRACE-TOMBSTONE-REPLAY] +4. **`FI-INV-04 — server-owned context.`** Every admitted operation uses one + server-resolved domain, target, resource, operation, and proven actor. + Unauthenticated input cannot replace them. [FI-TRACE-DOMAIN-SPOOF] +5. **`FI-INV-05 — independent evidence.`** Direct authorization requires a + current assertion and fresh Nostr proof. If the assertion names a key, it + equals the proven actor. [FI-TRACE-ASSERTION-KEY-MISMATCH] +6. **`FI-INV-06 — stable assertion policy.`** Assertion-policy identity changes + when accepted assertion semantics change, but not when only authenticated + key or status snapshot contents rotate. [FI-TRACE-VERIFIER-PARITY] +7. **`FI-INV-07 — current-snapshot verification.`** Evidence cannot survive + removal of the key or policy snapshot that authenticated it; a changed + snapshot requires revalidation. [FI-TRACE-JWKS-REMOVE] +8. **`FI-INV-08 — read-only preparation.`** Preparation creates no binding, + tombstone, replay claim, receipt, lease, publication, last-seen value, audit + authority, or application mutation. [FI-TRACE-FINAL-DENIAL-NO-MUTATION] +9. **`FI-INV-09 — atomic final admission.`** Enrollment, replay claims, + receipts, and required authorization evidence commit only after complete + final revalidation, all or none. [FI-TRACE-PREPARED-STALE] +10. **`FI-INV-10 — explicit lifecycle authority.`** Retirement, revocation, + rotation, and profile-defined lifecycle changes occur only through their + separately authorized transition. [FI-TRACE-LIFECYCLE-AUTHORITY] +11. **`FI-INV-11 — evidence-bounded leases.`** A lease ends no later than every + evidence, snapshot, proof, binding, local-policy, and implementation bound + on which it depends. [FI-TRACE-LEASE-BOUND] +12. **`FI-INV-12 — current-owner delegation.`** When NIP-FI-DELEG is claimed, + delegation requires the exact current eligible owner binding, fresh + delegate proof, capability intersection, and a positive finite deadline. + [FI-DELEG-OWNER-CURRENT] +13. **`FI-INV-13 — privacy-safe denial.`** Public rejection is many-to-one and + reveals no identity, key, claim, binding, tombstone, enrollment mode, key + identifier, or private policy fact. [FI-TRACE-DENIAL-ORACLE] +14. **`FI-INV-14 — fail closed.`** Unreadable, ambiguous, stale beyond policy, + or inconsistent evidence or authoritative state cannot produce authority. + [FI-TRACE-DEPENDENCY-FAIL-CLOSED] +15. **`FI-INV-15 — uniform authority.`** Every protected ingress in a domain + uses the same current domain policy and final-admission authority. An + uncovered or competing path is unavailable. [FI-TRACE-AUTHORITY-UNIFORM] +16. **`FI-INV-16 — canonical verifier.`** Assertion transports feed one closed, + provider-neutral normalized-result contract and cannot fork final + admission. [FI-TRACE-VERIFIER-PARITY] + +## Client-attached transport + +Server configuration selects `client-attached` before protected traffic is +accepted. Request fields cannot select, negotiate, or downgrade transport. +Failure never falls back to another transport. [FI-TRACE-TRANSPORT-CLOSED] + +The client sends exactly one field on the request or WebSocket upgrade: + +```text +Nostr-Federated-Identity: Bearer +``` + +`Authorization` remains reserved for NIP-98. Assertion and provenance fields +from any other profile are absent. Missing, repeated, comma-combined, empty, +malformed, non-Bearer, or mixed-profile fields deny. Assertions never appear in +URLs, query parameters, Nostr events, tags, filters, application history, or +public identity projections. [FI-TRACE-TRANSPORT-CLOSED] + +The core transport contract has deployment-local identity +`transport_contract_id`. It deterministically identifies the exact field, +parsing, request-attachment, no-fallback, and context-preservation semantics. +Changing any of those semantics changes the ID; changing request data does not. +[FI-TRACE-CONTRACT-IDENTITIES] + +## Assertion validation + +A configured assertion policy accepts exactly one bounded compact JWS and +returns this closed result: + +```text +VerifiedAssertion = ( + identity = (iss, sub), + asserted_key?, + claims_or_capabilities, + authority_deadlines, // non-empty + assertion_policy_id, + transport_contract_id, + revalidation_dependencies +) +``` + +The verifier rejects ambiguous protected-header or claim members, unknown +critical headers, `alg=none`, symmetric algorithms, algorithm/key mismatch, +incompatible JWK usage, ambiguous key selection, and signatures not valid +under exactly one accepted asymmetric key. It bounds the assertion, headers, +claims, subject, key identifiers, and authenticated key set before lookup or +logging. [FI-TRACE-ASSERTION-VALIDATION] + +The exact `iss` selects an authenticated policy and key source; `iss` and at +least one `aud` value exactly match configured values. `sub` is a non-empty +bounded string. `exp` and `iat` are finite NumericDate values satisfying +`now < exp`, `iat <= now + skew`, and `now < iat + maximum_assertion_age`. +Optional `nbf` satisfies `nbf <= now + skew`. Arithmetic is overflow-safe and +equality at an expiry is expired. [FI-TRACE-ASSERTION-VALIDATION] + +If configured, a Nostr-key claim resolves without ambiguity to one 32-byte key. +Lowercase hexadecimal is canonical. Authorization claims or capabilities use a +closed bounded input set and deterministic canonical encoding. Unchecked claims +never enter the result. [FI-TRACE-VERIFIER-PARITY] + +### Token class + +Policy selects exactly one token class before validation: + +- **portable access token**: an `aud=buzz` access token; when the issuer supports + RFC 9068, the protected `typ` is exactly `at+jwt`; +- **dedicated Buzz assertion**: a separately minted assertion whose protected + `typ` is exactly `nip-fi+jwt`; +- **named compatibility access token**: absent or generic `typ=JWT` only under + an explicit issuer policy with required and forbidden claims that are + mutually exclusive with every accepted ID-token or other JWT class. + +OIDC ID Tokens always deny, even when `iss`, `aud`, and `sub` match. A failed +class never falls back to another class. Token class and all class-specific +rules are inputs to `assertion_policy_id`. [FI-TRACE-TOKEN-CLASS] + +### Policy identity and snapshots + +`assertion_policy_id` is a deployment-local deterministic digest of the +canonical assertion-policy contract: issuer, audience, token class, allowed +algorithms, authenticated key/status sources, identity/key/claim mapping, time +and size rules, normalization, freshness class, and compiled verifier behavior. +A semantic change changes the ID; authenticated snapshot contents, key order, +cache timestamps, and retrieval time do not. [FI-TRACE-CONTRACT-IDENTITIES] + +Mutable state remains in `revalidation_dependencies`, including the assertion +snapshot version, verification-key identity, snapshot hard deadline, optional +status source/version/deadline, and a confidential handle to the exact compact +JWS. Adding, removing, or replacing an accepted key changes the snapshot +version. Changed dependencies require revalidation under the current snapshot; +a retained key may continue, while an absent key denies. Unknown-key refresh is +bounded and coalesced and has no attacker-triggered stale-key fallback. +[FI-TRACE-JWKS-ADD] [FI-TRACE-JWKS-REMOVE] + +The base contract compares the current authenticated snapshot and makes no +anti-rollback promise. A deployment claiming rollback prevention records a +separately authenticated monotonic floor and tests it. [deployment artifact: +assertion-policy review] + +### Freshness class + +Each policy declares exactly one server-owned freshness class, included in +`assertion_policy_id`: + +- **`offline-jwt`** validates the JWT and authenticated key snapshot only. + `upstream_authority_deadline` is the minimum of `exp`, + `iat + maximum_assertion_age`, and the key-snapshot hard deadline. It cannot + truthfully advertise an unconditional upstream-revocation bound. Enabling it + requires deployment evidence that revocation stops new accepted issuance; + discovery reports the residual bound as unknown. [deployment artifact: + issuer revocation review] +- **`current-status`** additionally requires an authenticated witness + `(iss, sub, token_or_session_id?, active=true, observed_at, valid_until, + status_version, authenticated_source_id)`. Subject and optional session + identifier exactly match the assertion. `valid_until` is finite and no later + than `observed_at + maximum_status_age`. The upstream deadline also includes + `valid_until`. Outage cannot mint or extend a witness, though an already + verified witness remains usable until its existing deadline. + [FI-TRACE-CURRENT-STATUS-STALE] + +A current-status deployment advertises a tested +`maximum_residual_upstream_revocation_seconds`; prepared evidence and leases +close within that value after upstream revocation. Poll/cache age and event +processing delay are included. [FI-TRACE-CURRENT-STATUS-REVOKED] + +Before enabling an issuer, the operator records authoritative evidence that +`sub` is stable for the account lifetime, never reassigned, and not intentionally +derived from mutable profile data. An issuer that cannot provide this property +is ineligible. [deployment artifact: issuer subject-stability review] + +## Nostr proof and body semantics + +The actor is always returned by fresh Nostr-proof validation, never by an +assertion or unsigned field. NIP-42 binds its AUTH event to the current +challenge, relay URL, connection, and freshness window. NIP-98 binds its event +to the exact server-resolved URL, method, and freshness window. All evidence +agrees with the same `D` and `R_t`. [FI-TRACE-DOMAIN-SPOOF] + +Each protected HTTP operation declares in server policy whether its body is +authorization-relevant; clients cannot select the declaration. + +For a relevant body, the NIP-98 event contains exactly one `payload` tag equal +to lowercase hexadecimal SHA-256 of the exact bytes consumed by the +application. Absence, duplication, mismatch, validation of only a prefix, or +post-validation transformation denies. For an irrelevant body, no +Authorization decision, target, capability, or effect selector derives from a +body field not bound by NIP-98. [FI-TRACE-BODY-BINDING] + +Every operation has finite body and spool bounds. A known oversized body is +rejected before hashing; a stream is rejected at octet `limit + 1`; admission +waits for EOF. Before EOF there is no application effect, replay mutation, +receipt, or partial digest authority. Quota failure cleans up staged bytes and +denies. [FI-TRACE-BODY-BOUNDS] + +## Direct preparation + +The following is normative pseudocode; every read is from authoritative state. + +```text +PrepareDirect(request, assertion, proof): + (D, R_t, operation, resource) := ResolveTargetContext(request) or DENY + e := ValidateClientAttached(assertion, D, R_t) or DENY + k := ValidateNostrProof(proof, D, R_t) or DENY + R := SealActor(R_t, k) + i := e.identity + + if e.asserted_key exists and e.asserted_key != k: DENY(key_mismatch) + atomically read B_D(i), B_D(k), T_D(i,k), Y_D(k), enrollment policy, + local policy, resource, and all dependency versions + if k in Y_D: DENY(key_revoked) + if (i,k) in T_D: DENY(pair_retired) + + if B_D(i) = B_D(k) = binding(i,k): + proposal := existing(binding.version, binding.provenance) + else if B_D(i) exists or B_D(k) exists: + DENY(binding_conflict) + else if enrollment policy = attested-key: + require e.asserted_key = k + proposal := enroll(i, k, attested-key) + else if enrollment policy = tofu: + proposal := enroll(i, k, e.asserted_key = k ? attested-key : tofu) + + EvaluateLocalPolicy(D, R, operation, resource, k, + e.claims_or_capabilities) or DENY + return PreparedAuthorization(evidence, proposal, witnesses, deadlines) +``` + +TOFU is optional private deployment posture and is not self-advertised. It +accepts that a stolen assertion for a never-enrolled identity can bind an +attacker's proven key; deployments enabling it retain a passing +FI-TRACE-TOFU-THEFT artifact. Binding provenance is immutable. A policy change +affects only future creation. [deployment artifact: TOFU risk review] + +Preparation, including first-use enrollment, is read-only and produces no +authoritative mutation. [FI-TRACE-FINAL-DENIAL-NO-MUTATION] + +## Final admission + +A prepared value is consumed at most once. Final admission first requires an +exact domain, context, operation, resource, actor, and transport match; both +contract IDs unchanged; and every bound live. A changed dependency is reread +and re-evaluated from authoritative evidence. [FI-TRACE-PREPARED-STALE] + +Two verified assertion results are **equivalent** when: + +1. identity-class fields are byte-equal: `iss`, `sub`, asserted-key presence and + value, canonical claims/capabilities, `assertion_policy_id`, and + `transport_contract_id`; +2. each bounds-class deadline is live now and is no later than its prepared + value; and +3. provenance-class fields — snapshot version, verification-key identity, + cache metadata, ordering, and retrieval time — are ignored after successful + current revalidation. + +Any new or unclassified field belongs to the identity class. A fresher assertion +cannot silently extend a prepared decision. [FI-TRACE-PREPARED-STALE] + +Final admission atomically rereads binding, tombstone, revocation, enrollment, +policy, resource, replay, receipt, and invalidation witnesses; recomputes the +complete decision; claims applicable proof replay identities; creates an +eligible proposed binding; and appends its request-bound receipt and required +authorization evidence. All commit or none. A concurrent identical enrollment +may recompute as the same `existing` binding; conflicting enrollment commits at +most one winner. [FI-TRACE-CONCURRENT-ENROLLMENT] + +A failed admission rolls back all authority mutation. The application operation +runs only after committed authorization. If it cannot share the transaction, a +request-bound idempotent receipt prevents the same proof from creating a second +effect. [FI-TRACE-FINAL-DENIAL-NO-MUTATION] + +## Base lifecycle + +Retirement, revocation, and rotation require separate privileged authority +bound to the exact domain, transition, identity, old binding version when +present, target key when present, and request. Each atomically rechecks current +state, appends immutable lifecycle history, and invalidates dependent leases +after commit. Every new target key supplies fresh target-bound Nostr proof and +any policy-required current matching issuer attestation. [FI-TRACE-LIFECYCLE-AUTHORITY] + +- **RetirePair** removes one exact active binding and durably retires its pair. +- **RevokeKey** records the key as revoked even if inactive; if active, it also + removes the binding and retires that pair. Repeating the same authorized + revocation is idempotent. +- **Rotate** replaces one exact active binding with one unused, unrevoked, + non-retired target key, retires the old pair, and creates a fresh binding + version. Rotation does not globally revoke the old key. + +Failure or stale state causes no partial mutation. Ordinary authorization cannot +perform or undo these transitions. Extended recovery, disablement, provisioning, +and administrative expiry are defined only by NIP-FI-LIFECYCLE. + +## Request and session bounds + +HTTP authority covers one exact request and is never reusable. + +A WebSocket lease is scoped to one actor, domain, operation set, binding +version, normalized result, policy/resource versions, and invalidation +witnesses. Its deadline is the earliest assertion, upstream-authority, +key-snapshot, proof/connection, local-policy, and implementation deadline. +Arithmetic is overflow-safe and equality is expired. [FI-TRACE-LEASE-BOUND] + +Before each protected use, the service checks actor, domain, operation, +resource, deadline, binding version, contract IDs, snapshot/status versions, +policy versions, and invalidation state. Changed dependencies require current +revalidation to an equivalent result; unreadable or ineligible state denies. +A lease for one key never authorizes another key on the same connection. +[FI-TRACE-MULTI-KEY-SESSION] + +Expiry ends the lease, not the binding. Renewal requires fresh attached +assertion, fresh Nostr proof, preparation, and final admission. Confidential +assertion revalidation material is destroyed on expiry, close, or invalidation. + +## Rejection and privacy + +Public class is a function only of evidence the requester supplied, never of +private server state. Under the private-posture rule, even `key_mismatch` joins +the private-state anonymity set. + +| Private condition | Public class | Nostr prefix and exact text | HTTP response | +|---|---|---|---| +| assertion/proof absent | `missing_evidence` | `auth-required: authentication required` | `401` + `authentication required\n` | +| malformed, invalid, expired, or replayed evidence | `evidence_rejected` | `restricted: evidence rejected` | `403` + `evidence rejected\n` | +| key mismatch; binding conflict; retired pair; revoked key; lifecycle gate; binding required/expired; local policy denial | `authorization_denied` | `restricted: authorization denied` | `403` + `authorization denied\n` | +| required current dependency unreadable | `authorization_unavailable` | `restricted: authorization unavailable` | `503` + `authorization unavailable\n` | + +Nostr text is the exact UTF-8 text after an applicable NIP-42/NIP-01 prefix; +HTTP body is exact UTF-8 `text/plain` with the shown LF and no other bytes. +Responses contain no free text, reason code, request ID, issuer, subject, key, +claim, binding state, enrollment posture, token material, or timing hint. All +private conditions in `authorization_denied` produce byte-identical responses. +[FI-TRACE-DENIAL-ORACLE] + +NIP-FI defines no public identity projection. Public events, tags, filters, +discovery, responses, logs, metrics, and traces contain no raw assertions or +unredacted `iss`, `sub`, email, display name, or private claim. Access-controlled +authoritative stores retain only what enforcement and investigation require. +A separate presentation protocol cannot confer NIP-FI authority. +[FI-TRACE-PRIVACY-NONPUBLIC] + +## Discovery + +A relay SHOULD advertise core support in NIP-11 as: + +```json +{ + "limitation": { "federated_identity": true }, + "federated_identity": { + "core": "client-attached", + "assertion_freshness": { + "class": "offline-jwt", + "maximum_residual_upstream_revocation_seconds": null + } + } +} +``` + +For `current-status`, the final value is a tested positive integer. Discovery +never states enrollment mode or TOFU posture and never exposes issuer URLs, +audiences, claim names, tenant IDs, or deployment-local identifiers. For every +enrollment policy, including `attested-key`, private `tofu`, and any companion +profile mode, the complete public discovery output is byte-identical: no field, +flag, value, omission, ordering, or object shape may distinguish the configured +mode. Profile documents own only non-enrollment public claims. +[FI-TRACE-DISCOVERY-PRIVATE] + +## Core behavioral oracles + +A core claim covers every applicable oracle below at one implementation and +policy revision. NIP-FI-CONF defines evidence and mutation-adequacy rules. + +| ID | Required outcome | +|---|---| +| `FI-TRACE-TRANSPORT-CLOSED` | Exact one-header input succeeds; missing, repeated, combined, malformed, mixed, URL, and fallback variants deny. | +| `FI-TRACE-ASSERTION-VALIDATION` | Valid boundary input passes; each signature, key-selection, issuer, audience, time, size, and ambiguity negative denies. | +| `FI-TRACE-TOKEN-CLASS` | ID tokens, wrong/generic types outside a named policy, client-only audiences, and cross-class fallback deny. | +| `FI-TRACE-CONTRACT-IDENTITIES` | Each semantic mutation changes only its owning contract ID; snapshot-only mutations change neither. | +| `FI-TRACE-VERIFIER-PARITY` | Equal authoritative input and policy produce the same canonical normalized result. | +| `FI-TRACE-JWKS-ADD` / `REMOVE` | Retained-key rotation can revalidate; removed-key evidence and leases deny. | +| `FI-TRACE-CURRENT-STATUS-REVOKED` / `STALE` | Revocation closes authority within the advertised bound; expiry equality/outage cannot extend a witness. | +| `FI-TRACE-BODY-BINDING` / `BOUNDS` | Exact complete relevant body passes; absent/duplicate/mutated/partial/transformed/oversized/quota variants deny without effects. | +| `FI-TRACE-DOMAIN-SPOOF` | Client routing and forwarded authority cannot replace server-owned context. | +| `FI-TRACE-ASSERTION-KEY-MISMATCH` | Mismatch denies with no mutation and the private-state response. | +| `FI-TRACE-BINDING-CONFLICT` / `TOMBSTONE-REPLAY` | Conflicts and fresh evidence for retired/revoked state deny without replacement. | +| `FI-TRACE-ASSERTION-REFRESH` | Fresh evidence reuses the same eligible durable binding after prior assertion expiry. | +| `FI-TRACE-PREPARED-STALE` | Changed identity-class witnesses or extended bounds deny; provenance-only rotation revalidates. | +| `FI-TRACE-CONCURRENT-ENROLLMENT` | Identical first use converges; conflicting first use commits at most one winner. | +| `FI-TRACE-FINAL-DENIAL-NO-MUTATION` | Every failed phase leaves all authoritative stores and effects unchanged. | +| `FI-TRACE-LIFECYCLE-AUTHORITY` | Unprivileged/stale transitions deny; authorized retirement/revocation/rotation is atomic. | +| `FI-TRACE-LEASE-BOUND` / `MULTI-KEY-SESSION` | Equality expires and one actor's lease never authorizes another. | +| `FI-TRACE-DENIAL-ORACLE` | Each private row produces its exact fixed bytes; all private-state rows compare byte-identical. | +| `FI-TRACE-DEPENDENCY-FAIL-CLOSED` | Each unreadable authoritative dependency denies. | +| `FI-TRACE-AUTHORITY-UNIFORM` | Every protected ingress reaches one current final-admission authority. | +| `FI-TRACE-CROSS-DOMAIN-COLLISION` | Equal subjects across issuers and equal pairs across domains remain distinct. | +| `FI-TRACE-PRIVACY-NONPUBLIC` / `DISCOVERY-PRIVATE` | Private identity does not enter public surfaces; complete discovery bytes remain identical across attested-key, TOFU, and companion enrollment modes. | +| `FI-TRACE-TOFU-THEFT` | Stolen-assertion first use denies unless private TOFU is enabled and the attacker also proves its chosen key. | + +## Security considerations + +Issuer compromise can impersonate a principal but cannot prove an uncompromised +bound Nostr key. Assertion theft cannot use an existing binding without that +key; private TOFU intentionally retains first-use theft risk. Snapshot +revalidation limits removed-key reuse but the base policy accepts authenticated +key-source rollback as residual issuer risk. Two-phase admission closes the +binding and policy TOCTOU window only when every authoritative witness is reread +atomically. Availability failures deny rather than degrade to Nostr-only access. + +## Sources + +- NIP-42 authentication: +- NIP-98 HTTP authentication: +- JWT BCP: +- JWT access-token profile: +- Non-normative composed model: [NIP-FI-MODEL.md](NIP-FI-MODEL.md) From 528c847262c43ede38689a62ca9ff7c72623c31d Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 15 Aug 2026 07:38:18 -0400 Subject: [PATCH 05/20] docs(nips): tighten FI assertion security semantics Co-authored-by: Max Signed-off-by: Max --- docs/nips/NIP-FI.md | 111 +++++++++++++++++++++++++++----------------- 1 file changed, 69 insertions(+), 42 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 2ae530be757..cd7d299fd85 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -179,37 +179,58 @@ never enter the result. [FI-TRACE-VERIFIER-PARITY] ### Token class -Policy selects exactly one token class before validation: +Policy selects exactly one token class before parsing claims: -- **portable access token**: an `aud=buzz` access token; when the issuer supports - RFC 9068, the protected `typ` is exactly `at+jwt`; +- **RFC 9068 access token**: a Buzz-resource access token whose protected + `typ` is exactly `at+jwt` and whose `aud` contains the configured Buzz + resource audience; - **dedicated Buzz assertion**: a separately minted assertion whose protected `typ` is exactly `nip-fi+jwt`; -- **named compatibility access token**: absent or generic `typ=JWT` only under - an explicit issuer policy with required and forbidden claims that are - mutually exclusive with every accepted ID-token or other JWT class. +- **named compatibility access token**: absent or generic protected `typ=JWT` + only under an explicit issuer policy whose required and forbidden claims, + audience, issuer, key source, and validation rules are mutually exclusive + with every accepted ID-token and other JWT class. -OIDC ID Tokens always deny, even when `iss`, `aud`, and `sub` match. A failed -class never falls back to another class. Token class and all class-specific -rules are inputs to `assertion_policy_id`. [FI-TRACE-TOKEN-CLASS] +OIDC ID Tokens always deny, even when `iss`, `aud`, and `sub` match. A generic +or absent type has no stock fallback. Failure under one class never triggers +validation under another. Token class and every class-specific validation rule +are inputs to `assertion_policy_id`. [FI-TRACE-TOKEN-CLASS] ### Policy identity and snapshots -`assertion_policy_id` is a deployment-local deterministic digest of the -canonical assertion-policy contract: issuer, audience, token class, allowed -algorithms, authenticated key/status sources, identity/key/claim mapping, time -and size rules, normalization, freshness class, and compiled verifier behavior. -A semantic change changes the ID; authenticated snapshot contents, key order, -cache timestamps, and retrieval time do not. [FI-TRACE-CONTRACT-IDENTITIES] - -Mutable state remains in `revalidation_dependencies`, including the assertion -snapshot version, verification-key identity, snapshot hard deadline, optional -status source/version/deadline, and a confidential handle to the exact compact -JWS. Adding, removing, or replacing an accepted key changes the snapshot -version. Changed dependencies require revalidation under the current snapshot; -a retained key may continue, while an absent key denies. Unknown-key refresh is -bounded and coalesced and has no attacker-triggered stale-key fallback. -[FI-TRACE-JWKS-ADD] [FI-TRACE-JWKS-REMOVE] +Core has exactly two semantic contract identities: + +```text +assertion_policy_id = H(canonical assertion-policy contract) +transport_contract_id = H(canonical transport contract) +``` + +Each uses one implementation-defined but deterministic, versioned encoding and +collision-resistant hash within a deployment. `assertion_policy_id` covers the +canonical issuer, audience, token class, allowed algorithms, authenticated +key/status-source contracts, identity/key/claim mapping, time and size rules, +normalization, freshness class, and compiled verifier behavior. The verifier +fingerprint is an input, not a third identity. `transport_contract_id` covers +the client-attached field, parsing, attachment, context preservation, and +no-fallback semantics; a companion transport may define its own canonical +contract under that same identity slot. A semantic change changes exactly its +owning ID. [FI-TRACE-CONTRACT-IDENTITIES] + +Mutable contents and deployment state are not contract identities. They remain +in `revalidation_dependencies`: authenticated assertion-snapshot version, +verification-key identity, key-snapshot hard deadline, optional status +source/version/deadline, binding/lifecycle/local-policy/resource versions, +proof and replay witnesses, and a confidential handle to the exact compact JWS. +Adding, removing, or replacing an accepted key changes the snapshot version, +not `assertion_policy_id`. Changed dependencies require revalidation under +current state; a retained key may continue, while an absent key denies. +Unknown-key refresh is bounded and coalesced and has no attacker-triggered +stale-key fallback. [FI-TRACE-JWKS-ADD] [FI-TRACE-JWKS-REMOVE] + +Thus two contract identities do not mean two total version values. Folding a +mutable snapshot into policy identity would make benign rotation change policy +lineage; omitting it would let evidence under a removed key survive. Stable +semantic IDs plus explicit mutable dependency versions preserve both outcomes. The base contract compares the current authenticated snapshot and makes no anti-rollback promise. A deployment claiming rollback prevention records a @@ -223,24 +244,30 @@ Each policy declares exactly one server-owned freshness class, included in - **`offline-jwt`** validates the JWT and authenticated key snapshot only. `upstream_authority_deadline` is the minimum of `exp`, - `iat + maximum_assertion_age`, and the key-snapshot hard deadline. It cannot - truthfully advertise an unconditional upstream-revocation bound. Enabling it - requires deployment evidence that revocation stops new accepted issuance; - discovery reports the residual bound as unknown. [deployment artifact: - issuer revocation review] + `iat + maximum_assertion_age`, and the key-snapshot hard deadline. Token age + bounds assertions minted before revocation; it cannot bound an issuer that + continues minting accepted assertions afterward. Enabling this class therefore + requires deployment evidence that revocation stops new accepted issuance, and + discovery reports the unconditional residual bound as unknown (`null`). It + MUST NOT advertise a finite unconditional residual bound. [deployment + artifact: issuer revocation review] - **`current-status`** additionally requires an authenticated witness `(iss, sub, token_or_session_id?, active=true, observed_at, valid_until, status_version, authenticated_source_id)`. Subject and optional session - identifier exactly match the assertion. `valid_until` is finite and no later - than `observed_at + maximum_status_age`. The upstream deadline also includes - `valid_until`. Outage cannot mint or extend a witness, though an already - verified witness remains usable until its existing deadline. - [FI-TRACE-CURRENT-STATUS-STALE] - -A current-status deployment advertises a tested -`maximum_residual_upstream_revocation_seconds`; prepared evidence and leases -close within that value after upstream revocation. Poll/cache age and event -processing delay are included. [FI-TRACE-CURRENT-STATUS-REVOKED] + identifier exactly match the assertion. Ambiguous, unauthenticated, inactive, + or expired status denies. `valid_until` is finite and no later than + `observed_at + maximum_status_age`. The upstream deadline is the minimum of + the offline assertion deadlines and `valid_until`. Source outage cannot mint + or extend a witness; an already verified witness remains usable only until + its existing `valid_until`. [FI-TRACE-CURRENT-STATUS-STALE] + +A current-status deployment advertises a tested positive +`maximum_residual_upstream_revocation_seconds`. Prepared evidence and leases +close within that value after upstream revocation, including a revocation racing +final admission. Poll/cache age, event-delivery and processing delay, and +enforcement delay all fit within the advertised value. A push implementation +may close authority sooner but cannot claim a value below its tested worst case. +[FI-TRACE-CURRENT-STATUS-REVOKED] Before enabling an issuer, the operator records authoritative evidence that `sub` is stable for the account lifetime, never reassigned, and not intentionally @@ -451,11 +478,11 @@ policy revision. NIP-FI-CONF defines evidence and mutation-adequacy rules. |---|---| | `FI-TRACE-TRANSPORT-CLOSED` | Exact one-header input succeeds; missing, repeated, combined, malformed, mixed, URL, and fallback variants deny. | | `FI-TRACE-ASSERTION-VALIDATION` | Valid boundary input passes; each signature, key-selection, issuer, audience, time, size, and ambiguity negative denies. | -| `FI-TRACE-TOKEN-CLASS` | ID tokens, wrong/generic types outside a named policy, client-only audiences, and cross-class fallback deny. | -| `FI-TRACE-CONTRACT-IDENTITIES` | Each semantic mutation changes only its owning contract ID; snapshot-only mutations change neither. | +| `FI-TRACE-TOKEN-CLASS` | An RFC 9068 `at+jwt` access token and a dedicated `nip-fi+jwt` assertion pass only their selected class. ID tokens, wrong/generic types outside a named compatibility policy, client-only audiences, and every attempted cross-class fallback deny. | +| `FI-TRACE-CONTRACT-IDENTITIES` | Mutate each assertion semantic, transport semantic, and mutable dependency independently: semantic mutations change only their owning contract ID; snapshot/binding/lifecycle/policy/resource/status mutations change neither ID but force current revalidation. | | `FI-TRACE-VERIFIER-PARITY` | Equal authoritative input and policy produce the same canonical normalized result. | | `FI-TRACE-JWKS-ADD` / `REMOVE` | Retained-key rotation can revalidate; removed-key evidence and leases deny. | -| `FI-TRACE-CURRENT-STATUS-REVOKED` / `STALE` | Revocation closes authority within the advertised bound; expiry equality/outage cannot extend a witness. | +| `FI-TRACE-CURRENT-STATUS-REVOKED` / `STALE` | Revocation, including one racing final admission, closes authority within the advertised tested bound. Inactive/ambiguous status denies; expiry equality, outage, delayed events, and changed status versions cannot mint or extend a witness. | | `FI-TRACE-BODY-BINDING` / `BOUNDS` | Exact complete relevant body passes; absent/duplicate/mutated/partial/transformed/oversized/quota variants deny without effects. | | `FI-TRACE-DOMAIN-SPOOF` | Client routing and forwarded authority cannot replace server-owned context. | | `FI-TRACE-ASSERTION-KEY-MISMATCH` | Mismatch denies with no mutation and the private-state response. | From 7dfaa74aa63d3eb9e64ec11ddfe50bd115eb5968 Mon Sep 17 00:00:00 2001 From: Dawn Date: Sat, 15 Aug 2026 07:41:55 -0400 Subject: [PATCH 06/20] docs(nips): specify FI conformance evidence profile Defines what counts as evidence that an implementation has the behavior core and the profiles require: the immutable claim unit, evidence and anti-evidence rules, the complete per-private-condition denial fixture enumeration, mutation adequacy, and the two-implementation interoperability exit test. Three rules carry the weight. Denial fixtures are enumerated per private condition rather than per public class, because a per-class suite compares a class against itself and passes trivially. Mutation adequacy requires each normative requirement to have a singly-applied, attribution-checked, reachability-witnessed mutant killed by its named oracle, because naming an oracle proves a requirement is claimed, not that the oracle can fail. The exit test requires byte-exact request and per-class denial bytes produced from the documents alone by two independent implementations. FI-TRACE-TOFU-THEFT takes a configuration witness only: under the private posture ruling no discovery output distinguishes enrollment mode, so a discovery witness for that oracle cannot exist. Co-authored-by: Dawn Signed-off-by: Dawn --- docs/nips/NIP-FI-CONF.md | 270 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 262 insertions(+), 8 deletions(-) diff --git a/docs/nips/NIP-FI-CONF.md b/docs/nips/NIP-FI-CONF.md index 9ea85005012..e36aec439af 100644 --- a/docs/nips/NIP-FI-CONF.md +++ b/docs/nips/NIP-FI-CONF.md @@ -1,13 +1,267 @@ -# NIP-FI-CONF: Conformance Profile +NIP-FI-CONF +=========== + +Conformance evidence profile +---------------------------- `draft` `optional` -> SKELETON — text owner: Dawn. Source: PLANS/NIP_FI_9999_CORRECTNESS_KIT.md. +**Dependencies**: NIP-FI core. Applies additionally to any claimed +NIP-FI-EDGE, NIP-FI-LIFECYCLE, and NIP-FI-DELEG profile. + +The key words "MUST", "MUST NOT", "REQUIRED", "SHOULD", "SHOULD NOT", and +"MAY" in this document are to be interpreted as described in BCP 14 (RFC 2119 +and RFC 8174) when, and only when, they appear in all capitals. + +## Abstract + +NIP-FI core and its profiles state required behavior. This profile states what +counts as evidence that an implementation has it: the claim unit, the evidence +rules, the complete denial-fixture enumeration, mutation adequacy, and the +interoperability exit test. + +This profile is separately claimable and is never advertised in discovery. +Conformance is a property of a reviewed revision, not a wire feature, and a +public claim of it would be an unverifiable assertion about the server's own +testing. + +This profile defines no wire behavior, denial mapping, invariant, or admission +rule. Where it names one, NIP-FI core or the owning profile is normative. + +## Claim unit + +A conformance claim names exactly one immutable tuple: + +```text +(implementation revision, + adapter revision, + build artifact digest, + deployment revision, + claimed profiles, + assertion_policy_id, + transport_contract_id, + enrollment mode) +``` + +Changing any element creates a new claim. Results from one tuple MUST NOT be +carried into another. A report contains every applicable oracle from core and +every claimed profile exactly once, with status `pass` or `not-applicable` +only. Blank, skipped, expected-failure, and not-run results cannot support a +claim (`FI-CONF-CLAIM-COMPLETE`). + +Enrollment mode is part of the claim unit and is private. It is recorded in the +access-controlled report, never in discovery or any public artifact. + +## Evidence rules + +Each passing oracle records the claim tuple, a stable test identifier and +adapter entry point, the command with start time, end time, exit status, and +any random seed, the synthetic input or a privacy-safe digest of it, the +before-and-after authoritative state relevant to the oracle, the expected +outcome and the observed outcome, and artifact locations with SHA-256 digests. +Stateful oracles use an isolated database or namespace and inspect committed +state rather than inferring it from a response. Concurrency oracles record +every contender and the single serialized outcome. Time-boundary oracles use a +controlled clock. + +Adapters MUST drive public or production-equivalent entry points. A storage +helper MAY inspect state or inject a dependency outage; it MUST NOT replace the +operation under test. Calling an internal authorization function without +traversing the protected ingress does not satisfy ingress coverage. + +None of the following satisfies any oracle: searching source, documentation, +schemas, or binaries for a token; asserting that a route calls a named +function; recording a test name without its execution result; using a mock to +prove a deployed network boundary; citing a check from another revision; or +marking an oracle passed because the feature is configured. + +`FI-TRACE-TOFU-THEFT` takes an access-controlled **configuration** witness +only. Under the private-posture rule no discovery output distinguishes +enrollment mode, so a discovery witness for that oracle cannot exist; requiring +one would make the oracle unsatisfiable. Discovery invariance is proved +separately by `FI-TRACE-DISCOVERY-PRIVATE`, which compares complete discovery +bytes across enrollment modes. + +Deployment-obligation requirements — those marked in core or a profile as +`[deployment artifact: ...]` — are evidenced by the named access-controlled +review record at the claimed deployment revision, not by a behavioral oracle. +A claim listing an artifact without the record is incomplete. + +Reports and artifacts hold private deployment detail and MUST remain access +controlled. They MUST NOT enter public reports, examples, discovery, or +protocol output, and MUST NOT contain raw assertions, secrets, or unredacted +`iss`, `sub`, or claim values. + +## Denial fixtures + +`FI-TRACE-DENIAL-ORACLE` requires one fixture per **private condition**, not +one per public class. A per-class suite passes trivially: it compares a class +against itself. The enumeration below is the required fixture set +(`FI-CONF-DENIAL-FIXTURES`). Its public-class column restates NIP-FI core, +which owns that mapping and the exact response bytes. + +| # | Private condition | Public class | Defined by | +|---|---|---|---| +| 1 | assertion, proof, or delegation evidence absent | `missing_evidence` | core | +| 2 | evidence present but rejected: signature, key selection, issuer, audience, time, size, ambiguity, token class, body binding, or edge provenance/replay | `evidence_rejected` | core, NIP-FI-EDGE | +| 3 | `key_mismatch` — asserted key is not the proven actor | `authorization_denied` | core | +| 4 | `attestation_required` — attested-key enrollment without a matching key claim | `authorization_denied` | core | +| 5 | `binding_conflict` — either side of the active relation is taken | `authorization_denied` | core | +| 6 | `pair_retired` | `authorization_denied` | core | +| 7 | `key_revoked` | `authorization_denied` | core | +| 8 | `policy_denied` — local operation policy | `authorization_denied` | core | +| 9 | `binding_required` — provisioned mode, no binding | `authorization_denied` | NIP-FI-LIFECYCLE | +| 10 | `identity_disabled` | `authorization_denied` | NIP-FI-LIFECYCLE | +| 11 | `explicit_replacement_required` — pending lineage | `authorization_denied` | NIP-FI-LIFECYCLE | +| 12 | `binding_expired` — administrative expiry | `authorization_denied` | NIP-FI-LIFECYCLE | +| 13 | `delegation_not_current` — owner or relationship no longer current | `authorization_denied` | NIP-FI-DELEG | +| 14 | `dependency_unreadable` | `authorization_unavailable` | core | + +The names in the private-condition column are fixture identifiers for this +enumeration. Four of them — `key_mismatch`, `binding_conflict`, `pair_retired`, +and `key_revoked` — are core's own denial symbols; the rest name conditions that +core and the profiles define in prose. None is a wire value, and a deployment +MAY use different private reason codes internally as long as every enumerated +condition has a fixture. + +Rows 3–13 are the private-state anonymity set. Their public responses MUST +compare byte-identical to each other, not merely equal in prefix or status. +Rows for an unclaimed profile are `not-applicable` with absence evidence. A +profile that introduces a new private condition MUST add its row; an +unenumerated condition escapes this oracle entirely. + +**Compared object.** Byte-identity is asserted over the response bytes an +implementation chooses, which excludes bytes a conforming HTTP server cannot +hold constant. Over Nostr the compared object is the complete relay message +excluding only the event or subscription identifier echoed from the request. +Over HTTP it is the status code, the ordered sequence of header field names, +every header field value except `Date`, and the complete body. `Date` is +excluded because RFC 9110 Section 6.6.1 requires an origin server with a clock +to generate it on every 4xx response, so two denials at different instants can +never be identical over the literal wire bytes; a suite comparing those would +fail every conforming implementation. Any other excluded field MUST be named in +the report with the reason it cannot be held constant, and its value MUST be +independent of the private condition. + +The oracle runs a fixed positive iteration count on a pinned isolated runner at +the exact claimed head. Before the run the operator records the environment, +public-response corpus, bounds, sampling method, statistical rule, noise +treatment, and acceptance threshold. A breach fails the gate, MUST NOT trigger +an automatic retry, and is retained and investigated before a separately +authorized rerun. + +`authorization_unavailable` is observably distinct from `authorization_denied`. +This is accepted residual: it discloses no per-principal state, and collapsing +it would make fail-closed behavior undiagnosable. + +The suite MUST include a negative control: an implementation deliberately +patched to vary its denial response by private condition MUST fail this oracle. +Without it the suite asserts that it works instead of demonstrating it. + +## Mutation adequacy + +Naming an oracle for a requirement proves the requirement is claimed, not that +the oracle can fail. A requirement whose oracle cannot fail is untested and +reads as tested, which is worse than an acknowledged gap. + +For each normative requirement in core and each claimed profile, the suite MUST +retain at least one **mutant**: an implementation variant that violates exactly +that requirement, together with the failing output of the oracle that requirement +names (`FI-CONF-MUTATION`). Evidence is the exact patch identity, the oracle +identifier, and the retained failure output at the claimed head. + +Four rules make the mutant meaningful: + +1. **One at a time.** Mutants are applied singly against an otherwise unmodified + implementation. Layered defenses mask each other: a guard looks covered + because a different guard denies first. +2. **Attribution.** The kill MUST come from the oracle the requirement names. A + mutant killed only by some other oracle establishes coverage for neither. +3. **Reachability.** The suite MUST witness that a fixture reaches the mutated + decision, not merely the enclosing operation. A mutant behind a bound, + length field, or earlier denial that no fixture ever passes is never + exercised, and the suite reports clean on an implementation that is + provably broken. +4. **Survivors are recorded.** A mutant its named oracle fails to kill is a + defect in the specification or the suite. It is recorded with that + disposition and MUST NOT be waived or replaced by an easier mutant. + +Two global controls bound the suite from both sides. A deny-everything +implementation MUST fail every positive oracle, proving each oracle has a +positive arm. An allow-everything implementation MUST fail every negative +oracle, proving each has a negative arm. Neither control substitutes for +per-requirement mutants; an implementation can pass both while violating any +individual requirement. + +## Interoperability exit test + +A claim of core conformance requires evidence that the document alone is +sufficient to build against (`FI-CONF-INTEROP-EXIT`). Two implementations that +have not shared code and have not consulted a common reference implementation +each produce, from NIP-FI core and any claimed profile documents alone: + +- one byte-exact valid `client-attached` request, over WebSocket upgrade and + over HTTP; and +- one byte-exact public denial response for each of the four public classes, on + both transports, compared over the object defined under **Denial fixtures**. + +The evidence is the produced bytes, the document revision used, and a statement +of independence. The test passes when the outputs compare equal byte for byte +and each implementation accepts the other's valid request and reproduces the +other's denials. Any divergence traced to an underspecified value is a defect +in the specification, not in either implementation, and is fixed there. + +## Applicability + +`not-applicable` requires a machine-readable reason and behavioral proof that +the surface is absent: + +- edge oracles only when no trusted-edge profile is accepted, none is + advertised, and executable cases reject every trusted-edge evidence shape; +- snapshot-rotation oracles only when no local key or status snapshot source is + configured and executable evidence proves the absence; +- `FI-TRACE-TOFU-THEFT` only when TOFU is neither configurable nor configured + and executable first-use cases deny; +- lifecycle and delegation oracles only when the profile is unclaimed, disabled, + and denied on every ingress; and +- every other oracle is required for an enforcing deployment. + +An implementation that supports an optional surface runs its oracles even when +one deployed domain does not activate it. + +## Release gate + +Before NIP-FI enforcement or discovery is enabled, reviewers verify that one +immutable claim tuple passes every applicable oracle at one reviewed revision; +that the protected-ingress inventory has no uncovered or competing authority; +that every core requirement has a killed, attributed, reachable mutant and every +survivor is recorded; that the denial-fixture enumeration is complete for the +claimed profiles and its negative control fails as required; that the +interoperability exit test has passed against an independent implementation; +that every named deployment artifact exists at the claimed deployment revision; +and that public and operational sinks pass privacy-canary inspection. + +Documentation review, source review, and static scans are useful review inputs. +They close no item in this gate. + +## Behavioral oracles + +| ID | Required outcome | +|---|---| +| `FI-CONF-CLAIM-COMPLETE` | A report missing an applicable oracle, duplicating one, carrying a result from another claim tuple, or claiming a status other than `pass`/`not-applicable` is rejected. | +| `FI-CONF-DENIAL-FIXTURES` | Every enumerated private condition has a fixture; anonymity-set responses compare byte-identical; the distinguishing negative control fails. | +| `FI-CONF-MUTATION` | Every normative requirement has a singly-applied, attributed, reachability-witnessed mutant killed by its named oracle; survivors are recorded, not waived. | +| `FI-CONF-INTEROP-EXIT` | Two independent implementations produce byte-identical valid requests and per-class denials from the documents alone and accept each other's output. | + +## Security considerations -## Scope +Conformance evidence is a privileged artifact: it enumerates private denial +conditions, enrollment posture, and deployment topology that the protocol +deliberately keeps off the wire. Publishing a report, a fixture corpus, or a +mutant catalogue would disclose exactly what `FI-INV-13` and +`FI-TRACE-DISCOVERY-PRIVATE` protect. -Claim tuple + evidence rules; per-profile trace subsets (core traces live in -core); 12-row denial reason→class mapping with closed public-response -vocabulary + byte-identical-response traces; mutation adequacy (one mutant per -core MUST, caught by its named trace); interop exit test (byte-exact request + -denial per class derivable from core doc alone). +A passing suite bounds the behaviors it exercises and nothing else. Mutation +adequacy raises the cost of a masked defect; it does not prove absence of +defects, and a claim that cites this profile as proof of security rather than +of tested behavior is misusing it. From 8fad46d5cbd97e255cdc01ebe7c8e3bd2aea9e64 Mon Sep 17 00:00:00 2001 From: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Date: Sat, 15 Aug 2026 11:44:23 -0400 Subject: [PATCH 07/20] docs(nip-fi): close direct enrollment denials Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> --- docs/nips/NIP-FI.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index cd7d299fd85..8bfa34d4415 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -321,10 +321,12 @@ PrepareDirect(request, assertion, proof): else if B_D(i) exists or B_D(k) exists: DENY(binding_conflict) else if enrollment policy = attested-key: - require e.asserted_key = k + if e.asserted_key != k: DENY(attestation_required) proposal := enroll(i, k, attested-key) else if enrollment policy = tofu: proposal := enroll(i, k, e.asserted_key = k ? attested-key : tofu) + else: + DENY(binding_required) EvaluateLocalPolicy(D, R, operation, resource, k, e.claims_or_capabilities) or DENY @@ -426,7 +428,7 @@ the private-state anonymity set. |---|---|---|---| | assertion/proof absent | `missing_evidence` | `auth-required: authentication required` | `401` + `authentication required\n` | | malformed, invalid, expired, or replayed evidence | `evidence_rejected` | `restricted: evidence rejected` | `403` + `evidence rejected\n` | -| key mismatch; binding conflict; retired pair; revoked key; lifecycle gate; binding required/expired; local policy denial | `authorization_denied` | `restricted: authorization denied` | `403` + `authorization denied\n` | +| key mismatch; attestation required; binding conflict; retired pair; revoked key; lifecycle gate; binding required/expired; local policy denial | `authorization_denied` | `restricted: authorization denied` | `403` + `authorization denied\n` | | required current dependency unreadable | `authorization_unavailable` | `restricted: authorization unavailable` | `503` + `authorization unavailable\n` | Nostr text is the exact UTF-8 text after an applicable NIP-42/NIP-01 prefix; From 99d9155e6f135c7ceda36cbb21bf28af9b739df3 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 15 Aug 2026 11:45:19 -0400 Subject: [PATCH 08/20] docs(nip-fi): require domain and issuer separation Co-authored-by: Max Signed-off-by: Max --- docs/nips/NIP-FI.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 8bfa34d4415..1d1889ea733 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -54,6 +54,11 @@ public identity projection, application membership policy, or user interface. - **lease**: a cached committed decision for one actor and bounded operation set. A lease is not a binding. +Identity and authorization-state comparisons preserve every tuple component. +Equal `sub` values under different `iss` values are distinct identities; equal +`(i, k)` pairs under different domains are distinct bindings, retired pairs, +and authorization state. [FI-TRACE-CROSS-DOMAIN-COLLISION] + Every identifier is either **interoperability-critical** or **deployment-local**. Header names, public response bytes, token type values, and trace identifiers are interoperability-critical and fixed here. From 1211a71591130b1b9f509fb06edfdc8f4cdb450a Mon Sep 17 00:00:00 2001 From: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Date: Sat, 15 Aug 2026 12:39:10 -0400 Subject: [PATCH 09/20] docs(nip-fi): close core interoperability gaps Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> --- docs/nips/NIP-FI-DELEG.md | 1 + docs/nips/NIP-FI.md | 41 ++++++++++++++++++++++++--------------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/docs/nips/NIP-FI-DELEG.md b/docs/nips/NIP-FI-DELEG.md index 5ba8421efff..4b7ec253ec0 100644 --- a/docs/nips/NIP-FI-DELEG.md +++ b/docs/nips/NIP-FI-DELEG.md @@ -37,6 +37,7 @@ DelegationEvidence = ( relationship_revision, audience, operations, + conditions, resource_or_target, not_before?, mandatory_expiry diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 1d1889ea733..f987dfa490e 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -44,7 +44,8 @@ public identity projection, application membership policy, or user interface. - **actor** (`k`): the 32-byte public key returned by Nostr-proof validation. - **request context** (`R`): `R_t` sealed with `k`. - **binding**: a durable, versioned association `(D, i, k)` with immutable - provenance `attested-key` or `tofu`. + provenance `attested-key`, `tofu`, or a companion-profile value such as + `provisioned`. - **retired pair**: a durable denial fact for an exact `(D, i, k)`. - **revoked key**: a durable denial fact for `(D, k)`. - **prepared authorization**: immutable, read-only evidence and witnesses for a @@ -396,7 +397,9 @@ any policy-required current matching issuer attestation. [FI-TRACE-LIFECYCLE-AUT revocation is idempotent. - **Rotate** replaces one exact active binding with one unused, unrevoked, non-retired target key, retires the old pair, and creates a fresh binding - version. Rotation does not globally revoke the old key. + version. The replacement provenance is `attested-key` when current matching + issuer attestation was validated and `provisioned` otherwise. Rotation does + not globally revoke the old key. Failure or stale state causes no partial mutation. Ordinary authorization cannot perform or undo these transitions. Extended recovery, disablement, provisioning, @@ -419,8 +422,9 @@ revalidation to an equivalent result; unreadable or ineligible state denies. A lease for one key never authorizes another key on the same connection. [FI-TRACE-MULTI-KEY-SESSION] -Expiry ends the lease, not the binding. Renewal requires fresh attached -assertion, fresh Nostr proof, preparation, and final admission. Confidential +Expiry ends the lease, not the binding. Renewal requires a new connection with +a fresh assertion attached to its WebSocket upgrade, fresh Nostr proof, +preparation, and final admission; there is no in-band renewal path. Confidential assertion revalidation material is destroyed on expiry, close, or invalidation. ## Rejection and privacy @@ -431,13 +435,17 @@ the private-state anonymity set. | Private condition | Public class | Nostr prefix and exact text | HTTP response | |---|---|---|---| -| assertion/proof absent | `missing_evidence` | `auth-required: authentication required` | `401` + `authentication required\n` | -| malformed, invalid, expired, or replayed evidence | `evidence_rejected` | `restricted: evidence rejected` | `403` + `evidence rejected\n` | -| key mismatch; attestation required; binding conflict; retired pair; revoked key; lifecycle gate; binding required/expired; local policy denial | `authorization_denied` | `restricted: authorization denied` | `403` + `authorization denied\n` | -| required current dependency unreadable | `authorization_unavailable` | `restricted: authorization unavailable` | `503` + `authorization unavailable\n` | - -Nostr text is the exact UTF-8 text after an applicable NIP-42/NIP-01 prefix; -HTTP body is exact UTF-8 `text/plain` with the shown LF and no other bytes. +| assertion/proof absent | `missing_evidence` | `auth-required: authentication required` | `401`; `WWW-Authenticate: Nostr`; `Content-Type: text/plain; charset=utf-8`; `authentication required\n` | +| malformed, invalid, expired, or replayed evidence | `evidence_rejected` | `restricted: evidence rejected` | `403`; `Content-Type: text/plain; charset=utf-8`; `evidence rejected\n` | +| key mismatch; attestation required; binding conflict; retired pair; revoked key; lifecycle gate; binding required/expired; local policy denial | `authorization_denied` | `restricted: authorization denied` | `403`; `Content-Type: text/plain; charset=utf-8`; `authorization denied\n` | +| required current dependency unreadable | `authorization_unavailable` | `restricted: authorization unavailable` | `503`; `Content-Type: text/plain; charset=utf-8`; `authorization unavailable\n` | + +Nostr text is the exact UTF-8 text after an applicable NIP-42/NIP-01 prefix. +For HTTP, the compared denial contract is closed over the status, complete body, +and exact values of only the header fields named in the table; header order and +other fields are outside that contract and their values cannot depend on the +private condition. The body is the shown UTF-8 bytes with one LF and no other +bytes. The `Nostr` challenge satisfies RFC 9110 Section 15.5.2. Responses contain no free text, reason code, request ID, issuer, subject, key, claim, binding state, enrollment posture, token material, or timing hint. All private conditions in `authorization_denied` produce byte-identical responses. @@ -469,11 +477,12 @@ A relay SHOULD advertise core support in NIP-11 as: For `current-status`, the final value is a tested positive integer. Discovery never states enrollment mode or TOFU posture and never exposes issuer URLs, -audiences, claim names, tenant IDs, or deployment-local identifiers. For every -enrollment policy, including `attested-key`, private `tofu`, and any companion -profile mode, the complete public discovery output is byte-identical: no field, -flag, value, omission, ordering, or object shape may distinguish the configured -mode. Profile documents own only non-enrollment public claims. +audiences, claim names, tenant IDs, or deployment-local identifiers. For a fixed +set of claimed profiles, the complete public discovery output is byte-identical +for every enrollment policy, including `attested-key`, private `tofu`, and any +companion profile mode: no field, flag, value, omission, ordering, or object shape +may distinguish the configured mode. Profile documents own only non-enrollment +public claims. [FI-TRACE-DISCOVERY-PRIVATE] ## Core behavioral oracles From 6b450122aca94905544a087a3dd7cb3bae6e6f9e Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 15 Aug 2026 12:42:03 -0400 Subject: [PATCH 10/20] docs(nip-fi): close assertion and lifecycle semantics Co-authored-by: Max Signed-off-by: Max --- docs/nips/NIP-FI-LIFECYCLE.md | 40 ++++++++++------------------------- docs/nips/NIP-FI-MODEL.md | 2 +- docs/nips/NIP-FI.md | 32 ++++++++++++++++++++++------ 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/docs/nips/NIP-FI-LIFECYCLE.md b/docs/nips/NIP-FI-LIFECYCLE.md index acb15b3e36f..c86f4a7a57b 100644 --- a/docs/nips/NIP-FI-LIFECYCLE.md +++ b/docs/nips/NIP-FI-LIFECYCLE.md @@ -5,10 +5,10 @@ ## Abstract This profile extends NIP-FI with provisioned enrollment, identity disablement, -recovery, re-enablement, and an administrative binding-expiry gate. It is for -deployments whose binding changes require separately authorized operator or -enterprise workflows. It does not change NIP-FI assertion validation, Nostr -proof, final admission, or public denial semantics. +re-enablement, and an administrative binding-expiry gate. It is for deployments +whose binding changes require separately authorized operator or enterprise +workflows. It does not change NIP-FI assertion validation, Nostr proof, final +admission, or public denial semantics. The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this @@ -135,22 +135,6 @@ Applying an authorized disablement repeatedly is idempotent. It MUST NOT erase or replace existing lineage. If `i` has no active binding, disablement creates no lineage (`FI-LC-DISABLE`). -### Recover - -```text -Recover(i, old_binding_version, k_new): - require i is not in X_D - require Q_D(i) = (i, k_old, old_binding_version) - require TargetEligible(i, k_new, false) - require fresh target-key evidence - consume that exact Q_D(i) - create Binding(i, k_new, new_version, ReplacementProvenance(evidence)) -``` - -Recovery preserves the old retired pair. It cannot consume absent, stale, or -different lineage and cannot recover a disabled identity -(`FI-LC-RECOVER`). - ### Re-enable identity ```text @@ -192,9 +176,9 @@ restore access; ordinary authorization cannot renew the bound Consumption of `Q_D` and creation of its replacement binding MUST be one compare-and-commit operation over the exact pending lineage. Of two concurrent -recoveries or re-enablings presenting the same lineage, at most one can commit. -The loser observes changed state and denies without creating a binding, -consuming another lineage, or changing history (`FI-LC-QD-ONCE`). +re-enablings presenting the same lineage, at most one can commit. The loser +observes changed state and denies without creating a binding, consuming another +lineage, or changing history (`FI-LC-QD-ONCE`). A lifecycle transition racing ordinary final admission is ordered by the same authoritative state transaction or dependency check. If the lifecycle commit @@ -218,10 +202,9 @@ denied case. | `FI-LC-TARGET-PROOF` | Missing, stale, wrong-key, wrong-request, or mismatched required attestation for a new target denies without mutation. | | `FI-LC-PROVISION` | Ordinary first use in provisioned mode denies; authorized provisioning creates one binding and no lease; later current ordinary admission may use it. | | `FI-LC-DISABLE` | Disabling an active identity atomically disables it, retires its exact pair, records exact lineage, and closes subsequent lease use; replay is idempotent and preserves lineage. | -| `FI-LC-RECOVER` | Exact pending lineage plus an eligible proven target creates one replacement and consumes that lineage; disabled, absent, stale, or mismatched lineage denies. | -| `FI-LC-REENABLE` | Re-enablement creates an eligible proven binding in the same commit that clears disabled state; a clear-only attempt and wrong lineage deny. | +| `FI-LC-REENABLE` | Re-enablement creates an eligible proven binding in the same commit that clears disabled state; absent or wrong expected lineage and a clear-only attempt deny. | | `FI-LC-ADMIN-EXPIRY` | Before the bound the binding may authorize; at equality it denies while still occupying the relation; only an authorized version-checked update changes the bound. | -| `FI-LC-QD-ONCE` | Two concurrent transitions consume the same `Q_D` lineage; exactly one commits and the loser leaves every authoritative store unchanged. | +| `FI-LC-QD-ONCE` | Two concurrent re-enablings consume the same `Q_D` lineage; exactly one commits and the loser leaves every authoritative store unchanged. | | `FI-LC-RACE` | Race each transition against prepared ordinary admission and lease use; no operation authorizes after observing the advanced lifecycle or binding dependency. | ## Security considerations @@ -233,9 +216,8 @@ approval UX or key custody. Disabled identities, retired pairs, revoked keys, and pending lineage serve different purposes. Re-enablement removes only the exact disabled fact and -optional exact lineage named by its transition. Recovery consumes lineage but -never removes a retired pair. No transition in this profile removes a core -revoked-key or retired-pair fact. +optional exact lineage named by its transition. No transition in this profile +removes a core revoked-key or retired-pair fact. Administrative expiry is local policy, not upstream revocation freshness. It cannot extend an assertion, status witness, Nostr proof, or lease deadline. diff --git a/docs/nips/NIP-FI-MODEL.md b/docs/nips/NIP-FI-MODEL.md index 95c0a073d29..821477755cc 100644 --- a/docs/nips/NIP-FI-MODEL.md +++ b/docs/nips/NIP-FI-MODEL.md @@ -91,6 +91,6 @@ and never become authorization witnesses. 1. NIP-FI for core state, wire behavior, invariants, and direct admission. 2. NIP-FI-EDGE for a trusted-enterprise edge. -3. NIP-FI-LIFECYCLE for provisioning, disablement, recovery, and re-enablement. +3. NIP-FI-LIFECYCLE for provisioning, disablement, and re-enablement. 4. NIP-FI-DELEG for delegated agents. 5. NIP-FI-CONF for claim and evidence rules. diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index f987dfa490e..b09e1f0746a 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -178,8 +178,11 @@ bounded string. `exp` and `iat` are finite NumericDate values satisfying Optional `nbf` satisfies `nbf <= now + skew`. Arithmetic is overflow-safe and equality at an expiry is expired. [FI-TRACE-ASSERTION-VALIDATION] -If configured, a Nostr-key claim resolves without ambiguity to one 32-byte key. -Lowercase hexadecimal is canonical. Authorization claims or capabilities use a +The Nostr-key claim is named `nostr_pubkey`. When present it MUST be a +lowercase hexadecimal encoding of exactly one 32-byte Nostr public key; other +encodings and aliases deny. In `attested-key` enrollment policy and wherever +current matching issuer attestation is required, this exact claim MUST be +present and equal the proven actor. Authorization claims or capabilities use a closed bounded input set and deterministic canonical encoding. Unchecked claims never enter the result. [FI-TRACE-VERIFIER-PARITY] @@ -199,8 +202,15 @@ Policy selects exactly one token class before parsing claims: OIDC ID Tokens always deny, even when `iss`, `aud`, and `sub` match. A generic or absent type has no stock fallback. Failure under one class never triggers -validation under another. Token class and every class-specific validation rule -are inputs to `assertion_policy_id`. [FI-TRACE-TOKEN-CLASS] +validation under another. An RFC 9068 token MUST contain one non-empty bounded +`client_id`. Issuer policy MUST distinguish a resource-owner token from a token +whose subject represents the OAuth client, including a client-credentials token, +using authenticated claim semantics and mutually exclusive validation rules. A +token that admits both interpretations denies. If client-subject tokens are +accepted, the issuer MUST guarantee that their `(iss, sub)` coordinates cannot +collide with resource-owner coordinates; otherwise that token class is +ineligible. Token class and every class-specific validation rule are inputs to +`assertion_policy_id`. [FI-TRACE-TOKEN-CLASS] ### Policy identity and snapshots @@ -275,6 +285,13 @@ enforcement delay all fit within the advertised value. A push implementation may close authority sooner but cannot claim a value below its tested worst case. [FI-TRACE-CURRENT-STATUS-REVOKED] +An external capability projection whose removal is required to close authority +within a declared revocation bound MUST enter authoritative local-policy state, +not `claims_or_capabilities` from the assertion. That state is reread during +preparation, final admission, and protected lease use. A deployment that carries +such a projection only in assertions cannot claim a revocation bound for its +changes. [FI-TRACE-CAPABILITY-REVOCATION] + Before enabling an issuer, the operator records authoritative evidence that `sub` is stable for the account lifetime, never reassigned, and not intentionally derived from mutable profile data. An issuer that cannot provide this property @@ -402,8 +419,8 @@ any policy-required current matching issuer attestation. [FI-TRACE-LIFECYCLE-AUT not globally revoke the old key. Failure or stale state causes no partial mutation. Ordinary authorization cannot -perform or undo these transitions. Extended recovery, disablement, provisioning, -and administrative expiry are defined only by NIP-FI-LIFECYCLE. +perform or undo these transitions. Extended disablement, re-enablement, +provisioning, and administrative expiry are defined only by NIP-FI-LIFECYCLE. ## Request and session bounds @@ -494,11 +511,12 @@ policy revision. NIP-FI-CONF defines evidence and mutation-adequacy rules. |---|---| | `FI-TRACE-TRANSPORT-CLOSED` | Exact one-header input succeeds; missing, repeated, combined, malformed, mixed, URL, and fallback variants deny. | | `FI-TRACE-ASSERTION-VALIDATION` | Valid boundary input passes; each signature, key-selection, issuer, audience, time, size, and ambiguity negative denies. | -| `FI-TRACE-TOKEN-CLASS` | An RFC 9068 `at+jwt` access token and a dedicated `nip-fi+jwt` assertion pass only their selected class. ID tokens, wrong/generic types outside a named compatibility policy, client-only audiences, and every attempted cross-class fallback deny. | +| `FI-TRACE-TOKEN-CLASS` | An RFC 9068 `at+jwt` access token and a dedicated `nip-fi+jwt` assertion pass only their selected class. ID tokens, wrong/generic types outside a named compatibility policy, client-only audiences, absent or ambiguous `client_id`, resource-owner/client-subject ambiguity, and every attempted cross-class fallback deny. | | `FI-TRACE-CONTRACT-IDENTITIES` | Mutate each assertion semantic, transport semantic, and mutable dependency independently: semantic mutations change only their owning contract ID; snapshot/binding/lifecycle/policy/resource/status mutations change neither ID but force current revalidation. | | `FI-TRACE-VERIFIER-PARITY` | Equal authoritative input and policy produce the same canonical normalized result. | | `FI-TRACE-JWKS-ADD` / `REMOVE` | Retained-key rotation can revalidate; removed-key evidence and leases deny. | | `FI-TRACE-CURRENT-STATUS-REVOKED` / `STALE` | Revocation, including one racing final admission, closes authority within the advertised tested bound. Inactive/ambiguous status denies; expiry equality, outage, delayed events, and changed status versions cannot mint or extend a witness. | +| `FI-TRACE-CAPABILITY-REVOCATION` | Removal of a revocation-bounded external capability projection from authoritative local policy closes prepared evidence and lease use within the declared bound; assertion-only projection cannot satisfy this oracle. | | `FI-TRACE-BODY-BINDING` / `BOUNDS` | Exact complete relevant body passes; absent/duplicate/mutated/partial/transformed/oversized/quota variants deny without effects. | | `FI-TRACE-DOMAIN-SPOOF` | Client routing and forwarded authority cannot replace server-owned context. | | `FI-TRACE-ASSERTION-KEY-MISMATCH` | Mismatch denies with no mutation and the private-state response. | From d163ad172fc255eb40796c0708a19514dc949ec2 Mon Sep 17 00:00:00 2001 From: Dawn Date: Sat, 15 Aug 2026 12:49:13 -0400 Subject: [PATCH 11/20] docs(nip-fi): make conformance oracles satisfiable The denial-fixture enumeration named four core denial symbols; core now denies six. Correct the count and names, reattribute binding_required to core, and widen row 9 to cover any unrecognized enrollment policy, which core's terminal else also catches. Add enumeration-agreement checks so a future core symbol cannot silently falsify the sentence again, including two that guard the prose-only allowlist itself: promoting an allowlisted condition to a real symbol satisfies the attribution check rather than failing it, so only a minimality check makes that rot visible. Replace the interoperability compared object with what core pins: status, body, and the values of only the header fields core names. Comparing ordered header names failed every conforming pair. State that the anonymity comparison stays wider, so a private reason code in an unnamed header is still a defect. Parameterize the exit test by a shared fixture. Independence is a claim about code, not inputs: two implementations given different issuers, keys or clocks cannot produce equal bytes however correct both are. Co-authored-by: Dawn Signed-off-by: Dawn --- docs/nips/NIP-FI-CONF.md | 145 ++++++++++++++++++++++++++++++++------- 1 file changed, 122 insertions(+), 23 deletions(-) diff --git a/docs/nips/NIP-FI-CONF.md b/docs/nips/NIP-FI-CONF.md index e36aec439af..27076a110ab 100644 --- a/docs/nips/NIP-FI-CONF.md +++ b/docs/nips/NIP-FI-CONF.md @@ -110,7 +110,7 @@ which owns that mapping and the exact response bytes. | 6 | `pair_retired` | `authorization_denied` | core | | 7 | `key_revoked` | `authorization_denied` | core | | 8 | `policy_denied` — local operation policy | `authorization_denied` | core | -| 9 | `binding_required` — provisioned mode, no binding | `authorization_denied` | NIP-FI-LIFECYCLE | +| 9 | `binding_required` — enrollment policy creates no binding at this request: provisioned mode with no binding, or any unrecognized policy value | `authorization_denied` | core | | 10 | `identity_disabled` | `authorization_denied` | NIP-FI-LIFECYCLE | | 11 | `explicit_replacement_required` — pending lineage | `authorization_denied` | NIP-FI-LIFECYCLE | | 12 | `binding_expired` — administrative expiry | `authorization_denied` | NIP-FI-LIFECYCLE | @@ -118,11 +118,16 @@ which owns that mapping and the exact response bytes. | 14 | `dependency_unreadable` | `authorization_unavailable` | core | The names in the private-condition column are fixture identifiers for this -enumeration. Four of them — `key_mismatch`, `binding_conflict`, `pair_retired`, -and `key_revoked` — are core's own denial symbols; the rest name conditions that -core and the profiles define in prose. None is a wire value, and a deployment -MAY use different private reason codes internally as long as every enumerated -condition has a fixture. +enumeration. Six of them — `key_mismatch`, `attestation_required`, +`binding_conflict`, `pair_retired`, `key_revoked`, and `binding_required` — are +the symbols core's preparation pseudocode denies by name, and that list MUST +equal core's set exactly. Rows 8 and 14, `policy_denied` and +`dependency_unreadable`, are core's conditions expressed only in prose — a bare +policy denial and `FI-INV-14` fail-closed — and core is not required to name +them symbolically; they are the only two core rows so exempted. The remaining +rows name conditions the profiles define in prose. None is a wire value, and a +deployment MAY use different private reason codes internally as long as every +enumerated condition has a fixture. Rows 3–13 are the private-state anonymity set. Their public responses MUST compare byte-identical to each other, not merely equal in prefix or status. @@ -130,18 +135,74 @@ Rows for an unclaimed profile are `not-applicable` with absence evidence. A profile that introduces a new private condition MUST add its row; an unenumerated condition escapes this oracle entirely. -**Compared object.** Byte-identity is asserted over the response bytes an -implementation chooses, which excludes bytes a conforming HTTP server cannot -hold constant. Over Nostr the compared object is the complete relay message -excluding only the event or subscription identifier echoed from the request. -Over HTTP it is the status code, the ordered sequence of header field names, -every header field value except `Date`, and the complete body. `Date` is -excluded because RFC 9110 Section 6.6.1 requires an origin server with a clock -to generate it on every 4xx response, so two denials at different instants can -never be identical over the literal wire bytes; a suite comparing those would -fail every conforming implementation. Any other excluded field MUST be named in -the report with the reason it cannot be held constant, and its value MUST be -independent of the private condition. +The anonymity comparison is **wider than the interoperability compared object +defined below, and deliberately so**. Between two private conditions on the same +implementation, every response byte MUST agree except values a server cannot +hold constant across two instants, such as `Date`. It is not limited to the +header fields core names. The narrower object below exists because two +*different* implementations cannot be required to agree on fields core does not +pin; that reasoning does not apply within one implementation, where any field +varying by private condition is a disclosure whatever its name. A suite that +reuses the interoperability object here would pass an implementation that +returns its private reason code in an unnamed header. + +**Enumeration agreement.** The preceding paragraph makes a quantified claim +about this table, and a fix verified against the one row it changes can still +falsify it. Rows 8 and 14 are the **prose-only allowlist**: core's conditions +that core is not required to name symbolically. The suite MUST check, +mechanically at the claimed head (`FI-CONF-DENIAL-FIXTURES`): + +1. every symbol core denies by name has a row here; +2. every symbol core denies by name is attributed to core; +3. the naming paragraph above lists exactly the symbols core denies by name; +4. the count word in that paragraph equals the number of symbols it lists; +5. every core-attributed row that is a named symbol carries the same public + class — quantified over core's symbolic set, not over all core-attributed + rows, since `dependency_unreadable` is correctly `authorization_unavailable`; +6. no allowlist entry appears in core's symbolic denial set; and +7. the set of core-attributed rows equals core's symbolic denial set together + with the allowlist, exactly. + +Checks 3 and 4 are independent and neither implies the other: an editor who +corrects the count without the names is caught by 3, and one who corrects the +names without the count is caught by 4. + +Checks 6 and 7 guard the allowlist itself, which is otherwise unguarded state +in a document whose subject is that unguarded claims rot. Check 6 is what makes +promotion visible: if a later core turns `policy_denied` or +`dependency_unreadable` into a named symbol, check 2 does **not** fail — that +row is already attributed to core, so promotion satisfies check 2 more, not +less — and an editor who updates this paragraph honestly at the same time +satisfies 3 and 4 as well. Only check 6 fails, and the stale entry is then the +thing to delete. Check 7 restores equality in both directions, weakened by +exactly the allowlist and nothing more, so a core-attributed row that core +never emits is caught without failing a conforming document. + +**Compared object.** This object governs the interoperability comparison between +two implementations. The anonymity comparison above is wider. Byte-identity is +asserted over the response bytes an implementation chooses, which excludes bytes +a conforming HTTP server cannot hold constant. Over Nostr the compared object is +the complete relay message excluding only the event or subscription identifier +echoed from the request. Over HTTP the compared object is exactly what NIP-FI +core pins: the status code, the complete body, and the exact values of only the +header fields core's denial table names. Header order and unnamed header fields +are outside it, and their values MUST NOT depend on the private condition — +which the anonymity requirement above already demands and tests directly. + +Comparing the ordered sequence of header field names, or every header value +except `Date`, would fail every conforming pair. Two independent servers emit +different automatic fields in different orders — `Server` and `Connection` are +the common divergences — so an exit test comparing them can never be passed by +anyone, and an oracle that no conforming implementation can satisfy is a defect +in this document rather than evidence about either implementation. The compared +object is therefore closed over what core names and nothing more; if core later +pins an additional field, it joins the compared object with no edit here. + +`Date` needs no special exclusion under this rule, since core does not name it; +RFC 9110 Section 6.6.1 requires an origin server with a clock to generate it on +every response, so it could never be held constant. Any field an implementation +must exclude despite core naming it MUST be reported with the reason it cannot +be held constant, and its value MUST be independent of the private condition. The oracle runs a fixed positive iteration count on a pinned isolated runner at the exact claimed head. Before the run the operator records the environment, @@ -205,11 +266,49 @@ each produce, from NIP-FI core and any claimed profile documents alone: - one byte-exact public denial response for each of the four public classes, on both transports, compared over the object defined under **Denial fixtures**. -The evidence is the produced bytes, the document revision used, and a statement -of independence. The test passes when the outputs compare equal byte for byte -and each implementation accepts the other's valid request and reproduces the -other's denials. Any divergence traced to an underspecified value is a defect -in the specification, not in either implementation, and is fixed there. +Independence is a claim about code and reference implementations, not about +inputs. Two implementations given different issuers, keys, or clocks cannot +produce equal bytes however correct both are, so the run is parameterized by a +**shared exit fixture** that both sides load and neither side authors: + +- one issuer identity and one JWK set, including the private key needed to mint + assertions and the `kid` selecting it; +- one assertion per denial class and one for the valid request, each with fixed + `iss`, `sub`, `aud`, `nostr_pubkey`, `client_id`, `iat`, `exp`, and token + class, expressed as complete pre-signature JWT claim sets; +- one Nostr secret key for the proof, with the exact event fields including + `created_at`, so both sides derive the same actor; +- one frozen evaluation instant, and the skew and lifetime bounds in force; and +- the domain, target resource, operation, and enrollment policy for each case. + +Every value the compared object depends on MUST be pinned here. A value left to +the implementation is a divergence the test will attribute to a defect in this +document, which is the correct disposition but a slow way to discover a missing +fixture field. The fixture records the document revision it was authored +against. + +The exchanged artifact per case is the complete request frame and the complete +response frame on each transport: for HTTP the request line, headers and body, +and the response status, headers and body; for Nostr the complete client +message and the complete relay message. Both sides emit whole frames even +though the compared object is narrower, because the request side and the +unnamed response fields are what the reader needs in order to explain a +mismatch. + +The evidence is the produced bytes, the fixture identity, the document revision +used, and a statement of independence. The test passes when the outputs compare +equal over the compared object and each implementation accepts the other's +valid request and reproduces the other's denials. Any divergence traced to an +underspecified value is a defect in the specification, not in either +implementation, and is fixed there. + +This test has a mandatory negative control. One implementation is patched to +emit a denial that differs from the other only outside the compared object — +adding a header core does not name, or reordering fields — and the run MUST +still pass. A run that fails this control is comparing more than core pins and +would reject conforming pairs; the exit test itself is then the defect. The +control is retained with the evidence, because a comparison that only ever +reports equality proves nothing about what it would have caught. ## Applicability From d123e4e0bb039ce41164aa7f3b830fc5637b539d Mon Sep 17 00:00:00 2001 From: Dawn Date: Sat, 15 Aug 2026 13:12:24 -0400 Subject: [PATCH 12/20] docs(nip-fi): make the conformance oracles derivable Two of the seven enumeration checks were red on the conforming document. Check 7 compared a set of rows against a set of symbols, and two core-attributed rows name no symbol, so it could not pass at any head. Check 5's head clause admitted the one row its own qualifier excluded. A check that is red on a conforming document detects nothing: it cannot be observed to flip, so every mutant reads as caught. Both are corrected and the requirement to run the checks green before scoring a mutant is now written where the checks live. Address the allowlist and the anonymity set by symbol instead of row number. This table is required to grow, and a profile appending one row renumbers the positional references: the allowlist would name the wrong condition and a new denied condition would fall outside the byte-identity requirement without any check firing. State the three readings the compared object left to convention. An independently written conforming implementation diverges on each by language default: a canonicalizing HTTP library emits Www-Authenticate, a server that sets no Content-Length frames the body as chunked, and a JSON encoder spaces its separators. Header names match case-insensitively per RFC 9110 Section 5.1, the compared body is the content per Section 6.4, and the Nostr message is compact JSON per NIP-01's serialization rules. Content-Length stays unpinned; framing is the sender's choice and pinning it would widen the object for no privacy gain. An exit test whose result depends on an unstated convention is the same defect as an oracle no pair can satisfy, one layer down. Co-authored-by: Dawn Signed-off-by: Dawn --- docs/nips/NIP-FI-CONF.md | 69 ++++++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/docs/nips/NIP-FI-CONF.md b/docs/nips/NIP-FI-CONF.md index 27076a110ab..558d4967515 100644 --- a/docs/nips/NIP-FI-CONF.md +++ b/docs/nips/NIP-FI-CONF.md @@ -121,47 +121,59 @@ The names in the private-condition column are fixture identifiers for this enumeration. Six of them — `key_mismatch`, `attestation_required`, `binding_conflict`, `pair_retired`, `key_revoked`, and `binding_required` — are the symbols core's preparation pseudocode denies by name, and that list MUST -equal core's set exactly. Rows 8 and 14, `policy_denied` and -`dependency_unreadable`, are core's conditions expressed only in prose — a bare -policy denial and `FI-INV-14` fail-closed — and core is not required to name +equal core's set exactly. `policy_denied` and `dependency_unreadable` are core's +conditions expressed only in prose — a bare policy denial and `FI-INV-14` +fail-closed — and core is not required to name them symbolically; they are the only two core rows so exempted. The remaining rows name conditions the profiles define in prose. None is a wire value, and a deployment MAY use different private reason codes internally as long as every enumerated condition has a fixture. -Rows 3–13 are the private-state anonymity set. Their public responses MUST -compare byte-identical to each other, not merely equal in prefix or status. +Every row whose public class is `authorization_denied` is in the private-state +anonymity set. Their public responses MUST compare byte-identical to each other, +not merely equal in prefix or status. Rows for an unclaimed profile are `not-applicable` with absence evidence. A profile that introduces a new private condition MUST add its row; an unenumerated condition escapes this oracle entirely. The anonymity comparison is **wider than the interoperability compared object defined below, and deliberately so**. Between two private conditions on the same -implementation, every response byte MUST agree except values a server cannot +implementation, every response byte as transmitted MUST agree — including +transfer framing, and not only the content — except values a server cannot hold constant across two instants, such as `Date`. It is not limited to the header fields core names. The narrower object below exists because two *different* implementations cannot be required to agree on fields core does not pin; that reasoning does not apply within one implementation, where any field varying by private condition is a disclosure whatever its name. A suite that reuses the interoperability object here would pass an implementation that -returns its private reason code in an unnamed header. +returns its private reason code in an unnamed header, or one that varies its +chunk boundaries by private condition. **Enumeration agreement.** The preceding paragraph makes a quantified claim about this table, and a fix verified against the one row it changes can still -falsify it. Rows 8 and 14 are the **prose-only allowlist**: core's conditions -that core is not required to name symbolically. The suite MUST check, -mechanically at the claimed head (`FI-CONF-DENIAL-FIXTURES`): +falsify it. `policy_denied` and `dependency_unreadable` are the **prose-only +allowlist**: core's conditions that core is not required to name symbolically. +Both sets here are named by symbol, never by row number, because this table is +required to grow and every positional reference silently retargets when it does. +The suite MUST check, mechanically at the claimed head +(`FI-CONF-DENIAL-FIXTURES`): 1. every symbol core denies by name has a row here; 2. every symbol core denies by name is attributed to core; 3. the naming paragraph above lists exactly the symbols core denies by name; 4. the count word in that paragraph equals the number of symbols it lists; -5. every core-attributed row that is a named symbol carries the same public - class — quantified over core's symbolic set, not over all core-attributed - rows, since `dependency_unreadable` is correctly `authorization_unavailable`; +5. every symbol core denies by name carries the same public class — quantified + over core's symbolic set, not over all core-attributed rows, since + `dependency_unreadable` is core-attributed and correctly + `authorization_unavailable`; 6. no allowlist entry appears in core's symbolic denial set; and -7. the set of core-attributed rows equals core's symbolic denial set together - with the allowlist, exactly. +7. the set of symbols named by core-attributed rows equals core's symbolic + denial set together with the allowlist, exactly. + +Every check above MUST be run against the unmutated document and be green before +any mutant is scored. A check that is red on a conforming document detects +nothing: it cannot be observed to flip, so every mutant reads as caught. Two of +these checks shipped red for exactly that reason. Checks 3 and 4 are independent and neither implies the other: an editor who corrects the count without the names is caught by 3, and one who corrects the @@ -183,11 +195,28 @@ two implementations. The anonymity comparison above is wider. Byte-identity is asserted over the response bytes an implementation chooses, which excludes bytes a conforming HTTP server cannot hold constant. Over Nostr the compared object is the complete relay message excluding only the event or subscription identifier -echoed from the request. Over HTTP the compared object is exactly what NIP-FI -core pins: the status code, the complete body, and the exact values of only the -header fields core's denial table names. Header order and unnamed header fields -are outside it, and their values MUST NOT depend on the private condition — -which the anonymity requirement above already demands and tests directly. +echoed from the request, encoded as compact JSON with no insignificant +whitespace, per NIP-01's serialization rules. Over HTTP the compared object is +exactly what NIP-FI core pins: the status code, the complete body, and the exact +values of only the header fields core's denial table names. Header field *names* +are matched case-insensitively per RFC 9110 Section 5.1; their values are +compared exactly. The compared body is the *content* per RFC 9110 Section 6.4 — +after transfer-decoding, chunk framing and trailer fields excluded — not the +message body on the wire. That reading is scoped to this interoperability object +and does not reach the anonymity comparison above, which stays over transmitted +octets. `Content-Length` is deliberately not pinned: framing is the sender's +choice and pinning it would widen the object for no privacy gain. +Header order and unnamed header fields are outside it, and their values MUST NOT +depend on the private condition — which the anonymity requirement above already +demands and tests directly. + +Each of these three readings is stated because an independently written +conforming implementation diverges on it by language default, not by error: a +canonicalizing HTTP library emits `Www-Authenticate`, a server that sets no +`Content-Length` frames the body as chunked, and a JSON encoder inserts spaces +after `,` and `:`. An exit test whose result depends on a convention this +document does not state is the same defect as an oracle no pair can satisfy, +one layer down. Comparing the ordered sequence of header field names, or every header value except `Date`, would fail every conforming pair. Two independent servers emit From ffc4e654c78563287adb1d6d7973a46ed0a0ef87 Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Mon, 17 Aug 2026 14:34:51 -0400 Subject: [PATCH 13/20] docs(nip-fi): close temporal, allocation, and denominator ambiguities Three defects from the independent review lap, each fixed by making the text impossible to read two ways: DELEG evidence boundaries used 'live' (undefined) and declared equality at either temporal boundary expired, which is incoherent for a lower bound and contradicted core's own inclusive-nbf/exclusive-exp idiom. State both comparisons explicitly, matching core: now < mandatory_expiry and not_before <= now + skew. The delegated lease deadline mixed a configured duration into a minimum of absolute instants. Anchor it: lease issue instant plus the configured delegated maximum. EDGE allocated proof-transport codes 0x03/0x04 to Git and Blossom profiles that do not exist, violating the table's own allocation rule (a published stable specification with validation, binding, freshness, replay, and vectors). Unassign both; the unassigned range now starts at 0x03. Core's oracle table compressed six pairs of oracle IDs with slash shorthand that has no consistent expansion rule: three pairs read replace-last, three read keep-prefix, so no reader can infer the grammar. Split all six into one row per literal ID. The table now lists exactly 30 rows, one complete ID each, making the oracle set mechanically enumerable as the mutation-adequacy denominator CONF will reference. Every prose citation resolves to a table row; no shorthand remains. Review findings: Wren (DELEG boundaries, delegated maximum, EDGE codes, shorthand ambiguity), Dawn (denominator enumerability measurement). Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- docs/nips/NIP-FI-DELEG.md | 7 ++++--- docs/nips/NIP-FI-EDGE.md | 4 +--- docs/nips/NIP-FI.md | 18 ++++++++++++------ 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/nips/NIP-FI-DELEG.md b/docs/nips/NIP-FI-DELEG.md index 4b7ec253ec0..769375c7828 100644 --- a/docs/nips/NIP-FI-DELEG.md +++ b/docs/nips/NIP-FI-DELEG.md @@ -50,8 +50,9 @@ when their concrete encoding belongs to the supplying delegation protocol. The evidence authenticates every field, has one unambiguous owner and delegate, matches the server-owned domain and exact request or target, and has a finite -expiry. If present, `not_before` is live. Equality at either temporal boundary -is expired. The proven actor equals `delegate_key`. [FI-DELEG-EVIDENCE-CLOSED] +expiry satisfying `now < mandatory_expiry`; equality at an expiry is expired. +Optional `not_before` satisfies `not_before <= now + skew`, as core defines for +`nbf`. The proven actor equals `delegate_key`. [FI-DELEG-EVIDENCE-CLOSED] A delegated request carries fresh request-appropriate Nostr proof and no `Nostr-Federated-Identity` or profile provenance field. Mixed direct and @@ -114,7 +115,7 @@ is no later than the minimum of: - owner binding administrative bound, when applicable; - current relationship bound; - local policy bound; -- the configured delegated maximum; and +- the lease issue instant plus the configured delegated maximum; and - any stronger owner-assertion bound the deployment requires. Missing finite configuration denies. Equality is expired and arithmetic is diff --git a/docs/nips/NIP-FI-EDGE.md b/docs/nips/NIP-FI-EDGE.md index e0d116f1e44..e96fb8a3deb 100644 --- a/docs/nips/NIP-FI-EDGE.md +++ b/docs/nips/NIP-FI-EDGE.md @@ -192,9 +192,7 @@ requires confidentiality and integrity. | `0x00` | Invalid; MUST deny. | | `0x01` | NIP-42 connection proof. | | `0x02` | NIP-98 HTTP proof. | -| `0x03` | Git smart-HTTP session proof profile. | -| `0x04` | Blossom proof profile. | -| `0x05`–`0x7f` | Unassigned; allocation requires a published stable specification. | +| `0x03`–`0x7f` | Unassigned; allocation requires a published stable specification. | | `0x80`–`0xfe` | Private use under an explicit shared proxy/verifier contract only. | | `0xff` | Reserved for a future extended encoding; invalid in HMAC-v2. | diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index b09e1f0746a..e8cfeb14fbe 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -514,24 +514,30 @@ policy revision. NIP-FI-CONF defines evidence and mutation-adequacy rules. | `FI-TRACE-TOKEN-CLASS` | An RFC 9068 `at+jwt` access token and a dedicated `nip-fi+jwt` assertion pass only their selected class. ID tokens, wrong/generic types outside a named compatibility policy, client-only audiences, absent or ambiguous `client_id`, resource-owner/client-subject ambiguity, and every attempted cross-class fallback deny. | | `FI-TRACE-CONTRACT-IDENTITIES` | Mutate each assertion semantic, transport semantic, and mutable dependency independently: semantic mutations change only their owning contract ID; snapshot/binding/lifecycle/policy/resource/status mutations change neither ID but force current revalidation. | | `FI-TRACE-VERIFIER-PARITY` | Equal authoritative input and policy produce the same canonical normalized result. | -| `FI-TRACE-JWKS-ADD` / `REMOVE` | Retained-key rotation can revalidate; removed-key evidence and leases deny. | -| `FI-TRACE-CURRENT-STATUS-REVOKED` / `STALE` | Revocation, including one racing final admission, closes authority within the advertised tested bound. Inactive/ambiguous status denies; expiry equality, outage, delayed events, and changed status versions cannot mint or extend a witness. | +| `FI-TRACE-JWKS-ADD` | Retained-key rotation revalidates successfully under the changed snapshot version. | +| `FI-TRACE-JWKS-REMOVE` | Evidence and leases under a removed key deny after snapshot change. | +| `FI-TRACE-CURRENT-STATUS-REVOKED` | Revocation, including one racing final admission, closes authority within the advertised tested bound. | +| `FI-TRACE-CURRENT-STATUS-STALE` | Inactive/ambiguous status denies; expiry equality, outage, delayed events, and changed status versions cannot mint or extend a witness. | | `FI-TRACE-CAPABILITY-REVOCATION` | Removal of a revocation-bounded external capability projection from authoritative local policy closes prepared evidence and lease use within the declared bound; assertion-only projection cannot satisfy this oracle. | -| `FI-TRACE-BODY-BINDING` / `BOUNDS` | Exact complete relevant body passes; absent/duplicate/mutated/partial/transformed/oversized/quota variants deny without effects. | +| `FI-TRACE-BODY-BINDING` | Exact complete relevant body passes; absent/duplicate/mutated/partial/transformed payload variants deny without effects. | +| `FI-TRACE-BODY-BOUNDS` | Oversized, over-quota, and pre-EOF variants deny with bounded work, cleanup, and no effects. | | `FI-TRACE-DOMAIN-SPOOF` | Client routing and forwarded authority cannot replace server-owned context. | | `FI-TRACE-ASSERTION-KEY-MISMATCH` | Mismatch denies with no mutation and the private-state response. | -| `FI-TRACE-BINDING-CONFLICT` / `TOMBSTONE-REPLAY` | Conflicts and fresh evidence for retired/revoked state deny without replacement. | +| `FI-TRACE-BINDING-CONFLICT` | A binding conflict denies without replacing either existing binding. | +| `FI-TRACE-TOMBSTONE-REPLAY` | Fresh eligible evidence for a retired pair or revoked key denies without recreation. | | `FI-TRACE-ASSERTION-REFRESH` | Fresh evidence reuses the same eligible durable binding after prior assertion expiry. | | `FI-TRACE-PREPARED-STALE` | Changed identity-class witnesses or extended bounds deny; provenance-only rotation revalidates. | | `FI-TRACE-CONCURRENT-ENROLLMENT` | Identical first use converges; conflicting first use commits at most one winner. | | `FI-TRACE-FINAL-DENIAL-NO-MUTATION` | Every failed phase leaves all authoritative stores and effects unchanged. | | `FI-TRACE-LIFECYCLE-AUTHORITY` | Unprivileged/stale transitions deny; authorized retirement/revocation/rotation is atomic. | -| `FI-TRACE-LEASE-BOUND` / `MULTI-KEY-SESSION` | Equality expires and one actor's lease never authorizes another. | +| `FI-TRACE-LEASE-BOUND` | A lease ends at its earliest bound; equality at any bound is expired. | +| `FI-TRACE-MULTI-KEY-SESSION` | One actor's lease never authorizes another key on the same connection. | | `FI-TRACE-DENIAL-ORACLE` | Each private row produces its exact fixed bytes; all private-state rows compare byte-identical. | | `FI-TRACE-DEPENDENCY-FAIL-CLOSED` | Each unreadable authoritative dependency denies. | | `FI-TRACE-AUTHORITY-UNIFORM` | Every protected ingress reaches one current final-admission authority. | | `FI-TRACE-CROSS-DOMAIN-COLLISION` | Equal subjects across issuers and equal pairs across domains remain distinct. | -| `FI-TRACE-PRIVACY-NONPUBLIC` / `DISCOVERY-PRIVATE` | Private identity does not enter public surfaces; complete discovery bytes remain identical across attested-key, TOFU, and companion enrollment modes. | +| `FI-TRACE-PRIVACY-NONPUBLIC` | Private identity does not enter public surfaces. | +| `FI-TRACE-DISCOVERY-PRIVATE` | Complete discovery bytes remain identical across attested-key, TOFU, and companion enrollment modes. | | `FI-TRACE-TOFU-THEFT` | Stolen-assertion first use denies unless private TOFU is enabled and the attacker also proves its chosen key. | ## Security considerations From edc66ef8d80f2d7dcb38d4515429b83494b64315 Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Mon, 17 Aug 2026 14:38:02 -0400 Subject: [PATCH 14/20] docs(nip-fi): add the promised worked wire example The PR description promises a worked wire example in core; none existed. Add one compact non-normative section: a protected HTTP POST under client-attached with NIP-98 proof and a relevant body, showing how the two evidence fields, body binding, and the admission phases compose on the wire. Byte-exact values remain the province of the NIP-FI-CONF exit fixture; the example elides credentials so it cannot be mistaken for a conformance vector. Twenty-six lines, kept deliberately smaller than the alternative of striking the promise would save in credibility. Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- docs/nips/NIP-FI.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index e8cfeb14fbe..3e82bc5347e 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -502,6 +502,32 @@ may distinguish the configured mode. Profile documents own only non-enrollment public claims. [FI-TRACE-DISCOVERY-PRIVATE] +## Worked example (non-normative) + +A protected HTTP POST under `client-attached` with NIP-98 proof and an +authorization-relevant body. Credentials are elided; the NIP-FI-CONF exit +fixture pins complete byte-exact values. + +```text +POST /media HTTP/1.1 +Host: relay.example +Nostr-Federated-Identity: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIs... +Authorization: Nostr eyJpZCI6IjE1ZTI3ZDc0Li4uIiwicHVia2V5IjoiOTljNzQ4Li4u... +Content-Type: application/octet-stream +Content-Length: 4 + +abcd +``` + +The bearer JWS validates under the configured assertion policy: exact `iss` +and `aud`, token class `at+jwt`, live time claims, and `nostr_pubkey` equal to +the NIP-98 event's `pubkey`. The NIP-98 event binds the server-resolved method +and URL, and its single `payload` tag equals the SHA-256 of the four body +bytes. Admission then follows Direct preparation and Final admission; success +returns the application response, and every failure class returns exactly the +bytes fixed in the Rejection table. On a WebSocket upgrade the same header +attaches to the upgrade request and NIP-42 supplies the proof after connect. + ## Core behavioral oracles A core claim covers every applicable oracle below at one implementation and From f29e2092b65de40b8f2fc445190a802645ecddda Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 14:45:40 -0400 Subject: [PATCH 15/20] docs(nip-fi): make the conformance denominator countable CONF's mutation adequacy said "each normative requirement in core," which admits four readings with a 3x spread. Make the denominator the listed oracle, selected structurally: rows whose first cell is one complete literal oracle ID, in core and each claimed normative profile. Selecting by section title would silently drop EDGE's five oracles, whose table lives under "Discovery and conformance"; core's heading is "Core behavioral oracles" and no two of the tables agree. Verified 30/5/11/7/4 = 57 rows, zero shorthand, zero duplicates, zero orphans, at a8fab1ad0. The exit test required byte-exact requests from two implementations. ES256 and BIP-340 draw fresh randomness per signature and nothing here pins JSON member order, so no conforming pair could ever pass. Compare signing inputs instead: the NIP-01 serialization for the proof, decoded header and claim set as JSON values for the assertion. Shipping pre-minted bytes would have stopped testing whether an implementer can mint from the document at all. Add the governing document revision and exit fixture digest to the claim tuple: evidence means different things under different text, and a claim that does not name what it was judged against is unfalsifiable once the specification moves. Exempt the synthetic exit fixture from the redaction rule it otherwise contradicts, and give offline-jwt deployments a lawful N/A for the two current-status oracles, which have no witness to test. Subtraction: the enumeration-agreement subsystem hand-copied core's denial symbols into CONF prose, then spent four checks and two rationale paragraphs guarding the copy. Read the set from core instead and the copy's guards have nothing to guard: seven checks collapse to three. Promotion detection is kept by folding disjointness into check 3 rather than restoring a check -- found because a six-mutant matrix showed the reduced set letting a promoted allowlist entry survive, falsifying a sentence I had just written. All six mutants now killed, green on the conforming document. Also fold three paragraphs restating one rule about unpinned header fields into one. Net +24 lines on CONF. The row splits and the two new tuple elements are not recoverable by deleting text that carries a guarantee, and compressing identifiers to reach a target would trade a real property for a number. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- docs/nips/NIP-FI-CONF.md | 190 ++++++++++++++++++++++----------------- 1 file changed, 107 insertions(+), 83 deletions(-) diff --git a/docs/nips/NIP-FI-CONF.md b/docs/nips/NIP-FI-CONF.md index 558d4967515..e3c91729987 100644 --- a/docs/nips/NIP-FI-CONF.md +++ b/docs/nips/NIP-FI-CONF.md @@ -37,6 +37,8 @@ A conformance claim names exactly one immutable tuple: adapter revision, build artifact digest, deployment revision, + governing document revision, + exit fixture digest, claimed profiles, assertion_policy_id, transport_contract_id, @@ -44,10 +46,14 @@ A conformance claim names exactly one immutable tuple: ``` Changing any element creates a new claim. Results from one tuple MUST NOT be -carried into another. A report contains every applicable oracle from core and -every claimed profile exactly once, with status `pass` or `not-applicable` -only. Blank, skipped, expected-failure, and not-run results cannot support a -claim (`FI-CONF-CLAIM-COMPLETE`). +carried into another. The governing document revision and exit fixture digest +are in the tuple because the same evidence means different things under +different text: a claim that does not name the revision it was judged against +is unfalsifiable once the specification moves, and a suite that does not name +its fixture cannot be shown to have run the pinned inputs. A report contains +every applicable oracle from core and every claimed profile exactly once, with +status `pass` or `not-applicable` only. Blank, skipped, expected-failure, and +not-run results cannot support a claim (`FI-CONF-CLAIM-COMPLETE`). Enrollment mode is part of the claim unit and is private. It is recorded in the access-controlled report, never in discovery or any public artifact. @@ -90,7 +96,9 @@ A claim listing an artifact without the record is incomplete. Reports and artifacts hold private deployment detail and MUST remain access controlled. They MUST NOT enter public reports, examples, discovery, or protocol output, and MUST NOT contain raw assertions, secrets, or unredacted -`iss`, `sub`, or claim values. +`iss`, `sub`, or claim values. The shared exit fixture is exempt: its values are +synthetic by construction and name no real principal, issuer, or key, and the +interoperability exit test cannot run on redacted inputs. ## Denial fixtures @@ -118,16 +126,15 @@ which owns that mapping and the exact response bytes. | 14 | `dependency_unreadable` | `authorization_unavailable` | core | The names in the private-condition column are fixture identifiers for this -enumeration. Six of them — `key_mismatch`, `attestation_required`, -`binding_conflict`, `pair_retired`, `key_revoked`, and `binding_required` — are -the symbols core's preparation pseudocode denies by name, and that list MUST -equal core's set exactly. `policy_denied` and `dependency_unreadable` are core's -conditions expressed only in prose — a bare policy denial and `FI-INV-14` -fail-closed — and core is not required to name -them symbolically; they are the only two core rows so exempted. The remaining -rows name conditions the profiles define in prose. None is a wire value, and a -deployment MAY use different private reason codes internally as long as every -enumerated condition has a fixture. +enumeration. Some are the symbols core's preparation pseudocode denies by name; +that set is read from core, never restated here, because a copied list is state +that drifts and the whole subject of this section is that unguarded restatements +rot. `policy_denied` and `dependency_unreadable` are core's conditions expressed +only in prose — a bare policy denial and `FI-INV-14` fail-closed — and core is +not required to name them symbolically; they are the only two core rows so +exempted. The remaining rows name conditions the profiles define in prose. None +is a wire value, and a deployment MAY use different private reason codes +internally as long as every enumerated condition has a fixture. Every row whose public class is `authorization_denied` is in the private-state anonymity set. Their public responses MUST compare byte-identical to each other, @@ -158,37 +165,28 @@ required to grow and every positional reference silently retargets when it does. The suite MUST check, mechanically at the claimed head (`FI-CONF-DENIAL-FIXTURES`): -1. every symbol core denies by name has a row here; -2. every symbol core denies by name is attributed to core; -3. the naming paragraph above lists exactly the symbols core denies by name; -4. the count word in that paragraph equals the number of symbols it lists; -5. every symbol core denies by name carries the same public class — quantified +1. every symbol core denies by name has a row here, attributed to core; +2. every symbol core denies by name carries the same public class — quantified over core's symbolic set, not over all core-attributed rows, since `dependency_unreadable` is core-attributed and correctly - `authorization_unavailable`; -6. no allowlist entry appears in core's symbolic denial set; and -7. the set of symbols named by core-attributed rows equals core's symbolic - denial set together with the allowlist, exactly. + `authorization_unavailable`; and +3. the set of symbols named by core-attributed rows equals core's symbolic + denial set together with the allowlist, exactly, and the allowlist is + disjoint from core's symbolic denial set. Every check above MUST be run against the unmutated document and be green before any mutant is scored. A check that is red on a conforming document detects nothing: it cannot be observed to flip, so every mutant reads as caught. Two of these checks shipped red for exactly that reason. -Checks 3 and 4 are independent and neither implies the other: an editor who -corrects the count without the names is caught by 3, and one who corrects the -names without the count is caught by 4. - -Checks 6 and 7 guard the allowlist itself, which is otherwise unguarded state -in a document whose subject is that unguarded claims rot. Check 6 is what makes +Check 3 keeps the allowlist honest in both directions. Its equality half catches +a core-attributed row core never emits. Its disjointness half is what makes promotion visible: if a later core turns `policy_denied` or -`dependency_unreadable` into a named symbol, check 2 does **not** fail — that -row is already attributed to core, so promotion satisfies check 2 more, not -less — and an editor who updates this paragraph honestly at the same time -satisfies 3 and 4 as well. Only check 6 fails, and the stale entry is then the -thing to delete. Check 7 restores equality in both directions, weakened by -exactly the allowlist and nothing more, so a core-attributed row that core -never emits is caught without failing a conforming document. +`dependency_unreadable` into a named symbol, every other check still passes — +the row is already attributed to core — and only disjointness fails, leaving the +stale allowlist entry as the thing to delete. Promotion MUST NOT be applied to +`dependency_unreadable` without moving it out of the set check 2 quantifies +over; it is the one core condition whose public class differs. **Compared object.** This object governs the interoperability comparison between two implementations. The anonymity comparison above is wider. Byte-identity is @@ -210,28 +208,19 @@ Header order and unnamed header fields are outside it, and their values MUST NOT depend on the private condition — which the anonymity requirement above already demands and tests directly. -Each of these three readings is stated because an independently written -conforming implementation diverges on it by language default, not by error: a -canonicalizing HTTP library emits `Www-Authenticate`, a server that sets no -`Content-Length` frames the body as chunked, and a JSON encoder inserts spaces -after `,` and `:`. An exit test whose result depends on a convention this -document does not state is the same defect as an oracle no pair can satisfy, -one layer down. - -Comparing the ordered sequence of header field names, or every header value -except `Date`, would fail every conforming pair. Two independent servers emit -different automatic fields in different orders — `Server` and `Connection` are -the common divergences — so an exit test comparing them can never be passed by -anyone, and an oracle that no conforming implementation can satisfy is a defect -in this document rather than evidence about either implementation. The compared -object is therefore closed over what core names and nothing more; if core later -pins an additional field, it joins the compared object with no edit here. - -`Date` needs no special exclusion under this rule, since core does not name it; -RFC 9110 Section 6.6.1 requires an origin server with a clock to generate it on -every response, so it could never be held constant. Any field an implementation -must exclude despite core naming it MUST be reported with the reason it cannot -be held constant, and its value MUST be independent of the private condition. +Each reading is stated because an independently written conforming +implementation diverges on it by language default, not by error: a canonicalizing +HTTP library emits `Www-Authenticate`, a server that sets no `Content-Length` +frames the body as chunked, a JSON encoder inserts spaces after `,` and `:`, and +two servers emit different automatic fields — `Server`, `Connection` — in +different orders. Comparing those would fail every conforming pair, and an +oracle no conforming implementation can satisfy is a defect in this document +rather than evidence about either implementation. The compared object is +therefore closed over what core names and nothing more; if core later pins an +additional field, it joins with no edit here. `Date` needs no special exclusion, +since core does not name it. Any field an implementation must exclude despite +core naming it MUST be reported with the reason it cannot be held constant, and +its value MUST be independent of the private condition. The oracle runs a fixed positive iteration count on a pinned isolated runner at the exact claimed head. Before the run the operator records the environment, @@ -254,25 +243,44 @@ Naming an oracle for a requirement proves the requirement is claimed, not that the oracle can fail. A requirement whose oracle cannot fail is untested and reads as tested, which is worse than an acknowledged gap. -For each normative requirement in core and each claimed profile, the suite MUST -retain at least one **mutant**: an implementation variant that violates exactly -that requirement, together with the failing output of the oracle that requirement -names (`FI-CONF-MUTATION`). Evidence is the exact patch identity, the oracle -identifier, and the retained failure output at the claimed head. - -Four rules make the mutant meaningful: +The denominator is the **listed oracle**. An oracle table is identified by its +rows: each names exactly one complete literal oracle identifier in its first +cell, with no shorthand and no name left to inference. The denominator is every +such row in NIP-FI core, in each claimed normative profile, and in this document +when CONF is claimed — selected by that first cell, not by section title, since +the tables do not share one. Enumerating it is reading rows, so two readers +obtain the same set. It is not the set of normative sentences, RFC 2119 +keywords, or invariant labels: none is enumerable without judgement, and a +denominator two readers count differently decides how much of the specification +is tested at all. + +For each listed oracle in core and each claimed profile, the suite MUST retain at +least one **mutant**: an implementation variant that violates a requirement that +oracle governs, together with that oracle's failing output (`FI-CONF-MUTATION`). +Evidence is the exact patch identity, the oracle identifier, and the retained +failure output at the claimed head. + +Normative prose outside the oracle tables remains binding. It is not a second +denominator. Prose that no listed oracle can detect is untestable text: add the +oracle that detects it, or delete it. + +Five rules make the mutant meaningful: 1. **One at a time.** Mutants are applied singly against an otherwise unmodified implementation. Layered defenses mask each other: a guard looks covered because a different guard denies first. -2. **Attribution.** The kill MUST come from the oracle the requirement names. A - mutant killed only by some other oracle establishes coverage for neither. -3. **Reachability.** The suite MUST witness that a fixture reaches the mutated +2. **Attribution.** The kill MUST come from the entry's own oracle. A mutant + killed only by some other oracle establishes coverage for neither. +3. **One entry per mutant.** A mutant satisfies only the entry it was selected + for, even when it also kills other oracles. Otherwise one broad mutant + discharges several obligations at once and the count reads complete while + the coverage is not. +4. **Reachability.** The suite MUST witness that a fixture reaches the mutated decision, not merely the enclosing operation. A mutant behind a bound, length field, or earlier denial that no fixture ever passes is never exercised, and the suite reports clean on an implementation that is provably broken. -4. **Survivors are recorded.** A mutant its named oracle fails to kill is a +5. **Survivors are recorded.** A mutant its named oracle fails to kill is a defect in the specification or the suite. It is recorded with that disposition and MUST NOT be waived or replaced by an easier mutant. @@ -290,8 +298,8 @@ sufficient to build against (`FI-CONF-INTEROP-EXIT`). Two implementations that have not shared code and have not consulted a common reference implementation each produce, from NIP-FI core and any claimed profile documents alone: -- one byte-exact valid `client-attached` request, over WebSocket upgrade and - over HTTP; and +- one valid `client-attached` request, over WebSocket upgrade and over HTTP, + compared over its signing inputs as defined below; and - one byte-exact public denial response for each of the four public classes, on both transports, compared over the object defined under **Denial fixtures**. @@ -310,26 +318,38 @@ produce equal bytes however correct both are, so the run is parameterized by a - one frozen evaluation instant, and the skew and lifetime bounds in force; and - the domain, target resource, operation, and enrollment policy for each case. +**Request compared object.** A minted request cannot be compared as bytes. +`ES256` and BIP-340 each draw fresh randomness per signature, so two correct +implementations signing one fixture produce different signature octets, and no +document here pins JWS or JSON member order. Requiring byte-equality would fail +every conforming pair — the defect this document names one layer down. The +compared object is therefore the **signing inputs**: for the NIP-98 proof, the +NIP-01 serialization the event id is taken over, which NIP-01 fixes +byte-for-byte; for the assertion, the decoded protected header and claim set +compared as JSON values, with member order excluded. Signature octets are +excluded because they are unequal by construction, not by disagreement; the +mutual-acceptance requirement below tests them instead. A divergence in a +signing input is a divergence in what this document told each side to sign, +which is what the exit test exists to detect. + Every value the compared object depends on MUST be pinned here. A value left to the implementation is a divergence the test will attribute to a defect in this document, which is the correct disposition but a slow way to discover a missing -fixture field. The fixture records the document revision it was authored -against. +fixture field. The exchanged artifact per case is the complete request frame and the complete response frame on each transport: for HTTP the request line, headers and body, and the response status, headers and body; for Nostr the complete client message and the complete relay message. Both sides emit whole frames even -though the compared object is narrower, because the request side and the -unnamed response fields are what the reader needs in order to explain a -mismatch. +though the compared objects are narrower, because the unnamed fields are what +the reader needs in order to explain a mismatch. -The evidence is the produced bytes, the fixture identity, the document revision -used, and a statement of independence. The test passes when the outputs compare -equal over the compared object and each implementation accepts the other's -valid request and reproduces the other's denials. Any divergence traced to an -underspecified value is a defect in the specification, not in either -implementation, and is fixed there. +The evidence is the produced bytes and a statement of independence; the claim +tuple already pins the fixture and the document revision. The test passes when +the outputs compare equal over their compared objects and each implementation +accepts the other's valid request and reproduces the other's denials. Any +divergence traced to an underspecified value is a defect in the specification, +not in either implementation, and is fixed there. This test has a mandatory negative control. One implementation is patched to emit a denial that differs from the other only outside the compared object — @@ -350,6 +370,10 @@ the surface is absent: configured and executable evidence proves the absence; - `FI-TRACE-TOFU-THEFT` only when TOFU is neither configurable nor configured and executable first-use cases deny; +- `FI-TRACE-CURRENT-STATUS-STALE` and `FI-TRACE-CURRENT-STATUS-REVOKED` only + when every configured assertion policy declares freshness class + `offline-jwt`, so no status witness exists to be stale or revoked, and + executable cases prove a presented witness is never consulted; - lifecycle and delegation oracles only when the profile is unclaimed, disabled, and denied on every ingress; and - every other oracle is required for an enforcing deployment. From 15d3fb2e0d20abe678b1917c06a9ad98f9899b08 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 17 Aug 2026 14:51:04 -0400 Subject: [PATCH 16/20] docs(nip-fi): close the denominator contract in its own oracle The denominator rewrite changed the body and left every summary of it behind. Three sites still promised the deleted requirement-level contract: the global-controls paragraph, the release gate, and -- worst -- the FI-CONF-MUTATION row itself, which is one of the four CONF entries the denominator counts. The gate contradicted the body it gates, so the original ambiguity survived inside the oracle that claims to close it. FI-CONF-INTEROP-EXIT likewise still demanded byte-identical requests the section above had already stopped requiring. All four now say listed oracle and compared object. The exit test promised a request on the WebSocket upgrade and defined a signing-input object only for NIP-98. Define the NIP-42 half alongside it, compared against its own transport's NIP-01 serialization, and pin the complete unsigned event fields for both transports in the fixture. The assertion object compared a protected header the fixture never pinned; pin the complete protected-header and claim-set JSON values. Correct a false premise: signatures were said to differ because ES256 and BIP-340 each draw fresh randomness. Deterministic ECDSA and fixed-aux BIP-340 are conforming and need not differ. The conclusion is unchanged -- conforming implementations may disagree on octets, so an object requiring equal octets fails conforming pairs -- but the reason now matches the algorithms as specified rather than as commonly configured. Give FI-TRACE-CAPABILITY-REVOCATION a lawful N/A. Core makes it conditional on a configured revocation-bounded external projection, while Applicability required every unlisted oracle, so a deployment with no such projection had no disposition it could honestly claim. Same defect class the offline-jwt fix closed for the current-status oracles, one oracle over. All findings are Wren's, verified against the cited lines before editing. A rewrite that changes a contract has to grep for every restatement of the old one; the body moved and the summaries did not, which is exactly the drift this document exists to make impossible. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- docs/nips/NIP-FI-CONF.md | 56 +++++++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/docs/nips/NIP-FI-CONF.md b/docs/nips/NIP-FI-CONF.md index e3c91729987..7080077ef09 100644 --- a/docs/nips/NIP-FI-CONF.md +++ b/docs/nips/NIP-FI-CONF.md @@ -288,7 +288,7 @@ Two global controls bound the suite from both sides. A deny-everything implementation MUST fail every positive oracle, proving each oracle has a positive arm. An allow-everything implementation MUST fail every negative oracle, proving each has a negative arm. Neither control substitutes for -per-requirement mutants; an implementation can pass both while violating any +per-entry mutants; an implementation can pass both while violating any individual requirement. ## Interoperability exit test @@ -310,27 +310,38 @@ produce equal bytes however correct both are, so the run is parameterized by a - one issuer identity and one JWK set, including the private key needed to mint assertions and the `kid` selecting it; -- one assertion per denial class and one for the valid request, each with fixed +- one assertion per denial class and one for the valid request, each expressed + as complete pre-signature protected-header and claim-set JSON values — + including `alg`, `typ`, `kid`, and every member the policy allows, and fixed `iss`, `sub`, `aud`, `nostr_pubkey`, `client_id`, `iat`, `exp`, and token - class, expressed as complete pre-signature JWT claim sets; -- one Nostr secret key for the proof, with the exact event fields including - `created_at`, so both sides derive the same actor; + class; +- one Nostr secret key for the proof, with the complete unsigned event fields + for each transport — the NIP-98 event over HTTP and the NIP-42 event with its + challenge and relay values over the WebSocket upgrade — including + `created_at`, so both sides derive the same actor and the same event id; - one frozen evaluation instant, and the skew and lifetime bounds in force; and - the domain, target resource, operation, and enrollment policy for each case. -**Request compared object.** A minted request cannot be compared as bytes. -`ES256` and BIP-340 each draw fresh randomness per signature, so two correct -implementations signing one fixture produce different signature octets, and no -document here pins JWS or JSON member order. Requiring byte-equality would fail -every conforming pair — the defect this document names one layer down. The -compared object is therefore the **signing inputs**: for the NIP-98 proof, the -NIP-01 serialization the event id is taken over, which NIP-01 fixes -byte-for-byte; for the assertion, the decoded protected header and claim set -compared as JSON values, with member order excluded. Signature octets are -excluded because they are unequal by construction, not by disagreement; the -mutual-acceptance requirement below tests them instead. A divergence in a -signing input is a divergence in what this document told each side to sign, -which is what the exit test exists to detect. +**Request compared object.** A minted request cannot be compared as bytes. Two +conforming implementations may produce different signature octets for the same +semantic input — randomized `ES256` and BIP-340 with fresh auxiliary input do, +while deterministic ECDSA and fixed-aux BIP-340 need not — and no document here +pins JWS or JSON member order. An object requiring equal octets would therefore +fail conforming pairs, which is the defect this document names one layer down. +The compared object is the **signing inputs**: + +- for a NIP-98 proof over HTTP and a NIP-42 proof over the WebSocket upgrade, + the NIP-01 serialization the event id is taken over, which NIP-01 fixes + byte-for-byte; each transport's proof is compared against its own transport's + serialization, not across transports; and +- for the assertion, the decoded protected header and claim set compared as + JSON values, with member order excluded. + +Signature octets are excluded because conforming implementations need not agree +on them, not because they disagree about this document; the mutual-acceptance +requirement below tests them instead. A divergence in a signing input is a +divergence in what this document told each side to sign, which is what the exit +test exists to detect. Every value the compared object depends on MUST be pinned here. A value left to the implementation is a divergence the test will attribute to a defect in this @@ -374,6 +385,9 @@ the surface is absent: when every configured assertion policy declares freshness class `offline-jwt`, so no status witness exists to be stale or revoked, and executable cases prove a presented witness is never consulted; +- `FI-TRACE-CAPABILITY-REVOCATION` only when no external capability projection + requiring a declared revocation bound is configured, and executable evidence + proves no assertion capability or local-policy value claims such a bound; - lifecycle and delegation oracles only when the profile is unclaimed, disabled, and denied on every ingress; and - every other oracle is required for an enforcing deployment. @@ -386,7 +400,7 @@ one deployed domain does not activate it. Before NIP-FI enforcement or discovery is enabled, reviewers verify that one immutable claim tuple passes every applicable oracle at one reviewed revision; that the protected-ingress inventory has no uncovered or competing authority; -that every core requirement has a killed, attributed, reachable mutant and every +that every listed oracle has a killed, attributed, reachable mutant and every survivor is recorded; that the denial-fixture enumeration is complete for the claimed profiles and its negative control fails as required; that the interoperability exit test has passed against an independent implementation; @@ -402,8 +416,8 @@ They close no item in this gate. |---|---| | `FI-CONF-CLAIM-COMPLETE` | A report missing an applicable oracle, duplicating one, carrying a result from another claim tuple, or claiming a status other than `pass`/`not-applicable` is rejected. | | `FI-CONF-DENIAL-FIXTURES` | Every enumerated private condition has a fixture; anonymity-set responses compare byte-identical; the distinguishing negative control fails. | -| `FI-CONF-MUTATION` | Every normative requirement has a singly-applied, attributed, reachability-witnessed mutant killed by its named oracle; survivors are recorded, not waived. | -| `FI-CONF-INTEROP-EXIT` | Two independent implementations produce byte-identical valid requests and per-class denials from the documents alone and accept each other's output. | +| `FI-CONF-MUTATION` | Every listed oracle has a singly-applied, attributed, reachability-witnessed mutant killed by that entry's own oracle; survivors are recorded, not waived. | +| `FI-CONF-INTEROP-EXIT` | Two independent implementations produce, from the documents alone, valid requests equal over the request compared object and per-class denials equal over the denial compared object, and accept each other's output. | ## Security considerations From 2511d1baff5f12da90cfafa97cb036ed94feea01 Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Mon, 17 Aug 2026 14:55:40 -0400 Subject: [PATCH 17/20] docs(nip-fi): align the worked example with the repaired exit contract The non-normative worked example still promised that the NIP-FI-CONF exit fixture pins complete byte-exact values. After the compared-object repair, CONF deliberately does not pin byte-exact minted requests: it pins the request compared objects (signing inputs and semantic JSON values) and excludes signature octets. Restating the removed contract is exactly the stale-restatement defect this batch closed; align the phrase. Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- docs/nips/NIP-FI.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 3e82bc5347e..4c83ae5957f 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -506,7 +506,7 @@ public claims. A protected HTTP POST under `client-attached` with NIP-98 proof and an authorization-relevant body. Credentials are elided; the NIP-FI-CONF exit -fixture pins complete byte-exact values. +fixture pins the complete request compared objects. ```text POST /media HTTP/1.1 From 513e03b0693ca880c721c0b7706a111830f32f2c Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Mon, 17 Aug 2026 22:32:29 -0400 Subject: [PATCH 18/20] docs(nip-fi): fix pinned NIP source links and identifier-classification contradiction Review findings from Mari and Quinn on #5946 at 2511d1baf: - Repoint the pinned NIP-42/NIP-98 source links from a nonexistent upstream commit (404) to the resolved commit 6d2979b3f503a8539c983efbcdcf901bbcf9ed23 (both 200). - NIP-FI-EDGE no longer classifies transport_contract_id as interoperability-critical, which contradicted core's exclusive deployment-local classification. The value stays deployment-local; the contract semantics EDGE contributes to it are what is normative. - Core's rejection-and-privacy absolute ("never of private server state") now carries the authorization_unavailable carve-out its own table and NIP-FI-CONF already relied on. Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- docs/nips/NIP-FI-EDGE.md | 10 +++++++--- docs/nips/NIP-FI.md | 10 ++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/nips/NIP-FI-EDGE.md b/docs/nips/NIP-FI-EDGE.md index e96fb8a3deb..b04129c7ab4 100644 --- a/docs/nips/NIP-FI-EDGE.md +++ b/docs/nips/NIP-FI-EDGE.md @@ -19,9 +19,13 @@ The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **OPTIONAL** are to be interpreted as described in BCP 14 when, and only when, they appear in all capitals. -`transport_contract_id` and every identifier serialized by this document are -interoperability-critical. Local adapter revision identifiers and key identifiers -are deployment-local and MUST NOT appear in public discovery. +Every identifier this document serializes on the wire — header names, the +profile identifier, provenance envelope fields, and proof transport codes — is +interoperability-critical. `transport_contract_id` remains deployment-local as +core classifies it: its value is opaque outside a deployment, while the +canonical contract semantics this profile contributes to it are normative and +fixed here. Local adapter revision identifiers and key identifiers are +deployment-local and MUST NOT appear in public discovery. ## Common trusted-edge requirements diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 4c83ae5957f..310fe39df92 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -447,8 +447,10 @@ assertion revalidation material is destroyed on expiry, close, or invalidation. ## Rejection and privacy Public class is a function only of evidence the requester supplied, never of -private server state. Under the private-posture rule, even `key_mismatch` joins -the private-state anonymity set. +private per-principal server state; `authorization_unavailable` is the sole +exception and reveals only that a required authoritative dependency is +unreadable, never any per-principal fact. Under the private-posture rule, even +`key_mismatch` joins the private-state anonymity set. | Private condition | Public class | Nostr prefix and exact text | HTTP response | |---|---|---|---| @@ -578,8 +580,8 @@ atomically. Availability failures deny rather than degrade to Nostr-only access. ## Sources -- NIP-42 authentication: -- NIP-98 HTTP authentication: +- NIP-42 authentication: +- NIP-98 HTTP authentication: - JWT BCP: - JWT access-token profile: - Non-normative composed model: [NIP-FI-MODEL.md](NIP-FI-MODEL.md) From 17d455a9bf223574b6f0ca71b19483cc6105e221 Mon Sep 17 00:00:00 2001 From: Dawn Date: Tue, 18 Aug 2026 11:32:56 -0400 Subject: [PATCH 19/20] docs(nip-fi): scope administrative expiry to the grant and stop rotation clearing it Two defects found by running a bounded state model of the composed core + LIFECYCLE machine in two encodings -- one holding every guard the documents state plus the guards a careful implementer would infer, one holding only the guards actually written. Both were invisible to four line-by-line readers because reading supplies the missing rule unconsciously. Defect 1: administrative expiry was escapable by rotation. LIFECYCLE said an expired binding "remains durable and occupies both sides of the core partial bijection", so it stays in the relation -- and core Rotate replaces one exact "active" binding with "a fresh binding version" while no document said the replacement inherits binding_not_after. Reading the expired binding as active let rotation authority alone clear a bound only expiry authority had set; reading it as inactive removed it from TargetEligible, letting ProvisionBinding double-bind the identity or bind the administratively terminated key to a different identity, violating FI-INV-01 -- the invariant LIFECYCLE was leaning on. FI-LC-ADMIN-EXPIRY did not detect either: core Rotate is authorized and version-checked, so the escape satisfied the oracle's own wording. Defect 2: nothing in the six files said what binding_not_after is a bound *on*. Two reviewers resolved it in opposite directions -- one grant-scoped, one identity-scoped -- and the readings differ by 25,200 reachable states. The identity-scoped reading also makes RetirePair followed by ordinary enrollment an escape, which under the grant-scoped reading is a legitimately new grant. Repairs: - Core Rotate states that it continues one grant onto a new key and preserves every profile-defined administrative bound as an opaque field core neither interprets nor clears. - LIFECYCLE declares the bound grant-scoped, names the transitions that establish a new grant carrying no prior bound, and points identity-scoped deadlines at the capability projection in authoritative local-policy state, which already carries the revocation-bound machinery such a deadline needs. Encoding one here instead would put an authorization-relevant deadline in a store with no revocation bound. - LIFECYCLE defines a binding with a reached bound as active for every core relation and eligibility test, closing the TargetEligible horn. - Binding-creating transitions must declare continuation or establishment; the two cases partition that set with no remainder, so a future profile cannot add a creator whose bound semantics are undefined. - FI-LC-ADMIN-EXPIRY gains the rotation-must-not-clear arm and the negative arm that retire-then-enroll yields an unbounded binding and is conformant, so the oracle no longer admits the violation it exists to catch. Verification of the shipped bytes, not the intent: re-encoding the model from this text gives 0 grant-scoped escapes (17,184 before) and 0 FI-INV-01/FI-INV-03 regressions over 282,528 states across all three enrollment policies. Each clause was mutated separately and each goes red only for its own clause -- dropping the Rotate carry restores 21,216 escapes, and dropping the active definition admits both FI-INV-01 violations. A clause that cannot be made to fail is not a guard. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- docs/nips/NIP-FI-LIFECYCLE.md | 41 ++++++++++++++++++++++++++++++----- docs/nips/NIP-FI.md | 6 ++++- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/docs/nips/NIP-FI-LIFECYCLE.md b/docs/nips/NIP-FI-LIFECYCLE.md index c86f4a7a57b..a4bc72d75c0 100644 --- a/docs/nips/NIP-FI-LIFECYCLE.md +++ b/docs/nips/NIP-FI-LIFECYCLE.md @@ -51,6 +51,27 @@ It also permits a core binding to carry `binding_not_after`, an optional administrative deadline. The pending lineage names one exact retired pair and binding version. There is at most one pending lineage per identity. +`binding_not_after` bounds the grant represented by **one binding**; it is not a +bound on the identity. Rotation continues the same grant: the replacement binding +preserves the carried bound, and only expiry authority changes it. Retirement, +revocation, disablement, re-enablement, provisioning, and ordinary enrollment +establish a *new* grant that carries no prior bound — so retirement of a bound +pair followed by ordinary enrollment under `attested-key` or `tofu` policy yields +an unbounded binding, and that is conformant. A deadline that must survive the end +of a grant — an identity-scoped access bound — belongs in the capability +projection of authoritative local-policy state, which core already requires for +any projection whose removal must close authority within a declared bound and +which is reread at preparation, final admission, and every protected lease use. +`binding_not_after` is not that mechanism and cannot substitute for it: it does +not survive the end of the grant that carries it, so a deployment relying on it +for identity-scoped expiry cannot claim a revocation bound for that expiry +(`FI-LC-ADMIN-EXPIRY`). + +A binding carrying a reached bound is **active** for every core relation and +eligibility test in this profile and in core, including the core partial +bijection and `TargetEligible` below. It is ineligible for authorization, not +absent from the binding relation. + `X_D`, `Q_D`, and `binding_not_after` are deployment-local state. Their versions are revalidation dependencies, not contract identities. A change invalidates a prepared decision and every dependent lease unless complete final-admission @@ -88,6 +109,14 @@ MUST leave all authoritative state unchanged (`FI-LC-ATOMIC`). Lease invalidation MAY be delivered asynchronously, but authorization use after the commit MUST recheck the advanced dependency before allowing an operation. +Every transition that creates a binding MUST state whether it **continues** an +existing grant, and therefore preserves that grant's administrative bound, or +**establishes** a new grant carrying no prior bound. The two cases partition the +binding-creating transitions with no remainder: core rotation continues, and +provisioning, re-enablement, and ordinary enrollment establish. A profile that +adds a binding-creating transition without this declaration cannot claim +conformance (`FI-LC-CLAIM`). + `TargetEligible(i, k, allow_disabled)` means that `k` is not revoked, `(i, k)` is not retired, neither `i` nor `k` has an active binding, and `i` is not disabled unless `allow_disabled` is true. Every new target key requires fresh, @@ -168,9 +197,11 @@ SetAdministrativeExpiry(i, k, old_version, binding_not_after?): This transition changes neither side of the pair nor its provenance. Setting, replacing, or clearing the bound advances the binding version. At equality the binding is ineligible but remains durable and occupies both sides of the core -partial bijection. Only this or another applicable privileged transition can -restore access; ordinary authorization cannot renew the bound -(`FI-LC-ADMIN-EXPIRY`). +partial bijection. This transition is the only expiry authority: no other +transition in this profile or in core sets, replaces, or clears the bound, and +core rotation carries it onto the replacement binding unchanged. Only this or +another applicable privileged transition can restore access; ordinary +authorization cannot renew the bound (`FI-LC-ADMIN-EXPIRY`). ## One-shot lineage and concurrency @@ -195,7 +226,7 @@ denied case. | ID | Setup and required result | |---|---| -| `FI-LC-CLAIM` | For a fixed profile set, compare complete discovery bytes across attested-key, TOFU, and provisioned configurations and across lifecycle states: they are identical. If one protected ingress omits lifecycle gates or uses a different lifecycle lineage, the domain cannot advertise the profile and the uncovered ingress fails closed. | +| `FI-LC-CLAIM` | For a fixed profile set, compare complete discovery bytes across attested-key, TOFU, and provisioned configurations and across lifecycle states: they are identical. If one protected ingress omits lifecycle gates or uses a different lifecycle lineage, the domain cannot advertise the profile and the uncovered ingress fails closed. If any claimed profile defines a binding-creating transition that declares neither grant continuation nor grant establishment, the domain cannot advertise that profile. | | `FI-LC-ORDINARY-GATES` | Fresh assertion and proof for a disabled identity, an identity with pending lineage, and a binding at administrative-expiry equality each deny without changing lifecycle state. | | `FI-LC-AUTHORITY` | An ordinary assertion plus valid Nostr proof, but no transition-specific authority, cannot perform any transition; mutation of any authority-bound field denies. | | `FI-LC-ATOMIC` | Inject failure at each transition write boundary; no binding, tombstone, disabled fact, lineage, history entry, or dependency version is partially committed. | @@ -203,7 +234,7 @@ denied case. | `FI-LC-PROVISION` | Ordinary first use in provisioned mode denies; authorized provisioning creates one binding and no lease; later current ordinary admission may use it. | | `FI-LC-DISABLE` | Disabling an active identity atomically disables it, retires its exact pair, records exact lineage, and closes subsequent lease use; replay is idempotent and preserves lineage. | | `FI-LC-REENABLE` | Re-enablement creates an eligible proven binding in the same commit that clears disabled state; absent or wrong expected lineage and a clear-only attempt deny. | -| `FI-LC-ADMIN-EXPIRY` | Before the bound the binding may authorize; at equality it denies while still occupying the relation; only an authorized version-checked update changes the bound. | +| `FI-LC-ADMIN-EXPIRY` | Before the bound the binding may authorize; at equality it denies while still occupying the relation, so a target eligibility test for either side of that pair fails. Rotating the expired binding to a new key carries the bound: the replacement denies at the same instant. No non-expiry transition clears it. Only an authorized version-checked update by the expiry authority changes the bound. Conversely, retirement, revocation, or re-enablement of the bound pair followed by an authorized or ordinary new grant produces an unbounded binding, which is the required result and not an escape. | | `FI-LC-QD-ONCE` | Two concurrent re-enablings consume the same `Q_D` lineage; exactly one commits and the loser leaves every authoritative store unchanged. | | `FI-LC-RACE` | Race each transition against prepared ordinary admission and lease use; no operation authorizes after observing the advanced lifecycle or binding dependency. | diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 310fe39df92..c9949d8742e 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -416,7 +416,11 @@ any policy-required current matching issuer attestation. [FI-TRACE-LIFECYCLE-AUT non-retired target key, retires the old pair, and creates a fresh binding version. The replacement provenance is `attested-key` when current matching issuer attestation was validated and `provisioned` otherwise. Rotation does - not globally revoke the old key. + not globally revoke the old key. Rotation continues one grant onto a new key + rather than establishing a new one: the replacement preserves every + profile-defined administrative bound carried by the binding it replaces, as an + opaque field core neither interprets nor clears. Only the authority that set + such a bound can change it. [FI-TRACE-LIFECYCLE-AUTHORITY] Failure or stale state causes no partial mutation. Ordinary authorization cannot perform or undo these transitions. Extended disablement, re-enablement, From 4f913a813298400db837a7faf05c31f36cd6076e Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 18 Aug 2026 11:43:07 -0400 Subject: [PATCH 20/20] docs(nip-fi): close discovery and conformance gaps Co-authored-by: Max Signed-off-by: Max --- docs/nips/NIP-FI-CONF.md | 21 ++++++++++++++++----- docs/nips/NIP-FI-DELEG.md | 13 +++++++++++++ docs/nips/NIP-FI-EDGE.md | 15 +++++++++++---- docs/nips/NIP-FI-LIFECYCLE.md | 23 +++++++++++++++++++---- docs/nips/NIP-FI.md | 8 +++++--- 5 files changed, 64 insertions(+), 16 deletions(-) diff --git a/docs/nips/NIP-FI-CONF.md b/docs/nips/NIP-FI-CONF.md index 7080077ef09..b19fe292525 100644 --- a/docs/nips/NIP-FI-CONF.md +++ b/docs/nips/NIP-FI-CONF.md @@ -132,8 +132,9 @@ that drifts and the whole subject of this section is that unguarded restatements rot. `policy_denied` and `dependency_unreadable` are core's conditions expressed only in prose — a bare policy denial and `FI-INV-14` fail-closed — and core is not required to name them symbolically; they are the only two core rows so -exempted. The remaining rows name conditions the profiles define in prose. None -is a wire value, and a deployment MAY use different private reason codes +exempted. The remaining rows name conditions in the owning profiles' literal +private-denial-condition tables. None is a wire value, and a deployment MAY use +different private reason codes internally as long as every enumerated condition has a fixture. Every row whose public class is `authorization_denied` is in the private-state @@ -172,14 +173,24 @@ The suite MUST check, mechanically at the claimed head `authorization_unavailable`; and 3. the set of symbols named by core-attributed rows equals core's symbolic denial set together with the allowlist, exactly, and the allowlist is - disjoint from core's symbolic denial set. + disjoint from core's symbolic denial set; and +4. for each claimed profile that owns a literal private-denial-condition table, + the set of `(identifier, public class)` pairs in that table equals the set of + pairs in rows attributed to that profile here, exactly. A row with multiple + profile owners contributes its pair to each owner named in that cell. Every check above MUST be run against the unmutated document and be green before any mutant is scored. A check that is red on a conforming document detects nothing: it cannot be observed to flip, so every mutant reads as caught. Two of these checks shipped red for exactly that reason. -Check 3 keeps the allowlist honest in both directions. Its equality half catches +Checks 3 and 4 keep copied enumerations honest in both directions. Check 4 +catches a profile condition renamed, added, removed, reclassified, or attributed +to the wrong owner without the matching fixture-table change. It is run for each +claimed owning profile. Rows for an unclaimed profile remain `not-applicable`. + +Check 3 keeps the core allowlist honest in both directions. Its equality half +catches a core-attributed row core never emits. Its disjointness half is what makes promotion visible: if a later core turns `policy_denied` or `dependency_unreadable` into a named symbol, every other check still passes — @@ -415,7 +426,7 @@ They close no item in this gate. | ID | Required outcome | |---|---| | `FI-CONF-CLAIM-COMPLETE` | A report missing an applicable oracle, duplicating one, carrying a result from another claim tuple, or claiming a status other than `pass`/`not-applicable` is rejected. | -| `FI-CONF-DENIAL-FIXTURES` | Every enumerated private condition has a fixture; anonymity-set responses compare byte-identical; the distinguishing negative control fails. | +| `FI-CONF-DENIAL-FIXTURES` | Every enumerated private condition has a fixture; core and each claimed profile pass exact identifier/class/owner enumeration agreement; anonymity-set responses compare byte-identical; the distinguishing negative control fails. | | `FI-CONF-MUTATION` | Every listed oracle has a singly-applied, attributed, reachability-witnessed mutant killed by that entry's own oracle; survivors are recorded, not waived. | | `FI-CONF-INTEROP-EXIT` | Two independent implementations produce, from the documents alone, valid requests equal over the request compared object and per-class denials equal over the denial compared object, and accept each other's output. | diff --git a/docs/nips/NIP-FI-DELEG.md b/docs/nips/NIP-FI-DELEG.md index 769375c7828..30f4fc44e80 100644 --- a/docs/nips/NIP-FI-DELEG.md +++ b/docs/nips/NIP-FI-DELEG.md @@ -58,6 +58,19 @@ A delegated request carries fresh request-appropriate Nostr proof and no `Nostr-Federated-Identity` or profile provenance field. Mixed direct and delegated evidence denies rather than selecting a path. [FI-DELEG-PATH-SEPARATION] +## Private denial conditions + +This profile defines exactly this private condition identifier and owning public +class for NIP-FI-CONF enumeration agreement: + +| Private condition identifier | Public class | +|---|---| +| `delegation_not_current` | `authorization_denied` | + +The identifier is a fixture name, not a wire value. Adding, removing, renaming, +or reclassifying it requires the same change in NIP-FI-CONF's denial-fixture +table. + ## Preparation Preparation resolves the exact server-owned domain, target context, operation, diff --git a/docs/nips/NIP-FI-EDGE.md b/docs/nips/NIP-FI-EDGE.md index b04129c7ab4..a728fd59ee5 100644 --- a/docs/nips/NIP-FI-EDGE.md +++ b/docs/nips/NIP-FI-EDGE.md @@ -251,7 +251,8 @@ assertion_digest_hex = 6103b52a52730bc065d65673247603a63c9810488c90d0ada3d8d227e The fixture JWT represents a separately minted `nip-fi+jwt` assertion and is opaque test input; its deliberately synthetic signature is not an assertion-validation vector. Implementations MUST reproduce each field, complete pre-MAC input, -diagnostic input digest, raw MAC, and wire MAC exactly. +diagnostic input digest, raw MAC, and wire MAC exactly +(`FI-TRACE-EDGE-VECTORS`). ### Vector 1: HTTP / NIP-98 / non-empty payload @@ -343,14 +344,20 @@ Every implementation MUST run these normative negative cases: ## Discovery and conformance -A relay that completely implements the stock profile MAY list -`trusted-proxy-hmac-v2` in NIP-11's NIP-FI transport discovery. It MUST NOT advertise -private adapters, keys, domains, field names, or code contracts. Claiming FI-EDGE +A relay that completely implements the stock profile MAY add exactly +`"edge_transports": ["trusted-proxy-hmac-v2"]` inside the top-level NIP-11 +`federated_identity` object. `edge_transports` is an array of unique ASCII string +profile identifiers in ascending bytewise order; this document assigns only the +single value shown. A relay that does not completely implement the stock profile +MUST omit the member. It MUST NOT advertise private adapters, keys, domains, field +names, or code contracts. No request may select behavior from this discovery +member; server-owned configuration selects the edge profile. Claiming FI-EDGE requires every configured edge profile to pass the applicable core conformance suite and these profile traces: | Trace | Required oracle | |---|---| +| `FI-TRACE-EDGE-VECTORS` | Reproduce all three normative vectors field-for-field, including each complete pre-MAC input, diagnostic input digest, raw MAC, and wire MAC; reproduce all five timestamp serialization rows; every listed serialization and negative-matrix case produces its required denial or configuration failure. | | `FI-TRACE-PROXY-SPOOF` | Direct ingress, unsigned/header-only identity, unauthenticated caller, or invalid provenance denies without fallback. | | `FI-TRACE-PROXY-REPLAY` | Two HMAC-v2 final admissions using one nonce commit at most one; preparation consumes neither. A private adapter proves its declared replay semantics. | | `FI-TRACE-PROXY-CROSS-REQUEST` | Each protected component mutation denies. HMAC-v2 covers assertion, domain, method, authority, path/query, complete body, proof transport, and peer. | diff --git a/docs/nips/NIP-FI-LIFECYCLE.md b/docs/nips/NIP-FI-LIFECYCLE.md index a4bc72d75c0..fdd08d4dfb3 100644 --- a/docs/nips/NIP-FI-LIFECYCLE.md +++ b/docs/nips/NIP-FI-LIFECYCLE.md @@ -54,10 +54,10 @@ binding version. There is at most one pending lineage per identity. `binding_not_after` bounds the grant represented by **one binding**; it is not a bound on the identity. Rotation continues the same grant: the replacement binding preserves the carried bound, and only expiry authority changes it. Retirement, -revocation, disablement, re-enablement, provisioning, and ordinary enrollment -establish a *new* grant that carries no prior bound — so retirement of a bound -pair followed by ordinary enrollment under `attested-key` or `tofu` policy yields -an unbounded binding, and that is conformant. A deadline that must survive the end +revocation, and disablement end the existing grant. Re-enablement, provisioning, +and ordinary enrollment establish a *new* grant that carries no prior bound — so +retirement of a bound pair followed by ordinary enrollment under `attested-key` +or `tofu` policy yields an unbounded binding, and that is conformant. A deadline that must survive the end of a grant — an identity-scoped access bound — belongs in the capability projection of authoritative local-policy state, which core already requires for any projection whose removal must close authority within a declared bound and @@ -84,6 +84,21 @@ absent `binding_not_after` has no administrative expiry. Assertion `exp`, `iat`, refresh, or maximum age never creates, renews, extends, or clears it. Time passage alone creates no tombstone, lineage, or history. +## Private denial conditions + +This profile defines exactly these private condition identifiers and owning +public classes for NIP-FI-CONF enumeration agreement: + +| Private condition identifier | Public class | +|---|---| +| `identity_disabled` | `authorization_denied` | +| `explicit_replacement_required` | `authorization_denied` | +| `binding_expired` | `authorization_denied` | + +The identifiers are fixture names, not wire values. Adding, removing, renaming, +or reclassifying one requires the same change in NIP-FI-CONF's denial-fixture +table. + ## Common transition contract Each transition below requires privileged authority distinct from an ordinary diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index c9949d8742e..1277963d428 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -498,9 +498,11 @@ A relay SHOULD advertise core support in NIP-11 as: } ``` -For `current-status`, the final value is a tested positive integer. Discovery -never states enrollment mode or TOFU posture and never exposes issuer URLs, -audiences, claim names, tenant IDs, or deployment-local identifiers. For a fixed +NIP-FI-EDGE owns the optional `edge_transports` member and its exact type, +placement, and value semantics. For `current-status`, the final value is a tested +positive integer. Discovery never states enrollment mode or TOFU posture and +never exposes issuer URLs, audiences, claim names, tenant IDs, or +deployment-local identifiers. For a fixed set of claimed profiles, the complete public discovery output is byte-identical for every enrollment policy, including `attested-key`, private `tofu`, and any companion profile mode: no field, flag, value, omission, ordering, or object shape