Skip to content

feat(buzz-auth): NIP-FI Phase A PR 4 — production authority interface spine - #7117

Closed
wpfleger96 wants to merge 16 commits into
duncan/nip-fi-assertion-runtimefrom
hayt/nip-fi-pg-authority
Closed

feat(buzz-auth): NIP-FI Phase A PR 4 — production authority interface spine#7117
wpfleger96 wants to merge 16 commits into
duncan/nip-fi-assertion-runtimefrom
hayt/nip-fi-pg-authority

Conversation

@wpfleger96

Copy link
Copy Markdown
Member

What

Adds the closed types and entry points PR 5 (relay ingress) branches from: the two-phase prepared/committed authorization contract, the PostgreSQL preparation and final-admission functions, and the complete error-to-DenialClass mapping.

Why

NIP-FI final admission requires two things that must be frozen before the relay ingress can be built: the Rust interface — sealed types, entry-point signatures, error enum — and the PostgreSQL implementation behind it. This PR delivers both in one commit so PR 5 has an exact branch point with no placeholder behavior.

Changes

buzz-auth: nip_fi/authority.rs (new)

  • PreparedAuthorization — sealed read-only evidence package produced by prepare_direct (FI-INV-08). pub constructor requires a VerifiedAssertion, proven actor, community UUID, BindingProposal, authority deadlines, PreparedDependencyVersions, and a correlation ID. Every field PR 5 reads is exposed via accessor methods; verified_assertion() provides the confidential JWS handle for revalidation.
  • BindingProposal — closed enum (Existing | Enroll) with BindingProvenance (AttestedKey / Provisioned / Tofu) aligned to the identity_bindings.binding_provenance column CHECK constraint.
  • PreparedDependencyVersions — snapshot of policy_revision, invalidation_generation, and authority_epoch read atomically during preparation. Final admission re-reads these to detect stale state.
  • CommittedAuthorization — sealed authority token produced by commit_admission. pub constructor requires all outputs of the atomic write. Accessors cover every field the relay ingress enforces without re-reading the DB.
  • AdmissionError — 13-variant closed enum; every variant maps to exactly one DenialClass via denial_class() and to a unique stable machine code via code(). No variant carries credential material (FI-INV-13). Variant uniqueness and denial-class mapping verified by unit tests.

buzz-db: store/nip_fi_authority.rs (new)

  • prepare_direct — reads Y_D(k), T_D(i,k), B_D(i), B_D(k), enrollment policy, and invalidation generation in a single REPEATABLE READ read-only transaction. Evaluates the binding proposal via the NIP-FI.md PrepareDirect pseudocode. Returns PreparedAuthorization. Writes nothing (FI-INV-08).
  • commit_admission — re-verifies deadline liveness, assertion equivalence (identity-class bytes, bounds-class deadline regression), and contract-ID stability before writing. SERIALIZABLE transaction: re-reads invalidation generation; re-checks binding conflicts for Enroll proposals; inserts enrollment receipt + lifecycle history + binding row (generated binding_version read back) or locks the existing binding FOR NO KEY UPDATE; inserts the protected-mutation receipt and admission result. All commit or none (FI-INV-09). Returns CommittedAuthorization.
  • PrepareError — 7-variant closed error with denial_class() and unique code().

Wire-up

buzz-db gains buzz-auth as a direct dependency (buzz-auth has no dependency on buzz-db — no cycle). New types re-exported from both crate roots.

Stack: #7109 → this PR

wpfleger96 and others added 13 commits August 28, 2026 18:08
Introduce the Phase-A NIP-FI schema as two internally-ordered migrations:
0040 lays down the core identity and base-lifecycle relations, and 0041
applies to 0040's resulting state to add the final-admission surface
(replay/receipt, audit events, invalidation, capacity, protected-object
authority, restore version deltas, and the closed admission result).

Identity is issuer-qualified (iss, sub) with no hardcoded issuer. All 15
NIP-FI relations are a durable, immutable, append-only security ledger:
both migrations widen the single SQL source of truth
community_write_fence_excluded_table so the relations are never
fence-attached, purged on community deletion, nor counted as tenant-scoped
drift by the deletion control plane's exact-set catalog check — the same
posture as product_feedback and rate_limit_violations. schema.sql keeps one
consolidated definition of that function whose body byte-matches 0041.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com>
…n guard

The authorization_invalidation_floor_guard_v1 trigger compared
NEW/OLD.relationship_revision_floor, but authorization_invalidation_floors
has no such column — a later FI-DELEG field correctly trimmed from the
Phase-A table when mining, yet left in the guard body. PL/pgSQL defers
record-field resolution, so the function CREATEs and all catalog/parity
tests pass, but the first real monotonic floor advancement aborts with
'record NEW has no field relationship_revision_floor', making the floor
update path unusable.

