Files
buzz/docs/operator-nip-fi-bindings.md
Will Pfleger 369ffa0380 feat(relay): verify NIP-FI agent tokens and admit agents through owners (#8401)
The relay side of NIP-FI agent tokens. An agent token has its own type,
`nip-fi-agent+jwt`, and carries its human owner's pubkey in
`nostr_owner_pubkey`. This PR verifies that claim, makes denying or
disconnecting the owner cut off every agent that owner authorized, and
makes the verified owner decide community admission. A relay without
agent support refuses the unknown type, so nothing needs to advertise or
configure agent support. The `NIP-FI.md` text for the agent type is
included.

Builds on #8372 and #8377 (both merged).

### Owner claim and owner deny

- **Token type binds the owner claim (`buzz-auth`).** A `DedicatedNipFi`
issuer policy accepts `nip-fi+jwt` (human) and `nip-fi-agent+jwt`
(agent); `AccessTokenAtJwt` policies stay `at+jwt`-only with their
`client_id` and subject rules unchanged, and unknown types are refused.
An agent token must carry a lowercase-hex `nostr_owner_pubkey` distinct
from its `nostr_pubkey`; a human or `at+jwt` token carrying the claim
with any value, including null, is `evidence_rejected`. The verified
owner is exposed as `VerifiedAssertion::owner()`.
- **No agent switch.** There is no relay setting, NIP-11 field or
operator binding field for agent support.
- **Session owner.** Each connection records its NIP-FI issuer and the
agent's owner (`NipFiSession`) on the root WebSocket manager and the
audio registry. `disconnect_nip_fi(iss, target)` closes a connection
when either its own key or its recorded owner equals the target.
- **Owner deny check.** `nip_fi_core::assertion_denied` checks the deny
set for both `(iss, k)` and `(iss, owner)`, and every existing deny
check uses it: WebSocket before upgrade and after registration, HTTP,
audio, and the shadow observer, so shadow and enforce make the same
decisions. The shadow disconnect observer matches a session's key or
owner under its issuer with the same rule, so an owner's disconnect
records a would-close for its agents.

### Shadow owner check

In shadow mode, WebSocket AUTH, audio join and HTTP admission record a
`Stage::Owner` verdict when an admitted agent token's owner is banned
(`owner/denied`), conflicts with the agent's stored owner
(`owner/denied`), or cannot be checked because a read failed
(`owner/unavailable`). These are reads only; shadow admission is
unchanged. Shadow applies the same stored-owner rule as enforce but
never writes the link, so it can't predict a later owner-link write
failure, or which of two racing first links wins.

### Admission through the verified owner

- **Owner bans, unconditionally.** `community_ban_outcome` takes the
token owner and checks its ban in place of a NIP-OA owner. That applies
whether the agent is a member itself, the relay is open, or the agent is
admitted through its owner.
- **Owner-derived membership.** In
`api::relay_members::check_membership`, an agent that is a member itself
is still admitted first. Otherwise, when a verified assertion names an
owner, the owner's membership decides (`MembershipDecision::ViaOwner`),
and no NIP-OA is needed on the connection. The issuer has already passed
this community's NIP-FI binding check at that point. When a token owner
is present it is the only owner considered.
- **Stored-owner consistency.** `link_agent_owner` replaces the boolean
`materialize_nip_oa_owner` result with `OwnerLink::{Linked, Conflict,
Unavailable}`. An unset stored owner is recorded, so relay bans and
roster removal of the owner reach the agent through
`revoke_live_access`. Shadow and enforce judge the stored owner through
one read, `stored_owner_link`, and `link_agent_owner` writes
(atomically, first write wins) only when that read finds no owner; a
failed read fails closed. If the stored owner differs from the token
owner, the request is refused with `authorization denied` on HTTP, WS
and audio. A NIP-OA owner conflict keeps its existing link-error
refusal.
- **One decision on every surface.** Root WS AUTH, audio registration
and every NIP-98 HTTP route (bridge, `buzz/v1`, git transport and
settings, invites, media, workflows, moderation reads) pass
`NipFiAdmission::token_owner()` / `VerifiedAssertion::owner()` into the
same ban, membership and owner-link steps. The final socket check
rereads bans and writer-side membership; it doesn't reread the owner
link, which is written once from NULL. HTTP membership still goes
through the bounded replica read. With a verified agent token, every
HTTP admission refusal, including the fresh ban rereads in `buzz/v1` and
Git settings, is NIP-FI's fixed `authorization_denied` (or
`authorization_unavailable` when a dependency fails), and audio checks
bans before writing the owner link. `enforce_relay_membership` now takes
the `TenantContext`, because it writes the owner link.
- **Publishing.** A ban caught while publishing over `POST /events`
after the shared membership check is also an admission refusal. Ingest
reports it as a typed `IngestError::Ban` (banned, or ban lookup
unavailable) at the write gate, the HTTP typing indicator's cached gate,
and the moderation and relay-admin command rereads. `IngestError::Ban`
carries the legacy error. The bridge turns it into the fixed
`authorization denied` / `authorization unavailable` only when the
request carries a verified agent token. WebSocket and token-less HTTP
responses are byte-for-byte unchanged, and timeouts, scopes, permissions
and every other ingest error keep their existing responses.

### Rollout

- **Deploy the relay before kgoose.** An older relay refuses
`nip-fi-agent+jwt`, so agents are locked out until both are deployed.
Humans are unaffected.
- **There is no agent-only switch.** Setting NIP-FI to shadow or off
turns it off for humans too.
- **Rollback is safe for owner links.** Stored owner links survive a
rollback, and the older code still honors them.
- **Shadow signals:** in `buzz_nip_fi_shadow_total`, `stage="owner"`
means an owner would-deny (`owner/denied`) or an owner read that was
unavailable (`owner/unavailable`), not a success. A clean agent
admission shows as `stage="admit", outcome="admit"`.
- **The relay checks that the owner is a member, not which issuer the
owner logged in through.** In a community that trusts several issuers,
any trusted issuer can name any member as owner. That matches the spec:
the issuer is trusted to vouch for the owner, and kgoose requires the
owner's attestation.

Agent tokens are only issued to relays whose NIP-FI binding trusts the
issuer, and NIP-FI itself is off unless configured. No deployed config
or secrets change.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-26 12:48:35 -04:00

218 lines
10 KiB
Markdown

# NIP-FI Community Issuer Bindings
A deployment that provisions communities under one host suffix, for example
`<name>.communities.buzz.xyz`, stores the NIP-FI issuers allowed to vouch for
each community in `community_nip_fi_issuers`. The relay writes these rows when
it creates a community; `buzz-admin nip-fi` backfills and edits them.
A host may hold a binding only when it is exactly one DNS label under the
suffix, has no port, and is not *reserved*. A host is reserved when
`https://<host>` is a `command_audiences` entry of any issuer in
`BUZZ_NIP_FI_ISSUERS`, so a command audience can never become a community
assertion audience. The relay refuses to start while a reserved host holds a
binding, and the operator lookup route reports no issuers for a host that is not
bindable or for an issuer that is not configured.
## Configuration
`BUZZ_NIP_FI_PROVISION_BINDING` is read in every NIP-FI mode. Unset or empty
binds nothing.
```json
{
"host_suffix": "communities.buzz.xyz",
"authorized_issuers": ["https://app.builderlab.xyz/api/goose"]
}
```
- `host_suffix` is a lowercase domain name without a port or trailing dot.
- `authorized_issuers` is non-empty, and every entry must be an `issuer` in
`BUZZ_NIP_FI_ISSUERS`. Each issuer is 1 to 2048 bytes.
- The suffix must not cover any `BUZZ_NIP_FI_COMMUNITIES` host, and every
`canonical_uri` there must be readable. The relay and `buzz-admin` both
refuse to start otherwise.
`buzz-admin` parses the same variables with the relay's parser. Run it with the
relay's environment: `BUZZ_NIP_FI_PROVISION_BINDING`, `BUZZ_NIP_FI_ISSUERS`,
`BUZZ_NIP_FI_COMMUNITIES`, and `DATABASE_URL`.
### Database community source
`BUZZ_NIP_FI_COMMUNITY_SOURCE=database` resolves communities from these
bindings instead of `BUZZ_NIP_FI_COMMUNITIES`. The relay refuses to start if
`BUZZ_NIP_FI_PROVISION_BINDING` is unset or `BUZZ_NIP_FI_COMMUNITIES` is set.
In `off` mode no snapshot is loaded.
| Variable | Default | Bound |
|----------|---------|-------|
| `BUZZ_NIP_FI_COMMUNITY_SOURCE` | `env` | `env` or `database` |
| `BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS` | 1,000,000 | 1 to 10,000,000 community-issuer rows; a larger load fails |
| `BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS` | 10,000 | under the 30s refresh interval |
| `BUZZ_NIP_FI_RESOLVER_NEGATIVE_CACHE_MAX` | 100,000 | 1 to 1,000,000 hosts |
| `BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT` | 8 | at least 1, clamped below the database pool size |
Each pod loads a snapshot before it binds any listener, and refreshes it
every 30s. A removed binding stops admitting new connections within 30s plus
the configured refresh timeout (40s by default) while refreshes succeed, and within two minutes of the last good load while they
fail. Open WebSocket sessions are not rechecked when a binding is removed;
they end at their own session deadline. Alert on
`time() - buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds > 120` and on
`absent(buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds)`.
## Commands
```sh
buzz-admin nip-fi backfill
buzz-admin nip-fi bind --host acme.communities.buzz.xyz --issuer https://issuer.example
buzz-admin nip-fi unbind --host acme.communities.buzz.xyz --issuer https://issuer.example
```
`backfill` binds the authorized issuers to every active, unbound, bindable
community, a page of 1000 hosts at a time. Archived communities are bound on
purpose, so unarchiving leaves a community that can log in. A community whose
deletion has started is skipped. It always prints its counts and exits 1 while
any bindable community remains unbound:
```json
{"rows_bound": 12, "unbound": 0, "unbound_hosts": [], "truncated": false}
```
`unbound_hosts` lists at most 100 hosts; `truncated` is true when there are
more.
`bind` refuses a reserved host, a host that is not one label under the suffix,
and an issuer not in `BUZZ_NIP_FI_ISSUERS`.
`unbind` works without `BUZZ_NIP_FI_PROVISION_BINDING`. It refuses to remove a
community's last issuer, because an empty community looks never-provisioned and
the next `backfill` would bind it again. To swap issuers, bind the new one and
then unbind the old one. To stop a community logging in, archive or delete it.
When the provisioning config is present and shows the host is not bindable (a
reserved host or one outside the suffix), `unbind` removes the last issuer too;
this is how to clear a reserved-host binding that blocks relay startup.
`backfill` never adds the default issuers beside an operator `bind`: both lock
the community row, and the backfill only inserts while the community still has
no binding.
## Lookup route
`GET /operator/communities/nip-fi-binding?host=<host>` is operator-signed and
returns `{"host": ..., "active": ..., "issuers": [...]}`. kgoose relies on this
contract:
- `host` is the host stored on the community row.
- Every lifecycle state answers 200. `active` is `false` once the community is
archived or its deletion has started quiescing, including a tombstoned
community; it stays `true` while a deletion is only requested.
- `issuers` is `[]` when `BUZZ_NIP_FI_PROVISION_BINDING` is unset, the host is
not bindable, or nothing is bound. Only issuers in `BUZZ_NIP_FI_ISSUERS` are
reported, in byte order.
- 404 means no community has this host; 400 means the host is malformed.
## Rollout
1. Deploy the relay with `BUZZ_NIP_FI_PROVISION_BINDING` set. New communities
are bound at creation from then on.
2. Run `buzz-admin nip-fi backfill` with the relay's environment. Repeat it until
it exits 0.
3. Do not turn on the database binding source in enforce mode while any
bindable community is unbound: an unbound community fails closed.
## Database source runbook
`BUZZ_NIP_FI_COMMUNITY_SOURCE=database` reads communities from these bindings
instead of `BUZZ_NIP_FI_COMMUNITIES`. Move one mode at a time, and only move
forward when the step's go signals hold.
### Off
Set `BUZZ_NIP_FI_COMMUNITY_SOURCE=database` with `BUZZ_NIP_FI_MODE=off`. No
snapshot loads in `off`, but every `BUZZ_NIP_FI_RESOLVER_*` and
`BUZZ_NIP_FI_SNAPSHOT_*` value is validated, so a bad one fails this deploy.
- Go: the rollout completes, and `buzz-admin nip-fi backfill` exits 0.
- No-go: a pod fails config validation. Fix the named variable.
### Shadow
Set `BUZZ_NIP_FI_MODE=shadow`. Each pod now loads the snapshot before any
listener binds, refreshes it every 30 s, and records what enforce would decide
without refusing anything. Shadow labels database-source records
`community="bound"` or `"unmapped"`, never a host.
- Go, held for at least a day of normal traffic:
- `time() - buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds` stays
under 60 on every pod.
- `buzz_nip_fi_binding_snapshot_load_failures_total` is flat.
- `buzz_nip_fi_binding_snapshot_rows / buzz_nip_fi_binding_snapshot_max_rows`
is under 0.5.
- `buzz_nip_fi_resolver_miss_rejections_total` is flat.
- The non-`admit` share of `buzz_nip_fi_shadow_total` is only what you expect
to deny; `community="unmapped"` comes only from hosts you do not serve.
- `stage="owner"` counts as would-deny: each is an agent enforce will refuse
for its token owner.
- No-go: any of these fails. Return to `off` and investigate.
### Enforce
Set `BUZZ_NIP_FI_MODE=enforce`. Watch the same signals, plus the rate of 503
responses on login.
- Go: the shadow signals keep holding, and 503s stay at their shadow baseline.
- No-go: logins return 503 or are refused unexpectedly. Set `mode=off` (below).
### Alerts
- Stale snapshot: `time() - buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds > 120`,
together with `absent(buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds)`
for a database-source pod.
- Near the row cap: `buzz_nip_fi_binding_snapshot_rows / buzz_nip_fi_binding_snapshot_max_rows > 0.8`.
`buzz_nip_fi_binding_snapshot_rows` is the size of the last good load.
- Over the cap or too slow: any increase of
`buzz_nip_fi_binding_snapshot_load_failures_total{reason=~"over_cap|timeout"}`.
- Live lookups shed: any increase of `buzz_nip_fi_resolver_miss_rejections_total`.
### When the snapshot is too big or too slow
A load fails when the snapshot has more rows than
`BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS` or takes longer than
`BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS`. Both get worse as communities are
added, so they usually arrive during normal growth, not at a deploy.
- New pods: the startup load fails and the process exits, so new pods
crash-loop and a rollout stalls. Boot already fails when the database is
unreachable; what this source adds is that snapshot size and load time can
also fail it.
- Running pods: their refreshes fail but the last good snapshot keeps
answering. Once it is 120 s old it is stale, and every login's host goes to
a live lookup. Live lookups share `BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT` slots
(8 by default). Concurrent logins for the same host share one lookup, and a
login waits up to 50 ms for a free slot; how many are shed depends on login
load across distinct hosts and on lookup latency. In enforce, a shed login
gets 503 (`authorization_unavailable`); in shadow it
is only recorded as a would-deny.
Ways out, in order:
1. Raise `BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS` (up to 10,000,000) or
`BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS` (under 30,000), whichever the
failure `reason` names, and roll out.
2. If that cannot land quickly, set `BUZZ_NIP_FI_MODE=off`. `off` loads no
snapshot, so pods boot and admit as they did before NIP-FI.
## Assertion policy ID change
This release changes every verified assertion's policy ID once, on purpose.
The base ID (`derive_assertion_policy_id` in `crates/buzz-auth/src/nip_fi/config.rs`)
no longer hashes the configured community audiences. The ID carried on a
verified assertion is now `IssuerPolicy::id_for_community`: a hash of that base
and the one community audience the request resolved to. The issuer is still
committed through the base.
Policy IDs from earlier releases therefore do not match. Inside the relay the ID
is only carried on the verified assertion and never compared with a stored ID, so
admission is unaffected. Anything outside the relay that recorded old policy IDs,
such as dashboards, log queries or allow-lists, should expect new values after
the upgrade.