Skip to content

refactor(buzz-relay): share NIP-FI assertion evaluation across adapters - #7990

Merged
wpfleger96 merged 32 commits into
mainfrom
hayt/nip-fi-auth-consolidation
Oct 1, 2026
Merged

wpfleger96 merged 32 commits into
mainfrom
hayt/nip-fi-auth-consolidation

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

🤖 Stack: #7990 → #8028

The three production NIP-FI assertion-verification sites (the HTTP assertion guard in router.rs, check_nip_fi_at_upgrade in nip_fi_upgrade.rs, and admit_nip_fi_http in nip_fi_http.rs) each carried their own extract → verify → map-to-denial sequence, and HTTP admission and enforce_nip_fi_key_pairing each carried their own key-equality check. The admin disconnect route (POST /api/nip-fi/disconnect) kept a second copy of the header parser and hand-typed its denial bodies. Community binding and shadow mode both need to change exactly those decisions, so they now live in one place.

No behavior change. Every denial class, status code, body, header and log line is preserved, including deny_protected (503), the startup-race 503, transport-before-verifier precedence, NIP-98-before-assertion ordering, and guard + handler double verification.

  • New relay-private nip_fi_core owns extract_bearer_token and http_denial (moved from nip_fi_http), evaluate_attached_assertion (the single verify_assertion call for all three adapters, returning a typed AssertionRejection), and asserted_key_matches (the pairing predicate; a claimless assertion never matches). http_denial and the disconnect route's plain-text renderer fall back to the bare denial status instead of panicking or answering an empty 200; both fallbacks are unreachable because every status and header value is a constant.
  • Adapters keep their own mode short-circuits, exemptions, ordering, metrics and code() debug logs; the guard still logs nothing.
  • The disconnect route parses its header with extract_bearer_token, renders header failures with http_denial, and renders every other rejection (malformed body, bad pubkey, no command verifier, every CommandError) through CommandError::http_status()/response_body(). Its check order (header → body → pubkey → verifier present → verify), DenySetFull warning and metric, success body and cross-pod publish are unchanged. The old Unicode whitespace check and the shared ASCII one agree on every reachable input, because HeaderValue::to_str only admits visible ASCII and tab.
  • The 12 HTTP admission callers, 3 WS upgrade placements, deny-map checks, expected-URL constructors and the command/admin proof path are untouched.
  • Characterization tests land before each refactor, green against the unmodified code, and pass unchanged after it. A routed test counts both verifications on one request (guard, then handler admission) and shows admission's own failure decides the response. Disconnect-route tests pin exact status, content type, challenge and body for every rejection, the observable check order, and that only DenySetFull increments buzz_nip_fi_disconnect_capacity_rejections_total. The test modules share one counting ScriptedVerifier from nip_fi_core::tests, and the session tests build connections through connection::tests::test_conn. nip_fi_core::tests:: is added to the unit selectors in Justfile and scripts/run-tests.sh.

🤖 Implemented by Hayt.

Duncan and others added 30 commits September 29, 2026 15:15
… map (S4)

Adds the NIP-FI admin command endpoint, a bounded per-issuer deny-until-TTL
map, and cross-pod disconnect fan-out. The deny check runs after each root
and audio socket registers its proven identity, so a concurrent disconnect
either finds the socket in its close scan or the check finds the entry.
Hooks attach at the S3 admission and pairing points without changing S3
terminal-frame or close-code behavior.

Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The root-AUTH witness could pass with the handler's deny check removed; add a pre-registration case the close scan misses and parse OK frames. Restore the clippy allowance to the eight-argument audio handler.

Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ents

Constrain the no-map baseline to 200/404 so identical upstream failures cannot satisfy the equality check.

Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The full expiry sweep ran on every write while holding the shard mutex that
every admission's is_denied also takes. It now runs only when a write would
otherwise hit capacity; reads already compare against now, and replay checks
treat a lingering expired jti as absent. Cross-pod merges share the local merge
path, removing an unreachable! that could kill the consumer task. jti is capped
at 512 bytes so the 2x-capacity reservation budget bounds memory.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
…criber starts

The broadcast receiver was created only after AppState construction and JWKS
warm, so disconnects relayed during boot hit a receiverless channel and were
dropped. Subscribing first lets the receiver buffer them until the consumer runs.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
…jecting

A pod whose clock trailed the origin rejected at-ceiling commands the origin had
already accepted with 200, leaving the target's sessions open on that pod. The
consumer now clamps until to its own ceiling and still merges and closes;
malformed pubkey, unknown issuer, and unrepresentable timestamps stay rejected.

Also documents that enforce-mode issuers must carry maximum_command_age_seconds
and authorized_principals (startup already required them), and drops a 4s
wall-clock admission test whose expiry behavior is pinned with an injected clock.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
FI-TRACE-DENIAL-ORACLE requires every authorization_denied row to be
byte-identical, but S3 key mismatch and lease expiry closed with Close(None)
while S4 deny-set hits and admin disconnects closed 1008. Every root and audio
authorization_denied now goes through one first-writer-wins transition that
enqueues the denial frame only for the winner and closes 1008, so a concurrent
registry scan can no longer queue a second audio denial. OK(true) is also
skipped when a concurrent disconnect already cancelled the session.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
…i-port

* origin/main:
  test(desktop): fix flaky forum empty-draft replacement e2e (#7981)
  chore(release): release Buzz Desktop version 0.5.26 (#7980)
  fix(desktop): correct Grok Build setup guide URL (#4846)
  fix(db): audit partition catalog before creation (#6515)
  feat(mobile): show contextual names in channel conversations (#7895)

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Pins the current contract of the three assertion-verification sites (HTTP
guard, WS upgrade, HTTP admission) and the two key-equality checks before
they are consolidated: verifier error classes, transport-before-verifier
precedence, NIP-98-before-assertion ordering, mode short-circuits, and
claimless-assertion pairing denials.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The HTTP guard, WS upgrade check and HTTP admission each carried their own
copy of extract-then-verify-then-map, and HTTP admission and WS pairing each
carried their own key-equality check. A relay-private nip_fi_core now owns
evaluation, denial rendering and the pairing predicate so later changes
(community binding, shadow mode) land in one place. Behavior is unchanged.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…cation

A routed request with a valid NIP-98 proof now counts both assertion
verifications (the HTTP guard, then handler admission) and shows that
admission's own failure decides the response. Also moves test-only imports
out of production scope in nip_fi_http and points the router ownership
comment at nip_fi_core.

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>
… claim

The (iss, jti) reservation lived only in the receiving pod's deny map, so one captured command JWT was accepted once per pod, each acceptance re-publishing a cluster-wide disconnect. The verifier now takes an atomic Redis SET NX EX claim after authentication, mirroring the NIP-98 guard, and fails closed when Redis errors. The claim is released when the local insert hits capacity so a 503 deny set full stays retryable.

Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
…i-port

* origin/main:
  fix(agents): stop built-in prompts from teaching sleep polling (#7992)
  feat(relay): add direct staff ban/timeout/delete with staff guard (#7883)
  fix(ci): gate security review on repo write access (#7986)
  feat(acp): wrap workers at the subprocess launch boundary (#7985)
  feat(buzz-relay): idempotent owner community deletion with quota reservation (#7969)
  feat(mobile): show contextual names in lists, Search and Pulse (#7896)
  Add Kimi Code's default install path to managed-agent binary discovery (#5997)

Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Truncating the remaining validity let the shared claim expire up to a second before a verifier trailing by the full skew stopped accepting the JWT; with skew above the guard's 120s floor the floor did not hide it. Also gate the local-only verify_at behind test-utils and state the fail-closed guarantee precisely.

Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
…blishes

A forged command carrying a real command's (iss, jti) must be rejected without touching the shared claim, so the legitimate command still succeeds on another pod. The Redis variants subscribe to the disconnect channel to prove rejected replays and guard errors publish nothing while the accepted command publishes once.

Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
A fixed sleep could miss a late prohibited publish or fail on a slow legitimate one, and a dead collector read as zero. Each count now waits for an accepted sentinel command's publication, giving every case a positive control, and fails loudly if the collector stops. Also split the verify() doc into the normal claim lifecycle and the separate ways a claim can outlive a failure.

Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Pins exact status, content type, challenge and body bytes for every
pre-success rejection and the header -> body -> pubkey -> verifier order
before the route is moved onto the shared parser and CommandError rendering.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…I disconnect

The disconnect route kept its own copy of the Bearer parser and hand-typed
denial bodies. It now uses nip_fi_core::extract_bearer_token/http_denial and
renders every command rejection through CommandError, so the strings live in
one place. Reachable header bytes are visible ASCII or tab (HeaderValue::to_str),
where the old Unicode and new ASCII whitespace checks agree.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…nip-fi-auth-consolidation

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Route transport rows reached the verifier, which returned the same 403,
so dropping a transport check went unnoticed. Send them with a malformed
body, and pin a comma-joined value ahead of the missing-verifier check.

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>
A capacity failure after the Redis claim relied on a DEL to keep the
command retryable; a failed DEL left the jti burned. Reserving a pending
slot under the shard lock first means DenySetFull never touches Redis, and
an RAII Reservation frees the slot on replay, guard error, or cancellation.
CommandReplayGuard::release is removed as it has no callers.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Disconnect publishes are detached tasks, so a sentinel publish alone did
not prove earlier ones had landed. Spawning them through a TaskTracker on
AppState lets the test wait for completion first; production still never
waits on it.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
merge_entry only exempted keys already in entries, so with a full shard a
remote deny for a key this pod had reserved was dropped, and lost entirely
if the local attempt then lost the shared claim. The publish-count test now
drains every pod that could publish, not just the sentinel pod.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The nip_fi_core doc line landed under nip_fi_gate's, leaving the gate
undocumented, and two comments still pointed at nip_fi_http for logic that
moved to nip_fi_core. The NIP-98-order test now notes where it departs from
the spec's admission order and why routed clients don't observe it.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
http_denial still used expect() while the disconnect route's command_denial
fell back to 500, and plain_response fell back to an empty 200. All three
branches are unreachable with constant inputs; they now fall back to a bare
denial status so the disconnect route fails one way, closed. The module doc
also names the disconnect route as a second consumer of the shared helpers.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
The core, HTTP, upgrade, and router tests each carried their own copy of the same fixed-result, call-counting verifier stub, so a VerifyAssertion change had to be repeated four times. They now share one in nip_fi_core::tests.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
…ed builder

A ConnectionState field change should touch one test builder, not three. The shared builder now accepts a NIP-FI assertion and returns every outbound receiver, so the session tests stop hand-building the struct.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
The capacity-rejection counter on the DenySetFull arm was untested: deleting it left the suite green. Pin that it counts DenySetFull exactly once and never counts other command-error arms.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Base automatically changed from duncan/nip-fi-deny-api-port to main October 1, 2026 19:40
Hayt and others added 2 commits October 1, 2026 15:43
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>
@wpfleger96
wpfleger96 marked this pull request as ready for review October 1, 2026 20:51
@wpfleger96
wpfleger96 requested a review from a team as a code owner October 1, 2026 20:51
@wpfleger96
wpfleger96 deployed to codex-review October 1, 2026 20:51 — with GitHub Actions Active
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown

🔐 Codex Security Review

Note: This is an automated, security-focused review generated by Codex.
Use it as a supplement to human review; false positives are possible.

Scope

  • Exact PR diff: 15772c54f36d1ed68c9c77ffb63235acb5ee8a64...0211525d23d9edab2ee52542ed4d84e172abf03d
  • Model: gpt-5.6-sol

💡 Click "edited" above to see earlier reviews for this PR.


Review Summary

Overall Risk: NONE

No concrete security, correctness, or reliability regressions were found in the authorized PR range. The NIP-FI refactor preserves transport validation, cryptographic verification, key pairing, denial mapping, and fail-closed behavior across HTTP, WebSocket, and command paths.

Findings

No concrete security, correctness, or reliability findings were identified.

Notes

  • No additional limitations were reported.

Generated by Codex Security Review |
Requested by: @wpfleger96 |
Workflow run

@wpfleger96
wpfleger96 enabled auto-merge (squash) October 1, 2026 22:37
@wpfleger96
wpfleger96 merged commit 9b08395 into main Oct 1, 2026
146 of 150 checks passed
@wpfleger96
wpfleger96 deleted the hayt/nip-fi-auth-consolidation branch October 1, 2026 22:52
wpfleger96 added a commit that referenced this pull request Oct 1, 2026
* commit '9b083957f^':
  Include thread roots in agent activity events (#8029)
  fix(relay): gate owner-only kinds in shared fan-out access filter (#8006)
  feat(acp): add BUZZ_GIT_IDENTITY switch for agent commit identity (#8024)
  fix(relay): deny channel writes when the channel lookup fails (#8007)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96 added a commit that referenced this pull request Oct 1, 2026
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96 added a commit that referenced this pull request Oct 1, 2026
🤖 Follows #7990 (merged).

Binds every enforce-mode NIP-FI assertion to the community served at the
request's `Host`. Before this, an assertion was accepted if its `aud`
matched any value in a deployment-wide set. That meant an issuer trusted
by one community could mint an assertion another community accepted.

**Rollout consequence: an enforce relay will not start without
`BUZZ_NIP_FI_COMMUNITIES`.** `off` and `deny_protected` do not read it,
so repair mode still boots.

## What changes

- **Host resolution runs first.** In enforce mode, right after the `off`
and `deny_protected` checks, the `Host` must map to a configured
community. An unmapped or missing `Host` returns 503 `authorization
unavailable`. This happens at all three call sites: the router guard,
the upgrade (WS root h1/h2 and audio), and HTTP admission. Off mode
behaves exactly as before.
- **The allowlist is checked before JWKS.**
`VerifyAssertion::verify_assertion(token, &CommunityBinding)` rejects an
issuer the community does not authorize before any key-source lookup.
Clients can't tell this rejection apart from an unknown `iss`. `aud`
must exactly equal the community's canonical URI.
- **New config: `BUZZ_NIP_FI_COMMUNITIES`.** It is a JSON array of
`{canonical_uri, authorized_issuers}`. Startup fails if:
- a `canonical_uri` is anything other than a canonical
`https://<host>[:port]`: the URL parser must reserialize the configured
authority byte-for-byte, so whitespace, control characters, backslashes,
uppercase, a redundant `:443`, and non-canonical IPv6 are rejected
rather than repaired. The authority must also already be
Host-normalized, so a trailing dot or `:80` is rejected and the Host map
key always equals the `aud` authority exactly (never empty);
  - two communities share a `Host`;
  - an allowlisted issuer is missing from `BUZZ_NIP_FI_ISSUERS`;
  - an allowlist is empty;
  - a configured issuer belongs to no community.

  Errors name entries by index only.
- **`IssuerPolicy.audiences` is removed.** Command JWTs use the new
per-issuer `command_audiences`, so #7977's admin-disconnect command
behavior is unchanged. A config that still sets `audiences`, even to
`null`, fails with a migration message instead of being silently
ignored.
- **`AssertionPolicyId` now covers the community allowlist.** It hashes
the `aud` of each community that authorizes the issuer, so changing the
allowlist changes the ID. This is code only; no spec file changes here.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
tlongwell-block pushed a commit that referenced this pull request Oct 2, 2026
Main added nine commits since the previous merge. The ones that matter
here rework NIP-FI admission under /buzz/v1: shared assertion evaluation
across adapters (#7990), binding assertions to the request Host's
community (#8028), and the community ban gaps (#8005, #8006, #8007,
#8036). Main changes 55 files and adds no migration.

Git merged the three files both sides change without conflict:
buzz-relay's api/bridge.rs, api/mod.rs and router.rs. No hand edit was
needed. This branch's diff against main is line for line the same before
and after the merge: 31 files, +5,726 / -81.

/buzz/v1 still authenticates through admit_nip_fi_http_on_state. Its
signature is unchanged and it now resolves the Host's community itself,
so the accessory routes take the new binding from the same shared
admission as the bridge routes. The three /buzz/v1 NIP-FI wire-contract
tests pass on the merged tree.

Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
abipalli pushed a commit to abipalli/buzz that referenced this pull request Oct 7, 2026
…rs (block#7990)

🤖 Stack: block#7990 → block#8028

The three production NIP-FI assertion-verification sites (the HTTP
assertion guard in `router.rs`, `check_nip_fi_at_upgrade` in
`nip_fi_upgrade.rs`, and `admit_nip_fi_http` in `nip_fi_http.rs`) each
carried their own extract → verify → map-to-denial sequence, and HTTP
admission and `enforce_nip_fi_key_pairing` each carried their own
key-equality check. The admin disconnect route (`POST
/api/nip-fi/disconnect`) kept a second copy of the header parser and
hand-typed its denial bodies. Community binding and shadow mode both
need to change exactly those decisions, so they now live in one place.

**No behavior change.** Every denial class, status code, body, header
and log line is preserved, including `deny_protected` (503), the
startup-race 503, transport-before-verifier precedence,
NIP-98-before-assertion ordering, and guard + handler double
verification.

- New relay-private `nip_fi_core` owns `extract_bearer_token` and
`http_denial` (moved from `nip_fi_http`), `evaluate_attached_assertion`
(the single `verify_assertion` call for all three adapters, returning a
typed `AssertionRejection`), and `asserted_key_matches` (the pairing
predicate; a claimless assertion never matches). `http_denial` and the
disconnect route's plain-text renderer fall back to the bare denial
status instead of panicking or answering an empty 200; both fallbacks
are unreachable because every status and header value is a constant.
- Adapters keep their own mode short-circuits, exemptions, ordering,
metrics and `code()` debug logs; the guard still logs nothing.
- The disconnect route parses its header with `extract_bearer_token`,
renders header failures with `http_denial`, and renders every other
rejection (malformed body, bad pubkey, no command verifier, every
`CommandError`) through `CommandError::http_status()`/`response_body()`.
Its check order (header → body → pubkey → verifier present → verify),
`DenySetFull` warning and metric, success body and cross-pod publish are
unchanged. The old Unicode whitespace check and the shared ASCII one
agree on every reachable input, because `HeaderValue::to_str` only
admits visible ASCII and tab.
- The 12 HTTP admission callers, 3 WS upgrade placements, deny-map
checks, expected-URL constructors and the command/admin proof path are
untouched.
- Characterization tests land before each refactor, green against the
unmodified code, and pass unchanged after it. A routed test counts both
verifications on one request (guard, then handler admission) and shows
admission's own failure decides the response. Disconnect-route tests pin
exact status, content type, challenge and body for every rejection, the
observable check order, and that only `DenySetFull` increments
`buzz_nip_fi_disconnect_capacity_rejections_total`. The test modules
share one counting `ScriptedVerifier` from `nip_fi_core::tests`, and the
session tests build connections through `connection::tests::test_conn`.
`nip_fi_core::tests::` is added to the unit selectors in `Justfile` and
`scripts/run-tests.sh`.

🤖 Implemented by Hayt.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
abipalli pushed a commit to abipalli/buzz that referenced this pull request Oct 7, 2026
…8028)

🤖 Follows block#7990 (merged).

Binds every enforce-mode NIP-FI assertion to the community served at the
request's `Host`. Before this, an assertion was accepted if its `aud`
matched any value in a deployment-wide set. That meant an issuer trusted
by one community could mint an assertion another community accepted.

**Rollout consequence: an enforce relay will not start without
`BUZZ_NIP_FI_COMMUNITIES`.** `off` and `deny_protected` do not read it,
so repair mode still boots.

## What changes

- **Host resolution runs first.** In enforce mode, right after the `off`
and `deny_protected` checks, the `Host` must map to a configured
community. An unmapped or missing `Host` returns 503 `authorization
unavailable`. This happens at all three call sites: the router guard,
the upgrade (WS root h1/h2 and audio), and HTTP admission. Off mode
behaves exactly as before.
- **The allowlist is checked before JWKS.**
`VerifyAssertion::verify_assertion(token, &CommunityBinding)` rejects an
issuer the community does not authorize before any key-source lookup.
Clients can't tell this rejection apart from an unknown `iss`. `aud`
must exactly equal the community's canonical URI.
- **New config: `BUZZ_NIP_FI_COMMUNITIES`.** It is a JSON array of
`{canonical_uri, authorized_issuers}`. Startup fails if:
- a `canonical_uri` is anything other than a canonical
`https://<host>[:port]`: the URL parser must reserialize the configured
authority byte-for-byte, so whitespace, control characters, backslashes,
uppercase, a redundant `:443`, and non-canonical IPv6 are rejected
rather than repaired. The authority must also already be
Host-normalized, so a trailing dot or `:80` is rejected and the Host map
key always equals the `aud` authority exactly (never empty);
  - two communities share a `Host`;
  - an allowlisted issuer is missing from `BUZZ_NIP_FI_ISSUERS`;
  - an allowlist is empty;
  - a configured issuer belongs to no community.

  Errors name entries by index only.
- **`IssuerPolicy.audiences` is removed.** Command JWTs use the new
per-issuer `command_audiences`, so block#7977's admin-disconnect command
behavior is unchanged. A config that still sets `audiences`, even to
`null`, fails with a migration message instead of being silently
ignored.
- **`AssertionPolicyId` now covers the community allowlist.** It hashes
the `aud` of each community that authorizes the issuer, so changing the
allowlist changes the ID. This is code only; no spec file changes here.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>

This branch was successfully deployed

1 active deployment
codex-review — 0211525d Deployed Oct 1, 2026 by wpfleger96 via Run Codex Security Review #6454
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

codex-security-review-current The posted Codex security review matches its recorded range.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants