Files
buzz/crates/buzz-relay/Cargo.toml
Will Pfleger 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::git::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>
2026-09-25 21:01:31 +00:00

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"] }