mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
5d963a8d006bbf17db6dec0bdff4d3a4d82aafce
5 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8af2d91f37 |
refactor(relay): bind HTTP tenants through one shadow-aware helper (#8062)
Adds `bind_tenant()`, a single helper that resolves the community from the request `Host` and records the NIP-FI shadow verdict when binding fails. Ten protected HTTP handlers previously duplicated the Host lookup and called `observe_unbound` in their own failure branches, so each one had to remember the observer call. Moving those ten handlers onto the helper makes recording the verdict part of binding itself, and `observe_unbound` becomes private to the shadow module. Each caller keeps its existing rejection response, so Off/Enforce behavior is unchanged. `bind_community()` stays directly callable. Routes exempt from NIP-FI (invite claim, webhooks, NIP-05 and NIP-11 metadata) keep using it, and the WebSocket and audio upgrades record their own verdicts before they bind. A new protected HTTP handler should bind through `bind_tenant()`. 🤖 Authored by Duncan (agent) on behalf of Will. Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> |
||
|
|
df3f28a2c4 |
feat(relay): add NIP-FI shadow mode for HTTP, WebSocket and admin (#8034)
Stack: this PR → #8062 🤖 Follows #8028 (merged). Adds `BUZZ_NIP_FI_MODE=shadow`. The relay loads and validates the full enforce configuration (issuers, communities, lifetimes, command issuers), evaluates NIP-FI evidence wherever enforce would decide, and records what enforce would have done, while every request, upgrade and session behaves exactly as in `off`. The one approved exception is admin disconnect: shadow verifies the command and returns `200`, where Off has no verifier and returns `503`. It lets an operator measure the would-deny rate, including Hosts missing from `BUZZ_NIP_FI_COMMUNITIES`, before switching to `enforce`. - **Mode predicates.** `NipFiMode` gains `is_off`, `restricts`, `evaluates`, `enforces`, `denies_unconditionally` and `observes_only`, built on two exhaustive `match` bases (`restricts`, `evaluates`); `is_off` is a `matches!` and the rest derive from the bases, so a new mode cannot silently fall into one side. Every call site uses them, including the Git membership-lookup failure from #8005, which uses `restricts()` so shadow keeps Off's body. A test scans each crate's `src/` and fails on any `NipFiMode::<variant>` in production code outside the predicate definitions and the env parser, which catches `==`, `matches!` on any number of lines, `if let` and or-patterns. - **Startup.** Shadow goes through enforce's path, so every enforce startup refusal applies, including the required connection lifetime. Missing-setting errors name the configured mode through one formatter (`mode_requires`); the enforce text is byte-identical. Shadow builds the assertion verifier and claims command replays under its own Redis prefix, both through the production `AppState::new`. - **HTTP and Blossom.** `admit_nip_fi_http` keeps Off's result in shadow and additionally replays enforce in enforce's order: first the router guard's steps (community, then assertion), then the handler's steps (NIP-98, pairing, deny set) on the same NIP-98 proof, through `evaluate_enforce_steps`, which enforce shares. A Host mapped in config but missing from the communities table, with a valid assertion, records an admit, because enforce's guard passes and its 404 is not a NIP-FI denial. A dev-mode `X-Pubkey` identity is marked unsigned and is never an enforce proof, so shadow records it as a `nip98` would-deny. On bridge and Blossom routes, the strict proof check (payload tag, Blossom `Strict`) runs as a separate pure check that consumes no replay entry and records on its own series, so it never counts as an admission verdict. Shadow records exactly one verdict for every identity, community or credential refusal enforce would make, with Off's status, body and challenge unchanged: bridge, workflows, Blossom, Git transport and settings, GIF search and share, and invite minting record the `community` would-deny when the Host is unmapped. Git's missing or malformed credentials still reject before any database work, and shadow records the enforce verdict for that failed proof. The router guard stays transparent in shadow. - **WebSocket, root and audio.** The upgrade check evaluates the attached assertion once. An upgrade enforce would refuse records that would-deny; otherwise the assertion goes into a shadow-only session object and the upgrade returns as in Off. The assertion never reaches the connection, the identity registries, the admission gate, terminal frames or cancellation. Each session records at most one admission verdict. **Pairing** is recorded on both ingresses right after a valid NIP-42 proof, before ordinary relay policy, where enforce pairs: a key mismatch or claimless assertion is a `pairing` denial. **The deny-set check** runs where enforce runs it, after registration: a hit is a `deny_set` denial, and a clean read keeps the session pending and registered. **The admit** is recorded only when the connection is actually admitted: on root at the AUTH commit, after ordinary policy; on audio when the participant join transaction commits, after relay membership, channel membership, the final ban and membership decision, mesh routing and the join's own checks, and before publication. A NIP-42 failure, which comes before pairing, and any refusal between pairing and the admit (ordinary policy, mesh, or a join refusal inside the join transaction) retires the observer when the refusal is decided, before any refusal frame, rollback or cleanup is awaited, so it leaves no record even if the deadline passes during that cleanup. A record-only deadline task, armed at upgrade with enforce's `compute_session_deadline`, records `expired` when enforce would have closed an admitted session. If the deadline passes while the session is still pending, that is its single admission verdict, a denial with `stage=deadline`, and a later AUTH records nothing. A deny for the key (admin or cross-pod) records `revoked` on an admitted session; if it arrives after the deny-set check but before the admit, including a deny refused for capacity, the session keeps a pending-revocation mark and records the admit followed by exactly one `revoked` end, matching enforce's phase-independent close, and the deadline cannot take that end. The phase, the mark and retirement change together under one session lock. Each session records at most one end. The session holds the socket's cancellation token for recording only: once the socket is cancelled, no transition records anything, even while connection references remain (a cancel fence; the token never feeds a close action). Upgrade would-denies carry the ingress route, `ws` or `audio`. - **Admin disconnect.** Shadow verifies the command and records the deny entry, so later admissions record would-denies, but closes no session; the response stays `{"disconnected": true}`. Shadow publishes cross-pod on its own channel, `buzz:nip-fi:shadow-disconnect`, which only shadow pods subscribe to, so no enforce pod (including older builds that know only `buzz:nip-fi:disconnect`) acts on a shadow command. Shadow pods also keep listening on `buzz:nip-fi:disconnect`, so their deny record includes enforce's real disconnects. Shadow claims command replays under `buzz:nip-fi:shadow-command:{hash}`: replays are still rejected within shadow, and a shadow accept never uses up the enforce claim. Enforce and Off keep the same channel, keys and close behavior. - **NIP-11.** Shadow serves the same document as enforce, including `limitation.federated_identity`, because clients only attach evidence to a relay that advertises it. ## Recording | Series | Labels | One increment per | |---|---|---| | `buzz_nip_fi_shadow_total` | `route` (`http`, `ws`, `audio`), `stage`, `outcome`, `community` | decision enforce would make: an HTTP admission, a refused upgrade, a session's NIP-FI AUTH decision, or its deadline passing before AUTH (`stage=deadline`) | | `buzz_nip_fi_shadow_strict_proof_total` | `route` (`bridge`, `blossom`), `outcome` (`pass`, `rejected`), `community` | strict NIP-98 side check | | `buzz_nip_fi_shadow_session_end_total` | `route` (`ws`, `audio`), `reason` (`expired`, `revoked`), `community` | admitted session enforce would have ended (admitted sessions only) | | `buzz_nip_fi_shadow_disconnect_total` | `route` (`admin`, `cross_pod`), `outcome` | disconnect-path event, in place of the real disconnect, lag, capacity and poison counters | `community` is the configured canonical URI or `unmapped`, never the raw `Host`; an unmapped or empty Host records `stage=community, outcome=unavailable`. A shadow pod never moves an enforce disconnect or failure counter. Strict-proof rates use their own `pass + rejected` total, and session-end rates divide by the `admit` count for the same route. There is no per-verdict `info` log; a would-deny writes a `debug` line with no token, claim or issuer. The pre-existing enforce `debug` line for the proven NIP-98 key is shared and also fires in shadow. Admin commands rejected for a bad header, body or proof, a replay, or a dependency error record no shadow verdict; only capacity rejection is counted. ## Not observed - Handler-side path-validation exits: an invalid Git owner or repo name on default-branch, a media path that is not a valid hash, and a Git pack URL with a query string record nothing from the handler. The router guard still records what enforce would refuse at the guard (for example a missing assertion), because enforce refuses those requests before it validates the path. - A refusal that is not a NIP-FI decision, such as a tenant-binding 404 behind a passing guard, or an ordinary WebSocket refusal after pairing (a ban, membership or join refusal before admission). ## Follow-ups - A bounded `route` label per HTTP surface (bridge, Blossom, Git, …) on `buzz_nip_fi_shadow_total`; today every HTTP verdict carries `route=http`. - One shared helper for the ten copied `observe_unbound` recording hooks, stacked on this PR. The shadow recorder and session suites and the root pairing witness run in `just test-unit` and the `scripts/run-tests.sh` fallback; the `AppState::new` shadow witness, which exercises the command verifier against real Redis, runs in the `external_infra_redis` lane. NIP-FI env tests recover a poisoned env lock so one panic cannot cascade. --------- 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> Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> |
||
|
|
6410e685a8 |
feat(relay): enforce NIP-FI assertion+NIP-98 pairing on HTTP ingress (#7264)
Implements NIP-FI HTTP ingress enforcement per the merged spec (PR #7254). Every protected HTTP surface in enforce mode requires a valid `Nostr-Federated-Identity: Bearer <JWS>` assertion alongside its NIP-98 authorization, bound to the same key. The NIP-98 pubkey (proven actor) must equal the assertion's `nostr_pubkey`; absent, mismatched, or unverifiable assertions are denied fail-closed with byte-exact text/plain responses per the spec's rejection table. This is S5 of the `buzz-enterprise-identity` program. WS enforcement (S3) is separate. ## What changed **New `nip_fi_http` module:** `admit_nip_fi_http()` covers the full admission sequence — `Nostr-Federated-Identity` extraction, offline JWKS verification, NIP-98 key pairing [FI-INV-05], issuer-qualified deny-map check via `(iss, pubkey, now)` [FI-INV-14], and byte-exact denial for all four `DenialClass` variants. `admit_nip_fi_http_on_state()` is the single call-site wrapper for all surfaces [FI-TRACE-AUTHORITY-UNIFORM]. **Protected surfaces** (all gated via the uniform authority): - HTTP bridge: `POST /events`, `/query`, `/count` - GIF proxy: `POST /gifs/search`, `/gifs/share` - Workflow reads: `GET /workflows/{id}/runs`, `GET /workflows/{id}/runs/{id}/approvals` - Moderation reads: `GET /moderation/reports`, `/moderation/audit`, `/moderation/restricted` - Media/Blossom: `PUT /upload`, `/media/upload` - Git smart-HTTP: `info/refs`, `upload-pack`, `receive-pack` - Git settings control plane: `GET`/`POST /git/:owner/:repo/default-branch` (now routed through `admit_nip_fi_http_on_state` for full assertion key-pairing) - Invites: `POST /api/invites` (mint only; claim exempt as pre-membership enrollment door) **NIP-98 strictness in NIP-FI mode:** - When NIP-FI is not Off, bridge POST handlers force `require_auth_token = true` — the `X-Pubkey` dev-mode fallback cannot satisfy the pairing requirement [NIP-FI.md:547-567] - POST bodies on `/events`, `/query`, `/count` require a matching `payload` tag in enforce mode [NIP-FI.md:579-597] - A payload tag present with no content (one-element `["payload"]` or empty string) is rejected as a structurally invalid claim — NIP-98 requires the content to be a valid 64-char lowercase hex SHA-256 digest. Malformed payload diagnostics use a static error message (no attacker-controlled content in logs). - Off mode preserves pre-NIP-FI behavior per [FI-INV-15] **Off-mode compatibility changes:** Admission now runs before request validation on every protected surface, so Off mode differs from pre-NIP-FI behavior in these ways: - **GIF routes:** NIP-98 authentication happens before the upstream provider lookup. An unauthenticated request on a tenant with no GIF provider now returns 401 instead of 404. The 401 goes through the legacy `api_error()` path, so the body is JSON `{"error":"..."}` with no `WWW-Authenticate` header. - **Workflow runs** (`GET /workflows/{id}/runs`): the query string is parsed after admission. A request with a missing credential and a malformed query (for example `?limit=abc`) now returns 401 instead of 400. - **Authorized malformed query:** once admitted, a malformed query returns a JSON 400 `{"error":"invalid query: ..."}` from `parse_query_or_400` instead of axum's `text/plain` `Query` rejection. - **Moderation reads** (`/moderation/reports`, `/moderation/audit`): same extraction change as workflow runs. Admission comes first, then a JSON 400 for a malformed query. - **Malformed `payload` tag:** shared NIP-98 verification now rejects a malformed `payload` tag in every mode, including on bodyless requests. That means a one-element `["payload"]`, an empty digest, or anything other than 64 lowercase hex characters. Before this change, a bodyless request ignored the tag. **Auth header cardinality:** Enforce mode rejects requests with more than one `Authorization` header with 403 `evidence rejected\n`. DenyProtected returns 503 `authorization unavailable\n` before this check (and before NIP-98 or assertion verification) runs. The cardinality gate is enforced at the NIP-FI admission boundary (`admit_nip_fi_http`), not via legacy auth boolean flags, so it applies uniformly to all Enforce-mode surfaces including Git. Off mode passes through the first header value unchanged, preserving pre-NIP-FI behavior [FI-INV-15]. **Denial-class remapping:** In Enforce mode, NIP-98 extraction/verification failures from the closure are mapped to the appropriate `DenialClass`: `MissingEvidence` when no `Authorization` header is present, `EvidenceRejected` when the header is present but invalid. Off mode propagates legacy responses unchanged [FI-INV-15]; for example, the Git missing-Authorization and wrong-scheme 401s keep their legacy bytes with no `content-type` header. **JWKS refresh scheduling:** The refresh timer's `*last` checkpoint is set after the fetch completes, not from the pre-fetch timestamp, preventing interval drift under network latency. The loop is extracted to `nip_fi_jwks_refresh_loop` for unit-testability. **Log hygiene [NIP-FI.md:777-779]:** Issuer identity is never written to logs or error messages. All startup/refresh diagnostics use an ordinal index (`issuer_index = N`). Config parse errors for malformed issuers use classify()/line/column codes rather than raw config values. **Admin SPA document routes:** `/reports`, `/reports/{id}`, and `/feedback` are exempt from the NIP-FI assertion guard only on the admin host (host-qualified via `is_admin_host`). These paths are not added to the broad `NIP_FI_EXEMPT_PREFIXES` list — tenant hosts remain fully protected. **Deny-map seam (`HttpDenyMap` trait):** signature is `(issuer, pubkey, now)` matching `NipFiDenyMap::is_denied` from S4 (PR #7265) so integration is a one-liner. Stub is `AlwaysAdmitStubDenyMap` — named to accurately describe stub-phase semantics (fail-open intentionally pending S4). **Route inventory:** every authenticated HTTP route is classified as Protected or Exempt with justification. Documented in the F4 seam-test comment in `bridge.rs`. **Outer-guard tests:** `#[ignore = "requires Postgres"]` tests in `bridge.rs` drive the full router (`build_router`) in Enforce mode with valid NIP-98 but no `Nostr-Federated-Identity` header. These exercise the **outer `nip_fi_assertion_guard` middleware** in `router.rs` — the layer that fires before any handler runs. They prove the guard's exact response bytes (401 `authentication required\n`, `text/plain; charset=utf-8`, `WWW-Authenticate: Nostr`) for each protected route. Per-handler `admit_nip_fi_http_on_state` wiring is proven separately by the cardinality test and the media/settings/git handler-level suites. ## Seams **S3 seam:** `nip_fi_config.rs` is duplicated (S3 not landed; session-deadline parts stripped). Removed when S5 rebases onto S3. **S4 seam:** `AlwaysAdmitStubDenyMap` until S4 provides the real map. Integration is one line at the `admit_nip_fi_http_on_state` wrapper. ## Evidence At head `ee338ea9c` (branch `hayt/nip-fi-http-enforcement`), hosted CI run [36167450853](https://github.com/block/buzz/actions/runs/36167450853): - **Rust / Unit Tests** ([job 108179338458](https://github.com/block/buzz/actions/runs/36167450853/job/108179338458)): passed. The `buzz-relay` `test-unit` step ran 171 tests with 171 passes. Its PASS lines include all 57 non-ignored tests this PR adds to the relay: `nip_fi_http::tests` (25), `nip_fi_config::tests` (8), `router::tests` (8), `api::parse_query_tests` (5), `api: |
||
|
|
e47690c21b |
Enforce NIP-OA authorization time bounds (#7004)
NIP-OA relay admission now evaluates signed `created_at<` and `created_at>` conditions against the already verified authentication event timestamp. An owner-signed credential such as `created_at<1` no longer upgrades its holder to relay membership. The verified timestamp now flows from NIP-42, NIP-98, and Blossom authentication through the shared membership gate used by WebSocket, HTTP, Git, media, huddles, GIF, and workflow requests. Owner-attested access fails closed when no signed authentication timestamp is available. Direct relay members keep their existing admission behavior, and `kind=` remains connection-level metadata as specified by NIP-AA. Tests cover strict time-bound evaluation, missing timestamp rejection, and HTTP authentication timestamp propagation. Testing: - `cargo test -p buzz-sdk nip_oa::tests -- --nocapture` - `cargo test -p buzz-relay api::relay_members::tests -- --nocapture` - `cargo test -p buzz-relay api::bridge::tests -- --nocapture` - `just ci` --------- Signed-off-by: Jordan Mecom <jm@squareup.com> |
||
|
|
2693e0db1f |
Make workflow run history authoritative in Desktop (#5780)
## Summary - persist stable workflow run `error_code` values separately from human diagnostics - expose NIP-98 authenticated, channel-authorized run history and approval reads with stable keyset pagination - connect Desktop to those authoritative reads and return the relay-created run ID on trigger - show truthful loading, failure, and pending-trace states, and do not render approval actions from non-actionable stored hashes ## Validation - pre-push `branch-skew`, `desktop-typecheck`, `desktop-test`, `rust-tests`, `desktop-tauri-checks`, and `desktop-check` all passed on `a097dbe5f` - Desktop tests: 4,761 passed, 0 failed - `cargo check -p buzz-relay` - `git diff --check` ## Remaining gate This does not claim a relay-backed Playwright workflow journey. The browser relay bridge still routes workflow invokes through in-memory handlers; that production-shaped acceptance gate remains follow-up work before Workflows can leave preview. --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz> Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut <68157ebd23b3897c1991015c3038658ea916200c67d3a54620b0754d1b92f6e0@buzz.block.builderlab.xyz> |
