docs(nip-fi): state, don't argue — CONF −29%, rationale section, four pins (alt to #5946) (#6437)

## What this is

An alternative shape for the NIP-FI document family, built on top of
#5946 at its current head `772ba7a72` (so it carries R1–R12 and diffs
cleanly against it). Thesis: the current text *argues* where a standard
should *state*, and is silent where a reader needs one paragraph of
*why*. This PR makes every section either a rule or labeled as not one,
and fixes four correctness items found in review.

Base: `772ba7a72` (= `eva/nip-fi-comprehensive`). Eight FF commits, no
rebase. Three files. Oracle census unchanged at **58** (30/6/4/11/7).

**Review lap (Wren, Dawn, Eva) folded in as FF commits
`be72f0a5d`…`db35013c1`** — see "Review findings applied" below.

## Changes

### NIP-FI-CONF — 4,325 → 3,088 words (−29%), no obligation lost
- Denial fixtures 1,516 → ~740 words. Same 16-row table.
Enumeration-agreement checks 4 → 3 (checks 1 and 2 quantified over the
same set). Design history ("Two of these checks shipped red…", the
`Www-Authenticate` canonicalization paragraph, "one layer down", "a slow
way to discover…") removed from normative text.
- Interoperability exit test 934 → ~620 words. Same fixture contents,
same compared objects, same deferral rule, same negative control.
- Release gate restructured as a list; identical conjuncts.
- **BCP 14 keyword census 37 → 36.** Every `MUST`/`MUST
NOT`/`MAY`/`REQUIRED` sentence in the old text was fuzzy-matched to the
new text; the two that don't map are (a) "Their public responses MUST
compare byte-identical to each other, not merely equal in prefix or
status" — fully subsumed by the anonymity comparison one paragraph
later, and (b) the standalone "The suite MUST include a negative
control:" whose obligation is retained in the **Negative control**
paragraph. Reviewers: please try to find a third.

### NIP-FI core
- **New non-normative section "Relationship to other work"** (~190
words): DPoP / mTLS `cnf` with the NIP-42/98 event as the proof; durable
binding vs per-token claim; why 1:1 vs WebAuthn's N credentials; why two
contract IDs plus dependency versions; RFC 6750 error codes; RFC 9421 /
SigV4 family for the edge MAC. The "Thus two contract identities…"
argument paragraph moved here from the normative snapshot section. Three
source links added.
- **Pin:** `skew`, `maximum_assertion_age`, `maximum_status_age`
declared as configured finite values (missing → deny). Previously used
at :177-178/:266/:278 and never declared; EDGE already did this for its
own.
- **Pin:** NIP-98 `payload` hashes one defined referent — "body bytes:
the complete content after transfer decoding and before any content
decoding" — the same stage EDGE MACs as `payload_octets`. Previously
:315 said "bytes consumed by the application" and :320 said "body bytes
received", which differ under `Content-Encoding`. **This is a semantic
shift, not a clarification** (Eva's A3): under the old "consumed"
reading a `Content-Encoding` flip in flight was caught by hash mismatch;
under this pin the same octets hash equal, so the defense moves onto the
server-resolved-body-semantics rule (core:305-308, EDGE:176-178) and the
EDGE Metadata negative row (:367), which are now load-bearing for it.
All four body-wording sites say "substitution of the protected octets",
not "transformation", so content decoding after validation is permitted.
- **Pin:** a denial decided on a WebSocket upgrade, before any NIP-42
proof exists, is the HTTP table row sent instead of `101`; post-connect
denials are the Nostr text. `FI-TRACE-DENIAL-ORACLE` requires each row
"on every surface where its condition can be decided" (a private-state
row needing the proof is not decidable at upgrade — Wren). CONF exit
list aligned.

### NIP-FI-EDGE
- **R12 exception class-scoped** (Dawn's finding): "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"
instead of naming the field. Members are illustrative ("such as") so
`0x03`/`0x04` can conform once their contracts publish. Prohibition
widened to "request or connection component" — Dawn's model shows this
closes 6144 unprotected-connection derivations to 0. Requirement 3 gets
the matching carve-out.
- **`Authorization` byte-identity gains an oracle — as a two-point
witness, not a denial.** EDGE:62-65 makes altering it nonconformant; no
oracle named it. First attempt said "altered … denies"; Eva (A1) and
Dawn's `a1_oracle.py` showed that's false as a universal — an edge
substituting a fresh, valid NIP-98 event from the same actor *admits*,
because `Authorization` is outside the MAC by R12's design.
`FI-TRACE-PROXY-CROSS-REQUEST` now requires the bytes at final admission
to equal the client-sent bytes, witnessed at both points, and names the
mutant that must fail it (valid-proof substitution). The Request matrix
row no longer claims a denial for it.

## Review findings applied (all FF commits on `dcd0b2ff2`)
| # | Finder | Finding | Fix |
|---|---|---|---|
| W1 | Wren | Denial oracle demanded every private row on all three
surfaces; undecidable pre-proof | core:586 "on every surface where its
condition can be decided"; CONF:235-237 aligned |
| W2 | Wren | EDGE:168 and :372 still forbade any post-snapshot
"transformation" | "substitution of the protected octets denies" at all
four sites (core:315, :573; EDGE:168, :372) |
| W3/A2 | Wren, Eva | CONF dropped exit-evidence obligation (old :393) |
CONF:287 "Exit evidence includes the exchanged artifacts and each
implementation's statement of independence." |
| W4/A6 | Wren | Two-device sentence prescribed custody outside the
standard | core:605 "additional devices do not create additional active
bindings." |
| D1/A4 | Dawn, Eva | Em-dash members read exhaustive; `0x03` couldn't
conform | "such as" |
| D2 | Dawn | "a missing value denies" had no detecting oracle |
`FI-TRACE-ASSERTION-VALIDATION` gains "missing-configuration" negative |
| A1 | Eva, Dawn | `Authorization` oracle asserted a false denial
universal | two-point byte witness + valid-substitution mutant (above) |
| A5/W5 | Eva, Wren | Promotion guard retargeted by 4→3 collapse, now
inert | sentence deleted; check 2's disjointness already fails promotion
until the allowlist entry is removed, check 1 validates the class. CONF
MUST NOT 9→8, deliberate |
| D3 | Dawn | Replacement sentence said "only check 2's disjointness
fails" — false in 2/4 promotion cases (check 1 fails too when classes
disagree); "then" implied an ordering the independent conjuncts don't
have | both words dropped (`624429bd8`) |

Not taken this lap (predate this PR): EDGE:180 `maximum_provenance_age`
missing-config oracle; `0x04` Blossom row says "authorization event" not
"Nostr", so the class exception can't reach it until its contract is
written.

## Not changed
LIFECYCLE, DELEG, MODEL: zero bytes. Oracle tables, HMAC vectors,
negative matrix, denial table: zero bytes beyond the two cells named
above.

## Verification
- `git rev-parse HEAD` == `624429bd8` in the shell that produced the
census/keyword numbers below; section word counts above are from
`dcd0b2ff2` (CONF now 3,1k).
- Oracle census: `rg -c '^\| \`FI-[A-Z0-9-]+\` \|'` per file →
30/6/4/11/7.
- Distribution markers: 0 hits across all six docs and the commit
message.
- Keyword census and sentence mapping: script in the PR thread on
request.

## Open questions
1. ~~Two-device sentence~~ — closed by Wren's wording.
2. ~~R12 widening~~ — Dawn's model: net stricter than `772ba7a72`.
3. ~~CONF floor~~ — Eva measured Evidence rules at 317 words, every
sentence an obligation. Floor reached.
4. ~~A3 body-referent shift~~ — accepted by Wren and Eva at `db35013c1`:
the defense is a listed oracle row, hence inside the mutation regime.

## Verdicts
At `624429bd8`: Wren 9/9/9/9 · Eva 9/9/9/9 · Meli 9/9/9/9, each
re-pinned at this exact head. Dawn's three instruments (conf/a1/r12)
rc=0 at this head. CI 14 pass / 0 fail / 9 skipped. Zero known
falsehoods in the text.

Census partition note (Dawn): "58" is unique oracle IDs per document
across FI-TRACE/CONF/LC/DELEG prefixes; partitioned per prefix across
all docs including FI-INV the same corpus totals 75. Quote the partition
with the number.

---------

Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
This commit is contained in:
Tyler
2026-08-21 09:22:40 -04:00
committed by GitHub
parent 772ba7a723
commit b8db13d884
3 changed files with 245 additions and 327 deletions

View File

@@ -20,13 +20,10 @@ counts as evidence that an implementation has it: the claim unit, the evidence
rules, the complete denial-fixture enumeration, mutation adequacy, and the
interoperability exit test.
This profile is separately claimable and is never advertised in discovery.
Conformance is a property of a reviewed revision, not a wire feature, and a
public claim of it would be an unverifiable assertion about the server's own
testing.
This profile defines no wire behavior, denial mapping, invariant, or admission
rule. Where it names one, NIP-FI core or the owning profile is normative.
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
@@ -46,30 +43,26 @@ A conformance claim names exactly one immutable tuple:
```
Changing any element creates a new claim. Results from one tuple MUST NOT be
carried into another. The governing document revision and exit fixture digest
are in the tuple because the same evidence means different things under
different text: a claim that does not name the revision it was judged against
is unfalsifiable once the specification moves, and a suite that does not name
its fixture cannot be shown to have run the pinned inputs. A report contains
every applicable oracle from core and every claimed profile exactly once, with
status `pass` or `not-applicable` only, except that `FI-CONF-INTEROP-EXIT`
alone may instead carry `deferred` under the condition defined in
**Interoperability exit test**. Blank, skipped, expected-failure, and
not-run results cannot support a claim (`FI-CONF-CLAIM-COMPLETE`).
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.
Enrollment mode is part of the claim unit and is private: it is recorded in
the access-controlled report, never in discovery or any public artifact.
## Evidence rules
Each passing oracle records the claim tuple, a stable test identifier and
adapter entry point, the command with start time, end time, exit status, and
any random seed, the synthetic input or a privacy-safe digest of it, the
before-and-after authoritative state relevant to the oracle, the expected
outcome and the observed outcome, and artifact locations with SHA-256 digests.
Stateful oracles use an isolated database or namespace and inspect committed
state rather than inferring it from a response. Concurrency oracles record
every contender and the single serialized outcome. Time-boundary oracles use a
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
@@ -84,31 +77,27 @@ prove a deployed network boundary; citing a check from another revision; or
marking an oracle passed because the feature is configured.
`FI-TRACE-TOFU-THEFT` takes an access-controlled **configuration** witness
only. Under the private-posture rule no discovery output distinguishes
enrollment mode, so a discovery witness for that oracle cannot exist; requiring
one would make the oracle unsatisfiable. Discovery invariance is proved
separately by `FI-TRACE-DISCOVERY-PRIVATE`, which compares complete discovery
bytes across enrollment modes.
only; under the private-posture rule no discovery witness for enrollment mode
can exist. Discovery invariance is proved separately by
`FI-TRACE-DISCOVERY-PRIVATE`.
Deployment-obligation requirements — those marked in core or a profile as
`[deployment artifact: ...]` — are evidenced by the named access-controlled
review record at the claimed deployment revision, not by a behavioral oracle.
A claim listing an artifact without the record is incomplete.
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, and the
interoperability exit test cannot run on redacted inputs.
`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 passes trivially: it compares a class
against itself. The enumeration below is the required fixture set
(`FI-CONF-DENIAL-FIXTURES`). Its public-class column restates NIP-FI core,
which owns that mapping and the exact response bytes.
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 |
|---|---|---|---|
@@ -129,289 +118,188 @@ which owns that mapping and the exact response bytes.
| 15 | `delegation_not_current` — owner or relationship no longer current | `authorization_denied` | NIP-FI-DELEG |
| 16 | `dependency_unreadable` | `authorization_unavailable` | core |
The names in the private-condition column are fixture identifiers for this
enumeration. Some are the symbols core's preparation pseudocode denies by name;
that set is read from core, never restated here, because a copied list is state
that drifts and the whole subject of this section is that unguarded restatements
rot. `policy_denied` and `dependency_unreadable` are core's conditions expressed
only in prose — a bare policy denial and `FI-INV-14` fail-closed — and core is
not required to name them symbolically; they are the only two core rows so
exempted. The remaining rows name conditions in the owning profiles' literal
private-denial-condition tables. None is a wire value, and a deployment MAY use
different private reason codes
internally as long as every enumerated condition has a fixture.
Every row whose public class is `authorization_denied` is in the private-state
anonymity set. Their public responses MUST compare byte-identical to each other,
not merely equal in prefix or status.
Rows for an unclaimed profile are `not-applicable` with absence evidence. A
profile that introduces a new private condition MUST add its row; an
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.
The anonymity comparison is **wider than the interoperability compared object
defined below, and deliberately so**. Between two private conditions on the same
implementation, every response byte as transmitted MUST agree — including
transfer framing, and not only the content — except values a server cannot
hold constant across two instants, such as `Date`. It is not limited to the
header fields core names. The narrower object below exists because two
*different* implementations cannot be required to agree on fields core does not
pin; that reasoning does not apply within one implementation, where any field
varying by private condition is a disclosure whatever its name. A suite that
reuses the interoperability object here would pass an implementation that
returns its private reason code in an unnamed header, or one that varies its
chunk boundaries by private condition.
**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:
**Enumeration agreement.** The preceding paragraph makes a quantified claim
about this table, and a fix verified against the one row it changes can still
falsify it. `policy_denied` and `dependency_unreadable` are the **prose-only
allowlist**: core's conditions that core is not required to name symbolically.
Both sets here are named by symbol, never by row number, because this table is
required to grow and every positional reference silently retargets when it does.
The suite MUST check, mechanically at the claimed head
(`FI-CONF-DENIAL-FIXTURES`):
1. every symbol core denies by name has a row here 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.
1. every symbol core denies by name has a row here, attributed to core;
2. every symbol core denies by name carries the same public class — quantified
over core's symbolic set, not over all core-attributed rows, since
`dependency_unreadable` is core-attributed and correctly
`authorization_unavailable`; and
3. the set of symbols named by core-attributed rows equals core's symbolic
denial set together with the allowlist, exactly, and the allowlist is
disjoint from core's symbolic denial set; and
4. for each claimed profile that owns a literal private-denial-condition table,
the set of `(identifier, public class)` pairs in that table equals the set of
pairs in rows attributed to that profile here, exactly. A row with multiple
profile owners contributes its pair to each owner named in that cell.
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.
Every check above MUST be run against the unmutated document and be green before
any mutant is scored. A check that is red on a conforming document detects
nothing: it cannot be observed to flip, so every mutant reads as caught. Two of
these checks shipped red for exactly that reason.
**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.
Checks 3 and 4 keep copied enumerations honest in both directions. Check 4
catches a profile condition renamed, added, removed, reclassified, or attributed
to the wrong owner without the matching fixture-table change. It is run for each
claimed owning profile. Rows for an unclaimed profile remain `not-applicable`.
**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.
Check 3 keeps the core allowlist honest in both directions. Its equality half
catches
a core-attributed row core never emits. Its disjointness half is what makes
promotion visible: if a later core turns `policy_denied` or
`dependency_unreadable` into a named symbol, every other check still passes —
the row is already attributed to core — and only disjointness fails, leaving the
stale allowlist entry as the thing to delete. Promotion MUST NOT be applied to
`dependency_unreadable` without moving it out of the set check 2 quantifies
over; it is the one core condition whose public class differs.
**Compared object.** This object governs the interoperability comparison between
two implementations. The anonymity comparison above is wider. Byte-identity is
asserted over the response bytes an implementation chooses, which excludes bytes
a conforming HTTP server cannot hold constant. Over Nostr the compared object is
the complete relay message excluding only the event or subscription identifier
echoed from the request, encoded as compact JSON with no insignificant
whitespace, per NIP-01's serialization rules. Over HTTP the compared object is
exactly what NIP-FI core pins: the status code, the complete body, and the exact
values of only the header fields core's denial table names. Header field *names*
are matched case-insensitively per RFC 9110 Section 5.1; their values are
compared exactly. The compared body is the *content* per RFC 9110 Section 6.4 —
after transfer-decoding, chunk framing and trailer fields excluded — not the
message body on the wire. That reading is scoped to this interoperability object
and does not reach the anonymity comparison above, which stays over transmitted
octets. `Content-Length` is deliberately not pinned: framing is the sender's
choice and pinning it would widen the object for no privacy gain.
Header order and unnamed header fields are outside it, and their values MUST NOT
depend on the private condition — which the anonymity requirement above already
demands and tests directly.
Each reading is stated because an independently written conforming
implementation diverges on it by language default, not by error: a canonicalizing
HTTP library emits `Www-Authenticate`, a server that sets no `Content-Length`
frames the body as chunked, a JSON encoder inserts spaces after `,` and `:`, and
two servers emit different automatic fields — `Server`, `Connection` — in
different orders. Comparing those would fail every conforming pair, and an
oracle no conforming implementation can satisfy is a defect in this document
rather than evidence about either implementation. The compared object is
therefore closed over what core names and nothing more; if core later pins an
additional field, it joins with no edit here. `Date` needs no special exclusion,
since core does not name it. Any field an implementation must exclude despite
core naming it MUST be reported with the reason it cannot be held constant, and
its value MUST be independent of the private condition.
The oracle runs a fixed positive iteration count on a pinned isolated runner at
the exact claimed head. Before the run the operator records the environment,
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.
**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.
The suite MUST include a negative control: an implementation deliberately
patched to vary its denial response by private condition MUST fail this oracle.
Without it the suite asserts that it works instead of demonstrating it.
**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
Naming an oracle for a requirement proves the requirement is claimed, not that
the oracle can fail. A requirement whose oracle cannot fail is untested and
reads as tested, which is worse than an acknowledged gap.
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.
The denominator is the **listed oracle**. An oracle table is identified by its
rows: each names exactly one complete literal oracle identifier in its first
cell, with no shorthand and no name left to inference. The denominator is every
such row in NIP-FI core, in each claimed normative profile, and in this document
when CONF is claimed — selected by that first cell, not by section title, since
the tables do not share one. Enumerating it is reading rows, so two readers
obtain the same set. It is not the set of normative sentences, RFC 2119
keywords, or invariant labels: none is enumerable without judgement, and a
denominator two readers count differently decides how much of the specification
is tested at all.
For each listed oracle 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 each listed oracle in core and each claimed profile, the suite MUST retain at
least one **mutant**: an implementation variant that violates a requirement that
oracle governs, together with that oracle's failing output (`FI-CONF-MUTATION`).
Evidence is the exact patch identity, the oracle identifier, and the retained
failure output at the claimed head.
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.
While `FI-CONF-INTEROP-EXIT` is validly deferred under **Interoperability exit
test**, it remains in claim completeness but is excluded from the mutation and
global-control obligations of this section: its failing output cannot exist
without the run itself. 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. It is not a second
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.
Five rules make the mutant meaningful:
1. **One at a time.** Mutants are applied singly against an otherwise unmodified
implementation. Layered defenses mask each other: a guard looks covered
because a different guard denies first.
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 some other oracle establishes coverage for neither.
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. Otherwise one broad mutant
discharges several obligations at once and the count reads complete while
the coverage is not.
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. A mutant behind a bound,
length field, or earlier denial that no fixture ever passes is never
exercised, and the suite reports clean on an implementation that is
provably broken.
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 from both sides. A deny-everything
implementation MUST fail every positive oracle, proving each oracle has a
positive arm. An allow-everything implementation MUST fail every negative
oracle, proving each has a negative arm. Neither control substitutes for
per-entry mutants; an implementation can pass both while violating any
individual requirement.
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 document alone is
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
both transports, compared over the object defined under **Denial fixtures**.
- 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 and reference implementations, not about
inputs. Two implementations given different issuers, keys, or clocks cannot
produce equal bytes however correct both are, so the run is parameterized by a
**shared exit fixture** that both sides load and neither side authors:
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 expressed
as complete pre-signature protected-header and claim-set JSON values —
including `alg`, `typ`, `kid`, and every member the policy allows, and fixed
- 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`, so both sides derive the same actor and the same event id;
- one frozen evaluation instant, and the skew and lifetime bounds in force; and
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 shared exit fixture is authored by this document's editors, not
by any claiming implementation, and MUST be published as a single file in the
same repository as these documents, at `docs/nips/fixtures/nip-fi-conf-exit.json`,
together 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 file is not yet published, the exit fixture digest
element of the claim tuple records the reserved value
`pending-canonical-fixture`; that value is valid only in a claim whose
`FI-CONF-INTEROP-EXIT` result is `deferred`. Publication of the fixture changes
the element and therefore creates a new claim.
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.** A minted request cannot be compared as bytes. Two
conforming implementations may produce different signature octets for the same
semantic input — randomized `ES256` and BIP-340 with fresh auxiliary input do,
while deterministic ECDSA and fixed-aux BIP-340 need not — and no document here
pins JWS or JSON member order. An object requiring equal octets would therefore
fail conforming pairs, which is the defect this document names one layer down.
The compared object is the **signing inputs**:
**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.
- for a NIP-98 proof over HTTP and a NIP-42 proof over the WebSocket upgrade,
the NIP-01 serialization the event id is taken over, which NIP-01 fixes
byte-for-byte; each transport's proof is compared against its own transport's
serialization, not across transports; and
- for the assertion, the decoded protected header and claim set compared as
JSON values, with member order excluded.
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.
Signature octets are excluded because conforming implementations need not agree
on them, not because they disagree about this document; the mutual-acceptance
requirement below tests them instead. A divergence in a signing input is a
divergence in what this document told each side to sign, which is what the exit
test exists to detect.
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.
Every value the compared object depends on MUST be pinned here. A value left to
the implementation is a divergence the test will attribute to a defect in this
document, which is the correct disposition but a slow way to discover a missing
fixture field.
The exchanged artifact per case is the complete request frame and the complete
response frame on each transport: for HTTP the request line, headers and body,
and the response status, headers and body; for Nostr the complete client
message and the complete relay message. Both sides emit whole frames even
though the compared objects are narrower, because the unnamed fields are what
the reader needs in order to explain a mismatch.
The evidence is the produced bytes and a statement of independence; the claim
tuple already pins the fixture and the document revision. The test passes when
the outputs compare equal over their compared objects and each implementation
accepts the other's valid request and reproduces the other's denials. Any
divergence traced to an underspecified value is a defect in the specification,
not in either implementation, and is fixed there.
This test has a mandatory negative control. One implementation is patched to
emit a denial that differs from the other only outside the compared object —
adding a header core does not name, or reordering fields — and the run MUST
still pass. A run that fails this control is comparing more than core pins and
would reject conforming pairs; the exit test itself is then the defect. The
control is retained with the evidence, because a comparison that only ever
reports equality proves nothing about what it would have caught.
**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 above exists. Until one exists the test cannot be
run, and a conformance claim MUST instead 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.
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
@@ -420,19 +308,20 @@ 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;
- 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`, so no status witness exists to be stale or revoked, and
executable cases prove a presented witness is never consulted;
- `FI-TRACE-CAPABILITY-REVOCATION` only when no external capability projection
requiring a declared revocation bound is configured, and executable evidence
proves no assertion capability or local-policy value claims such a bound;
- lifecycle and delegation oracles only when the profile is unclaimed, disabled,
and denied on every ingress; and
`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
@@ -440,24 +329,26 @@ one deployed domain does not activate it.
## Release gate
Before NIP-FI enforcement or discovery is enabled, reviewers verify that one
immutable claim tuple passes every applicable oracle, other than a validly
deferred `FI-CONF-INTEROP-EXIT`, at one reviewed revision;
that, if the canonical fixture has been published before the review, the claim
tuple's exit fixture digest is not `pending-canonical-fixture`;
that the protected-ingress inventory has no uncovered or competing authority;
that every listed oracle, other than a validly deferred `FI-CONF-INTEROP-EXIT`
under **Mutation adequacy**, has a killed, attributed, reachable mutant and every
survivor is recorded; that the denial-fixture enumeration is complete for the
claimed profiles and its negative control fails as required; that the
interoperability exit test has passed against an independent implementation, or
is recorded as deferred under **Interoperability exit test** because no
independent implementation exists;
that every named deployment artifact exists at the claimed deployment revision;
and that public and operational sinks pass privacy-canary inspection.
Before NIP-FI enforcement or discovery is enabled, reviewers verify, at one
reviewed revision, that:
Documentation review, source review, and static scans are useful review inputs.
They close no item in this gate.
- 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

View File

@@ -45,7 +45,8 @@ Every trusted edge MUST:
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;
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
@@ -164,16 +165,17 @@ time.
before content-coding decompression. These are exactly the octets forwarded by the
edge and exposed to verification. HTTP framing, chunk delimiters, and trailers are
excluded; `Content-Encoding` is not decoded. A WebSocket upgrade uses the empty
payload. Any transformation after the protected snapshot is forbidden.
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 component outside the protected pre-MAC components,
except the independent NIP-98 proof conveyed in the `Authorization` field,
whose integrity is protected by its own signed event rather than by this MAC;
body interpretation follows the server-resolved body semantics, never
unprotected transport metadata such as `Content-Type` or `Content-Encoding`.
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
@@ -367,7 +369,7 @@ Every implementation MUST run these normative negative cases:
| Authority | Unbracketed or non-RFC-5952 IPv6, uppercase host, trailing dot, or missing port denies before MAC comparison. |
| Peer | Textual `::ffff:192.0.2.128`, padded IPv4, uppercase/noncanonical IPv6, or whitespace denies before MAC comparison. |
| Proof | `0x00`, `0xff`, unknown stock code, or private code without a shared configured contract denies. |
| Body | Known and unknown lengths `0`, `limit-1`, and `limit` may proceed only after EOF; `limit+1`, disconnect before EOF, aggregate-quota exhaustion, or any post-snapshot transform denies with no replay or authoritative mutation. |
| 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. |
@@ -389,7 +391,7 @@ and these profile traces:
| `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. |
| `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. |

View File

@@ -173,10 +173,13 @@ logging. [FI-TRACE-ASSERTION-VALIDATION]
The exact `iss` selects an authenticated policy and key source; `iss` and at
least one `aud` value exactly match configured values. `sub` is a non-empty
bounded string. `exp` and `iat` are finite NumericDate values satisfying
`now < exp`, `iat <= now + skew`, and `now < iat + maximum_assertion_age`.
Optional `nbf` satisfies `nbf <= now + skew`. Arithmetic is overflow-safe and
equality at an expiry is expired. [FI-TRACE-ASSERTION-VALIDATION]
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
@@ -246,11 +249,6 @@ current state; a retained key may continue, while an absent key denies.
Unknown-key refresh is bounded and coalesced and has no attacker-triggered
stale-key fallback. [FI-TRACE-JWKS-ADD] [FI-TRACE-JWKS-REMOVE]
Thus two contract identities do not mean two total version values. Folding a
mutable snapshot into policy identity would make benign rotation change policy
lineage; omitting it would let evidence under a removed key survive. Stable
semantic IDs plus explicit mutable dependency versions preserve both outcomes.
The base contract compares the current authenticated snapshot and makes no
anti-rollback promise. A deployment claiming rollback prevention records a
separately authenticated monotonic floor and tests it. [deployment artifact:
@@ -312,14 +310,14 @@ Each protected HTTP operation declares in server policy whether its body is
authorization-relevant; clients cannot select the declaration.
For a relevant body, the NIP-98 event contains exactly one `payload` tag equal
to lowercase hexadecimal SHA-256 of the exact bytes consumed by the
application. Absence, duplication, mismatch, validation of only a prefix, or
post-validation transformation denies. For an irrelevant body, no
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
lowercase hexadecimal SHA-256 of the exact body bytes received; duplication or
mismatch denies. [FI-TRACE-BODY-BINDING]
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
@@ -479,7 +477,11 @@ private-posture rule, even `key_mismatch` joins the private-state anonymity set.
| required current dependency unreadable | `authorization_unavailable` | `restricted: authorization unavailable` | `503`; `Content-Type: text/plain; charset=utf-8`; `authorization unavailable\n` |
Nostr text is the exact UTF-8 text after an applicable NIP-42/NIP-01 prefix.
For HTTP, the compared denial contract is closed over the status, complete body,
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
@@ -559,7 +561,7 @@ policy revision. NIP-FI-CONF defines evidence and mutation-adequacy rules.
| ID | Required outcome |
|---|---|
| `FI-TRACE-TRANSPORT-CLOSED` | Exact one-header input succeeds; missing, repeated, combined, malformed, mixed, URL, and fallback variants deny. |
| `FI-TRACE-ASSERTION-VALIDATION` | Valid boundary input passes; each signature, key-selection, issuer, audience, time, size, and ambiguity negative denies. |
| `FI-TRACE-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. |
@@ -568,7 +570,7 @@ policy revision. NIP-FI-CONF defines evidence and mutation-adequacy rules.
| `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/transformed payload variants deny without effects; a payload tag on an irrelevant-body operation validates identically and denies on duplication or mismatch. |
| `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. |
@@ -581,7 +583,7 @@ policy revision. NIP-FI-CONF defines evidence and mutation-adequacy rules.
| `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; all private-state rows compare byte-identical. |
| `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. |
@@ -589,6 +591,26 @@ policy revision. NIP-FI-CONF defines evidence and mutation-adequacy rules.
| `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
@@ -605,4 +627,7 @@ atomically. Availability failures deny rather than degrade to Nostr-only access.
- NIP-98 HTTP authentication: <https://github.com/nostr-protocol/nips/blob/6d2979b3f503a8539c983efbcdcf901bbcf9ed23/98.md>
- JWT BCP: <https://www.rfc-editor.org/rfc/rfc8725>
- JWT access-token profile: <https://www.rfc-editor.org/rfc/rfc9068>
- DPoP: <https://www.rfc-editor.org/rfc/rfc9449>
- OAuth 2.0 mTLS client certificate-bound tokens: <https://www.rfc-editor.org/rfc/rfc8705>
- HTTP Message Signatures: <https://www.rfc-editor.org/rfc/rfc9421>
- Non-normative composed model: [NIP-FI-MODEL.md](NIP-FI-MODEL.md)