Remove both comparisons from the migration and its byte-matched schema.sql
mirror, and add a behavioral regression test that advances a floor through
the live trigger (forward generation and binding_version_floor commit;
equal/regressive updates reject) — coverage a deferred PL/pgSQL failure
structurally evades in catalog tests.

Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
…egression

The FK on identity_bindings previously equated binding_provenance with the
enrollment policy's enrollment_mode via a composite reference:
  (community_id, policy_revision, binding_provenance)
    → identity_enrollment_policies (community_id, policy_revision, enrollment_mode)

This contract-breaks NIP-FI §352 and §424: provenance is determined from
operation evidence, not the policy mode. A TOFU-mode policy (mode=3) with an
attested-key binding (provenance=1) — a valid and specified admission path —
would fail at commit with a FK violation.

Narrow the FK to (community_id, policy_revision) → (community_id,
policy_revision), which is already the PK of identity_enrollment_policies.
The redundant UNIQUE (community_id, policy_revision, enrollment_mode) on
identity_enrollment_policies is removed; it existed only to satisfy the old
composite FK and has no other consumer.

Both changes applied in lockstep to the migration and the schema.sql mirror.
The parity assertion in admin_schema_parity_between_desired_state_and_migrations
continues to hold.

Add behavioral regression identity_binding_provenance_is_independent_of_enrollment_mode:
seeds a TOFU-mode policy, inserts an attested-key binding in a single deferred
transaction, and asserts the commit succeeds with provenance=1 and mode=3
persisted independently. Mutation-verified: restoring the composite FK causes
the test to fail with the exact FK violation (code 23503) the fix removes.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
Extends identity_binding_provenance_is_independent_of_enrollment_mode with
the negative half required for two-sided mutation sensitivity.

A second deferred transaction inserts an otherwise-valid identity_bindings
row referencing policy_revision 999 (nonexistent in
identity_enrollment_policies) and asserts the INSERT fails with SQLSTATE
23503 from the narrowed FK identity_bindings(community_id, policy_revision)
→ identity_enrollment_policies(community_id, policy_revision).

Non-vacuity verified: removing the FK from the migration causes the
absent-policy INSERT to succeed (rows_affected: 1) and the expect_err
assertion to fire, confirming the negative half detects a dropped or
neutered FK. The existing positive half catches the old composite FK;
together they give full two-sided coverage.

Zero production changes: migrations 0040/0041 and schema.sql are
byte-untouched.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
main landed 0040_push_message_kinds.sql (#6269) which collides with the
previous NIP-FI numbering. Renumber:
  0040_nip_fi_identity_foundation.sql   → 0041
  0041_nip_fi_authorization_foundation.sql → 0042

Update all test references, run_to() calls, and schema.sql comments to
match. The push_match_trigger test (migrations[39].version == 40) is
unchanged — it covers the push-notification migration at 0040, not NIP-FI.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
…ion result cardinality, denial attempt binding

Finding 1 (policy revision monotonicity): add
identity_enrollment_policy_revision_guard_v1() BEFORE INSERT on
identity_enrollment_policies. Uses a per-community advisory lock via
hashtextextended so concurrent writers serialize the max-revision read,
then asserts both policy_revision and effective_at strictly exceed the
current community maximum (FI-INV-06 — stable assertion policy).

Finding 2 (admission result ↔ kind-11 receipt cardinality): add
authorization_admission_result_guard_v1(), bidirectional deferred
constraint trigger on both authorization_operation_receipts (kind-11
receipt must have exactly one result) and authorization_admission_results
(result must attach to a kind-11 receipt). Mirrors the pattern of the
existing authorization_operation_receipt_event_guard_v1.

Finding 3 (denial event ↔ attempt binding): add
authorization_denial_attempt_guard_v1(), bidirectional deferred
constraint trigger on both authorization_events (kind-9 event must have
exactly one denial attempt) and
authorization_authentication_denial_attempts (attempt must reference an
existing kind-9 event). The existing FK binds (audit_event_kind=9) but
does not require a kind-9 event to have a matching attempt row; this
guard closes that gap.

All three fixes applied identically in migrations/0041, migrations/0042,
and schema/schema.sql; the parity assertion continues to pass.

Tests added (all three mutation-sensitive, two-sided):
- identity_enrollment_policy_revision_is_monotonic
- authorization_admission_result_requires_kind_11_receipt_bidirectional
- authorization_denial_attempt_requires_kind_9_event_bidirectional

No new tables; fence exclusion list unchanged; #[ignore] deletion suite
need not rerun.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
Three IMPORTANT findings fixed:

1. Drop effective_at monotonicity from policy revision guard
   identity_enrollment_policy_revision_guard_v1() now enforces only
   strict-greater policy_revision per community. The effective_at check
   had no NIP-FI basis (FI-INV-06 defines assertion_policy_id stability,
   not revision chronology) and would reject legitimately-sequenced
   revisions — the downstream constructor stamps every immediately-
   effective revision with Unix epoch, so revision 2 would fail after
   revision 1 under the old guard.

2. Bind semantic coordinates in denial attempt guard
   authorization_authentication_denial_attempts gains attempt_id UUID
   NOT NULL with a deferred FK to authorization_events on
   (community_id, operation_id, event_kind, attempt_id). The guard
   authorization_denial_attempt_guard_v1() now additionally compares
   correlation_id and reason_code between the event and its denial
   attempt row, raising check_violation (23514) with named constraint
   authorization_denial_attempt_semantic_binding on mismatch. This
   closes the Carl finding 3 gap: a kind-9 event for correlation A /
   reason X can no longer be paired with a denial row carrying
   correlation B / reason Y.

3. Rewrite regression tests to prove the contracts
   - Policy test: seeds a gap (100->101) then inserts unused revision 99
     and asserts 23514 from the named guard (not 23505, which would
     fire on a PK duplicate and not prove the monotonic comparison).
     Adds a two-transaction concurrency regression: two distinct forward
     revisions (102, 103) race through separate connections; both commit
     because the advisory lock serializes them and each is valid.
   - Denial test: replaces the ambiguous negative-B (23503 OR 23514)
     with three single-coordinate-mismatch cases attributed to the named
     guard: B1 correlation_id mismatch (23514), B2 reason_code mismatch
     (23514), B3 attempt_id mismatch (23503 via deferred FK).
   - Admission test: adds negative C -- mismatched request_fingerprint
     rejected by the immediate composite FK (23503) at INSERT, proving
     the coordinate binding half of Carl finding 2.

All four changed files byte-identical between migration files and
schema/schema.sql (verified by extraction+cmp). All 5 NIP-FI tests,
admin_schema_parity, and 2 unit tests pass.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
Three IMPORTANT findings addressed:

IMPORTANT 1 (denial semantic binding, partial): Bind the remaining two
unbound denial identity coordinates.

- Add semantic_fingerprint BYTEA to authorization_events: required non-zero
  for kind-9 events, NULL for all other event kinds (enforced by CHECK
  on the table). This is the redaction-safe intent_digest coordinate.
- Add authorization_denial_reason_reason_code_binding CHECK to
  authorization_authentication_denial_attempts: encodes the canonical
  OperatorAuthenticationDenialReason <-> AuthorizationReasonCode mapping
  from operator_lifecycle.rs:700-706 and authorization_events.rs:215-226:
  MissingCredential(1)<->Missing(2), InvalidCredential(2)<->Invalid(3),
  Unauthenticated(3)<->Unauthenticated(4). Fires at INSERT, not COMMIT.
- Extend authorization_denial_attempt_guard_v1() to compare
  semantic_fingerprint between event and denial attempt in both firing
  directions, raising 23514 'authorization_denial_attempt_semantic_binding'
  on mismatch. Carl's mismatched-reason and mismatched-fingerprint
  cross-attachments are now fully closed.

IMPORTANT 2 (concurrency regression): Replace the 10ms-sleep approach
with a tokio::sync::Barrier(2) that holds both connections after BEGIN
and before INSERT. Both race to pg_advisory_xact_lock; one blocks, the
winner commits, the loser sees MAX=102 and fails with 23514 (not 23505).
XOR assertion proves exactly one INSERT succeeds, and the loser's 23514
(not PK 23505) proves the advisory lock — not just PK uniqueness — is the
serialization mechanism. Contradictory comments fixed.

MINORs (folded in):
- Fix stale test doc comment claiming effective_at must advance (it does
  not; the downstream constructor stamps Unix epoch for immediate policy).
- Fix equal-revision comment incorrectly claiming different policy_digest
  avoids the PK; the PK is (community_id, policy_revision).

CI fmt failure: cargo fmt --all run; whitespace-only reformatting of
some query blocks in migration.rs.

Regressions added/updated:
- B4: denial_reason/reason_code mapping violation rejected at INSERT by
  the immediate CHECK (23514 from authorization_denial_reason_reason_code_binding).
- B5: semantic_fingerprint mismatch between event and denial attempt
  rejected at COMMIT by the deferred guard (23514 from
  authorization_denial_attempt_semantic_binding).

Byte-parity (extraction+cmp):
- authorization_events table: 3394 bytes, migration == schema.sql
- authorization_authentication_denial_attempts table: 2062 bytes, migration == schema.sql
- authorization_denial_attempt_guard_v1(): 5579 bytes, migration == schema.sql
- identity_enrollment_policy_revision_guard_v1(): 1113 bytes, migration == schema.sql
- authorization_admission_result_guard_v1(): 2164 bytes, migration == schema.sql

All five NIP-FI tests green locally (run in isolation to avoid pre-existing
pool-state flakiness in the full suite).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
…tion schedule

The previous concurrency regression used a tokio::sync::Barrier to
synchronize two connections before racing to INSERT the same revision.
That construction synchronizes client-side INSERT dispatch, not trigger
execution; a lock-free schedule where one INSERT completes and commits
before the other reads MAX still satisfies the XOR + 23514 assertions,
so the test offered no deterministic proof that pg_advisory_xact_lock
is required.

Replace with a controlled two-connection schedule:

  1. tx1 opens a transaction and inserts revision 102. The BEFORE INSERT
     trigger acquires pg_advisory_xact_lock and completes; tx1 holds the
     advisory lock until commit.
  2. tx2 opens a transaction on a second backend, reports its pg_backend_pid
     over a oneshot channel, then issues INSERT for revision 103. The
     trigger fires and blocks on the advisory lock held by tx1.
  3. The main task polls pg_stat_activity WHERE pid = tx2_pid AND
     wait_event_type = 'Lock' AND wait_event = 'advisory' with a 10 s
     bounded timeout. Without pg_advisory_xact_lock in the guard the
     trigger returns immediately, tx2 never enters the advisory wait, and
     the poll times out — making the regression deterministically red.
  4. tx1 commits, releasing the lock. tx2 unblocks, its trigger reads the
     fresh MAX=102, and INSERT 103 succeeds. tx2 commits.
  5. Final count asserts six revisions (1, 2, 100, 101, 102, 103).

Zero production changes: migrations/0041, migrations/0042, and
schema/schema.sql are byte-untouched (single-file diff confirmed).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
Add the JWKS discovery/caching layer, startup validation gate, and
NIP-11 discovery output that complete the NIP-FI assertion runtime.

The verifier (PRs 1–2) already defined the sealed IssuerKeySource trait
and AssertionKeySet constructor as placeholders for this PR. This PR
fills that contract with a production implementation:

- jwks: ProductionJwksSource<F> implements IssuerKeySource via an
  injectable JwksFetcher trait (sealed; HttpJwksFetcher for production).
  Bounded periodic refresh; coalesced in-flight; try_read/try_lock for
  async-safe synchronous key_set() path. Never serves an expired
  snapshot; fails closed on fetch/parse error. [FI-TRACE-JWKS-REMOVE]

- startup: validate_nip_fi_config() rejects incomplete or unsafe
  configurations before the relay accepts protected traffic: empty
  registry, unmatched JWKS configs, invalid timing bounds, and
  current-status issuers missing a JWKS source. Off/DenyProtected modes
  accept without validation. [FI-INV-14, FI-INV-15]

- discovery: FederatedIdentityDiscovery serializes the NIP-11
  federated_identity object. Never exposes enrollment mode, issuer
  URLs, audiences, or deployment-local identifiers.
  [FI-TRACE-DISCOVERY-PRIVATE]

- config: IssuerRegistry gains all_policies() iterator.
- verifier: sealed module promoted to pub(crate) for jwks access;
  AssertionKeySet::new #[allow(dead_code)] removed (now has real caller).

Security checklist:
- Issuer binding sealed at constructor: no relabelling possible
- Hard deadline enforced on every snapshot access
- MAX_JWKS_RESPONSE_BYTES checked before parse
- Key count bounded by MAX_JWKS_KEYS
- try_read/try_lock: fails closed rather than panicking or blocking
- No key material, issuer URLs, or token bytes in errors or Debug

Tests: 23 new unit tests (12 JWKS, 11 startup); all green.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…n and comment quality

HTTP boundary (finding 1):
- Add validate_jwks_uri(): HTTPS-only, no credentials/fragments, bare IP
  private-address rejection via buzz_core::network::is_private_ip
- HttpJwksFetcher::new() builds a hardened client: no redirects, 10s intrinsic
  deadline; with_client() documents caller invariants
- Stream response body incrementally (bytes_stream + StreamExt), stop at
  MAX_JWKS_RESPONSE_BYTES + 1 before any deserialization
- Reject non-2xx status before reading body
- Add reqwest 'stream' feature to workspace; add futures-util to buzz-auth deps
- Add MAX_JWKS_TIMING_SECONDS = 1 year upper bound on timing fields
- Regression tests: non-HTTPS, loopback/private IP, credentials, fragment,
  oversized timing, duplicate issuer all rejected at construction

CurrentStatus posture (finding 2):
- Rename error variant DuplicateIssuer(String) -> DuplicateIssuer (sanitized)
- Add UnsupportedPosture error variant
- validate_nip_fi_config() rejects any CurrentStatus policy with
  UnsupportedPosture — verifier has no status witness; startup fails closed
- discovery.rs: remove FreshnessClassDiscovery::CurrentStatus variant and
  FederatedIdentityDiscovery::current_status() constructor entirely
- Test asserts rejection both with and without JWKS config

Duplicate issuer detection (finding 3):
- validate_nip_fi_config(): explicit duplicate detection in JWKS config slice
  (collect() was silently overwriting); returns DuplicateIssuer on collision
- ProductionJwksSource::new(): rejects duplicate issuer via HashMap::contains_key
  before insert

Timing bounds and overflow (finding 4):
- MAX_JWKS_TIMING_SECONDS constant bounds both refresh and hard-deadline fields
- i64::try_from() + Duration::try_seconds() eliminates u64->i64 cast panic
- Validated at both ProductionJwksSource::new() and validate_nip_fi_config()
- Test: new_rejects_timing_above_maximum()

Generation monotonicity (finding 5):
- Replace wall-clock millis with SHA-256 content digest per issuer
- Generation counter advances (saturating_add) only when digest changes;
  identical documents preserve the prior generation
- Regressions: generation_stable_for_identical_document(),
  generation_advances_for_changed_document()

Clippy (finding 6):
- manual_async_fn: replaced RPITIT form with native 'async fn' in impl block
- single_match (startup): replaced match { None => .., Some(_) => {} } with
  if let / !contains_key
- unnecessary_get_then_check: replaced .get().is_none() with !contains_key()

Comment quality (all files):
- Remove module-to-PR table from nip_fi/mod.rs
- Remove all 'Phase A', 'PR 1/3', 'PRs 4-5' references from every doc comment
- Remove WHAT comments (field-name paraphrases, narrated steps, section
  banners with no contract content, 'Construct with a default reqwest client')
- Retain WHY: security invariants, exact NIP-FI spec refs, fail-closed choices,
  FI-TRACE/FI-INV stable identifiers

Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…nternal markers

- Remove with_client() bypass: HttpJwksFetcher is now a unit struct;
  each fetch_jwks call builds a dedicated per-request pinned client.
- Add resolve_and_check_ssrf: DNS-resolves host:port via spawn_blocking,
  rejects any resolved private/reserved IP (closes DNS-rebinding TOCTOU).
- Per-request client enforces: redirect(Policy::none()), no_proxy(),
  .resolve(host, pinned_ip), and timeout(JWKS_REQUEST_TIMEOUT_SECS).
- Drop unused client field (dead_code warning) now that no shared pool
  is needed.
- Remove pure-paraphrase doc on IssuerRegistry::all_policies(); replace
  with doc stating constraint (unspecified order, startup use).
- Remove all 'PR N' internal markers from doc comments; replace with
  production-stable references to the jwks runtime.

Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…h_jwks

- Call validate_jwks_uri at entry of fetch_jwks_inner: direct callers of
  HttpJwksFetcher are protected regardless of ProductionJwksSource
  pre-validation. HTTP/credentials/fragment URIs rejected before any
  DNS resolution or connection attempt.
- Introduce with_deadline(fut, duration): private generic helper that
  wraps any future in tokio::time::timeout. HttpJwksFetcher::fetch_jwks
  passes fetch_jwks_inner(uri) through it with the fixed 10-second
  constant. Remove the RequestBuilder::timeout — the outer deadline
  covers the whole operation including a stalled OS resolver.
- Add with_deadline_fires_before_outer_guard: tokio::test(start_paused)
  passes std::future::pending() to with_deadline with Duration::ZERO.
  The inner timeout fires immediately; removing it leaves the future
  permanently pending and the outer test guard fires — seam verified.
- Fix IPv6-literal handling in resolve_and_check_ssrf: use (host, port)
  tuple form of ToSocketAddrs, not format!("{host}:{port}"), which is
  ambiguous for IPv6 addresses returned without brackets by host_str().
  Add IP-literal fast path that skips the OS resolver for bare IP hosts.
- Add production-boundary tests: four HttpJwksFetcher direct-call
  regressions (http/credentials/fragment/private-IP) and two IPv6 SSRF
  fast-path tests (loopback rejected, public accepted).
- Add tokio test-util dev-dependency to buzz-auth for start_paused.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 requested a review from a team as a code owner August 31, 2026 17:01
@wpfleger96
wpfleger96 force-pushed the hayt/nip-fi-pg-authority branch from 9b46207 to 98f107e Compare August 31, 2026 17:04
…nvariant tests

Network policy (buzz-core):
- Rename is_private_ip → is_not_global_unicast and add is_private_ip alias;
  the new name states the actual predicate. Update the owned JWKS caller to
  use the new name; unowned callers are covered by the alias.
- Extend the predicate to cover every IANA non-globally-reachable IPv4 and
  IPv6 range (source: IANA Special-Purpose Address Registries, 2024-02):
  IPv4 — 192.0.0.0/24 IETF protocol assignments (exceptions: 192.0.0.9
  PCP anycast RFC 7723 and 192.0.0.10 TURN anycast RFC 8155 are globally
  reachable), 192.88.99.0/24 deprecated 6to4 relay anycast (RFC 7526).
  IPv6 — 100::/64 discard-only (RFC 6666), 2001:2::/48 benchmarking
  (RFC 5180), 2001:20::/28 ORCHIDv2 (RFC 7343).
- Audit: buzz-workflow::check_ssrf and desktop link_preview both use
  is_private_ip; the alias preserves their behavior while the stricter
  predicate closes the previously admitted ranges for all three callers.
- Replace per-range test functions with grouped table-driven tests derived
  from IANA registry entries; public positive controls are explicit.

Cancellation-safe refresh permit (buzz-auth):
- Per-issuer IssuerState holds an Arc<Mutex<()>> refresh_permit; a second
  concurrent caller that loses try_lock_owned returns the current snapshot
  without a second fetch. The OwnedMutexGuard spans the complete fetch and
  state commit; if the caller future is cancelled the guard drops automatically,
  releasing the permit for the next caller.
- concurrent_refresh_coalesces_without_second_fetch: BlockingFetcher fires an
  entered oneshot before yielding so the test waits for confirmed permit
  ownership before issuing the second call. Mutation: early permit drop →
  call_count 2, test fails.
- aborted_first_caller_releases_permit_for_next_caller: uses the same source
  for both calls. SequencedFetcher hands out distinct enter/release channels
  per call. First call is aborted after the entered barrier; second call on
  the same source fetches and succeeds. Asserts call_count 2 and result is
  Some. Mutation: manual boolean cleared only on success → second call returns
  None, test fails.

Central invariant regressions (buzz-auth):
- expired_snapshot_never_served_after_hard_deadline: warms a 2 s hard-deadline
  snapshot, sleeps 3 s with a failing fetcher, verifies both get_snapshot and
  key_set return None.
- two_issuer_keys_and_generations_are_isolated: warms two issuers with distinct
  bodies, advances only issuer A's document, verifies A's generation advances
  and B's is unchanged; asserts per-issuer key bindings before and after.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 force-pushed the duncan/nip-fi-assertion-runtime branch from eabaaf7 to 620dca3 Compare August 31, 2026 17:15
Hayt and others added 2 commits August 31, 2026 13:37
Add the closed types and entry points that PR 5 (relay ingress) branches from.

buzz-auth: authority module
- PreparedAuthorization — read-only evidence package produced by prepare_direct
  (FI-INV-08: no mutation during preparation). Public constructor requires a
  VerifiedAssertion, proven actor, community UUID, BindingProposal, authority
  deadlines, PreparedDependencyVersions, and a correlation ID. Accessor methods
  expose every field PR 5 reads; verified_assertion() provides the confidential
  JWS handle for revalidation.

- BindingProposal — closed enum (Existing | Enroll) with BindingProvenance
  (AttestedKey / Provisioned / Tofu) and db_code() aligned to the
  identity_bindings.binding_provenance CHECK constraint (1/2/3).

- PreparedDependencyVersions — snapshot of policy_revision,
  invalidation_generation, and authority_epoch read atomically during
  preparation. Final admission re-reads these to detect stale state.

- CommittedAuthorization — authority token produced by commit_admission.
  Public constructor requires all outputs of the atomic write: actor, identity,
  capabilities, authority deadlines, binding_id, binding_version, operation_id,
  correlation_id, expires_at. Only the buzz-db admission path produces these
  values. Accessors cover every field the relay ingress enforces without
  re-reading the DB.

- AdmissionError — closed, stable enum; every variant maps to exactly one
  DenialClass via denial_class() and to a unique stable machine code via code().
  Variants cover deadline expiry, equivalence failure, contract-ID change,
  private-state denials (key revoked, pair retired, binding conflict, attestation
  required, binding required, local policy, invalidation advanced, stale version),
  and availability failures (audit capacity, dependency). No variant carries
  credential material (FI-INV-13). 13-variant uniqueness and denial-class mapping
  verified by unit tests.

buzz-db: nip_fi_authority store module
- prepare_direct — reads Y_D(k), T_D(i,k), B_D(i), B_D(k), enrollment policy,
  and invalidation generation in a single REPEATABLE READ read-only transaction.
  Evaluates the binding proposal via the NIP-FI.md PrepareDirect pseudocode
  (existing → Existing; conflict → BindingConflict; no binding → enrollment policy
  check). Returns PreparedAuthorization. Writes nothing (FI-INV-08).

- commit_admission — re-verifies deadline liveness, assertion equivalence,
  contract-ID stability, and bounds-class deadline regression before writing.
  Serializable transaction: re-reads invalidation generation; re-checks binding
  conflicts for Enroll proposals; inserts enrollment receipt + lifecycle history +
  binding row (with generated binding_version read back) or locks the existing
  binding FOR NO KEY UPDATE; inserts the protected-mutation receipt and admission
  result. All commit or none (FI-INV-09). Returns CommittedAuthorization.

- PrepareError — 7-variant closed error with denial_class() and unique code().
  Denial-class mapping and code uniqueness verified by unit tests.

Dependencies: buzz-db gains buzz-auth as a direct dependency (buzz-auth has no
dependency on buzz-db, so no cycle). buzz-auth gains uuid (already present).

All 168 buzz-auth tests and 116 buzz-db unit tests pass. fmt-check and
clippy both clean.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main:
  feat: render agent avatars as squircles (#7106)
  fix(ci): salvage Codex review output on PTY-shutdown hang (#7042)
  fix: retrieving cold memories; add regression task (#6950)
  Enforce NIP-OA authorization time bounds (#7004)
  feat(db): configurable writer session timeouts (lock, idle-txn, statement) (#6229)
  feat(desktop): use segmented controls for channel creation (#6845)
  feat(buzz-agent): surface stop reason and silent-turn WARN in telemetry (#7038)
  fix(desktop): surface channel history load failures (#7013)
  fix(composer): polish automatic mentions (#6956)

Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
@wpfleger96
wpfleger96 force-pushed the duncan/nip-fi-assertion-runtime branch 2 times, most recently from b80a46c to d11c6b2 Compare August 31, 2026 17:46
@wpfleger96
wpfleger96 force-pushed the hayt/nip-fi-pg-authority branch from 98f107e to eab11dc Compare August 31, 2026 17:51
@wpfleger96
wpfleger96 force-pushed the duncan/nip-fi-assertion-runtime branch 4 times, most recently from f6fe646 to 122ac43 Compare August 31, 2026 20:49

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Changes requested

Reviewed head eab11dce773f3b59380d83dad6be39e25b61e0e8 against base 122ac4329e9090667ac27984fb43883405f1d3d0. This is the production authority spine and its stacked database/JWKS foundations, not the later relay-ingress implementation. The contract is read-only preparation followed by atomic, current-evidence final admission and protected use.

P1: Align the production SQL with the schema this PR installs

nip_fi_authority.rs:526–535 queries current_epoch, but migration 0042 defines authorization_authority_epochs.authority_epoch. An otherwise valid preparation therefore returns DependencyUnavailable before it can produce a token. This is not just a spelling error: the fence read/write at lines 543–545 and 1051–1059 uses nonexistent fence_generation/last_operation_* columns and a four-column conflict target, whereas the installed table has a three-column primary key and requires the actor, binding, policy, epoch, fence and receipt witnesses. The epoch upsert at lines 858–867 also omits mandatory fence/receipt fields.

The enrollment transaction has the same producer/schema integration gap: line 1476 hardcodes successor_binding_version = 1 before the global identity sequence allocates the actual binding version, breaking the exact-birth foreign key after an earlier allocation; it also omits the enrollment authorization_events row required by 0042's deferred receipt/event cardinality trigger. These are independently fatal even after the first column error is fixed.

Fix/exit: use the installed authority/receipt model end to end, read the generated binding version into its history, and write the required enrollment audit evidence. Exercise the actual public prepare → commit → protected-use path against the migrated schema, including an existing binding, multiple fresh enrollments and transaction rollback. The inspected migration-catalog and enum tests do not execute this path.

Scope of the following safety findings: the SQL failure currently prevents successful admission. They are defects in the proposed production contract that remain after that failure is repaired, not claims of a demonstrated deployed-ingress exploit.

P1: Seal the proof-origin context instead of accepting arbitrary raw values

authority.rs:268–293 exposes every VerifiedServerDirectContext field, and new at lines 304–327 is public and performs no validation. A sibling consumer can construct an arbitrary actor, proof ID, proof deadline and community without supplying a Nostr signature; prepare_direct then trusts those coordinates. The raw channel UUID can also disagree with the object hash used for subsequent authority and receipt matching. The type is documented as origin-sealed, but currently enforces none of that contract.

Fix/exit: make the context immutable and mint it only through successful server-target and Nostr-proof validation, deriving the object coordinate from that same target. Add negative API coverage for unverified construction/substitution. A caller comment cannot substitute for the promised proof boundary (NIP-FI FI-INV-04/05).

P1: Reject a present asserted key that differs from the proven actor

build_proposal:1320–1326 treats a nonmatching asserted key as ordinary TOFU, and the Existing branch likewise never compares the asserted key with the actor. No unconditional comparison precedes binding evaluation. The verifier accepts only the token, not an actor; commit re-verifies the assertion against itself rather than against the proof.

Source counterexample: supply a valid assertion explicitly naming K1 and a genuine proof by K2, with no binding under TOFU, or an existing identity/K2 binding. The authority path does not reject this contradiction. TOFU permits an absent key claim, not an explicitly different one (NIP-FI line 338).

Fix/exit: reject every present-key mismatch before proposal evaluation, with AuthorizationDenied, for both Existing and enrollment paths. Test this independently of context sealing, which cannot establish agreement between two separately valid evidence sources.

P1: Recompute enrollment eligibility when its policy changes

commit_admission_inner:771–798 selects the current enrollment mode but only extracts its revision. A changed revision reruns the fixed capability/object/intent matrix, not enrollment eligibility. The Enroll branch then uses the old proposal's provenance and policy revision at lines 1407–1412 and 1505–1506. The schema allows that historical revision and has no latest-mode backstop.

Source counterexample: prepare an unattested first-use enrollment under TOFU, change policy to provisioned or attested-key, then commit. Final admission still attempts the original TOFU enrollment despite the now-disallowed creation. This violates NIP-FI's explicit final recomputation of enrollment and policy state.

Fix/exit: recompute the binding proposal against the current policy in the commit transaction, preserving the rule that policy changes affect future creation rather than retroactively changing existing provenance. Cover both restrictive policy transitions and no-authority-mutation on denial.

P1: Preserve and revalidate current authority at the protected-use boundary

authorize_protected_use:1004–1033 discards the current invalidation generation and checks only binding version/state. CommittedAuthorization has discarded binding expiry, the assertion/revalidation handle, contract IDs and snapshot/invalidation witnesses; the use API accepts no verifier or callback. It also never rechecks the resource.

Source counterexample: commit a binding whose expiry precedes the proof/assertion deadlines, then attempt use after that binding expiry without changing its row. expires_at() considers only proof/assertion, and the binding read omits expires_at, so this boundary cannot detect that authority ended. Resource deletion and signing-key removal expose the same missing-current-authority contract, rather than separate findings.

Fix/exit: retain the required bounds and witnesses and perform current revalidation before granting a use, or fail closed for a use the spine cannot yet validate. Cover passive binding expiry, invalidation, resource deletion and key-snapshot removal through the real gate (NIP-FI lines 436–450).

P2: Preserve dependency-unavailable errors during final revalidation

commit_admission_inner:667–669 maps every verifier failure to AssertionEquivalenceViolation, which becomes EvidenceRejected. Prepare valid live evidence, then make its current key source unavailable: the verifier returns KeySourceUnavailable with AuthorizationUnavailable, but admission changes that into a 403 evidence rejection instead of the required 503 availability denial.

Fix/exit: preserve the verifier's dependency-unavailable class via AuthoritativeDependencyUnavailable; test the prepare → commit outage transition, not only the enum mapping table.

Validation and exclusions

All delegated lanes are closed and integrated. Discovery/JWKS/network inspection established no additional material blocker; actual HTTP/DNS, deadline/cancellation behavior and unrelated network-helper callers outside the cached scope were not runtime-validated.

Source and test-oracle inspection only, against exact-SHA blobs and exact-base product/NIP documents. No checkout, build, import, execution of PR code, runtime tests or conformance PASS is claimed. The current-status posture and non-Phase-A operation tuples intentionally fail closed; later ingress/lifecycle/profile implementations are outside this PR's delivery contract. The failures above concern the production entry points this PR does expose.

@wpfleger96

Copy link
Copy Markdown
Member Author

🤖 This attempt is superseded by #7148, which replaces the PR 4 architecture and is the active correction vehicle. Closing this one so we do not present two PRs as Phase A PR 4.

@wpfleger96 wpfleger96 closed this Aug 31, 2026
@wpfleger96

Copy link
Copy Markdown
Member Author

🤖 Canonical pointer: #7157 is the sole Phase A PR 4. This interface-spine attempt remains superseded and closed; its applicable feedback is consolidated on #7157.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants