diff --git a/docs/nips/NIP-FI-CONF.md b/docs/nips/NIP-FI-CONF.md new file mode 100644 index 00000000000..c6ba387977f --- /dev/null +++ b/docs/nips/NIP-FI-CONF.md @@ -0,0 +1,375 @@ +NIP-FI-CONF +=========== + +Conformance evidence profile +---------------------------- + +`draft` `optional` + +**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. It +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, + governing document revision, + exit fixture digest, + 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, except that `FI-CONF-INTEROP-EXIT` alone may instead carry `deferred` +under the condition in **Interoperability exit test**. 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 and +observed outcomes, 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 witness for enrollment mode +can exist. Discovery invariance is proved separately by +`FI-TRACE-DISCOVERY-PRIVATE`. + +Requirements marked `[deployment artifact: ...]` in core or a profile 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. The shared exit fixture is exempt: its values +are synthetic by construction and name no real principal, issuer, or key. + +## Denial fixtures + +`FI-TRACE-DENIAL-ORACLE` requires one fixture per **private condition**, not +one per public class; a per-class suite compares a class against itself. The +enumeration below is the required fixture set (`FI-CONF-DENIAL-FIXTURES`). The +public-class column restates NIP-FI core, which owns the mapping and the bytes. + +| # | Private condition | Public class | Defined by | +|---|---|---|---| +| 1 | assertion, proof, or delegation evidence absent | `missing_evidence` | core | +| 2 | edge provenance absent or incomplete on an edge-required route (assertion may be present) | `missing_evidence` | NIP-FI-EDGE | +| 3 | evidence present but rejected: signature, key selection, issuer, audience, time, size, ambiguity, token class, body binding, or edge provenance (present but rejected) | `evidence_rejected` | core, NIP-FI-EDGE | +| 4 | replayed evidence — committed replay identity already claimed | `authorization_denied` | core, NIP-FI-EDGE | +| 5 | `key_mismatch` — asserted key is not the proven actor | `authorization_denied` | core | +| 6 | `attestation_required` — attested-key enrollment without a matching key claim | `authorization_denied` | core | +| 7 | `binding_conflict` — either side of the active relation is taken | `authorization_denied` | core | +| 8 | `pair_retired` | `authorization_denied` | core | +| 9 | `key_revoked` | `authorization_denied` | core | +| 10 | `policy_denied` — local operation policy | `authorization_denied` | core | +| 11 | `binding_required` — enrollment policy creates no binding at this request: provisioned mode with no binding, or any unrecognized policy value | `authorization_denied` | core | +| 12 | `identity_disabled` | `authorization_denied` | NIP-FI-LIFECYCLE | +| 13 | `explicit_replacement_required` — pending lineage | `authorization_denied` | NIP-FI-LIFECYCLE | +| 14 | `binding_expired` — administrative expiry | `authorization_denied` | NIP-FI-LIFECYCLE | +| 15 | `delegation_not_current` — owner or relationship no longer current | `authorization_denied` | NIP-FI-DELEG | +| 16 | `dependency_unreadable` | `authorization_unavailable` | core | + +Private-condition names are fixture identifiers, not wire values; a deployment +MAY use other internal reason codes if every enumerated condition has a +fixture. Rows for an unclaimed profile are `not-applicable` with absence +evidence. A profile that introduces a private condition MUST add its row; an +unenumerated condition escapes this oracle entirely. + +**Enumeration agreement.** `policy_denied` and `dependency_unreadable` are the +*prose-only allowlist*: core conditions that core states in prose and does not +name symbolically. The suite MUST check mechanically at the claimed head, by +symbol and never by row number, and every check MUST be green on the unmutated +documents before any mutant is scored: + +1. every symbol core denies by name has a row here attributed to core with the + same public class; +2. the set of symbols in core-attributed rows equals core's symbolic denial set + together with the allowlist, exactly, and the allowlist is disjoint from + that set; and +3. for each claimed profile that owns a private-denial-condition table, the + set of `(identifier, public class)` pairs in that table equals the set of + pairs attributed to that profile here, exactly; a row with multiple owners + contributes its pair to each. + +If a later core names an allowlisted symbol, check 2's disjointness fails +until the allowlist entry is deleted, and check 1 validates the promoted +symbol's class. + +**Anonymity comparison.** Every `authorization_denied` row is in the +private-state anonymity set. Between two private conditions on one +implementation, every response byte as transmitted MUST agree — transfer +framing included — except values a server cannot hold constant across two +instants, such as `Date`. This is wider than the interoperability object +below: within one implementation, any byte that varies by private condition is +a disclosure, whatever field it sits in. + +**Interoperability compared object.** Between two implementations, comparison +is over what core pins and nothing more. Over Nostr: the complete relay message +excluding only the event or subscription identifier echoed from the request, +as compact JSON with no insignificant whitespace per NIP-01. Over HTTP: the +status code; the content per RFC 9110 Section 6.4, after transfer decoding with +chunk framing and trailers excluded; and the exact values of only the header +fields core's denial table names, field names matched case-insensitively per +RFC 9110 Section 5.1. `Content-Length` is not pinned. Header order and unnamed +fields are outside the object, and their values MUST NOT depend on the private +condition. A field core names that an implementation cannot hold constant MUST +be reported with the reason, and its value MUST be independent of the private +condition. If core later pins another field, it joins with no edit here. + +**Run discipline.** 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. + +**Negative control.** The suite MUST include an implementation deliberately +patched to vary its denial response by private condition, and it MUST fail +this oracle. + +## Mutation adequacy + +An oracle that cannot fail is untested text that reads as tested. The +denominator is the **listed oracle**: every table row whose first cell names +exactly one complete literal oracle identifier, in NIP-FI core, in each claimed +normative profile, and in this document when CONF is claimed — selected by +that cell, not by section title. It is not the set of normative sentences, RFC +2119 keywords, or invariant labels, none of which two readers enumerate alike. + +For each listed oracle 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. For this document's own oracles the implementation +under test includes the conformance suite and its report; a mutant is a single +variant of the suite or report that the entry's own oracle rejects. + +While `FI-CONF-INTEROP-EXIT` is validly deferred it remains in claim +completeness but is excluded from this section's mutation and global-control +obligations, since its failing output cannot exist without the run. Both +obligations attach with the run and MUST be discharged before either +implementation's interoperable conformance claim is accepted. No other +oracle's obligation under this section is deferrable. + +Normative prose outside the oracle tables remains binding but is not a second +denominator. Prose that no listed oracle can detect is untestable text: add the +oracle that detects it, or delete it. + +1. **One at a time.** Mutants are applied singly against an otherwise + unmodified implementation, so layered defenses cannot mask each other. +2. **Attribution.** The kill MUST come from the entry's own oracle. A mutant + killed only by another 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. +4. **Reachability.** The suite MUST witness that a fixture reaches the mutated + decision, not merely the enclosing operation. +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. + +Two global controls bound the suite. A deny-everything implementation MUST +fail every positive oracle; an allow-everything implementation MUST fail every +negative oracle. Neither substitutes for per-entry mutants. + +## Interoperability exit test + +A claim of core conformance requires evidence that the documents alone are +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 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 each transport where the class can be decided, compared over the + interoperability compared object under **Denial fixtures**. + +Independence is a claim about code, not inputs: two implementations given +different issuers, keys, or clocks cannot produce equal bytes. The run is +therefore 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 as + complete pre-signature protected-header and claim-set JSON values — + including `alg`, `typ`, `kid`, every member the policy allows, and fixed + `iss`, `sub`, `aud`, `nostr_pubkey`, `client_id`, `iat`, `exp`, and token + 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`; +- one frozen evaluation instant, and the skew and lifetime bounds in force; + and +- the domain, target resource, operation, and enrollment policy for each case. + +The canonical fixture is authored by this document's editors, not by any +claiming implementation, and MUST be published as a single file at +`docs/nips/fixtures/nip-fi-conf-exit.json` in the same repository as these +documents, with its SHA-256 digest, before any `FI-CONF-INTEROP-EXIT` run. +Both sides MUST load that file, MUST verify the digest before the run, and +MUST record the digest with the evidence; a run against any other fixture +instance is not `FI-CONF-INTEROP-EXIT` evidence. While the canonical fixture +is unpublished, the claim tuple's exit fixture digest records the reserved +value `pending-canonical-fixture`, valid only in a claim whose +`FI-CONF-INTEROP-EXIT` result is `deferred`. Publication changes the element +and therefore creates a new claim. + +**Request compared object.** Signature octets are excluded, because conforming +implementations need not agree on them (randomized `ES256` and fresh-aux +BIP-340 do not) and no document here pins JWS or JSON member order. The +compared object is the **signing inputs**: for each transport's Nostr proof, +the NIP-01 serialization the event id is taken over, compared against its own +transport's serialization; for the assertion, the decoded protected header and +claim set compared as JSON values with member order excluded. Every value the +compared object depends on MUST be pinned in the fixture. + +The exchanged artifact per case is the complete request and 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 and relay messages — +so that a mismatch can be explained from fields outside the compared object. + +The test passes when outputs compare equal over their compared objects and +each implementation accepts the other's valid request and reproduces the +other's denials. Exit evidence includes the exchanged artifacts and each +implementation's statement of independence. A divergence traced to an +underspecified value is a defect in the specification, not in either +implementation, and is fixed there. + +**Negative control.** One implementation is patched to emit a denial that +differs from the other only outside the compared object — a header core does +not name, or reordered fields — and the run MUST still pass. A run that fails +this control is comparing more than core pins; the exit test is then the +defect. The control is retained with the evidence. + +`FI-CONF-INTEROP-EXIT` is REQUIRED only once a second implementation meeting +the independence conditions exists. Until then a conformance claim MUST record +it as deferred with the machine-readable reason +`no-independent-implementation`. A deferred exit test MUST be run and passed +before the second implementation's conformance claim is accepted, and the +first implementation's claim MUST be re-evidenced against that run. + +## 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; +- `FI-TRACE-CURRENT-STATUS-STALE` and `FI-TRACE-CURRENT-STATUS-REVOKED` only + when every configured assertion policy declares freshness class + `offline-jwt` 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. + +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, at one +reviewed revision, that: + +- one immutable claim tuple passes every applicable oracle other than a + validly deferred `FI-CONF-INTEROP-EXIT`; +- if the canonical fixture was published before the review, the tuple's exit + fixture digest is not `pending-canonical-fixture`; +- the protected-ingress inventory has no uncovered or competing authority; +- every listed oracle, other than a validly deferred `FI-CONF-INTEROP-EXIT`, + has a killed, attributed, reachable mutant and every survivor is recorded; +- the denial-fixture enumeration is complete for the claimed profiles and its + negative control fails as required; +- the interoperability exit test has passed against an independent + implementation, or is recorded as deferred because none exists; +- every named deployment artifact exists at the claimed deployment revision; + and +- public and operational sinks pass privacy-canary inspection. + +Documentation review, source review, and static scans are 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, claiming a status other than `pass`/`not-applicable` — or `deferred` on any oracle other than `FI-CONF-INTEROP-EXIT` — or omitting mutant evidence for any oracle other than a deferred `FI-CONF-INTEROP-EXIT`, or recording the exit fixture digest `pending-canonical-fixture` with any `FI-CONF-INTEROP-EXIT` result other than `deferred`, is rejected. | +| `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 — except `FI-CONF-INTEROP-EXIT` while validly deferred, per **Mutation adequacy** — has a singly-applied, attributed, reachability-witnessed mutant killed by that entry's own oracle; the deny-everything and allow-everything global controls fail every oracle **Mutation adequacy** requires of them, with retained evidence; 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 + +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. + +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. diff --git a/docs/nips/NIP-FI-DELEG.md b/docs/nips/NIP-FI-DELEG.md new file mode 100644 index 00000000000..9b9134c2b3d --- /dev/null +++ b/docs/nips/NIP-FI-DELEG.md @@ -0,0 +1,186 @@ +NIP-FI-DELEG +============ + +Delegated agent authorization profile +-------------------------------------- + +`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 + +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. Because a trusted edge inserts assertion and +provenance fields on every request it forwards, and `FI-DELEG-PATH-SEPARATION` +denies any such field on a delegated request, delegated requests cannot traverse +a route that requires edge provenance; they use ingress on which NIP-FI-EDGE is +not required. + +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, + conditions, + 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 satisfying `now < mandatory_expiry`; equality at an expiry is expired. +Optional `not_before` satisfies `not_before <= now + skew`, using the +configured delegated `skew`; arithmetic is overflow-safe. A missing +configured `skew` denies. 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] + +## 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, +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 and a non-negative +finite delegated `skew`. 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 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 +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-11 +`federated_identity` object 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-EDGE.md b/docs/nips/NIP-FI-EDGE.md new file mode 100644 index 00000000000..1f54d36e37d --- /dev/null +++ b/docs/nips/NIP-FI-EDGE.md @@ -0,0 +1,417 @@ +# NIP-FI-EDGE: Trusted Edge Profile + +`draft` `optional` + +## Scope + +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. + +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 + +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 `Nostr-Federated-Identity`, + `Nostr-Federated-Identity-Provenance`, and `Nostr-Federated-Identity-Client-Peer` + field, and of every other edge profile's assertion, identity, capability, + provenance, and client-peer field, before inserting its own fields. A trusted + edge MUST NOT remove or modify the `Authorization` field, which remains reserved + for the independent NIP-98 proof and MUST reach final admission unmodified; +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 other than + an independent Nostr proof, which is protected by its own signature; +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. A trusted edge that strips, rewrites, or reorders the +`Authorization` field, or that admits a proof-transport-`0x02` request whose +`Authorization` field did not arrive at the verifier byte-identical to the +client-sent value, 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. Substitution of the protected octets after the snapshot denies. +- **Proof transport:** Serialize exactly one assigned octet from the registry below. +- **Client peer:** Serialize the exact canonical ASCII field value. + +No authorization decision, target, resource, capability, or effect selector +derives from any request or connection component outside the protected pre-MAC +components, except an independent Nostr proof validated on its own signature, +such as the NIP-98 event in `Authorization` or the NIP-42 event after connect, +which the MAC does not protect; body interpretation follows the server-resolved +body semantics, never unprotected transport metadata such as `Content-Type` or +`Content-Encoding`. + +### 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. +On a route that requires edge provenance, absent or incomplete provenance — +including provenance that omits the proxy-authenticated end-client peer — maps +to the `missing_evidence` public class, regardless of whether an assertion is +present. Provenance that is present and complete but fails verification maps to +`evidence_rejected`. +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: the proxy verifies a session-scoped Nostr authorization for a Git smart-HTTP request before forwarding. Reserved; allocation completes on publication of its transport contract (see below). | +| `0x04` | Blossom media proof: the proxy verifies a Blossom media-HTTP authorization event for the request before forwarding. Reserved; allocation completes on publication of its transport contract (see below). | +| `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. + +Codes `0x03` and `0x04` are reserved to fix their meanings and prevent +reassignment; their transport contracts are not yet published, so their +allocations are not complete. Until the contract for such a code is published, +the code is valid only under an explicit shared proxy/verifier contract, +exactly as for private use, and MUST NOT be presented 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 +(`FI-TRACE-EDGE-VECTORS`). + +### 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. | +| Metadata | Mutating `Content-Type` or `Content-Encoding` in flight changes no authorization decision, target, capability, or effect selector; a request whose server-resolved body semantics no longer hold 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 substitution of the protected octets 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 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. On a `0x02` route the `Authorization` bytes at final admission equal the client-sent bytes, witnessed at both points; an edge that substitutes a valid proof from the same actor fails the witness. | +| `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. diff --git a/docs/nips/NIP-FI-LIFECYCLE.md b/docs/nips/NIP-FI-LIFECYCLE.md new file mode 100644 index 00000000000..fdd08d4dfb3 --- /dev/null +++ b/docs/nips/NIP-FI-LIFECYCLE.md @@ -0,0 +1,269 @@ +# NIP-FI-LIFECYCLE: Binding Lifecycle Profile + +`draft` `optional` + +## Abstract + +This profile extends NIP-FI with provisioned enrollment, identity disablement, +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 +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. + +`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, 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 +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 +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. + +## 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 +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. + +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, +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`). + +### 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. 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 + +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 +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. 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. | +| `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-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, 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. | + +## 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. 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 new file mode 100644 index 00000000000..821477755cc --- /dev/null +++ b/docs/nips/NIP-FI-MODEL.md @@ -0,0 +1,96 @@ +NIP-FI-MODEL +============ + +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, 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 new file mode 100644 index 00000000000..663210659be --- /dev/null +++ b/docs/nips/NIP-FI.md @@ -0,0 +1,632 @@ +NIP-FI +====== + +Federated identity authorization — core +---------------------------------------- + +`draft` `optional` `relay` + +**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. + +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`, `tofu`, or `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 + 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. + +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. +`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. Each policy configures a non-negative finite `skew`, a positive +finite `maximum_assertion_age`, and, for `current-status`, a positive finite +`maximum_status_age`; a missing value denies. `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] + +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] + +### Token class + +Policy selects exactly one token class before parsing claims: + +- **`at+jwt` access token**: a Buzz-resource access token whose protected + `typ` is exactly `at+jwt` and whose `aud` contains the configured Buzz + resource audience. This class selects tokens carrying the RFC 9068 `at+jwt` + type but validates them under this document's claim contract; it does not + implement the full RFC 9068 validation profile, and the long-form media type + `application/at+jwt` is not accepted; +- **dedicated Buzz assertion**: a separately minted assertion whose protected + `typ` is exactly `nip-fi+jwt`; +- **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 generic +or absent type has no stock fallback. Failure under one class never triggers +validation under another. An `at+jwt` access 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 + +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] + +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. 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)`. Issuer, subject, and optional + session 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] + +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 +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 **body bytes**: the complete content +after transfer decoding and before any content decoding. Absence, duplication, +mismatch, validation of only a prefix, or substitution of the body bytes after +validation denies. For an irrelevant body, no +authorization decision, target, capability, or effect selector derives from a +body field not bound by NIP-98. A `payload` tag present on an operation whose +body is declared authorization-irrelevant is validated identically against the +body bytes; duplication or mismatch denies. [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: + 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 + 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 — every `authority_deadlines` member, the + key-snapshot hard deadline, and any status deadline — is live now and is no + later than its prepared value; and +3. provenance-class fields — snapshot version, verification-key identity, + status source and version, binding, lifecycle, local-policy, and resource + versions, proof and replay witnesses, the confidential JWS handle, cache + metadata, ordering, and retrieval time — are ignored after successful + current revalidation. + +Every `revalidation_dependencies` member is bounds-class if it is a deadline +and provenance-class otherwise. Any new or unclassified assertion-content 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, status, 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. The replacement provenance is `attested-key` when current matching + issuer attestation was validated and `provisioned` otherwise. Rotation does + 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, +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 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 + +Public class is a function only of evidence the requester supplied, never of +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. Replay status is a function of +committed per-principal server state, not of the supplied evidence alone; +replayed evidence is therefore classed `authorization_denied`, indistinguishable +from any other private-state denial, so that resubmitting captured evidence +reveals nothing about whether the original request committed. 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`; `WWW-Authenticate: Nostr`; `Content-Type: text/plain; charset=utf-8`; `authentication required\n` | +| malformed, invalid, or expired evidence | `evidence_rejected` | `restricted: evidence rejected` | `403`; `Content-Type: text/plain; charset=utf-8`; `evidence rejected\n` | +| replayed evidence; 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. +A denial decided on a WebSocket upgrade request, before any NIP-42 proof +exists, is the HTTP response in the table, sent instead of `101`; a denial +decided after the connection is established is the Nostr text. 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. +[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 + } + } +} +``` + +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 +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 the complete request compared objects. + +```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 +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, ambiguity, and missing-configuration negative denies. | +| `FI-TRACE-TOKEN-CLASS` | An `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` | 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; an issuer, subject, or session-identifier mismatch 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` | Exact complete relevant body passes; absent/duplicate/mutated/partial/substituted payload variants deny without effects; a payload tag on an irrelevant-body operation validates identically and denies on duplication or mismatch. | +| `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` | 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` | 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 on every surface where its condition can be decided — HTTP, a WebSocket upgrade, or after connect; 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` | 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. | + +## Relationship to other work (non-normative) + +NIP-FI binds an access token to a key the resource server itself verifies, the +goal DPoP (RFC 9449) and mTLS-bound tokens (RFC 8705) reach through a `cnf` +claim. Here the proof is the NIP-42 or NIP-98 event the relay already +validates, so no second proof is defined and the issuer need not attest the +key; `nostr_pubkey` is the optional `cnf` analogue. Unlike those profiles the +binding is durable server state rather than a per-token claim: a stolen +assertion cannot reach an enrolled identity without its key, and revocation is +a local fact rather than a token-lifetime race. One identity, one key per +domain is stricter than WebAuthn's many-credentials-per-account model because +the Nostr key is itself the public identity; additional devices do not create +additional active bindings. Two contract identities plus explicit dependency +versions exist because folding a mutable key snapshot into policy identity would make benign +rotation change policy lineage, while omitting it would let evidence under a +removed key survive. Denial responses deliberately collapse the conditions that +RFC 6750 error codes distinguish. `trusted-proxy-hmac-v2` in NIP-FI-EDGE is a +fixed-component request MAC in the family of HTTP Message Signatures (RFC 9421) +and AWS SigV4, without negotiation and with length-prefixed canonicalization. + +## 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: +- DPoP: +- OAuth 2.0 mTLS client certificate-bound tokens: +- HTTP Message Signatures: +- Non-normative composed model: [NIP-FI-MODEL.md](NIP-FI-MODEL.md)