mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
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::transport::off_mode_precedence_tests` (5), `tests::jwks_refresh_*` (3), and `composition_tests` (3). The same job ran the `buzz-auth` suite, which includes `nip_fi::jwks::tests::key_set_serves_live_snapshot_under_reader_and_refresh_overlap`. - **Rust / Rust Lint**: passed. - **Relay and PostgreSQL / Backend Integration (relay e2e)**: passed. - **PostgreSQL Domain / PostgreSQL Tests** ([job 108183303436](https://github.com/block/buzz/actions/runs/36167450853/job/108183303436)): passed. 542 tests run, 542 passed. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
107 lines
4.1 KiB
TOML
107 lines
4.1 KiB
TOML
[package]
|
|
name = "buzz-relay"
|
|
default-run = "buzz-relay"
|
|
# Independent version: buzz-relay ships as a pinnable artifact
|
|
# (ghcr.io/block/buzz), released on its own cadence via `just release-relay`.
|
|
# It does NOT inherit the workspace version.
|
|
version = "0.2.1"
|
|
edition.workspace = true
|
|
rust-version.workspace = true
|
|
license.workspace = true
|
|
repository.workspace = true
|
|
description = "WebSocket relay server for the Buzz communications platform"
|
|
|
|
[[bin]]
|
|
name = "buzz-relay"
|
|
path = "src/main.rs"
|
|
|
|
[dependencies]
|
|
buzz-core = { workspace = true }
|
|
buzz-conformance = { workspace = true }
|
|
buzz-db = { workspace = true }
|
|
buzz-datastore-tracing = { workspace = true }
|
|
buzz-deletion = { workspace = true }
|
|
buzz-auth = { workspace = true }
|
|
jsonwebtoken = { workspace = true }
|
|
buzz-pubsub = { workspace = true }
|
|
buzz-audit = { workspace = true }
|
|
buzz-search = { workspace = true }
|
|
buzz-relay-mesh = { workspace = true }
|
|
async-trait = "0.1"
|
|
postcard = { workspace = true }
|
|
axum = { workspace = true }
|
|
tokio = { workspace = true }
|
|
# `io` (ReaderStream) is needed to stream git subprocess stdout straight into
|
|
# the HTTP response body — see api::git::transport read-path streaming. The
|
|
# workspace default only enables rt+codec.
|
|
tokio-util = { workspace = true, features = ["io"] }
|
|
tower = { workspace = true }
|
|
tower-http = { workspace = true }
|
|
nostr = { workspace = true }
|
|
serde = { workspace = true }
|
|
serde_json = { workspace = true }
|
|
serde_urlencoded = "0.7"
|
|
tracing = { workspace = true }
|
|
tracing-subscriber = { workspace = true }
|
|
tracing-opentelemetry = { workspace = true }
|
|
opentelemetry = { workspace = true }
|
|
opentelemetry_sdk = { workspace = true }
|
|
opentelemetry-otlp = { workspace = true }
|
|
thiserror = { workspace = true }
|
|
anyhow = { workspace = true }
|
|
uuid = { workspace = true }
|
|
chrono = { workspace = true }
|
|
dashmap = { workspace = true }
|
|
futures-util = { workspace = true }
|
|
deadpool-redis = { workspace = true }
|
|
redis = { workspace = true }
|
|
# Redis TLS (rediss://, e.g. ElastiCache) uses rustls, which needs a process
|
|
# CryptoProvider installed at startup. Both aws-lc-rs and ring are compiled in
|
|
# transitively, so rustls can't auto-select one — we install ring explicitly in
|
|
# main(). Mirrors buzz-acp's rustls setup for wss://.
|
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
|
sqlx = { workspace = true }
|
|
reqwest = { workspace = true }
|
|
base64 = "0.22"
|
|
buzz-sdk = { workspace = true }
|
|
buzz-workflow = { workspace = true, features = ["reqwest"] }
|
|
buzz-media = { workspace = true }
|
|
s3 = { version = "0.37", package = "rust-s3", default-features = false, features = ["tokio-rustls-tls", "fail-on-err", "tags"] }
|
|
tempfile = "3"
|
|
bytes = "1"
|
|
infer = "0.19"
|
|
serde_yaml = { workspace = true }
|
|
sha2 = { workspace = true }
|
|
hmac = { workspace = true }
|
|
subtle = { workspace = true }
|
|
rand = { workspace = true }
|
|
hex = { workspace = true }
|
|
url = { workspace = true }
|
|
moka = { workspace = true }
|
|
metrics = { workspace = true }
|
|
metrics-exporter-prometheus = { workspace = true }
|
|
metrics-util = { workspace = true }
|
|
pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] }
|
|
async-compression = { version = "0.4.42", features = ["tokio", "gzip"] }
|
|
|
|
[features]
|
|
dev = ["buzz-auth/dev"]
|
|
|
|
[dev-dependencies]
|
|
# The mesh-llm smoke harnesses moved to `crates/buzz-mesh-smoke`. A
|
|
# dev-dependency feeds every target of its package, so keeping the mesh-llm
|
|
# SDK here forced the entire mesh-llm/skippy/rmcp tree to compile before any
|
|
# relay unit test could build — the Desktop E2E Relay 30m timeout.
|
|
# `use_pem` enables EncodingKey::from_ec_pem for minting ES256 test assertions
|
|
# in settings_tests.rs (key-pairing build_router tests).
|
|
jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs", "use_pem"] }
|
|
http-body = "1"
|
|
http-body-util = "0.1"
|
|
buzz-core = { workspace = true, features = ["test-utils"] }
|
|
buzz-auth = { workspace = true, features = ["dev", "test-utils"] }
|
|
reqwest = { workspace = true }
|
|
tokio-tungstenite = { workspace = true }
|
|
futures = "0.3"
|
|
flate2 = "1.1.9"
|
|
opentelemetry_sdk = { workspace = true, features = ["testing"] }
|
