diff --git a/.env.example b/.env.example index 683bceb4cc..1216730968 100644 --- a/.env.example +++ b/.env.example @@ -75,7 +75,7 @@ RELAY_URL=ws://localhost:3000 # BUZZ_WEB_DIR=./web/dist # NIP-PL mobile push is an explicit deployment opt-in. A gateway URL alone -# never enables it. When enabled, the exact HTTPS delivery URL is required. +# never enables it. When enabled, the exact HTTP(S) delivery URL is required. BUZZ_PUSH_ENABLED=false BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns @@ -120,6 +120,12 @@ BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns # and provisioning requests fail closed until it is set. # RELAY_OPERATOR_API_ORIGIN=http://127.0.0.1:3000 +# Deployment-global operator-listener routes. Each configured Nostr identity +# may register target pubkeys and receives mention notifications at its URL. +# Format: pubkey:deliveryUrl;pubkey:deliveryUrl +# BUZZ_OPERATOR_LISTENER_TIMEOUT_MS=5000 +# BUZZ_OPERATOR_LISTENERS=<64-char hex pubkey>:https://listener.example/mentions + # Optional relay-owned KLIPY key. When set, NIP-11 advertises GIF search and # authenticated desktop clients use this relay as the metadata/search proxy. # Keep the real value in your deployment's secret manager; never commit it. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94336011cb..e5e60a91be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,11 +22,11 @@ jobs: contents: read pull-requests: read outputs: - rust: ${{ steps.filter.outputs.rust }} - desktop: ${{ steps.filter.outputs.desktop }} - desktop-rust: ${{ steps.filter.outputs.desktop-rust }} - web: ${{ steps.filter.outputs.web }} - mobile: ${{ steps.filter.outputs.mobile }} + rust: ${{ steps.selection.outputs.rust }} + desktop: ${{ steps.selection.outputs.desktop }} + desktop-rust: ${{ steps.selection.outputs.desktop-rust }} + web: ${{ steps.selection.outputs.web }} + mobile: ${{ steps.selection.outputs.mobile }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -35,7 +35,11 @@ jobs: - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter with: - token: '' + # PR files come from GitHub's PR diff. A tokenless comparison uses the + # checked-out merge commit and can include newer, unrelated base changes. + token: ${{ github.token }} + # Root and docs/ Markdown do not select runtime suites. Markdown under + # runtime directories remains covered by the directory filters below. filters: | rust: - 'crates/**' @@ -84,8 +88,13 @@ jobs: - '.github/workflows/mobile-release-candidate.yml' - '.github/workflows/ci.yml' - '.github/workflows/_ci-*.yml' + - name: Select runtime suites conservatively at the PR file-list limit + id: selection + env: + FILTER_OUTPUTS: ${{ toJSON(steps.filter.outputs) }} + run: node scripts/ci-runtime-selection.mjs - name: Validate PostgreSQL test discovery - if: github.event_name == 'push' || steps.filter.outputs.rust == 'true' + if: github.event_name == 'push' || steps.selection.outputs.rust == 'true' run: | scripts/test-postgres-test-discovery.sh scripts/test-postgres-test-wrapper.sh @@ -116,7 +125,7 @@ jobs: - name: CI required-context isolation contract run: scripts/test-ci-required-context-isolation.sh - name: CI path selection regression tests - run: node --test scripts/ci-selection.test.mjs + run: node --test scripts/ci-selection.test.mjs scripts/ci-required-gates.test.mjs - name: File size policy run: just file-size-check @@ -244,7 +253,7 @@ jobs: rust-lint: name: Rust Lint - if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true') + if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true'))) needs: [changes, rust] runs-on: ubuntu-latest timeout-minutes: 5 @@ -252,12 +261,13 @@ jobs: steps: - name: Check Rust Lint result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.rust.outputs.rust_lint_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success unit-tests: name: Unit Tests - if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.rust == 'true'))) needs: [changes, rust] runs-on: ubuntu-latest timeout-minutes: 5 @@ -265,12 +275,13 @@ jobs: steps: - name: Check Unit Tests result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.rust.outputs.unit_tests_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success windows-rust: name: Windows Rust (x86_64-pc-windows-msvc) - if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true') + if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true'))) needs: [changes, rust] runs-on: ubuntu-latest timeout-minutes: 5 @@ -278,12 +289,13 @@ jobs: steps: - name: Check Windows Rust result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.rust.outputs.windows_rust_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success desktop: name: Desktop - if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'))) needs: [changes, desktop-domain] runs-on: ubuntu-latest timeout-minutes: 5 @@ -291,12 +303,13 @@ jobs: steps: - name: Check Desktop result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.desktop-domain.outputs.desktop_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success desktop-build-macos: name: Desktop Build (macOS) - if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'))) needs: [changes, desktop-macos-domain] runs-on: ubuntu-latest timeout-minutes: 5 @@ -304,12 +317,13 @@ jobs: steps: - name: Check Desktop Build (macOS) result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.desktop-macos-domain.outputs.desktop_macos_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success desktop-e2e-relay: name: Desktop E2E Relay - if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'))) needs: [changes, relay-artifacts-domain] runs-on: ubuntu-latest timeout-minutes: 5 @@ -317,12 +331,13 @@ jobs: steps: - name: Check Desktop E2E Relay result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.relay-artifacts-domain.outputs.desktop_e2e_relay_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success desktop-e2e-integration: name: Desktop E2E Integration - if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') + if: always() && (needs.changes.result != 'success' || (needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'))) needs: [changes, relay-artifacts-domain, relay-domain] runs-on: ubuntu-latest timeout-minutes: 5 @@ -330,12 +345,13 @@ jobs: steps: - name: Check Desktop E2E Integration result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.relay-domain.outputs.desktop_e2e_integration_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success backend-integration: name: Backend Integration (relay e2e) - if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + if: always() && (needs.changes.result != 'success' || (needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true'))) needs: [changes, relay-artifacts-domain, relay-domain] runs-on: ubuntu-latest timeout-minutes: 5 @@ -343,12 +359,13 @@ jobs: steps: - name: Check Backend Integration result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.relay-domain.outputs.backend_integration_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success postgres-tests: name: PostgreSQL Tests - if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + if: always() && (needs.changes.result != 'success' || (needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true'))) needs: [changes, relay-artifacts-domain, postgres-domain] runs-on: ubuntu-latest timeout-minutes: 5 @@ -356,12 +373,13 @@ jobs: steps: - name: Check PostgreSQL Tests result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.postgres-domain.outputs.postgres_tests_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success relay-e2e: name: Relay E2E - if: always() && needs.changes.result == 'success' && needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + if: always() && (needs.changes.result != 'success' || (needs.relay-artifacts-domain.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true'))) needs: [changes, relay-artifacts-domain, relay-domain] runs-on: ubuntu-latest timeout-minutes: 5 @@ -369,12 +387,13 @@ jobs: steps: - name: Check Relay E2E result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.relay-domain.outputs.relay_e2e_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success web: name: Web - if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.web == 'true') + if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.web == 'true'))) needs: [changes, clients] runs-on: ubuntu-latest timeout-minutes: 5 @@ -382,12 +401,13 @@ jobs: steps: - name: Check Web result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.clients.outputs.web_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success mobile: name: Mobile - if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.mobile == 'true') + if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.mobile == 'true'))) needs: [changes, clients] runs-on: ubuntu-latest timeout-minutes: 5 @@ -395,12 +415,13 @@ jobs: steps: - name: Check Mobile result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.clients.outputs.mobile_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success security: name: Security - if: always() && needs.changes.result == 'success' && (github.event_name == 'push' || needs.changes.outputs.rust == 'true') + if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.rust == 'true'))) needs: [changes, security-domain] runs-on: ubuntu-latest timeout-minutes: 5 @@ -408,5 +429,6 @@ jobs: steps: - name: Check Security result env: + SELECTION_RESULT: ${{ needs.changes.result }} RESULT: ${{ needs.security-domain.outputs.security_result }} - run: test "$RESULT" = success + run: test "$SELECTION_RESULT" = success && test "$RESULT" = success diff --git a/AGENTS.md b/AGENTS.md index 58309c0865..3c9d578bb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,48 @@ and runtime evidence answer different questions. --- +## Before opening a PR + +Open work in progress as a draft PR. Mark it ready for review only when this +checklist holds for the current change. Scale it to what changed: +documentation-only changes need content, link, and diff checks plus human +confirmation, not app runs. + +1. **Agent review ran** under [Reviewing](#reviewing), and its recommended + blockers were fixed or explicitly declined by the human author. Optional + suggestions do not gate readiness. +2. **An agent exercised the changed behavior.** Client changes: the affected flow + in the app, using the native app or a device when the behavior needs it + (browser or headless Playwright counts only for what it can exercise). Relay + changes: a local relay, exercising the changed events or endpoints. CLI or + tooling changes: the affected command or workflow. The human may skip this + step, for example for a small change or while iterating. +3. **A human then tested it themselves**: in the app, against the local relay + (through the app or `curl`), or by running the changed command. Agent testing + does not substitute. Agents give the human exact steps and what working looks + like, then wait for explicit confirmation. Never mark this step done yourself. +4. **Add `buzz-review-completed` to the PR description** once steps 1–3 hold. It + attests the checklist, and automated reviewers may skip review because of it. + If later edits change behavior, remove it and return the PR to draft until the + affected steps are redone. + +## Reviewing + +- Before reviewing, read [VISION.md](VISION.md), the `VISION_*.md` docs for the affected surface, and the PR's stated goal and linked + issue. Review the change against what it is trying to do. +- Check the change against the [Review-Proven Rules](#review-proven-rules): the defects reviewers here find most often. +- Judge minimalism, elegance, and correctness, aiming for 9/10 on each. A score + below 9 names the concrete defect and the fix. +- Recommend blocking only for concrete correctness, security, or agreed-contract + defects with a realistic failure scenario: state the defect, how it fails, and + the fix. Label everything else (nits, wording, speculative hardening, + out-of-scope improvements) as optional. +- Put all findings in the first review. Later reviews check prior blockers and + defects the fixes introduced; reopen other areas only on new evidence of a + material defect. +- Agents post reviews as comments, never Request Changes. Humans decide which + findings must be fixed. + ## Ecosystem Buzz spans five repos. This one (`block/buzz`) is the OSS source for the relay, desktop, mobile, and CLI. The others handle internal builds and deployment: @@ -165,8 +207,8 @@ the same clusters and measured how often authors actually fix each class once flagged: test-seam binding and unbounded-resource findings were fixed **100%** of the time, swallowed-error findings **90%**, stale-state races **70%** — these are not style opinions, they are defects authors agree -with on sight. Apply the rules **before writing code**; each cites the -PRs where reviewers litigated it. +with on sight. Authors apply them **before writing code**, and reviewers check +against them; each cites the PRs where reviewers litigated it. 1. **Every caught failure must leave a durable retry record or propagate.** Never catch-log-and-return-success (opt-out revocation permanently diff --git a/Cargo.lock b/Cargo.lock index fd40f824ff..95e80f1c12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1444,7 +1444,10 @@ dependencies = [ "futures-util", "hex", "hmac 0.13.0", + "http-body", + "http-body-util", "infer", + "jsonwebtoken", "metrics", "metrics-exporter-prometheus", "metrics-util", @@ -1462,6 +1465,7 @@ dependencies = [ "rustls", "serde", "serde_json", + "serde_urlencoded", "serde_yaml", "sha2 0.11.0", "sqlx", diff --git a/Justfile b/Justfile index f94527b427..8bc31ab21a 100644 --- a/Justfile +++ b/Justfile @@ -444,18 +444,11 @@ test-unit: # and a red one could ship green (exactly how a broken admin test slipped # past every gate once). Scoped to api::admin, not the whole buzz-relay # --lib, because api::media has non-ignored tests that require Postgres. - # Two api::admin tests are excluded: both exercise a read-route DB - # fallthrough and pass without a database only by waiting out the sqlx - # acquire timeout (~30s each), so they do not belong in the infra-free - # unit job. nip98_mode_unrostered_signer_does_not_consume_a_replay_slot - # asserts a unique replay-guard invariant, so it is wired into the - # Postgres-backed Backend Integration job (see ci.yml "Admin API - # unrostered-signer replay invariant"). disabled_mode_allows_ - # unauthenticated_requests_on_the_admin_host has no unique invariant: - # disabled-mode unauthenticated success is covered by - # disabled_mode_regression_pin_unauthenticated_request_is_served on the - # DB-free /probe route, and its Host/Origin gating is covered here by - # disabled_mode_still_requires_the_correct_host / _a_matching_origin. + # DB-backed api::admin tests are #[ignore]d and run in the PostgreSQL + # lane; the non-ignored ones reject before touching the database. Any + # new non-ignored test here must stay DB-free: without a database, a + # DB fallthrough only "passes" by waiting out the ~30s sqlx acquire + # timeout. # The second clause adds the relay's pure authorization-decision tests: # the NIP-29 channel membership grid (handlers::channel_authz), the # moderation capability grid (handlers::moderation_authz), and the pure @@ -469,8 +462,17 @@ test-unit: # non-postgres_tests cases only "pass" without a database by waiting out # the ~30s sqlx acquire timeout, so they do not belong in the infra-free # unit job either. - cargo nextest run -p buzz-relay --lib \ - -E '(test(/^api::admin::/) - test(=api::admin::tests::disabled_mode_allows_unauthenticated_requests_on_the_admin_host) - test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/)' + # The third clause adds the NIP-FI HTTP ingress and its router/config + # neighbours: nip_fi_http, nip_fi_config, router, api::parse_query_tests, + # and the Git transport off_mode_precedence_tests. All are infra-free + # and finish in well under a second with no DATABASE_URL, so none wait + # out the sqlx acquire timeout. `--bin buzz-relay` adds main.rs's + # `tests::` module (JWKS refresh cadence) and `composition_tests::` + # (JWKS source + refresh-loop composition), which `--lib` cannot reach + # because they live in the binary target; the nested + # `tests::postgres_tests::` stays in the PostgreSQL lane. + cargo nextest run -p buzz-relay --lib --bin buzz-relay \ + -E 'test(/^api::admin::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/)) - test(/^tests::postgres_tests::/))' # ACP author-gate and queue tests protect the trust boundary between # relay events and agent prompts. They are infra-free; ignored lifecycle # tests remain excluded and run in their dedicated integration lanes. diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index c21872351f..eb486e9b9b 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -52,7 +52,7 @@ pub use nip_fi::{ IssuerJwksConfig, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry, JwksFetchError, JwksFetcher, JwksSourceContract, NipFiMode, NipFiStartupError, ProductionJwksSource, RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass, - TransportContractId, VerifiedAssertion, VerifierError, CLIENT_ATTACHED_HEADER, + TransportContractId, VerifiedAssertion, VerifierError, VerifyAssertion, CLIENT_ATTACHED_HEADER, NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, }; @@ -61,6 +61,10 @@ pub use access::MockAccessChecker; #[cfg(any(test, feature = "test-utils"))] pub use nip98_replay::AlwaysFreshReplayGuard; #[cfg(any(test, feature = "test-utils"))] +pub use nip_fi::jwks::ScriptedJwksFetcher; +#[cfg(any(test, feature = "test-utils"))] +pub use nip_fi::StaticIssuerKeySource; +#[cfg(any(test, feature = "test-utils"))] pub use rate_limit::AlwaysAllowRateLimiter; /// How the connection was authenticated. diff --git a/crates/buzz-auth/src/nip98.rs b/crates/buzz-auth/src/nip98.rs index a5ed98167c..90953dd8eb 100644 --- a/crates/buzz-auth/src/nip98.rs +++ b/crates/buzz-auth/src/nip98.rs @@ -149,6 +149,13 @@ pub fn verify_nip98_event( // sign without one. Rejecting duplicates closes the real attack: a valid-first // /contradictory-second pair would let `.find()` accept the first and silently // ignore the second, bypassing the body-hash check. + // + // Digest format contract: the content value must be exactly 64 lowercase + // hex characters (a valid sha256 digest). A one-element `["payload"]` tag + // with no content, or a malformed digest, must be rejected — the tag + // *claims* body-hash binding but provides no valid digest. An absent tag + // makes no claim; body-bearing routes that opt out of payload binding + // (e.g. `/events`, `/query`, `/count`) fall here legitimately. { let count = event .tags @@ -161,11 +168,40 @@ pub fn verify_nip98_event( ))); } } - // Keep a present-but-malformed tag distinct from an absent (optional) tag. - if let (Some(payload_tag), Some(body_bytes)) = (event.tags.find(TagKind::Payload), body) { - let payload_hex = payload_tag.content().ok_or_else(|| { - AuthError::Nip98Invalid("payload tag is missing its SHA-256 hash".to_string()) - })?; + // Validate the payload tag digest when the tag is present. + // + // A present tag *claims* body-hash binding. A missing hash value (one-element + // tag or empty string content) is structurally invalid — the claim is made but + // no digest is provided — and must be rejected. An absent tag makes no claim; + // body-bearing routes that opt out of payload binding (e.g. `/events`, `/query`, + // `/count`) fall here legitimately. + // + // When the tag is present, the content must be exactly 64 lowercase hex chars. + let payload_tag = if let Some(tag) = event.tags.find(TagKind::Payload) { + let hex_str = tag.content().unwrap_or(""); + if hex_str.is_empty() { + return Err(AuthError::Nip98Invalid( + "payload tag is missing its SHA-256 hash".to_string(), + )); + } + // Must be exactly 64 lowercase hex chars (valid sha256 digest). + // Use a static diagnostic — attacker-controlled tag content must not + // appear in error messages (length, format code, or unicode boundary + // slicing could panic or leak attacker data). + if hex_str.len() != 64 + || !hex_str.chars().all(|c| c.is_ascii_hexdigit()) + || hex_str.chars().any(|c| c.is_ascii_uppercase()) + { + return Err(AuthError::Nip98Invalid( + "payload tag digest must be exactly 64 lowercase hex chars (sha256)".to_string(), + )); + } + Some(hex_str) + } else { + None + }; + + if let (Some(payload_hex), Some(body_bytes)) = (payload_tag, body) { let computed: [u8; 32] = Sha256::digest(body_bytes).into(); let computed_hex = hex::encode(computed); if computed_hex != payload_hex { @@ -479,6 +515,112 @@ mod tests { ); } + // ── F1 regression: payload tag present with no or empty digest ───────── + // + // The old code did `.and_then(|t| t.content())` which returned `None` for a + // one-element `["payload"]` tag — silently skipping the body-hash check. + // A client could sign an event with `["payload"]` (no digest), present any + // body, and the verifier would not check the body against the tag. + // + // Fix: a present tag with no content (or empty string) is rejected as + // structurally invalid — the claim is made but no digest is provided. + // An absent tag makes no claim and is accepted. A present tag with content + // must be exactly 64 lowercase hex chars; invalid format rejects the event. + // + // Mutation evidence: removing the format check makes `unwrap_err()` panic. + + #[test] + fn payload_tag_malformed_digest_rejected() { + // A payload tag present with a value that is NOT 64 lowercase hex chars + // must be rejected — it is a structurally invalid event. + use nostr::Tag; + let keys = Keys::generate(); + let body = b"any body"; + + // Too short. + let json = make_nip98_event_raw_tags( + &keys, + vec![ + Tag::parse(["u", TEST_URL]).unwrap(), + Tag::parse(["method", TEST_METHOD]).unwrap(), + Tag::parse(["payload", "deadbeef"]).unwrap(), // 8 chars, not 64 + ], + ); + let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(body)); + assert!( + matches!(result, Err(AuthError::Nip98Invalid(_))), + "payload tag with short digest must be rejected: {result:?}" + ); + + // Uppercase hex (structurally invalid per NIP-98 lowercase-hex contract). + let keys2 = Keys::generate(); + let hash: [u8; 32] = Sha256::digest(body).into(); + let upper_hex = hex::encode(hash).to_uppercase(); + let json2 = make_nip98_event_raw_tags( + &keys2, + vec![ + Tag::parse(["u", TEST_URL]).unwrap(), + Tag::parse(["method", TEST_METHOD]).unwrap(), + Tag::parse(["payload", &upper_hex]).unwrap(), + ], + ); + let result2 = verify_nip98_event(&json2, TEST_URL, TEST_METHOD, Some(body)); + assert!( + matches!(result2, Err(AuthError::Nip98Invalid(_))), + "payload tag with uppercase hex must be rejected: {result2:?}" + ); + + // Non-hex content. + let keys3 = Keys::generate(); + let non_hex = "z".repeat(64); + let json3 = make_nip98_event_raw_tags( + &keys3, + vec![ + Tag::parse(["u", TEST_URL]).unwrap(), + Tag::parse(["method", TEST_METHOD]).unwrap(), + Tag::parse(["payload", &non_hex]).unwrap(), + ], + ); + let result3 = verify_nip98_event(&json3, TEST_URL, TEST_METHOD, Some(body)); + assert!( + matches!(result3, Err(AuthError::Nip98Invalid(_))), + "payload tag with non-hex content must be rejected: {result3:?}" + ); + } + + #[test] + fn payload_tag_multibyte_boundary_does_not_panic() { + // Regression for R2 (Thufir round-1): the old code did + // `&hex_str[..hex_str.len().min(80)]` + // on attacker-controlled tag content. 79 ASCII 'a' chars followed by + // 'é' (U+00E9, 2 UTF-8 bytes) produces a 81-byte str; truncating at + // byte 80 splits the multibyte character and panics with a byte-index + // boundary panic. + // + // Mutation evidence: restoring the old byte-slice panic reproduces the + // panic here rather than returning an ordinary `AuthError`. + use nostr::Tag; + let keys = Keys::generate(); + // 79 lowercase 'a' chars + 'é' (2 UTF-8 bytes) = 81 bytes, not 64 chars. + let malformed = format!("{}{}", "a".repeat(79), "é"); + assert_eq!(malformed.len(), 81, "precondition: 81 UTF-8 bytes"); + assert_eq!(malformed.chars().count(), 80, "precondition: 80 chars"); + let json = make_nip98_event_raw_tags( + &keys, + vec![ + Tag::parse(["u", TEST_URL]).unwrap(), + Tag::parse(["method", TEST_METHOD]).unwrap(), + Tag::parse(["payload", &malformed]).unwrap(), + ], + ); + // Must return an ordinary AuthError, not panic. + let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None); + assert!( + matches!(result, Err(AuthError::Nip98Invalid(_))), + "multibyte-boundary malformed payload tag must return AuthError, not panic: {result:?}" + ); + } + #[test] fn loopback_aliases_are_distinct_hosts() { // Under multi-tenant, the `u`-tag host is the row-zero community diff --git a/crates/buzz-auth/src/nip_fi/assertion.rs b/crates/buzz-auth/src/nip_fi/assertion.rs index 8b8a566cf6..66a6e2a320 100644 --- a/crates/buzz-auth/src/nip_fi/assertion.rs +++ b/crates/buzz-auth/src/nip_fi/assertion.rs @@ -199,6 +199,31 @@ impl VerifiedAssertion { pub const fn revalidation_dependencies(&self) -> &RevalidationDependencies { &self.revalidation_dependencies } + + /// Test-only constructor: mint a minimal `VerifiedAssertion` for a given + /// `asserted_key`. All other fields are set to safe, arbitrary defaults. + /// + /// Used in unit tests that need to supply a `VerifiedAssertion` with a + /// specific `asserted_key` without performing a real JWKS verification. + #[cfg(any(test, feature = "test-utils"))] + pub fn new_for_test(asserted_key: nostr::PublicKey) -> Self { + use chrono::Duration; + Self::seal( + "https://test.issuer.example".to_owned(), + "test-subject".to_owned(), + Some(asserted_key), + CanonicalCapabilities::from_pairs(vec![]), + vec![Utc::now() + Duration::seconds(3600)], + AssertionPolicyId::zero(), + TransportContractId::zero(), + RevalidationDependencies::new( + "test-kid".to_owned(), + 1, + Utc::now() + Duration::seconds(3600), + "test.header.sig".to_owned(), + ), + ) + } } impl fmt::Debug for VerifiedAssertion { diff --git a/crates/buzz-auth/src/nip_fi/config.rs b/crates/buzz-auth/src/nip_fi/config.rs index 8dabb00b12..9991059842 100644 --- a/crates/buzz-auth/src/nip_fi/config.rs +++ b/crates/buzz-auth/src/nip_fi/config.rs @@ -104,6 +104,12 @@ impl AssertionPolicyId { pub const fn as_bytes(&self) -> &[u8; 32] { &self.0 } + + /// All-zeros sentinel for use in tests only. + #[cfg(any(test, feature = "test-utils"))] + pub fn zero() -> Self { + Self([0u8; 32]) + } } impl fmt::Debug for AssertionPolicyId { @@ -144,6 +150,12 @@ impl TransportContractId { pub const fn as_bytes(&self) -> &[u8; 32] { &self.0 } + + /// All-zeros sentinel for use in tests only. + #[cfg(any(test, feature = "test-utils"))] + pub fn zero() -> Self { + Self([0u8; 32]) + } } impl fmt::Debug for TransportContractId { diff --git a/crates/buzz-auth/src/nip_fi/jwks/mod.rs b/crates/buzz-auth/src/nip_fi/jwks/mod.rs index 618ee6b069..936318a2a2 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/mod.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/mod.rs @@ -34,7 +34,7 @@ use jsonwebtoken::jwk::JwkSet; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::sync::Arc; -use tokio::sync::{Mutex, RwLock}; +use tokio::sync::Mutex; use tracing::warn; use url::Url; @@ -251,12 +251,46 @@ struct CachedSnapshot { content_digest: [u8; 32], } +/// Per-issuer cache slot. The published snapshot lives outside the refresh +/// mutex so the sync [`IssuerKeySource::key_set`] read path never contends +/// with other readers or an in-flight refresh. Only `get_snapshot` writes +/// `published`, and only while holding `refresh`, so writers stay serialized. +struct IssuerSlot { + refresh: Mutex, + /// Held only for a clone or an assignment — never across an await. + published: std::sync::RwLock>, +} + +impl IssuerSlot { + fn new() -> Self { + Self { + refresh: Mutex::new(IssuerState::new()), + published: std::sync::RwLock::new(None), + } + } + + fn read_published(&self) -> std::sync::RwLockReadGuard<'_, Option> { + self.published.read().unwrap_or_else(|e| e.into_inner()) + } + + fn write_published(&self) -> std::sync::RwLockWriteGuard<'_, Option> { + self.published.write().unwrap_or_else(|e| e.into_inner()) + } + + /// Clones the published key set iff it is still before its hard deadline. + fn live_key_set(&self, now: DateTime) -> Option { + self.read_published() + .as_ref() + .filter(|c| now < c.hard_deadline) + .map(|c| c.key_set.clone()) + } +} + struct IssuerState { - snapshot: Option, /// Advances only when `content_digest` changes; never wraps (saturating). generation_counter: u64, /// Owned permit for in-flight refresh. Held across the complete fetch + - /// state commit; dropped automatically if the caller future is cancelled. + /// snapshot commit; dropped automatically if the caller future is cancelled. /// `try_lock_owned()` succeeds iff no refresh is in progress. refresh_permit: Arc>, } @@ -264,7 +298,6 @@ struct IssuerState { impl IssuerState { fn new() -> Self { Self { - snapshot: None, generation_counter: 0, refresh_permit: Arc::new(tokio::sync::Mutex::new(())), } @@ -499,7 +532,7 @@ fn parse_and_bound_jwks(body: &str) -> Result { /// /// Must be constructed at startup after /// [`super::startup::validate_nip_fi_config`] passes. Shared across async -/// tasks via the inner `Arc>`. +/// tasks by wrapping the source in an `Arc`. /// /// ## Security /// @@ -508,7 +541,7 @@ fn parse_and_bound_jwks(body: &str) -> Result { /// - Errors are logged with a stable code; no key material appears in logs. pub struct ProductionJwksSource { configs: HashMap, - states: Arc>>>, + states: HashMap, fetcher: Arc, /// Clock used for `hard_deadline` computation and expiry checks. Always /// `Arc::new(Utc::now)` in production; tests supply a controlled clock. @@ -533,12 +566,12 @@ impl ProductionJwksSource { return None; } let issuer = c.issuer.clone(); - state_map.insert(issuer.clone(), Mutex::new(IssuerState::new())); + state_map.insert(issuer.clone(), IssuerSlot::new()); config_map.insert(issuer, c); } Some(Self { configs: config_map, - states: Arc::new(RwLock::new(state_map)), + states: state_map, fetcher: Arc::new(fetcher), now_fn: Arc::new(Utc::now), }) @@ -546,8 +579,8 @@ impl ProductionJwksSource { /// **Test-only.** Construct with an injectable clock so tests can advance /// `now` past snapshot hard deadlines without wall-clock sleep. - #[cfg(test)] - pub(crate) fn new_with_clock( + #[cfg(any(test, feature = "test-utils"))] + pub fn new_with_clock( configs: Vec, fetcher: F, now_fn: Arc DateTime + Send + Sync>, @@ -562,12 +595,12 @@ impl ProductionJwksSource { return None; } let issuer = c.issuer.clone(); - state_map.insert(issuer.clone(), Mutex::new(IssuerState::new())); + state_map.insert(issuer.clone(), IssuerSlot::new()); config_map.insert(issuer, c); } Some(Self { configs: config_map, - states: Arc::new(RwLock::new(state_map)), + states: state_map, fetcher: Arc::new(fetcher), now_fn, }) @@ -638,29 +671,30 @@ impl ProductionJwksSource { /// cancelled while DNS, HTTP, or streaming is pending, the guard drops and /// the permit is released, so the next caller can start a new fetch. pub async fn get_snapshot(&self, issuer: &str) -> Option { - let states = self.states.read().await; - let state_mutex = states.get(issuer)?; - let mut state = state_mutex.lock().await; + let slot = self.states.get(issuer)?; + let config = self.configs.get(issuer)?; + let state = slot.refresh.lock().await; let now = (self.now_fn)(); - let config = self.configs.get(issuer)?; - - if let Some(ref cached) = state.snapshot { - if now >= cached.hard_deadline { - state.snapshot = None; + let (needs_refresh, prev_digest) = { + let mut published = slot.write_published(); + if published.as_ref().is_some_and(|c| now >= c.hard_deadline) { + *published = None; } - } - - let needs_refresh = match state.snapshot { - None => true, - Some(ref cached) => { - let age_secs = (now - cached.fetched_at).num_seconds().max(0) as u64; - age_secs >= config.contract.refresh_interval_seconds() + match published.as_ref() { + None => (true, None), + Some(cached) => { + let age_secs = (now - cached.fetched_at).num_seconds().max(0) as u64; + ( + age_secs >= config.contract.refresh_interval_seconds(), + Some(cached.content_digest), + ) + } } }; if !needs_refresh { - return state.snapshot.as_ref().map(|c| c.key_set.clone()); + return slot.live_key_set(now); } // Try to acquire the per-issuer refresh permit. Failure means another @@ -668,36 +702,23 @@ impl ProductionJwksSource { // starting a second fetch. let permit = match Arc::clone(&state.refresh_permit).try_lock_owned() { Ok(g) => g, - Err(_) => return state.snapshot.as_ref().map(|c| c.key_set.clone()), + Err(_) => return slot.live_key_set(now), }; - let prev_digest = state.snapshot.as_ref().map(|c| c.content_digest); let prev_generation = state.generation_counter; drop(state); - drop(states); let fresh = self.fetch_fresh(issuer, prev_digest, prev_generation).await; // Re-acquire state to commit and release the permit atomically. - let states = self.states.read().await; - if let Some(state_mutex) = states.get(issuer) { - let mut st = state_mutex.lock().await; - if let Some((ref cached, new_generation)) = fresh { - st.generation_counter = new_generation; - st.snapshot = Some(cached.clone()); - } - // Drop the permit only after the state commit is visible. - drop(permit); - let now2 = (self.now_fn)(); - return st - .snapshot - .as_ref() - .filter(|c| now2 < c.hard_deadline) - .map(|c| c.key_set.clone()); + let mut state = slot.refresh.lock().await; + if let Some((cached, new_generation)) = fresh { + state.generation_counter = new_generation; + *slot.write_published() = Some(cached); } - + // Drop the permit only after the snapshot commit is visible. drop(permit); - None + slot.live_key_set((self.now_fn)()) } } @@ -707,19 +728,11 @@ impl IssuerKeySource for ProductionJwksSource { /// Called per-request by the verifier after the cache has been warmed via /// [`get_snapshot`][Self::get_snapshot]. /// - /// Uses `try_read`/`try_lock` — safe to call from any async context. - /// Fails closed (returns `None`) when the lock is momentarily held by an - /// in-flight refresh, rather than blocking or panicking. [FI-INV-14] + /// Reads the published snapshot without touching the refresh mutex, so + /// concurrent readers and an in-flight refresh never make it fail. Fails + /// closed (returns `None`) only when no snapshot is live. [FI-INV-14] fn key_set(&self, issuer: &str) -> Option { - let states = self.states.try_read().ok()?; - let state_mutex = states.get(issuer)?; - let state = state_mutex.try_lock().ok()?; - let now = (self.now_fn)(); - state - .snapshot - .as_ref() - .filter(|c| now < c.hard_deadline) - .map(|c| c.key_set.clone()) + self.states.get(issuer)?.live_key_set((self.now_fn)()) } } @@ -736,3 +749,55 @@ impl std::fmt::Debug for ProductionJwksSource { #[cfg(test)] mod tests; + +/// A scripted JWKS fetcher for integration tests outside this crate. +/// +/// Returns pre-queued responses in FIFO order. When the queue is +/// exhausted every subsequent call returns `NetworkError`. The queued +/// values are immediate `Result` — latency cannot +/// be added inside the fetcher itself. To simulate nonzero fetch latency, +/// add a `tokio::time::sleep` in the outer callback that wraps the fetcher +/// call (see `composition_nonzero_latency_and_not_due_cache_hit`). +/// +/// Sealed for `JwksFetcher` so callers never need to name the sealed trait. +#[cfg(any(test, feature = "test-utils"))] +pub struct ScriptedJwksFetcher { + /// Remaining responses, front = next to return. Thread-safe. + pub responses: std::sync::Arc< + std::sync::Mutex>>, + >, + /// Incremented on each call regardless of outcome. Thread-safe. + pub call_count: std::sync::Arc, +} + +#[cfg(any(test, feature = "test-utils"))] +impl ScriptedJwksFetcher { + /// Create a new `ScriptedJwksFetcher` with the given queued responses (FIFO). + pub fn new(responses: impl IntoIterator>) -> Self { + Self { + responses: std::sync::Arc::new(std::sync::Mutex::new(responses.into_iter().collect())), + call_count: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)), + } + } +} + +#[cfg(any(test, feature = "test-utils"))] +impl super::verifier::sealed::Sealed for ScriptedJwksFetcher {} + +#[cfg(any(test, feature = "test-utils"))] +impl JwksFetcher for ScriptedJwksFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + self.call_count + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let result = self + .responses + .lock() + .unwrap() + .pop_front() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } +} diff --git a/crates/buzz-auth/src/nip_fi/jwks/tests.rs b/crates/buzz-auth/src/nip_fi/jwks/tests.rs index df75e70da0..2456b65127 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/tests.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/tests.rs @@ -1440,14 +1440,10 @@ async fn resolved_target_and_pin_key_seam_public_ipv6_and_fec0_rejection() { /// cache rather than through shared-arc rotation. **A2 acceptance is the /// reliable shared-source oracle here.** /// -/// Note: the expiry-purge (`state.snapshot = None` in `get_snapshot`) is -/// correctness-critical for concurrent callers: it clears the expired snapshot -/// before permit acquisition, so a caller that loses the permit race and falls -/// back to `state.snapshot` receives `None` rather than an expired snapshot. -/// A1 rejection after the deadline is also enforced independently by the `key_set` -/// read path (`filter(|c| now < c.hard_deadline)`), but the purge is what -/// prevents the fallback path from serving a stale snapshot to concurrent -/// refresh losers, so no separate purge mutation oracle is claimed here. +/// Note: `get_snapshot` purges an expired published snapshot before permit +/// acquisition, and every return path (including the refresh-loser fallback) +/// re-applies the `now < hard_deadline` filter, as does the `key_set` read +/// path, so no separate purge mutation oracle is claimed here. #[tokio::test] async fn shared_arc_source_verifier_rejects_expired_a1_accepts_a2() { use crate::nip_fi::{ @@ -1619,3 +1615,113 @@ async fn shared_arc_source_verifier_rejects_expired_a1_accepts_a2() { .verify(&sign_token(PKCS8_A1, KID_A1, issuer, audience)) .expect_err("A1 must be rejected after expiry + rotation"); } + +/// Fetcher whose calls after the initial warm-up park until `release` is +/// notified, so a test can hold a real `get_snapshot` refresh in flight +/// deterministically. +struct GatedJwksFetcher { + body: String, + entered: Arc, + release: Arc, + call_count: Arc, +} + +impl super::super::verifier::sealed::Sealed for GatedJwksFetcher {} + +impl JwksFetcher for GatedJwksFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let n = self.call_count.fetch_add(1, Ordering::SeqCst); + async move { + if n > 0 { + self.entered.notify_one(); + self.release.notified().await; + } + Ok(self.body.clone()) + } + } +} + +/// Regression: `key_set` must serve the warm, unexpired snapshot while other +/// readers or a refresh hold per-issuer locks, and still fail closed on +/// expiry / unknown issuer. +/// +/// ## Mutation oracle +/// Reintroduce the old refresh-mutex `try_lock` in `key_set` +/// (`let _g = self.states.get(issuer)?.refresh.try_lock().ok()?;`): the +/// "reader overlapping refresh-mutex holder" assertion fails. +#[tokio::test] +async fn key_set_serves_live_snapshot_under_reader_and_refresh_overlap() { + use std::sync::atomic::AtomicI64; + let issuer = "https://overlap.example"; + let t0 = Utc::now().timestamp(); + let clock = Arc::new(AtomicI64::new(t0)); + let clock_fn = Arc::clone(&clock); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let fetcher = GatedJwksFetcher { + body: minimal_jwks_json("k1"), + entered: Arc::clone(&entered), + release: Arc::clone(&release), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let source = Arc::new( + ProductionJwksSource::new_with_clock( + vec![make_config(issuer)], + fetcher, + Arc::new(move || DateTime::from_timestamp(clock_fn.load(Ordering::SeqCst), 0).unwrap()), + ) + .unwrap(), + ); + + // Control: fail closed before warm-up. + assert!( + source.key_set(issuer).is_none(), + "cold cache must fail closed" + ); + source.get_snapshot(issuer).await.expect("warm-up fetch"); + let slot = source.states.get(issuer).unwrap(); + + // Reader overlapping another reader. + { + let _other_reader = slot.read_published(); + assert!( + source.key_set(issuer).is_some(), + "reader overlapping another reader must see the live snapshot" + ); + } + + // Reader overlapping a refresh-mutex holder (what get_snapshot holds). + { + let _refresh_holder = slot.refresh.lock().await; + assert!( + source.key_set(issuer).is_some(), + "reader overlapping refresh-mutex holder must see the live snapshot" + ); + } + + // Reader overlapping a real in-flight refresh: make the snapshot stale + // (past refresh interval, before hard deadline) and park the refetch. + clock.store(t0 + 301, Ordering::SeqCst); + let refresher = { + let source = Arc::clone(&source); + tokio::spawn(async move { source.get_snapshot(issuer).await }) + }; + entered.notified().await; + assert!( + source.key_set(issuer).is_some(), + "reader overlapping an in-flight refresh must see the live snapshot" + ); + release.notify_one(); + assert!(refresher.await.unwrap().is_some()); + + // Controls: unknown issuer and expiry still fail closed. + assert!(source.key_set("https://unknown.example").is_none()); + clock.store(t0 + 301 + 3600, Ordering::SeqCst); + assert!( + source.key_set(issuer).is_none(), + "expired snapshot must fail closed" + ); +} diff --git a/crates/buzz-auth/src/nip_fi/mod.rs b/crates/buzz-auth/src/nip_fi/mod.rs index ce97709064..cc5a9cc8f4 100644 --- a/crates/buzz-auth/src/nip_fi/mod.rs +++ b/crates/buzz-auth/src/nip_fi/mod.rs @@ -34,4 +34,9 @@ pub use jwks::{ ProductionJwksSource, }; pub use startup::{validate_nip_fi_config, NipFiMode, NipFiStartupError}; -pub use verifier::{AssertionKeySet, FederatedAssertionVerifier, IssuerKeySource, VerifierError}; +pub use verifier::{ + AssertionKeySet, FederatedAssertionVerifier, IssuerKeySource, VerifierError, VerifyAssertion, +}; + +#[cfg(any(test, feature = "test-utils"))] +pub use verifier::StaticIssuerKeySource; diff --git a/crates/buzz-auth/src/nip_fi/verifier.rs b/crates/buzz-auth/src/nip_fi/verifier.rs index cf20b57a86..bc6afcf0f1 100644 --- a/crates/buzz-auth/src/nip_fi/verifier.rs +++ b/crates/buzz-auth/src/nip_fi/verifier.rs @@ -127,6 +127,20 @@ impl AssertionKeySet { }) } + /// Test-utils / test-only constructor: same validation as the crate-private + /// `new`, exposed under the `test-utils` Cargo feature and `cfg(test)` so + /// integration tests in dependent crates (e.g., `buzz-relay`) can build + /// snapshots for `StaticIssuerKeySource` without requiring a live JWKS fetch. + #[cfg(any(test, feature = "test-utils"))] + pub fn new_for_test( + issuer: String, + generation: u64, + jwks: JwkSet, + hard_deadline: DateTime, + ) -> Option { + Self::new(issuer, generation, jwks, hard_deadline) + } + /// The exact `iss` this snapshot authenticates. pub fn issuer(&self) -> &str { &self.issuer @@ -209,20 +223,20 @@ impl IssuerKeySource for std::sync::Arc { /// reconstruct the authority. An honest source returns only the snapshot bound /// to the exact issuer requested, the invariant the real runtime source /// guarantees. -#[cfg(test)] +#[cfg(any(test, feature = "test-utils"))] #[derive(Clone, Default)] -pub(crate) struct StaticIssuerKeySource { +pub struct StaticIssuerKeySource { snapshots: std::collections::HashMap, /// When set, returned for every requested issuer regardless of its binding, /// to exercise the verifier's defensive issuer re-check. misbound: Option, } -#[cfg(test)] +#[cfg(any(test, feature = "test-utils"))] impl StaticIssuerKeySource { /// Build an honest source from a set of snapshots, keyed by each snapshot's /// issuer. - pub(crate) fn new(snapshots: impl IntoIterator) -> Self { + pub fn new(snapshots: impl IntoIterator) -> Self { Self { snapshots: snapshots .into_iter() @@ -235,7 +249,7 @@ impl StaticIssuerKeySource { /// A hostile/buggy source that returns the given snapshot — bound to a /// different issuer than requested — for every lookup, to exercise the /// verifier's defensive issuer re-check. - pub(crate) fn misbinding(snapshot: AssertionKeySet) -> Self { + pub fn misbinding(snapshot: AssertionKeySet) -> Self { Self { snapshots: std::collections::HashMap::new(), misbound: Some(snapshot), @@ -243,10 +257,10 @@ impl StaticIssuerKeySource { } } -#[cfg(test)] +#[cfg(any(test, feature = "test-utils"))] impl sealed::Sealed for StaticIssuerKeySource {} -#[cfg(test)] +#[cfg(any(test, feature = "test-utils"))] impl IssuerKeySource for StaticIssuerKeySource { fn key_set(&self, issuer: &str) -> Option { self.misbound @@ -255,6 +269,24 @@ impl IssuerKeySource for StaticIssuerKeySource { } } +/// Object-safe wrapper for assertion verification, allowing type-erased storage +/// in `AppState` and test injection of `StaticIssuerKeySource`-backed verifiers. +/// +/// `FederatedAssertionVerifier` implements this for any `S: IssuerKeySource`. +/// The sealed `IssuerKeySource` trait still constrains who can build a real +/// verifier — this trait only erases the `S` type parameter at the storage boundary. +pub trait VerifyAssertion: Send + Sync { + /// Verify one compact JWS assertion. Semantics identical to + /// [`FederatedAssertionVerifier::verify`]. + fn verify_assertion(&self, token: &str) -> Result; +} + +impl VerifyAssertion for FederatedAssertionVerifier { + fn verify_assertion(&self, token: &str) -> Result { + self.verify(token) + } +} + /// The provider-neutral assertion verifier over a closed multi-issuer registry /// and a trusted [`IssuerKeySource`]. #[derive(Debug, Clone)] diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 2f5d3e8aac..3dac0b1ba2 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -63,9 +63,10 @@ pub(crate) use runtime::{ }; pub use store::{ admin_moderation, allowlist, api_token, archived_identities, channel, channel_members, - community, deletion, dm, event, feed, git_repo, moderation, partition, product_feedback, push, - reaction, read_state, relay_admin_actions, relay_invite, relay_members, relay_operators, - reminder, replaceable, storage_accounting, thread, thread_window, usage, user, workflow, + community, deletion, dm, event, feed, git_repo, moderation, operator_listener, partition, + product_feedback, push, reaction, read_state, relay_admin_actions, relay_invite, relay_members, + relay_operators, reminder, replaceable, storage_accounting, thread, thread_window, usage, user, + workflow, }; pub use allowlist::AllowlistEntry; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 9bcc5654ee..66e3645915 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -490,6 +490,8 @@ mod postgres_tests { "relay_admin_outbox", "relay_operator_audit", "storage_accounting_snapshots", + "operator_listener_pubkeys", + "operator_listener_outbox", ] { if normalized[insert_pos..].contains(&format!("'{value}'")) { globals.insert(value.to_owned()); @@ -703,14 +705,14 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 50); + assert_eq!(migrations.len(), 51); assert_eq!(migrations[48].version, 49); - assert_eq!(migrations[49].version, 51); + assert_eq!(migrations[50].version, 51); assert!(migrations[48] .sql .as_str() .contains("idx_thread_metadata_window")); - assert!(migrations[49] + assert!(migrations[50] .sql .as_str() .contains("community_deletion_owner_provenance")); @@ -1298,10 +1300,63 @@ mod postgres_tests { .sql .as_str() .contains("CREATE TABLE storage_accounting_snapshots")); - // schema.sql exclusion list must match the restored (pre-0041) body. + assert_eq!(migrations[47].version, 48); + assert!(migrations[47] + .sql + .as_str() + .contains("ADD COLUMN hash_version SMALLINT")); + assert_eq!(migrations[49].version, 50); + assert!(migrations[49] + .sql + .as_str() + .contains("CREATE TABLE operator_listener_pubkeys")); + assert!(migrations[49] + .sql + .as_str() + .contains("CREATE TABLE operator_listener_outbox")); + assert!(migrations[49] + .sql + .as_str() + .contains("CREATE OR REPLACE FUNCTION community_write_fence_excluded_table")); + assert!(migrations[49] + .sql + .as_str() + .contains("'rate_limit_violations', 'operator_listener_outbox'")); + for index in [ + "operator_listener_pubkeys_target", + "operator_listener_pubkeys_created_at", + "operator_listener_outbox_due", + "operator_listener_outbox_recovery", + "operator_listener_outbox_created_at", + ] { + assert!( + migrations[49].sql.as_str().contains(index), + "migration 0050 must declare {index}" + ); + assert!( + desired_schema.contains(index), + "schema.sql must declare {index}" + ); + } + for index_shape in [ + "ON operator_listener_outbox (next_attempt_at, created_at, id)", + "ON operator_listener_outbox (lease_until, created_at, id)", + ] { + assert!( + migrations[49].sql.as_str().contains(index_shape), + "migration 0050 must declare {index_shape}" + ); + assert!( + desired_schema.contains(index_shape), + "schema.sql must declare {index_shape}" + ); + } + // schema.sql keeps the restored (pre-0041) deletion exclusions and also + // leaves the deployment-global listener outbox outside tenant fencing. assert!( - desired_schema.contains("'rate_limit_violations'\n ]::TEXT[])"), - "schema.sql exclusion list must match the pre-0041 body after ledger removal" + desired_schema + .contains("'rate_limit_violations', 'operator_listener_outbox'\n ]::TEXT[])"), + "schema.sql must exclude the deployment-global listener outbox from tenant fencing" ); } diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs index 7d70d0a643..21d0e73823 100644 --- a/crates/buzz-db/src/runtime/tests.rs +++ b/crates/buzz-db/src/runtime/tests.rs @@ -638,6 +638,26 @@ async fn migration_schema_database_guard_covers_legacy_writer_and_nip09_deletion assert_eq!(watermark.1, c.id.as_bytes().as_slice()); } +#[tokio::test] +#[ignore = "requires Postgres"] +async fn migration_schema_operator_listener_outbox_is_not_tenant_scoped() { + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let inventory = db + .deletion_store() + .inventory_schema(community) + .await + .expect("migrated deletion catalog should validate"); + + assert!( + !inventory + .scoped_tables + .iter() + .any(|table| table == "operator_listener_outbox"), + "operator-listener outbox is deployment-global, not tenant-scoped" + ); +} + // ---- Read-replica routing ------------------------------------------------ // // These tests pin the routing contract of `Db::read()` and the two routed diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index 411c518240..6259a02c5d 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -360,7 +360,14 @@ pub async fn insert_event( crate::observability::WriterOperation::EventWrite, ) .await?; - insert_event_on(&mut connection, community_id, event, channel_id).await + if crate::operator_listener::is_listener_mention_kind(u32::from(event.kind.as_u16())) { + let mut tx = Transaction::begin(connection, None).await?; + let result = insert_event_in_transaction(&mut tx, community_id, event, channel_id).await?; + tx.commit().await?; + Ok(result) + } else { + insert_event_on(&mut connection, community_id, event, channel_id).await + } } /// Insert a Nostr event in a caller-owned PostgreSQL transaction. @@ -374,7 +381,11 @@ pub async fn insert_event_in_transaction( event: &Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { - insert_event_on(tx.as_mut(), community_id, event, channel_id).await + let result = insert_event_on(tx.as_mut(), community_id, event, channel_id).await?; + if result.1 { + crate::operator_listener::enqueue_mentions_in_transaction(tx, community_id, event).await?; + } + Ok(result) } async fn insert_event_on( @@ -1528,6 +1539,8 @@ pub(crate) async fn insert_event_with_thread_metadata_tx( } } } + + crate::operator_listener::enqueue_mentions_in_transaction(tx, community_id, event).await?; } Ok(( diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 559460e15b..51b8717db1 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -26,6 +26,8 @@ pub mod feed; pub mod git_repo; /// Community moderation: reports, bans/timeouts, audit actions. pub mod moderation; +/// Deployment-global operator-listener mention registrations and delivery queues. +pub mod operator_listener; /// Monthly table partition management. pub mod partition; /// Buzz product-feedback sidecar persistence. diff --git a/crates/buzz-db/src/store/operator_listener.rs b/crates/buzz-db/src/store/operator_listener.rs new file mode 100644 index 0000000000..7b08a34572 --- /dev/null +++ b/crates/buzz-db/src/store/operator_listener.rs @@ -0,0 +1,1036 @@ +//! Deployment-global operator-listener mention registrations and delivery outbox. + +use std::collections::HashSet; + +use buzz_datastore_tracing::datastore_span; +use chrono::{DateTime, TimeDelta, Utc}; +use nostr::Event; +use sqlx::{PgPool, Postgres, QueryBuilder, Row as _, Transaction}; +use uuid::Uuid; + +use crate::error::Result; +use crate::{observability, CommunityId, Db}; + +/// Event kinds whose `p` tags represent user-visible message mentions. +pub const OPERATOR_LISTENER_MENTION_KINDS: [u32; 4] = [9, 40002, 45001, 45003]; +/// Maximum notification attempts before a delivery becomes terminally failed. +pub const MAX_DELIVERY_ATTEMPTS: i32 = 9; +/// Maximum time a queued delivery remains useful when no worker can route it. +pub const OUTBOX_RETENTION: TimeDelta = TimeDelta::minutes(15); +/// Registration lifetime before the daily cleanup removes it. +pub const PUBKEY_RETENTION: TimeDelta = TimeDelta::days(30); + +async fn acquire_maintenance_writer( + pool: &PgPool, +) -> sqlx::Result> { + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await +} + +/// A claimed operator-listener notification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaimedDelivery { + /// Durable delivery identifier and claim fence key. + pub id: Uuid, + /// Claim fencing token. + pub claim_id: Uuid, + /// Configured operator-listener identity. + pub listener_pubkey: Vec, + /// Registered target identity mentioned by the event. + pub target_pubkey: Vec, + /// Community containing the event. + pub community: CommunityId, + /// Community host used in the notification payload. + pub community_host: String, + /// Mentioning event id. + pub event_id: Vec, + /// Mentioning event kind. + pub event_kind: i32, + /// Author-controlled event timestamp. + pub event_created_at: DateTime, + /// Attempt number, starting at one. + pub attempt: i32, +} + +/// Return whether an event kind should generate operator-listener notifications. +#[must_use] +pub const fn is_listener_mention_kind(kind: u32) -> bool { + let mut index = 0; + while index < OPERATOR_LISTENER_MENTION_KINDS.len() { + if OPERATOR_LISTENER_MENTION_KINDS[index] == kind { + return true; + } + index += 1; + } + false +} + +fn event_targets(event: &Event) -> Vec> { + let mut targets = Vec::new(); + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(String::as_str) != Some("p") { + continue; + } + let Some(value) = parts.get(1) else { + continue; + }; + let Ok(target) = hex::decode(value) else { + continue; + }; + if target.len() == 32 && !targets.iter().any(|known| known == &target) { + targets.push(target); + } + } + targets +} + +/// Insert one outbox row for each registered listener matching the event's +/// `p` tags. The caller owns the transaction and must commit it with the event. +pub(crate) async fn enqueue_mentions_in_transaction( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + event: &Event, +) -> Result { + let kind = u32::from(event.kind.as_u16()); + if !is_listener_mention_kind(kind) { + return Ok(0); + } + let targets = event_targets(event); + if targets.is_empty() { + return Ok(0); + } + let event_created_at = DateTime::from_timestamp(event.created_at.as_secs() as i64, 0).ok_or( + crate::error::DbError::InvalidTimestamp(event.created_at.as_secs() as i64), + )?; + + let mut query = QueryBuilder::::new( + "INSERT INTO operator_listener_outbox \ + (listener_pubkey, target_pubkey, community_id, event_id, event_kind, event_created_at) \ + SELECT r.listener_pubkey, r.target_pubkey, ", + ); + query + .push_bind(community_id.as_uuid()) + .push(", ") + .push_bind(event.id.as_bytes().as_slice()) + .push(", ") + .push_bind(kind as i32) + .push(", ") + .push_bind(event_created_at) + .push(" FROM operator_listener_pubkeys r WHERE r.target_pubkey IN ("); + let mut separated = query.separated(", "); + for target in &targets { + separated.push_bind(target.as_slice()); + } + separated.push_unseparated(") ON CONFLICT DO NOTHING"); + + Ok(query.build().execute(&mut **tx).await?.rows_affected()) +} + +/// Register target pubkeys for one deployment-global listener. +pub async fn register_pubkeys( + pool: &PgPool, + listener_pubkey: &[u8], + target_pubkeys: &[Vec], +) -> Result { + if target_pubkeys.is_empty() { + return Ok(0); + } + let mut seen = HashSet::with_capacity(target_pubkeys.len()); + let unique_target_pubkeys: Vec<&[u8]> = target_pubkeys + .iter() + .filter_map(|target| seen.insert(target.as_slice()).then_some(target.as_slice())) + .collect(); + let mut connection = acquire_maintenance_writer(pool).await?; + let mut query = QueryBuilder::::new( + "INSERT INTO operator_listener_pubkeys (listener_pubkey, target_pubkey) ", + ); + query.push_values(unique_target_pubkeys, |mut bind, target| { + bind.push_bind(listener_pubkey).push_bind(target); + }); + query.push( + " ON CONFLICT (listener_pubkey, target_pubkey) DO UPDATE SET created_at = EXCLUDED.created_at", + ); + Ok(query + .build() + .execute(&mut *connection) + .await? + .rows_affected()) +} + +/// Remove target pubkeys for one deployment-global listener. +pub async fn remove_pubkeys( + pool: &PgPool, + listener_pubkey: &[u8], + target_pubkeys: &[Vec], +) -> Result { + if target_pubkeys.is_empty() { + return Ok(0); + } + let mut connection = acquire_maintenance_writer(pool).await?; + let mut query = QueryBuilder::::new( + "DELETE FROM operator_listener_pubkeys WHERE listener_pubkey = ", + ); + query + .push_bind(listener_pubkey) + .push(" AND target_pubkey IN ("); + let mut separated = query.separated(", "); + for target in target_pubkeys { + separated.push_bind(target.as_slice()); + } + separated.push_unseparated(")"); + Ok(query + .build() + .execute(&mut *connection) + .await? + .rows_affected()) +} + +/// Delete registrations older than the operator-listener retention period. +pub async fn delete_expired_pubkeys(pool: &PgPool) -> Result { + let mut connection = acquire_maintenance_writer(pool).await?; + let cutoff = Utc::now() - PUBKEY_RETENTION; + Ok( + sqlx::query("DELETE FROM operator_listener_pubkeys WHERE created_at < $1") + .bind(cutoff) + .execute(&mut *connection) + .await? + .rows_affected(), + ) +} + +/// Claim due notification deliveries and recover claims whose visibility timeout expired. +pub async fn claim_deliveries( + pool: &PgPool, + limit: i64, + lease_until: DateTime, +) -> Result> { + let claim_id = Uuid::new_v4(); + let cutoff = Utc::now() - OUTBOX_RETENTION; + let mut connection = acquire_maintenance_writer(pool).await?; + let rows = sqlx::query( + "WITH candidates AS ( \ + SELECT o.id, o.community_id \ + FROM operator_listener_outbox o \ + WHERE o.attempts < $2 \ + AND o.next_attempt_at <= now() \ + AND o.created_at >= $4 \ + AND (o.state = 'pending' OR (o.state = 'sending' AND o.lease_until < now())) \ + ORDER BY o.next_attempt_at, o.created_at, o.id \ + FOR UPDATE OF o SKIP LOCKED \ + LIMIT $3 \ + ) \ + UPDATE operator_listener_outbox o \ + SET state = 'sending', claim_id = $1, lease_until = $5, attempts = o.attempts + 1 \ + FROM candidates c \ + JOIN communities community ON community.id = c.community_id \ + WHERE o.id = c.id \ + RETURNING o.id, o.claim_id, o.listener_pubkey, o.target_pubkey, o.community_id, \ + community.host, o.event_id, o.event_kind, o.event_created_at, o.attempts", + ) + .bind(claim_id) + .bind(MAX_DELIVERY_ATTEMPTS) + .bind(limit) + .bind(cutoff) + .bind(lease_until) + .fetch_all(&mut *connection) + .await?; + + rows.into_iter() + .map(|row| { + Ok(ClaimedDelivery { + id: row.try_get("id")?, + claim_id: row.try_get("claim_id")?, + listener_pubkey: row.try_get("listener_pubkey")?, + target_pubkey: row.try_get("target_pubkey")?, + community: CommunityId::from_uuid(row.try_get("community_id")?), + community_host: row.try_get("host")?, + event_id: row.try_get("event_id")?, + event_kind: row.try_get("event_kind")?, + event_created_at: row.try_get("event_created_at")?, + attempt: row.try_get("attempts")?, + }) + }) + .collect() +} + +/// Release a delivery claim that this pod cannot route. +pub async fn release_unroutable_delivery( + pool: &PgPool, + id: Uuid, + claim_id: Uuid, + next: DateTime, +) -> Result { + let mut connection = acquire_maintenance_writer(pool).await?; + Ok(sqlx::query( + "UPDATE operator_listener_outbox \ + SET state = 'pending', claim_id = NULL, lease_until = NULL, \ + next_attempt_at = $3, attempts = GREATEST(attempts - 1, 0) \ + WHERE id = $1 AND claim_id = $2 AND state = 'sending'", + ) + .bind(id) + .bind(claim_id) + .bind(next) + .execute(&mut *connection) + .await? + .rows_affected() + == 1) +} + +/// Mark one fenced delivery successful. +pub async fn complete_delivery(pool: &PgPool, id: Uuid, claim_id: Uuid) -> Result { + delete_claimed_delivery(pool, id, claim_id).await +} + +/// Retry one fenced delivery after a transient failure. +pub async fn retry_delivery( + pool: &PgPool, + id: Uuid, + claim_id: Uuid, + next: DateTime, +) -> Result { + let mut connection = acquire_maintenance_writer(pool).await?; + Ok(sqlx::query( + "UPDATE operator_listener_outbox \ + SET state = 'pending', claim_id = NULL, lease_until = NULL, next_attempt_at = $3 \ + WHERE id = $1 AND claim_id = $2 AND state = 'sending' AND attempts < $4", + ) + .bind(id) + .bind(claim_id) + .bind(next) + .bind(MAX_DELIVERY_ATTEMPTS) + .execute(&mut *connection) + .await? + .rows_affected() + == 1) +} + +/// Delete one fenced delivery after all delivery attempts fail. +pub async fn fail_delivery(pool: &PgPool, id: Uuid, claim_id: Uuid) -> Result { + delete_claimed_delivery(pool, id, claim_id).await +} + +async fn delete_claimed_delivery(pool: &PgPool, id: Uuid, claim_id: Uuid) -> Result { + let mut connection = acquire_maintenance_writer(pool).await?; + Ok(sqlx::query( + "DELETE FROM operator_listener_outbox \ + WHERE id = $1 AND claim_id = $2 AND state = 'sending'", + ) + .bind(id) + .bind(claim_id) + .execute(&mut *connection) + .await? + .rows_affected() + == 1) +} + +/// Delete delivery rows that have exceeded the outbox retention window. +pub async fn reap_deliveries(pool: &PgPool) -> Result { + let mut connection = acquire_maintenance_writer(pool).await?; + let cutoff = Utc::now() - OUTBOX_RETENTION; + Ok( + sqlx::query("DELETE FROM operator_listener_outbox WHERE created_at < $1") + .bind(cutoff) + .execute(&mut *connection) + .await? + .rows_affected(), + ) +} + +impl Db { + /// Register target pubkeys for one configured operator listener. + #[datastore_span(name = "register_operator_listener_pubkeys", system = "postgresql")] + pub async fn register_operator_listener_pubkeys( + &self, + listener_pubkey: &[u8], + target_pubkeys: &[Vec], + ) -> Result { + register_pubkeys(&self.pool, listener_pubkey, target_pubkeys).await + } + + /// Remove target pubkeys for one configured operator listener. + #[datastore_span(name = "remove_operator_listener_pubkeys", system = "postgresql")] + pub async fn remove_operator_listener_pubkeys( + &self, + listener_pubkey: &[u8], + target_pubkeys: &[Vec], + ) -> Result { + remove_pubkeys(&self.pool, listener_pubkey, target_pubkeys).await + } + + /// Claim due operator-listener deliveries. + #[datastore_span(name = "claim_operator_listener_deliveries", system = "postgresql")] + pub async fn claim_operator_listener_deliveries( + &self, + limit: i64, + lease_until: DateTime, + ) -> Result> { + claim_deliveries(&self.pool, limit, lease_until).await + } + + /// Mark one operator-listener delivery successful. + #[datastore_span(name = "complete_operator_listener_delivery", system = "postgresql")] + pub async fn complete_operator_listener_delivery( + &self, + id: Uuid, + claim_id: Uuid, + ) -> Result { + complete_delivery(&self.pool, id, claim_id).await + } + + /// Retry one operator-listener delivery. + #[datastore_span(name = "retry_operator_listener_delivery", system = "postgresql")] + pub async fn retry_operator_listener_delivery( + &self, + id: Uuid, + claim_id: Uuid, + next: DateTime, + ) -> Result { + retry_delivery(&self.pool, id, claim_id, next).await + } + + /// Release a delivery claim that this pod cannot route. + #[datastore_span(name = "release_operator_listener_delivery", system = "postgresql")] + pub async fn release_operator_listener_delivery( + &self, + id: Uuid, + claim_id: Uuid, + next: DateTime, + ) -> Result { + release_unroutable_delivery(&self.pool, id, claim_id, next).await + } + + /// Delete one operator-listener delivery after terminal failure. + #[datastore_span(name = "fail_operator_listener_delivery", system = "postgresql")] + pub async fn fail_operator_listener_delivery(&self, id: Uuid, claim_id: Uuid) -> Result { + fail_delivery(&self.pool, id, claim_id).await + } + + /// Reap stale deliveries. + #[datastore_span(name = "reap_operator_listener_deliveries", system = "postgresql")] + pub async fn reap_operator_listener_deliveries(&self) -> Result { + reap_deliveries(&self.pool).await + } + + /// Delete operator-listener registrations older than thirty days. + #[datastore_span( + name = "delete_expired_operator_listener_pubkeys", + system = "postgresql" + )] + pub async fn delete_expired_operator_listener_pubkeys(&self) -> Result { + delete_expired_pubkeys(&self.pool).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_message_kinds_are_listener_mentions() { + for kind in OPERATOR_LISTENER_MENTION_KINDS { + assert!(is_listener_mention_kind(kind)); + } + assert!(!is_listener_mention_kind(1)); + assert!(!is_listener_mention_kind(40003)); + } +} + +#[cfg(test)] +mod postgres_tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use sqlx::PgPool; + + async fn setup_pool() -> PgPool { + PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect to test DB") + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn event_insert_enqueues_registered_listener_in_same_transaction() { + let pool = setup_pool().await; + let community_id = Uuid::new_v4(); + let listener = Keys::generate(); + let target = Keys::generate(); + let target_pubkey = target.public_key().to_bytes().to_vec(); + + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "operator-listener-test-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert test community"); + register_pubkeys( + &pool, + listener.public_key().as_bytes(), + std::slice::from_ref(&target_pubkey), + ) + .await + .expect("register listener target"); + + let event = EventBuilder::new(Kind::Custom(9), "mention") + .tag(Tag::public_key(target.public_key())) + .sign_with_keys(&Keys::generate()) + .expect("sign test event"); + let (_, inserted) = + crate::event::insert_event(&pool, CommunityId::from_uuid(community_id), &event, None) + .await + .expect("insert event"); + assert!(inserted); + + let outbox_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM operator_listener_outbox WHERE community_id = $1 AND event_id = $2", + ) + .bind(community_id) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count listener deliveries"); + assert_eq!(outbox_count, 1); + + sqlx::query("DELETE FROM operator_listener_outbox WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test outbox rows"); + sqlx::query("DELETE FROM operator_listener_pubkeys WHERE listener_pubkey = $1") + .bind(listener.public_key().as_bytes().as_slice()) + .execute(&pool) + .await + .expect("delete test registration"); + sqlx::query("DELETE FROM event_mentions WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test mentions"); + sqlx::query("DELETE FROM events WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test event"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn thread_metadata_insert_fans_out_once_to_matching_listeners() { + let pool = setup_pool().await; + let community_id = Uuid::new_v4(); + let first_listener = Keys::generate(); + let second_listener = Keys::generate(); + let unrelated_listener = Keys::generate(); + let target = Keys::generate(); + let unrelated_target = Keys::generate(); + let target_pubkey = target.public_key().to_bytes().to_vec(); + let unrelated_pubkey = unrelated_target.public_key().to_bytes().to_vec(); + + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "operator-listener-thread-test-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert test community"); + for listener in [&first_listener, &second_listener] { + register_pubkeys( + &pool, + listener.public_key().as_bytes(), + std::slice::from_ref(&target_pubkey), + ) + .await + .expect("register matching listener target"); + } + register_pubkeys( + &pool, + unrelated_listener.public_key().as_bytes(), + std::slice::from_ref(&unrelated_pubkey), + ) + .await + .expect("register unrelated listener target"); + + let event = EventBuilder::new(Kind::Custom(9), "thread mention") + .tags([ + Tag::public_key(target.public_key()), + Tag::public_key(target.public_key()), + ]) + .sign_with_keys(&Keys::generate()) + .expect("sign test event"); + let community = CommunityId::from_uuid(community_id); + let (_, inserted) = + crate::event::insert_event_with_thread_metadata(&pool, community, &event, None, None) + .await + .expect("insert event through thread-metadata path"); + assert!(inserted); + + let delivered_listeners: Vec> = sqlx::query_scalar( + "SELECT listener_pubkey FROM operator_listener_outbox WHERE community_id = $1 AND event_id = $2 ORDER BY listener_pubkey", + ) + .bind(community_id) + .bind(event.id.as_bytes().as_slice()) + .fetch_all(&pool) + .await + .expect("read listener fanout"); + let mut expected_listeners = vec![ + first_listener.public_key().to_bytes().to_vec(), + second_listener.public_key().to_bytes().to_vec(), + ]; + expected_listeners.sort(); + assert_eq!(delivered_listeners, expected_listeners); + + let (_, duplicate_inserted) = + crate::event::insert_event_with_thread_metadata(&pool, community, &event, None, None) + .await + .expect("retry duplicate event through thread-metadata path"); + assert!(!duplicate_inserted); + let outbox_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM operator_listener_outbox WHERE community_id = $1 AND event_id = $2", + ) + .bind(community_id) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count listener deliveries after duplicate event"); + assert_eq!(outbox_count, 2); + + sqlx::query("DELETE FROM operator_listener_outbox WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test outbox rows"); + sqlx::query("DELETE FROM operator_listener_pubkeys WHERE listener_pubkey IN ($1, $2, $3)") + .bind(first_listener.public_key().as_bytes().as_slice()) + .bind(second_listener.public_key().as_bytes().as_slice()) + .bind(unrelated_listener.public_key().as_bytes().as_slice()) + .execute(&pool) + .await + .expect("delete test registrations"); + sqlx::query("DELETE FROM events WHERE community_id = $1 AND id = $2") + .bind(community_id) + .bind(event.id.as_bytes().as_slice()) + .execute(&pool) + .await + .expect("delete test event"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn event_and_listener_outbox_roll_back_together() { + let pool = setup_pool().await; + let community_id = Uuid::new_v4(); + let listener = Keys::generate(); + let target = Keys::generate(); + let target_pubkey = target.public_key().to_bytes().to_vec(); + + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "operator-listener-rollback-test-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert test community"); + register_pubkeys( + &pool, + listener.public_key().as_bytes(), + std::slice::from_ref(&target_pubkey), + ) + .await + .expect("register listener target"); + let event = EventBuilder::new(Kind::Custom(9), "mention") + .tag(Tag::public_key(target.public_key())) + .sign_with_keys(&Keys::generate()) + .expect("sign test event"); + + let mut tx = pool.begin().await.expect("begin event transaction"); + let (_, inserted) = crate::event::insert_event_in_transaction( + &mut tx, + CommunityId::from_uuid(community_id), + &event, + None, + ) + .await + .expect("insert event and enqueue mention"); + assert!(inserted); + tx.rollback().await.expect("roll back event transaction"); + + let event_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE community_id = $1 AND id = $2") + .bind(community_id) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count rolled-back event"); + let outbox_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM operator_listener_outbox WHERE community_id = $1 AND event_id = $2", + ) + .bind(community_id) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count rolled-back delivery"); + assert_eq!(event_count, 0); + assert_eq!(outbox_count, 0); + + sqlx::query("DELETE FROM operator_listener_pubkeys WHERE listener_pubkey = $1") + .bind(listener.public_key().as_bytes().as_slice()) + .execute(&pool) + .await + .expect("delete test registration"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn repeated_registration_renews_created_at() { + let pool = setup_pool().await; + let listener = Keys::generate(); + let target = Keys::generate(); + let listener_pubkey = listener.public_key().as_bytes().to_vec(); + let target_pubkey = target.public_key().as_bytes().to_vec(); + + register_pubkeys( + &pool, + &listener_pubkey, + std::slice::from_ref(&target_pubkey), + ) + .await + .expect("register listener target"); + + let expired_at = Utc::now() - PUBKEY_RETENTION - TimeDelta::hours(1); + sqlx::query( + "UPDATE operator_listener_pubkeys SET created_at = $3 \ + WHERE listener_pubkey = $1 AND target_pubkey = $2", + ) + .bind(&listener_pubkey) + .bind(&target_pubkey) + .bind(expired_at) + .execute(&pool) + .await + .expect("age test registration"); + + register_pubkeys( + &pool, + &listener_pubkey, + std::slice::from_ref(&target_pubkey), + ) + .await + .expect("renew listener target"); + + let renewed_at: DateTime = sqlx::query_scalar( + "SELECT created_at FROM operator_listener_pubkeys \ + WHERE listener_pubkey = $1 AND target_pubkey = $2", + ) + .bind(&listener_pubkey) + .bind(&target_pubkey) + .fetch_one(&pool) + .await + .expect("read renewed registration"); + assert!(renewed_at > expired_at); + + sqlx::query( + "DELETE FROM operator_listener_pubkeys \ + WHERE listener_pubkey = $1 AND target_pubkey = $2", + ) + .bind(&listener_pubkey) + .bind(&target_pubkey) + .execute(&pool) + .await + .expect("delete test registration"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn claim_deliveries_skips_rows_past_retention() { + let pool = setup_pool().await; + let community_id = Uuid::new_v4(); + let listener = Keys::generate(); + let target = Keys::generate(); + let stale_id = Uuid::new_v4(); + let fresh_id = Uuid::new_v4(); + let stale_at = Utc::now() - OUTBOX_RETENTION - TimeDelta::seconds(1); + let event_created_at = Utc::now(); + + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "operator-listener-claim-test-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert test community"); + for (id, event_marker, created_at) in [ + (stale_id, 1_u8, stale_at), + (fresh_id, 2_u8, event_created_at), + ] { + sqlx::query( + "INSERT INTO operator_listener_outbox \ + (id, listener_pubkey, target_pubkey, community_id, event_id, event_kind, \ + event_created_at, created_at) \ + VALUES ($1, $2, $3, $4, $5, 9, $6, $7)", + ) + .bind(id) + .bind(listener.public_key().as_bytes().as_slice()) + .bind(target.public_key().as_bytes().as_slice()) + .bind(community_id) + .bind(vec![event_marker; 32]) + .bind(event_created_at) + .bind(created_at) + .execute(&pool) + .await + .expect("insert test delivery"); + } + + let claimed = claim_deliveries(&pool, 1, Utc::now() + TimeDelta::seconds(30)) + .await + .expect("claim deliveries"); + assert_eq!(claimed.len(), 1); + assert_eq!(claimed[0].id, fresh_id); + + sqlx::query("DELETE FROM operator_listener_outbox WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test deliveries"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn expired_claim_is_recovered_and_old_claim_token_is_fenced() { + let pool = setup_pool().await; + let community_id = Uuid::new_v4(); + let listener = Keys::generate(); + let target = Keys::generate(); + let delivery_id = Uuid::new_v4(); + + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "operator-listener-lease-test-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert test community"); + sqlx::query( + "INSERT INTO operator_listener_outbox \ + (id, listener_pubkey, target_pubkey, community_id, event_id, event_kind, event_created_at) \ + VALUES ($1, $2, $3, $4, $5, 9, $6)", + ) + .bind(delivery_id) + .bind(listener.public_key().as_bytes().as_slice()) + .bind(target.public_key().as_bytes().as_slice()) + .bind(community_id) + .bind(vec![0x33_u8; 32]) + .bind(Utc::now()) + .execute(&pool) + .await + .expect("insert test delivery"); + + let first = claim_deliveries(&pool, 1, Utc::now() + TimeDelta::seconds(30)) + .await + .expect("claim delivery"); + assert_eq!(first.len(), 1); + assert_eq!(first[0].id, delivery_id); + assert!( + claim_deliveries(&pool, 1, Utc::now() + TimeDelta::seconds(30)) + .await + .expect("don't claim active lease") + .is_empty() + ); + + sqlx::query("UPDATE operator_listener_outbox SET lease_until = $2 WHERE id = $1") + .bind(delivery_id) + .bind(Utc::now() - TimeDelta::seconds(1)) + .execute(&pool) + .await + .expect("expire first lease"); + let recovered = claim_deliveries(&pool, 1, Utc::now() + TimeDelta::seconds(30)) + .await + .expect("recover expired claim"); + assert_eq!(recovered.len(), 1); + assert_eq!(recovered[0].id, delivery_id); + assert_ne!(recovered[0].claim_id, first[0].claim_id); + assert_eq!(recovered[0].attempt, first[0].attempt + 1); + assert!(!complete_delivery(&pool, delivery_id, first[0].claim_id) + .await + .expect("old claim cannot complete delivery")); + assert!(!retry_delivery( + &pool, + delivery_id, + first[0].claim_id, + Utc::now() + TimeDelta::seconds(10), + ) + .await + .expect("old claim cannot retry delivery")); + assert!(!release_unroutable_delivery( + &pool, + delivery_id, + first[0].claim_id, + Utc::now() + TimeDelta::seconds(10), + ) + .await + .expect("old claim cannot release delivery")); + assert!(!fail_delivery(&pool, delivery_id, first[0].claim_id) + .await + .expect("old claim cannot delete delivery")); + assert!(complete_delivery(&pool, delivery_id, recovered[0].claim_id) + .await + .expect("current claim completes delivery")); + + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reapers_delete_expired_rows_and_preserve_recent_rows() { + let pool = setup_pool().await; + let listener = Keys::generate(); + let old_target = Keys::generate(); + let recent_target = Keys::generate(); + let listener_pubkey = listener.public_key().as_bytes().to_vec(); + let old_target_pubkey = old_target.public_key().as_bytes().to_vec(); + let recent_target_pubkey = recent_target.public_key().as_bytes().to_vec(); + + register_pubkeys( + &pool, + &listener_pubkey, + &[old_target_pubkey.clone(), recent_target_pubkey.clone()], + ) + .await + .expect("register test targets"); + let expired_at = Utc::now() - PUBKEY_RETENTION - TimeDelta::seconds(1); + sqlx::query( + "UPDATE operator_listener_pubkeys SET created_at = $3 \ + WHERE listener_pubkey = $1 AND target_pubkey = $2", + ) + .bind(&listener_pubkey) + .bind(&old_target_pubkey) + .bind(expired_at) + .execute(&pool) + .await + .expect("age test registration"); + + let community_id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "operator-listener-reaper-test-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert test community"); + let old_delivery = Uuid::new_v4(); + let recent_delivery = Uuid::new_v4(); + for (id, marker, created_at) in [ + ( + old_delivery, + 0x44_u8, + Utc::now() - OUTBOX_RETENTION - TimeDelta::seconds(1), + ), + (recent_delivery, 0x55_u8, Utc::now()), + ] { + sqlx::query( + "INSERT INTO operator_listener_outbox \ + (id, listener_pubkey, target_pubkey, community_id, event_id, event_kind, \ + event_created_at, created_at) \ + VALUES ($1, $2, $3, $4, $5, 9, $6, $7)", + ) + .bind(id) + .bind(&listener_pubkey) + .bind(&recent_target_pubkey) + .bind(community_id) + .bind(vec![marker; 32]) + .bind(Utc::now()) + .bind(created_at) + .execute(&pool) + .await + .expect("insert test delivery"); + } + + assert!( + delete_expired_pubkeys(&pool) + .await + .expect("reap expired registrations") + >= 1 + ); + assert!( + reap_deliveries(&pool) + .await + .expect("reap expired deliveries") + >= 1 + ); + + let registration_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM operator_listener_pubkeys \ + WHERE listener_pubkey = $1 AND target_pubkey = $2", + ) + .bind(&listener_pubkey) + .bind(&recent_target_pubkey) + .fetch_one(&pool) + .await + .expect("count recent registration"); + let old_delivery_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM operator_listener_outbox WHERE id = $1") + .bind(old_delivery) + .fetch_one(&pool) + .await + .expect("count expired delivery"); + let recent_delivery_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM operator_listener_outbox WHERE id = $1") + .bind(recent_delivery) + .fetch_one(&pool) + .await + .expect("count recent delivery"); + assert_eq!(registration_count, 1); + assert_eq!(old_delivery_count, 0); + assert_eq!(recent_delivery_count, 1); + + sqlx::query("DELETE FROM operator_listener_outbox WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test deliveries"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test community"); + sqlx::query("DELETE FROM operator_listener_pubkeys WHERE listener_pubkey = $1") + .bind(&listener_pubkey) + .execute(&pool) + .await + .expect("delete test registrations"); + } +} diff --git a/crates/buzz-push-gateway/src/config.rs b/crates/buzz-push-gateway/src/config.rs index 74012c0b0b..0f3014affc 100644 --- a/crates/buzz-push-gateway/src/config.rs +++ b/crates/buzz-push-gateway/src/config.rs @@ -38,8 +38,6 @@ pub struct KeyConfig { pub struct Config { pub bind_addr: SocketAddr, pub health_addr: SocketAddr, - /// External gateway origin, delivery URL, and registered transcript audiences. - pub gateway_urls: GatewayUrls, pub max_grant_lifetime_seconds: i64, pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, @@ -55,53 +53,18 @@ pub struct Config { pub token_keys: Vec, } -/// Gateway transport URLs and registered NIP-PL v1 transcript audiences. -#[derive(Debug, Clone)] -pub struct GatewayUrls { - /// External HTTPS origin serving the gateway. - pub origin: url::Url, - /// Exact NIP-98 delivery endpoint used by relays. - pub delivery: url::Url, - /// App Attest audience for installation enrollment. - pub enroll_audience: String, - /// App Attest audience for relay delegation. - pub delegate_audience: String, - /// App Attest audience for endpoint rotation. - pub rotate_endpoint_audience: String, - /// App Attest audience for delegation revocation. - pub revoke_delegation_audience: String, - /// App Attest audience for installation revocation. - pub revoke_installation_audience: String, -} +// Registered NIP-PL v1 App Attest audiences are protocol constants, not routing URLs. +/// Fixed NIP-PL v1 App Attest audience for installation enrollment. +pub const ENROLL_AUDIENCE: &str = "https://push.buzz.xyz/v1/installations"; +/// Fixed NIP-PL v1 App Attest audience for creating a delegation. +pub const DELEGATE_AUDIENCE: &str = "https://push.buzz.xyz/v1/delegations"; +/// Fixed NIP-PL v1 App Attest audience for rotating an installation endpoint. +pub const ROTATE_ENDPOINT_AUDIENCE: &str = "https://push.buzz.xyz/v1/installations/endpoint"; +/// Fixed NIP-PL v1 App Attest audience for revoking a delegation. +pub const REVOKE_DELEGATION_AUDIENCE: &str = "https://push.buzz.xyz/v1/delegations/revoke"; +/// Fixed NIP-PL v1 App Attest audience for revoking an installation. +pub const REVOKE_INSTALLATION_AUDIENCE: &str = "https://push.buzz.xyz/v1/installations/revoke"; -impl GatewayUrls { - pub(crate) fn from_origin(origin: url::Url) -> Result { - let derive = |path: &str| { - origin - .join(path) - .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_GATEWAY_ORIGIN")) - }; - let delivery = derive("v1/deliveries/apns")?; - // NIP-PL v1 registers these exact audience strings. The configurable - // origin controls transport only; changing transcript bytes requires - // a separately versioned protocol profile. - let enroll_audience = "https://push.buzz.xyz/v1/installations".to_owned(); - let delegate_audience = "https://push.buzz.xyz/v1/delegations".to_owned(); - let rotate_endpoint_audience = "https://push.buzz.xyz/v1/installations/endpoint".to_owned(); - let revoke_delegation_audience = "https://push.buzz.xyz/v1/delegations/revoke".to_owned(); - let revoke_installation_audience = - "https://push.buzz.xyz/v1/installations/revoke".to_owned(); - Ok(Self { - origin, - delivery, - enroll_audience, - delegate_audience, - rotate_endpoint_audience, - revoke_delegation_audience, - revoke_installation_audience, - }) - } -} #[derive(Debug, Error)] pub enum ConfigError { #[error("missing required environment variable {0}")] @@ -203,21 +166,6 @@ impl Config { }) { return Err(ConfigError::Invalid("BUZZ_PUSH_TOKEN_KEYS")); } - let gateway_origin = req(e, "BUZZ_PUSH_GATEWAY_ORIGIN")? - .parse::() - .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_GATEWAY_ORIGIN"))?; - if gateway_origin.scheme() != "https" - || gateway_origin.host().is_none() - || gateway_origin.port().is_some() - || gateway_origin.path() != "/" - || gateway_origin.query().is_some() - || gateway_origin.fragment().is_some() - || !gateway_origin.username().is_empty() - || gateway_origin.password().is_some() - { - return Err(ConfigError::Invalid("BUZZ_PUSH_GATEWAY_ORIGIN")); - } - let gateway_urls = GatewayUrls::from_origin(gateway_origin)?; let max_grant_lifetime_seconds = req(e, "BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS")? .parse::() .ok() @@ -263,7 +211,6 @@ impl Config { Ok(Self { bind_addr, health_addr, - gateway_urls, max_grant_lifetime_seconds, max_installation_lifetime_seconds, endpoint_quota_window_seconds, @@ -339,10 +286,6 @@ mod tests { STANDARD.encode([4; 32]) ), ), - ( - "BUZZ_PUSH_GATEWAY_ORIGIN".into(), - "https://push.example".into(), - ), ( "BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS".into(), "2592000".into(), @@ -399,31 +342,21 @@ mod tests { } #[test] - fn gateway_transport_uses_configured_origin_and_transcript_audiences_stay_registered() { - let config = Config::from_map(&base()).unwrap(); - assert_eq!(config.gateway_urls.origin.as_str(), "https://push.example/"); + fn startup_needs_no_origin_and_transcript_audiences_stay_registered() { + assert!(!base().contains_key("BUZZ_PUSH_GATEWAY_ORIGIN")); + Config::from_map(&base()).unwrap(); + assert_eq!(ENROLL_AUDIENCE, "https://push.buzz.xyz/v1/installations"); + assert_eq!(DELEGATE_AUDIENCE, "https://push.buzz.xyz/v1/delegations"); assert_eq!( - config.gateway_urls.delivery.as_str(), - "https://push.example/v1/deliveries/apns" - ); - assert_eq!( - config.gateway_urls.enroll_audience, - "https://push.buzz.xyz/v1/installations" - ); - assert_eq!( - config.gateway_urls.delegate_audience, - "https://push.buzz.xyz/v1/delegations" - ); - assert_eq!( - config.gateway_urls.rotate_endpoint_audience, + ROTATE_ENDPOINT_AUDIENCE, "https://push.buzz.xyz/v1/installations/endpoint" ); assert_eq!( - config.gateway_urls.revoke_delegation_audience, + REVOKE_DELEGATION_AUDIENCE, "https://push.buzz.xyz/v1/delegations/revoke" ); assert_eq!( - config.gateway_urls.revoke_installation_audience, + REVOKE_INSTALLATION_AUDIENCE, "https://push.buzz.xyz/v1/installations/revoke" ); } @@ -441,10 +374,6 @@ mod tests { #[test] fn malformed_security_configuration_fails_startup() { for (key, value) in [ - ("BUZZ_PUSH_GATEWAY_ORIGIN", "http://push.example"), - ("BUZZ_PUSH_GATEWAY_ORIGIN", "https://push.example/path"), - ("BUZZ_PUSH_GATEWAY_ORIGIN", "https://push.example?token=x"), - ("BUZZ_PUSH_GATEWAY_ORIGIN", "https://user@push.example"), ("BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", ""), ("BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT", "staging"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "0"), diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index 657c549b1e..1becbddedb 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -5,14 +5,17 @@ use crate::{ authority::{ AuthorityError, AuthorityStore, Challenge, Delegation, DeliveryDisposition, NewInstallation, }, - config::GatewayUrls, + config::{ + DELEGATE_AUDIENCE, ENROLL_AUDIENCE, REVOKE_DELEGATION_AUDIENCE, + REVOKE_INSTALLATION_AUDIENCE, ROTATE_ENDPOINT_AUDIENCE, + }, grant::GrantKeyring, model::*, token::TokenKeyring, }; use axum::{ body::Bytes, - extract::State, + extract::{OriginalUri, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, @@ -21,7 +24,7 @@ use axum::{ use base64::{engine::general_purpose::STANDARD, Engine as _}; use nostr::{ nips::nip98::{verify_auth_header, HttpMethod}, - Event, JsonUtil, Timestamp, + Event, JsonUtil, TagKind, TagStandard, Timestamp, }; use std::{ sync::{ @@ -47,8 +50,6 @@ pub struct AppState { /// Server-owned dogfood application identity and APNs transport. The wire /// profile selector is fixed and App Attest verifies the configured app ID. pub profile: Arc, - /// Security-sensitive endpoints and audiences derived from one gateway origin. - pub gateway_urls: Arc, pub max_grant_lifetime_seconds: i64, pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, @@ -71,14 +72,46 @@ fn valid_relay_pubkey(v: &str) -> bool { && v.bytes() .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) } -fn auth_event_id(header: &str) -> Option { +pub(crate) const DELIVERY_PATH: &str = "/v1/deliveries/apns"; + +// Keep preliminary decoding bounded to the pinned nostr verifier's limits. +fn delivery_auth(header: &str, body: &[u8]) -> Option<(String, String)> { let (prefix, encoded) = header.split_once(' ')?; - if prefix != "Nostr" { + const MAX_EVENT_BYTES: usize = 64 * 1024; + const MAX_ENCODED_BYTES: usize = MAX_EVENT_BYTES.div_ceil(3) * 4; + if prefix != "Nostr" || encoded.len() > MAX_ENCODED_BYTES { return None; } - Event::from_json(STANDARD.decode(encoded).ok()?) - .ok() - .map(|e| e.id.to_hex()) + let decoded = STANDARD.decode(encoded).ok()?; + if decoded.len() > MAX_EVENT_BYTES { + return None; + } + let event = Event::from_json(decoded).ok()?; + let signed_url = match event.tags.find_standardized(TagKind::u())? { + TagStandard::AbsoluteURL(url) => url, + _ => return None, + }; + if !matches!(signed_url.scheme(), "http" | "https") + || signed_url.path() != DELIVERY_PATH + || signed_url.query().is_some() + || signed_url.fragment().is_some() + || !signed_url.username().is_empty() + || signed_url.password().is_some() + { + return None; + } + // This delivery profile binds method and path, not request origin. Passing + // the validated signed URL retains the library's kind, method, timestamp, + // body hash, event ID and signature checks without reconstructing a URL. + let relay = verify_auth_header( + header, + signed_url, + HttpMethod::POST, + Timestamp::now(), + Some(body), + ) + .ok()?; + Some((event.id.to_hex(), relay.to_hex())) } fn decode_challenge(value: &str) -> Option<[u8; 32]> { @@ -192,7 +225,7 @@ async fn enroll(State(s): State, body: Bytes) -> Response { }; let t = EnrollTranscript { v: r.v, - audience: &s.gateway_urls.enroll_audience, + audience: ENROLL_AUDIENCE, challenge_id: r.challenge_id, challenge: &r.challenge, key_id: &r.key_id, @@ -366,7 +399,7 @@ async fn delegate(State(s): State, body: Bytes) -> Response { } let t = DelegateTranscript { v: r.v, - audience: &s.gateway_urls.delegate_audience, + audience: DELEGATE_AUDIENCE, challenge_id: r.challenge_id, challenge: &r.challenge, installation_handle: r.installation_handle, @@ -463,7 +496,7 @@ async fn rotate_endpoint(State(s): State, body: Bytes) -> Response { }; let t = RotateTranscript { v: r.v, - audience: &s.gateway_urls.rotate_endpoint_audience, + audience: ROTATE_ENDPOINT_AUDIENCE, challenge_id: r.challenge_id, challenge: &r.challenge, installation_handle: r.installation_handle, @@ -526,7 +559,7 @@ async fn revoke_delegation(State(s): State, body: Bytes) -> Response { } let t = RevokeDelegationTranscript { v: r.v, - audience: &s.gateway_urls.revoke_delegation_audience, + audience: REVOKE_DELEGATION_AUDIENCE, challenge_id: r.challenge_id, challenge: &r.challenge, installation_handle: r.installation_handle, @@ -581,7 +614,7 @@ async fn revoke_installation(State(s): State, body: Bytes) -> Response } let t = RevokeInstallationTranscript { v: r.v, - audience: &s.gateway_urls.revoke_installation_audience, + audience: REVOKE_INSTALLATION_AUDIENCE, challenge_id: r.challenge_id, challenge: &r.challenge, installation_handle: r.installation_handle, @@ -618,7 +651,12 @@ async fn revoke_installation(State(s): State, body: Bytes) -> Response } } -async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> Response { +async fn deliver( + State(s): State, + OriginalUri(uri): OriginalUri, + headers: HeaderMap, + body: Bytes, +) -> Response { let r: DeliveryRequest = match crate::strict_json::from_slice(&body) { Ok(x) => x, Err(_) => return error(StatusCode::BAD_REQUEST, "invalid_request"), @@ -633,20 +671,13 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> Some(x) => x, None => return error(StatusCode::UNAUTHORIZED, "invalid_auth"), }; - let event_id = match auth_event_id(auth) { - Some(x) => x, + if uri.query().is_some() { + return error(StatusCode::UNAUTHORIZED, "invalid_auth"); + } + let (event_id, relay) = match delivery_auth(auth, &body) { + Some(auth) => auth, None => return error(StatusCode::UNAUTHORIZED, "invalid_auth"), }; - let relay = match verify_auth_header( - auth, - &s.gateway_urls.delivery, - HttpMethod::POST, - Timestamp::now(), - Some(&body), - ) { - Ok(x) => x.to_hex(), - Err(_) => return error(StatusCode::UNAUTHORIZED, "invalid_auth"), - }; let grant = match s.grant_keyring.open(&r.endpoint_grant) { Ok(x) => x, Err(_) => return error(StatusCode::NOT_FOUND, "invalid_grant"), @@ -828,7 +859,7 @@ pub fn router_with_metrics( .route("/v1/delegations/revoke", post(revoke_delegation)) .route("/v1/installations/endpoint", post(rotate_endpoint)) .route("/v1/installations/revoke", post(revoke_installation)) - .route("/v1/deliveries/apns", post(deliver)) + .route(DELIVERY_PATH, post(deliver)) .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BYTES)); let public = Router::new() .merge(enrollment) @@ -906,9 +937,6 @@ mod request_limit_tests { app_attest: Arc::new(app_attest), transport: Arc::new(NeverTransport), }), - gateway_urls: Arc::new( - GatewayUrls::from_origin("https://push.example".parse().unwrap()).unwrap(), - ), max_grant_lifetime_seconds: 86_400, max_installation_lifetime_seconds: 86_400, endpoint_quota_window_seconds: 60, diff --git a/crates/buzz-push-gateway/src/http_delivery_tests.rs b/crates/buzz-push-gateway/src/http_delivery_tests.rs new file mode 100644 index 0000000000..e5dd48f816 --- /dev/null +++ b/crates/buzz-push-gateway/src/http_delivery_tests.rs @@ -0,0 +1,370 @@ +//! Delivery authentication through the public router, with an in-memory authority +//! store and APNs transport. No external service or credentials are used. +use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, +}; + +use crate::{ + apns::{DeliveryAttempt, DeliveryOutcome, PushTransport}, + app_attest::AppAttestVerifier, + authority::{AuthorityStore, Delegation, MemoryAuthorityStore, NewInstallation}, + grant::{GrantKey, GrantKeyring}, + http::ProfileRuntime, + model::{AppProfile, EndpointGrant}, + router, + token::{TokenKey, TokenKeyring}, + AppState, +}; +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use sha2::{Digest, Sha256}; +use tower::ServiceExt; +use uuid::Uuid; + +const DIRECT_URL: &str = "http://push.example:8080/v1/deliveries/apns"; +const FORWARDED_URL: &str = "https://push.example/v1/deliveries/apns"; + +struct TestTransport(Arc); +#[async_trait::async_trait] +impl PushTransport for TestTransport { + async fn send(&self, _: DeliveryAttempt, endpoint: &str) -> DeliveryOutcome { + assert_eq!(endpoint, "01020304"); + self.0.fetch_add(1, Ordering::SeqCst); + DeliveryOutcome::Accepted + } +} + +fn now() -> i64 { + 1_750_000_000 +} + +async fn fixture() -> (axum::Router, Keys, Vec, Arc) { + let keys = Keys::generate(); + let authority = Arc::new(MemoryAuthorityStore::default()); + let token_keyring = + Arc::new(TokenKeyring::new(vec![TokenKey::new("test", &[2; 32]).unwrap()]).unwrap()); + let grant_keyring = + Arc::new(GrantKeyring::new(vec![GrantKey::new("test", &[1; 32]).unwrap()]).unwrap()); + let installation_id = Uuid::new_v4(); + authority + .create_installation( + NewInstallation { + id: installation_id, + app_attest_key_id: vec![3; 32], + app_attest_public_key: vec![4; 65], + assertion_counter: 0, + profile: AppProfile::BuzzIosDogfood, + token_ciphertext: token_keyring.seal(&[1, 2, 3, 4]).unwrap(), + token_fingerprint: [5; 32], + endpoint_epoch: 1, + expires_at: now() + 600, + }, + now(), + ) + .await + .unwrap(); + let delegation_id = Uuid::new_v4(); + authority + .upsert_delegation(Delegation { + id: delegation_id, + installation_id, + relay_pubkey: keys.public_key().to_hex(), + endpoint_epoch: 1, + generation: 1, + not_before: now(), + expires_at: now() + 600, + revoked: false, + }) + .await + .unwrap(); + let grant = grant_keyring + .issue(&EndpointGrant { + v: 1, + delegation_id, + relay_pubkey: keys.public_key().to_hex(), + app_profile: AppProfile::BuzzIosDogfood, + endpoint_epoch: 1, + generation: 1, + expires_at: now() + 600, + }) + .unwrap(); + let sends = Arc::new(AtomicUsize::new(0)); + let (app, _) = router(AppState { + grant_keyring, + authority, + token_keyring, + profile: Arc::new(ProfileRuntime { + app_attest: Arc::new( + AppAttestVerifier::new( + "TEAMID.xyz.block.buzz.dogfood.mobile".into(), + include_bytes!("../tests/fixtures/apple-app-attestation-root.pem").to_vec(), + ) + .unwrap(), + ), + transport: Arc::new(TestTransport(sends.clone())), + }), + max_grant_lifetime_seconds: 600, + max_installation_lifetime_seconds: 600, + endpoint_quota_window_seconds: 60, + endpoint_quota_max_deliveries: 10, + now, + accepting: Arc::new(AtomicBool::new(true)), + }); + let body = serde_json::to_vec(&serde_json::json!({ + "v": 1, "endpoint_grant": grant, "request_id": Uuid::new_v4(), "expires_at": now() + 60, + })) + .unwrap(); + (app, keys, body, sends) +} + +fn signed_header(keys: &Keys, url: &str, method: &str, body: &[u8]) -> String { + let hash = hex::encode(Sha256::digest(body)); + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags([ + Tag::parse(["u", url]).unwrap(), + Tag::parse(["method", method]).unwrap(), + Tag::parse(["payload", &hash]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + format!( + "Nostr {}", + STANDARD.encode(serde_json::to_vec(&event).unwrap()) + ) +} + +fn request(url: &str, proto: Option<&str>, auth: String, body: Vec) -> Request { + let url = url::Url::parse(url).unwrap(); + let authority = &url[url::Position::BeforeHost..url::Position::AfterPort]; + let path = &url[url::Position::BeforePath..url::Position::AfterQuery]; + let mut request = Request::post(path) + .header("host", authority) + .header("authorization", auth); + if let Some(proto) = proto { + request = request.header("x-forwarded-proto", proto); + } + request.body(Body::from(body)).unwrap() +} + +#[tokio::test] +async fn delivery_binds_path_not_origin_or_forwarding_headers() { + for signed_url in [ + DIRECT_URL, + FORWARDED_URL, + "https://other.example:8443/v1/deliveries/apns", + ] { + let (app, keys, body, sends) = fixture().await; + let auth = signed_header(&keys, signed_url, "POST", &body); + let mut request = request(DIRECT_URL, Some("invalid,https"), auth, body); + request.headers_mut().remove("host"); + request + .headers_mut() + .append("x-forwarded-proto", "http".parse().unwrap()); + request + .headers_mut() + .insert("x-forwarded-host", "unrelated.example".parse().unwrap()); + request.headers_mut().insert( + "forwarded", + "proto=ftp;host=unrelated.example".parse().unwrap(), + ); + assert_eq!(app.oneshot(request).await.unwrap().status(), StatusCode::OK); + assert_eq!(sends.load(Ordering::SeqCst), 1); + } +} + +#[tokio::test] +async fn signature_is_bound_to_delivery_path_method_and_body() { + for (url, method, change_body) in [ + ("https://push.example/v1/other", "POST", false), + ("https://push.example/v1/deliveries/apns/", "POST", false), + ("https://push.example/v1/deliveries/%61pns", "POST", false), + (DIRECT_URL, "GET", false), + (DIRECT_URL, "POST", true), + ] { + let (app, keys, mut body, sends) = fixture().await; + let auth = signed_header(&keys, url, method, &body); + if change_body { + body.push(b' '); + } + assert_eq!( + app.oneshot(request(DIRECT_URL, None, auth, body)) + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED, + "{url} {method}" + ); + assert_eq!(sends.load(Ordering::SeqCst), 0); + } +} + +#[tokio::test] +async fn signed_url_modifiers_and_non_http_schemes_are_rejected() { + for url in [ + "https://push.example/v1/deliveries/apns?mode=one", + "https://push.example/v1/deliveries/apns?", + "https://push.example/v1/deliveries/apns#fragment", + "https://push.example/v1/deliveries/apns#", + "https://user@push.example/v1/deliveries/apns", + "ftp://push.example/v1/deliveries/apns", + ] { + let (app, keys, body, sends) = fixture().await; + let auth = signed_header(&keys, url, "POST", &body); + assert_eq!( + app.oneshot(request(DIRECT_URL, None, auth, body)) + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED, + "{url}" + ); + assert_eq!(sends.load(Ordering::SeqCst), 0); + } +} + +#[tokio::test] +async fn request_queries_are_rejected() { + let (app, keys, body, sends) = fixture().await; + let auth = signed_header(&keys, DIRECT_URL, "POST", &body); + assert_eq!( + app.oneshot(request(&format!("{DIRECT_URL}?mode=one"), None, auth, body)) + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); + assert_eq!(sends.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn router_rejects_other_methods_and_paths() { + for (method, path, expected) in [ + ( + "GET", + crate::http::DELIVERY_PATH, + StatusCode::METHOD_NOT_ALLOWED, + ), + ("POST", "/v1/other", StatusCode::NOT_FOUND), + ] { + let (app, keys, body, sends) = fixture().await; + let auth = signed_header(&keys, DIRECT_URL, "POST", &body); + let request = Request::builder() + .method(method) + .uri(path) + .header("authorization", auth) + .body(Body::from(body)) + .unwrap(); + assert_eq!(app.oneshot(request).await.unwrap().status(), expected); + assert_eq!(sends.load(Ordering::SeqCst), 0); + } +} + +#[tokio::test] +async fn verifier_retains_kind_timestamp_and_payload_requirements() { + for (kind, timestamp, payload) in [ + (Kind::TextNote, Timestamp::now(), true), + ( + Kind::HttpAuth, + Timestamp::from(Timestamp::now().as_secs() - 120), + true, + ), + ( + Kind::HttpAuth, + Timestamp::from(Timestamp::now().as_secs() + 120), + true, + ), + (Kind::HttpAuth, Timestamp::now(), false), + ] { + let (app, keys, body, sends) = fixture().await; + let hash = hex::encode(Sha256::digest(&body)); + let mut tags = vec![ + Tag::parse(["u", DIRECT_URL]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + ]; + if payload { + tags.push(Tag::parse(["payload", &hash]).unwrap()); + } + let event = EventBuilder::new(kind, "") + .tags(tags) + .custom_created_at(timestamp) + .sign_with_keys(&keys) + .unwrap(); + let auth = format!( + "Nostr {}", + STANDARD.encode(serde_json::to_vec(&event).unwrap()) + ); + assert_eq!( + app.oneshot(request(DIRECT_URL, None, auth, body)) + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); + assert_eq!(sends.load(Ordering::SeqCst), 0); + } +} + +#[tokio::test] +async fn invalid_event_id_and_signature_are_rejected() { + for field in ["id", "sig"] { + let (app, keys, body, sends) = fixture().await; + let auth = signed_header(&keys, DIRECT_URL, "POST", &body); + let mut event: serde_json::Value = serde_json::from_slice( + &STANDARD + .decode(auth.strip_prefix("Nostr ").unwrap()) + .unwrap(), + ) + .unwrap(); + let size = event[field].as_str().unwrap().len(); + event[field] = "0".repeat(size).into(); + let auth = format!( + "Nostr {}", + STANDARD.encode(serde_json::to_vec(&event).unwrap()) + ); + assert_eq!( + app.oneshot(request(DIRECT_URL, None, auth, body)) + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); + assert_eq!(sends.load(Ordering::SeqCst), 0); + } +} + +#[tokio::test] +async fn grant_signer_and_replay_checks_remain_enforced() { + let (app, keys, body, sends) = fixture().await; + let wrong_signer = signed_header(&Keys::generate(), DIRECT_URL, "POST", &body); + assert_eq!( + app.clone() + .oneshot(request(DIRECT_URL, None, wrong_signer, body.clone())) + .await + .unwrap() + .status(), + StatusCode::NOT_FOUND + ); + assert_eq!(sends.load(Ordering::SeqCst), 0); + let auth = signed_header(&keys, DIRECT_URL, "POST", &body); + assert_eq!( + app.clone() + .oneshot(request(DIRECT_URL, None, auth.clone(), body.clone())) + .await + .unwrap() + .status(), + StatusCode::OK + ); + assert_eq!( + app.oneshot(request(DIRECT_URL, None, auth, body)) + .await + .unwrap() + .status(), + StatusCode::NOT_FOUND + ); + assert_eq!(sends.load(Ordering::SeqCst), 1); +} diff --git a/crates/buzz-push-gateway/src/lib.rs b/crates/buzz-push-gateway/src/lib.rs index 563d725db9..4f4b5d208f 100644 --- a/crates/buzz-push-gateway/src/lib.rs +++ b/crates/buzz-push-gateway/src/lib.rs @@ -11,3 +11,6 @@ pub mod postgres; pub(crate) mod strict_json; pub mod token; pub use http::{router, router_with_metrics, AppState}; + +#[cfg(test)] +mod http_delivery_tests; diff --git a/crates/buzz-push-gateway/src/main.rs b/crates/buzz-push-gateway/src/main.rs index cd3b75d93a..cf21ea20d3 100644 --- a/crates/buzz-push-gateway/src/main.rs +++ b/crates/buzz-push-gateway/src/main.rs @@ -97,7 +97,6 @@ async fn main() -> Result<(), Box> { authority, token_keyring: Arc::new(token_keyring), profile: Arc::new(profile), - gateway_urls: Arc::new(c.gateway_urls), max_grant_lifetime_seconds: c.max_grant_lifetime_seconds, max_installation_lifetime_seconds: c.max_installation_lifetime_seconds, endpoint_quota_window_seconds: c.endpoint_quota_window_seconds, diff --git a/crates/buzz-push-gateway/tests/startup_app_attest.rs b/crates/buzz-push-gateway/tests/startup_app_attest.rs index 30dbd8da18..e1e1ee3dcd 100644 --- a/crates/buzz-push-gateway/tests/startup_app_attest.rs +++ b/crates/buzz-push-gateway/tests/startup_app_attest.rs @@ -18,7 +18,6 @@ fn parsed_configuration_reaches_executable_verifier() { "BUZZ_PUSH_TOKEN_KEYS", format!("token:{}", STANDARD.encode([2; 32])), ), - ("BUZZ_PUSH_GATEWAY_ORIGIN", "https://push.example".into()), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "2592000".into()), ("DATABASE_URL", "unused".into()), ( diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 39f8b26969..db2e287a60 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -22,6 +22,7 @@ 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 } @@ -39,6 +40,7 @@ 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 } @@ -90,8 +92,13 @@ dev = ["buzz-auth/dev"] # 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"] } +buzz-auth = { workspace = true, features = ["dev", "test-utils"] } reqwest = { workspace = true } tokio-tungstenite = { workspace = true } futures = "0.3" diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index e59591f771..5ca74ec659 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -1980,6 +1980,7 @@ mod postgres_tests { } #[tokio::test] + #[ignore = "requires PostgreSQL"] async fn disabled_mode_allows_unauthenticated_requests_on_the_admin_host() { let state = disabled_mode_state().await; for uri in read_routes() { @@ -1993,7 +1994,7 @@ mod postgres_tests { ) .await; // The routes return 200 (or 404 for unknown resources) — never 401. - // 404 is fine here: there is no real DB, so the row lookups fail. + // 404 is fine for unknown resources; the assertion is only that auth is skipped. assert_ne!( response.status(), StatusCode::UNAUTHORIZED, diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 4714ce1343..13437d9c0d 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -10,18 +10,19 @@ use std::sync::Arc; use axum::{ extract::{Path, Query, RawQuery, State}, http::{HeaderMap, StatusCode}, - response::Json, + response::{IntoResponse, Json, Response}, }; use base64::Engine; use serde_json::Value; -use buzz_auth::{LimitType, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS}; +use buzz_auth::{LimitType, Nip98ReplayGuard, NipFiMode, DEFAULT_REPLAY_TTL_SECS}; use buzz_core::TenantContext; use crate::handlers::ingest::{IngestAuth, IngestError}; +use crate::nip_fi_http::{admit_nip_fi_http_on_state, Nip98Proof}; use crate::state::AppState; -use super::{api_error, internal_error, not_found}; +use super::{api_error, internal_error, not_found, parse_query_or_400}; mod thread_roots; mod thread_window; @@ -75,6 +76,11 @@ type BridgeAuthResult = Result)>; /// Returns the authenticated public key, an event ID for replay detection, and /// the verified signed auth timestamp. For X-Pubkey dev mode, the event ID is /// a zero hash and the timestamp is absent. +/// +/// Most callers use [`make_nip98_closure_for_admission`] (admitted surfaces), +/// [`verify_nip98_exempt_invite_claim`] / [`verify_nip98_exempt_operator`] +/// (explicitly-named exempt paths), or the `pub(crate)` form below for +/// git-settings and other crate-local specialized handlers. pub(crate) fn verify_bridge_auth( headers: &HeaderMap, method: &str, @@ -94,6 +100,11 @@ pub(crate) fn verify_bridge_auth_with_options( require_payload: bool, ) -> BridgeAuthResult { // Try NIP-98 first (Authorization: Nostr ) + // + // Cardinality is enforced at the NIP-FI admission boundary + // (`admit_nip_fi_http`) for Enforce mode. Off-mode passes + // through legacy first-value behavior per [FI-INV-15]. + if let Some(auth_str) = headers .get("authorization") .and_then(|v| v.to_str().ok()) @@ -151,6 +162,100 @@ pub(crate) fn verify_bridge_auth_with_options( Err(api_error(StatusCode::UNAUTHORIZED, "missing Nostr auth")) } +// ── NIP-FI Authority boundary ───────────────────────────────────────────────── +// +// The two functions below are the ONLY `pub(crate)` entry points to the raw +// NIP-98 verifier. All other callers must use one of: +// +// • `make_nip98_closure_for_admission` — for HTTP surfaces under NIP-FI +// admission. The closure is passed directly to `admit_nip_fi_http_on_state` +// and its result is never projected outside a `NipFiAdmission`. +// +// • `verify_nip98_exempt_invite_claim` / `verify_nip98_exempt_operator` — +// for the two explicitly NIP-FI-exempt paths that pre-date NIP-FI and must +// continue to run independently of the NIP-FI state machine. +// +// [FI-TRACE-AUTHORITY-EXEMPT]: grep this tag to audit all exempt call sites. + +/// Build a NIP-98 extraction closure suitable for passing directly to +/// [`crate::nip_fi_http::admit_nip_fi_http_on_state`]. +/// +/// The closure captures all needed parameters by value and, when called, +/// runs the full NIP-98 verification (including optional payload-tag check and +/// X-Pubkey dev-mode fallback) with the same semantics as the private +/// `verify_bridge_auth_with_options`. +/// +/// Callers outside `bridge.rs` MUST use this instead of calling the private +/// verifier directly. The pubkey in the closure's result is only accessible +/// through the `NipFiAdmission` produced by `admit_nip_fi_http_on_state` — +/// it cannot be projected without completing the mode-appropriate admission +/// path (pairing and deny-map run only in Enforce). +/// +/// [FI-TRACE-AUTHORITY-UNIFORM] +// Response is intentionally large (axum's design); see nip_fi_http.rs allow blocks. +#[allow(clippy::result_large_err)] +#[allow(clippy::type_complexity)] // The return type IS the admission closure contract; a type alias cannot name impl Trait +pub(crate) fn make_nip98_closure_for_admission( + headers: HeaderMap, + method: &'static str, + url: String, + body: Option>, + require_auth_token: bool, + require_payload: bool, +) -> impl FnOnce() -> Result)>, axum::http::Response> +{ + move || { + verify_bridge_auth_with_options( + &headers, + method, + &url, + body.as_deref(), + require_auth_token, + require_payload, + ) + .map(|auth| Nip98Proof::new(auth.pubkey, (auth.event_id_bytes, auth.signed_created_at))) + .map_err(|e| e.into_response()) + } +} + +/// NIP-FI-exempt NIP-98 verifier for the invite-claim path. +/// +/// Invite claims run before a tenant's NIP-FI config is consulted and are +/// structurally outside the NIP-FI state machine. This function makes the +/// exemption nameable and greppable. [FI-TRACE-AUTHORITY-EXEMPT] +pub(crate) fn verify_nip98_exempt_invite_claim( + headers: &HeaderMap, + method: &str, + url: &str, + body: Option<&[u8]>, +) -> BridgeAuthResult { + verify_bridge_auth_with_options( + headers, method, url, body, + true, // invite-claim always requires NIP-98; no X-Pubkey dev fallback + true, // POST bodies must be covered by a payload tag + ) +} + +/// NIP-FI-exempt NIP-98 verifier for operator-management endpoints. +/// +/// Operator endpoints use a separate auth origin and are structurally outside +/// the per-tenant NIP-FI state machine. [FI-TRACE-AUTHORITY-EXEMPT] +pub(crate) fn verify_nip98_exempt_operator( + headers: &HeaderMap, + method: &str, + url: &str, + body: Option<&[u8]>, +) -> BridgeAuthResult { + verify_bridge_auth_with_options( + headers, + method, + url, + body, + true, // operator endpoints always require NIP-98; no X-Pubkey dev fallback + body.is_some(), + ) +} + /// Check NIP-98 replay and record the event ID atomically. /// /// The correctness boundary is the shared, community-scoped Redis seen-set on @@ -778,11 +883,13 @@ fn truncate_reason(s: &str, max_bytes: usize) -> &str { } /// Submit a signed Nostr event via HTTP bridge (NIP-98 auth). +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers pub async fn submit_event( State(state): State>, headers: HeaderMap, body: axum::body::Bytes, -) -> Result, (StatusCode, Json)> { +) -> Result { + use axum::response::IntoResponse as _; // Row zero: bind this HTTP request to its community from the request host // before any tenant-scoped write, identical to the WS door in `router.rs`. // Unmapped host or lookup failure fails closed with a generic 404 — never a @@ -798,20 +905,36 @@ pub async fn submit_event( StatusCode::NOT_FOUND, "relay: no community is configured for this host", ) + .into_response() })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/events"); - let VerifiedBridgeAuth { - pubkey, - event_id_bytes, - signed_created_at, - } = verify_bridge_auth( - &headers, - "POST", - &url, - Some(&body), - state.config.require_auth_token, - )?; + // In NIP-FI enforce/deny-protected mode, a real NIP-98 event is mandatory — + // the X-Pubkey dev-mode fallback must never satisfy the pairing requirement. + // [NIP-FI.md:594-607, FI-TRACE-HTTP-INGRESS] + let nip_fi_active = !matches!(state.config.nip_fi.mode, NipFiMode::Off); + // POST /events carries an authorization-relevant body (the event determines + // resource, effect, and state change), so a payload tag is required in + // NIP-FI enforce mode. [NIP-FI.md:619-637] + let nip_fi_enforce = matches!(state.config.nip_fi.mode, NipFiMode::Enforce); + + // NIP-FI admission: NIP-98 extraction runs inside the closure, followed by + // assertion verify → pair → deny-map in fixed order. The proven pubkey is + // only available through the returned NipFiAdmission. [FI-TRACE-AUTHORITY-UNIFORM] + let admission = admit_nip_fi_http_on_state(&state, &headers, || { + verify_bridge_auth_with_options( + &headers, + "POST", + &url, + Some(&body), + state.config.require_auth_token || nip_fi_active, + nip_fi_enforce, + ) + .map(|auth| Nip98Proof::new(auth.pubkey, (auth.event_id_bytes, auth.signed_created_at))) + .map_err(|e| e.into_response()) + })?; + let pubkey = *admission.proven_pubkey(); + let (event_id_bytes, signed_created_at) = admission.into_extra(); let pubkey_hex = pubkey.to_hex(); // Everything after auth — admission, replay, membership, parse, ingest — @@ -883,7 +1006,7 @@ pub async fn submit_event( } } - outcome.into_response() + Ok(outcome.into_response().into_response()) } /// Log-context outcome for a single [`submit_event`] call. @@ -1086,11 +1209,13 @@ async fn submit_event_authed( /// Query events via HTTP bridge (NIP-98 auth). Returns JSON array of events. /// /// Enforces channel access: results are filtered to channels the user can access. +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers pub async fn query_events( State(state): State>, headers: HeaderMap, body: axum::body::Bytes, -) -> Result, (StatusCode, Json)> { +) -> Result { + use axum::response::IntoResponse as _; // Row zero: bind this HTTP request to its community from the request host // before any tenant-scoped read, identical to the WS door in `router.rs`. // An unmapped host or lookup failure fails closed with a generic 404 — never @@ -1107,20 +1232,33 @@ pub async fn query_events( StatusCode::NOT_FOUND, "relay: no community is configured for this host", ) + .into_response() })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/query"); - let VerifiedBridgeAuth { - pubkey, - event_id_bytes, - signed_created_at, - } = verify_bridge_auth( - &headers, - "POST", - &url, - Some(&body), - state.config.require_auth_token, - )?; + // In NIP-FI enforce/deny-protected mode, a real NIP-98 event is mandatory. + // [NIP-FI.md:594-607, FI-TRACE-HTTP-INGRESS] + let nip_fi_active = !matches!(state.config.nip_fi.mode, NipFiMode::Off); + // POST /query carries an authorization-relevant body (filter selects the + // resources returned), so a payload tag is required in enforce mode. + // [NIP-FI.md:619-637] + let nip_fi_enforce = matches!(state.config.nip_fi.mode, NipFiMode::Enforce); + + // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] + let admission = admit_nip_fi_http_on_state(&state, &headers, || { + verify_bridge_auth_with_options( + &headers, + "POST", + &url, + Some(&body), + state.config.require_auth_token || nip_fi_active, + nip_fi_enforce, + ) + .map(|auth| Nip98Proof::new(auth.pubkey, (auth.event_id_bytes, auth.signed_created_at))) + .map_err(|e| e.into_response()) + })?; + let pubkey = *admission.proven_pubkey(); + let (event_id_bytes, signed_created_at) = admission.into_extra(); let pubkey_hex = pubkey.to_hex(); // Admission, replay, membership, and filter execution all run inside the @@ -1159,7 +1297,7 @@ pub async fn query_events( ); } } - result + Ok(result.into_response()) } /// Filter execution for [`query_events`], run once NIP-98 auth succeeds. @@ -1699,11 +1837,13 @@ async fn repair_requested_channel_access( /// /// Enforces channel access: only counts events in channels the user can access. /// For filters without a `#h` tag, falls back to per-event counting with access checks. +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers pub async fn count_events( State(state): State>, headers: HeaderMap, body: axum::body::Bytes, -) -> Result, (StatusCode, Json)> { +) -> Result { + use axum::response::IntoResponse as _; // Row zero: bind this HTTP request to its community from the request host // before any tenant-scoped read, identical to the WS door in `router.rs` // and `query_events`/`submit_event` above. Fail-closed; never a default @@ -1719,20 +1859,33 @@ pub async fn count_events( StatusCode::NOT_FOUND, "relay: no community is configured for this host", ) + .into_response() })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/count"); - let VerifiedBridgeAuth { - pubkey, - event_id_bytes, - signed_created_at, - } = verify_bridge_auth( - &headers, - "POST", - &url, - Some(&body), - state.config.require_auth_token, - )?; + // In NIP-FI enforce/deny-protected mode, a real NIP-98 event is mandatory. + // [NIP-FI.md:594-607, FI-TRACE-HTTP-INGRESS] + let nip_fi_active = !matches!(state.config.nip_fi.mode, NipFiMode::Off); + // POST /count carries an authorization-relevant body (filter selects what + // is counted), so a payload tag is required in enforce mode. + // [NIP-FI.md:619-637] + let nip_fi_enforce = matches!(state.config.nip_fi.mode, NipFiMode::Enforce); + + // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] + let admission = admit_nip_fi_http_on_state(&state, &headers, || { + verify_bridge_auth_with_options( + &headers, + "POST", + &url, + Some(&body), + state.config.require_auth_token || nip_fi_active, + nip_fi_enforce, + ) + .map(|auth| Nip98Proof::new(auth.pubkey, (auth.event_id_bytes, auth.signed_created_at))) + .map_err(|e| e.into_response()) + })?; + let pubkey = *admission.proven_pubkey(); + let (event_id_bytes, signed_created_at) = admission.into_extra(); let pubkey_hex = pubkey.to_hex(); // Admission, replay, membership, and count execution all run inside the @@ -1769,7 +1922,7 @@ pub async fn count_events( ); } } - result + Ok(result.into_response()) } /// Filter execution for [`count_events`], run once NIP-98 auth succeeds. @@ -2498,12 +2651,13 @@ async fn synthesize_presence( /// (`restricted`) pass `None` and keep the bare-path expectation. The verbatim /// request query is used (not a re-serialized parse) so the match stays byte-exact /// with what the client signed regardless of param order or encoding. +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers async fn authorize_moderation_read( state: &Arc, headers: &HeaderMap, path: &str, raw_query: Option<&str>, -) -> Result)> { +) -> Result { let raw_host = headers .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) @@ -2515,6 +2669,7 @@ async fn authorize_moderation_read( StatusCode::NOT_FOUND, "relay: no community is configured for this host", ) + .into_response() })?; let path_with_query = match raw_query { @@ -2522,12 +2677,29 @@ async fn authorize_moderation_read( _ => path.to_string(), }; let url = nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); - let VerifiedBridgeAuth { - pubkey, - event_id_bytes, - .. - } = verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; - check_nip98_replay(state, &tenant, event_id_bytes).await?; + // In NIP-FI enforce/deny-protected mode a real NIP-98 event is mandatory — + // the X-Pubkey dev-mode fallback must never satisfy the pairing requirement. + // [NIP-FI.md:594-607, FI-TRACE-HTTP-INGRESS] + let nip_fi_active = !matches!(state.config.nip_fi.mode, NipFiMode::Off); + + // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] + let admission = admit_nip_fi_http_on_state(state, headers, || { + verify_bridge_auth( + headers, + "GET", + &url, + None, + state.config.require_auth_token || nip_fi_active, + ) + .map(|auth| Nip98Proof::new(auth.pubkey, auth.event_id_bytes)) + .map_err(|e| e.into_response()) + })?; + let pubkey = *admission.proven_pubkey(); + let event_id_bytes = admission.into_extra(); + + check_nip98_replay(state, &tenant, event_id_bytes) + .await + .map_err(|e| e.into_response())?; let pubkey_bytes = pubkey.to_bytes().to_vec(); crate::handlers::moderation_authz::authorize_moderation_action( @@ -2544,6 +2716,7 @@ async fn authorize_moderation_read( StatusCode::FORBIDDEN, "restricted: moderator access required", ) + .into_response() })?; Ok(tenant) @@ -2571,16 +2744,28 @@ pub async fn moderation_reports( State(state): State>, headers: HeaderMap, RawQuery(raw_query): RawQuery, - Query(q): Query, -) -> Result, (StatusCode, Json)> { - let tenant = authorize_moderation_read( +) -> Response { + let tenant = match authorize_moderation_read( &state, &headers, "/moderation/reports", raw_query.as_deref(), ) - .await?; - let rows = state + .await + { + Ok(t) => t, + Err(r) => return r, + }; + // Parse query after admission so malformed params cannot 400 before the + // NIP-FI gate fires. A parse failure after admission is a caller error + // (400), not an auth failure; defaulting silently would change query + // semantics (e.g. drop a valid `status=` together with a bad `limit=`). + // [FI-TRACE-HTTP-INGRESS] + let q: ModerationReadQuery = match parse_query_or_400(raw_query.as_deref()) { + Ok(q) => q, + Err(e) => return e.into_response(), + }; + match state .db .list_moderation_reports( tenant.community(), @@ -2588,8 +2773,10 @@ pub async fn moderation_reports( clamp_limit(q.limit), ) .await - .map_err(|e| internal_error(&format!("list reports: {e}")))?; - Ok(Json(Value::Array(rows.iter().map(report_json).collect()))) + { + Ok(rows) => Json(Value::Array(rows.iter().map(report_json).collect())).into_response(), + Err(e) => internal_error(&format!("list reports: {e}")).into_response(), + } } /// `GET /moderation/audit` — the moderation audit log (NIP-98 + mod-authz). @@ -2597,32 +2784,54 @@ pub async fn moderation_audit( State(state): State>, headers: HeaderMap, RawQuery(raw_query): RawQuery, - Query(q): Query, -) -> Result, (StatusCode, Json)> { - let tenant = - authorize_moderation_read(&state, &headers, "/moderation/audit", raw_query.as_deref()) - .await?; - let rows = state +) -> Response { + let tenant = match authorize_moderation_read( + &state, + &headers, + "/moderation/audit", + raw_query.as_deref(), + ) + .await + { + Ok(t) => t, + Err(r) => return r, + }; + // Parse query after admission so malformed params cannot 400 before the + // NIP-FI gate fires. A parse failure after admission is a caller error + // (400), not an auth failure; defaulting silently would change query + // semantics. [FI-TRACE-HTTP-INGRESS] + let q: ModerationReadQuery = match parse_query_or_400(raw_query.as_deref()) { + Ok(q) => q, + Err(e) => return e.into_response(), + }; + match state .db .list_moderation_actions(tenant.community(), clamp_limit(q.limit)) .await - .map_err(|e| internal_error(&format!("list actions: {e}")))?; - Ok(Json(Value::Array(rows.iter().map(action_json).collect()))) + { + Ok(rows) => Json(Value::Array(rows.iter().map(action_json).collect())).into_response(), + Err(e) => internal_error(&format!("list actions: {e}")).into_response(), + } } /// `GET /moderation/restricted` — currently banned/timed-out members. pub async fn moderation_restricted( State(state): State>, headers: HeaderMap, -) -> Result, (StatusCode, Json)> { +) -> Response { let tenant = - authorize_moderation_read(&state, &headers, "/moderation/restricted", None).await?; - let rows = state + match authorize_moderation_read(&state, &headers, "/moderation/restricted", None).await { + Ok(t) => t, + Err(r) => return r, + }; + match state .db .list_community_restrictions(tenant.community()) .await - .map_err(|e| internal_error(&format!("list restrictions: {e}")))?; - Ok(Json(Value::Array(rows.iter().map(ban_json).collect()))) + { + Ok(rows) => Json(Value::Array(rows.iter().map(ban_json).collect())).into_response(), + Err(e) => internal_error(&format!("list restrictions: {e}")).into_response(), + } } fn report_json(r: &buzz_db::moderation::ReportRecord) -> Value { @@ -4726,6 +4935,2223 @@ mod postgres_tests { ); } + // ── NIP-FI production-seam tests (F4) ──────────────────────────────────── + // + // These tests drive real HTTP requests through the axum router with NIP-FI + // in Enforce mode and a valid NIP-98 event but NO assertion header. Each + // test must go red if the `admit_nip_fi_http_on_state` call is deleted or + // inverted at the corresponding production call site. + // + // Falsifiability: a request with valid NIP-98 + no assertion in Enforce + // mode → NIP-FI gate fires → 401 (MissingEvidence). If the gate is removed, + // the request proceeds past NIP-FI to community lookup → succeeds (community + // is provisioned) → further processing → some other status (200, 400, etc.) + // that is NOT 401. The assert_eq fires. + // + // Why `#[ignore = "requires Postgres"]`: the handlers call bind_community + // before the NIP-FI gate; the community must exist for the NIP-98 URL to + // match. All four protected surfaces need Postgres for the NIP-FI seam test + // to be exercised (vs. bailing at community lookup with 404 before NIP-FI). + // + // ## NIP-FI route classification + // + // Route classification (PROTECTED vs. EXEMPT) is now owned by + // `router.rs::NIP_FI_EXEMPT_PREFIXES` and enforced by the + // `nip_fi_assertion_guard` middleware layer. See the comment block at the + // top of `router.rs` for the complete classification and the rationale. + // + // The tests below exercise the *outer* assertion guard in `router.rs` + // (router.rs:232-234): in Enforce mode, a missing or crypto-invalid + // `Nostr-Federated-Identity` token is rejected BEFORE the handler runs. + // + // These tests do NOT prove per-handler `admit_nip_fi_http_on_state` wiring + // — deleting a handler's admission call would not change these results. + // The cardinality test (`r3_cardinality_actual_caller_query_off_passes_enforce_denies`) + // exercises the handler-level gate with a valid assertion. Per-handler + // key-pairing and deny-map are tested in `settings_tests.rs` and the + // crypto-seam test above. + + /// Build an AppState with NIP-FI in Enforce mode for production-seam tests. + /// + /// Sets `nip_fi.mode = Enforce` while leaving `nip_fi_verifier = None` + /// (startup race: no issuers configured → verifier not built). This is + /// sufficient for the seam test because the NIP-FI gate fires with 401 + /// (MissingEvidence) when the assertion header is absent, BEFORE any + /// verifier lookup. `require_auth_token = true` forces real NIP-98. + /// + /// Returns `None` when local Postgres is not reachable. + async fn nip_fi_enforce_test_state() -> Option> { + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = crate::test_support::database_url(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "wss://nip-fi-test.local".to_string(); + config.require_auth_token = true; + config.require_relay_membership = false; + config.nip_fi.mode = buzz_auth::NipFiMode::Enforce; + // Pin the GIF provider absent: `Config::from_env()` imports + // `BUZZ_KLIPY_API_KEY`, and the GIF positive control's exact 404 + // (`gifs.rs` "GIF search is not configured") depends on `klipy = None`. + config.klipy = None; + // No issuers configured → nip_fi_verifier = None (startup-race path). + // The seam test fires before verifier is needed (missing assertion → 401). + + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + + let (mut state, _audit_shutdown) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Some(Arc::new(state)) + } + + // EC P-256 test key constants shared by positive-control and cardinality tests. + // Private key (PKCS#8 PEM) + public key coordinates (JWK x/y/kid). + // Used by `nip_fi_enforce_test_state_with_verifier()` and + // `signed_assertion_for_pubkey()`. + const HANDLER_TEST_ISSUER: &str = "https://issuer.example"; + const HANDLER_TEST_AUDIENCE: &str = "https://relay.example"; + const HANDLER_TEST_KID: &str = "test-key-1"; + const HANDLER_TEST_EC_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + + /// Build a NIP-FI Enforce AppState with a real injected P-256 verifier. + /// + /// Used by positive-control tests (same-key admission proves the handler + /// was reached, not just deny-all). Same key material as used in the + /// cardinality test and the `signed_assertion_for_pubkey` helper below. + async fn nip_fi_enforce_test_state_with_verifier() -> Option> { + use buzz_auth::{ + AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, + IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, + }; + use jsonwebtoken::{jwk::JwkSet, Algorithm}; + + let mut state = (*nip_fi_enforce_test_state().await?).clone(); + + let jwks: JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", + "kid": HANDLER_TEST_KID, + "x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI", + "y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA" + }] + })) + .expect("valid test JWKS"); + let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); + let key_set = + AssertionKeySet::new_for_test(HANDLER_TEST_ISSUER.to_owned(), 1, jwks, hard_deadline) + .expect("valid test key set"); + let jwks_contract = buzz_auth::JwksSourceContract::new( + format!("{HANDLER_TEST_ISSUER}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid jwks contract"); + let policy = IssuerPolicy::new( + HANDLER_TEST_ISSUER.to_owned(), + vec![HANDLER_TEST_AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, + 3600, + None, + jwks_contract, + ) + .expect("valid issuer policy"); + let mut registry = IssuerRegistry::new(); + registry.insert(policy); + let verifier: Arc = Arc::new(FederatedAssertionVerifier::new( + registry, + StaticIssuerKeySource::new([key_set]), + )); + state.nip_fi_verifier = Some(verifier); + Some(Arc::new(state)) + } + + /// Mint a signed NIP-FI assertion whose `nostr_pubkey` = `pubkey_hex`, + /// using the shared HANDLER_TEST_* key material. + fn signed_assertion_for_pubkey(pubkey_hex: &str) -> String { + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": HANDLER_TEST_ISSUER, + "aud": HANDLER_TEST_AUDIENCE, + "iat": now, + "exp": now + 600, + "sub": "test-subject", + "nostr_pubkey": pubkey_hex, + }); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(HANDLER_TEST_KID.to_owned()); + header.typ = Some("nip-fi+jwt".to_owned()); + let key = + EncodingKey::from_ec_pem(HANDLER_TEST_EC_PEM.as_bytes()).expect("valid test EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign assertion") + } + + /// Build a HeaderMap containing a valid NIP-98 Authorization header + + /// a valid NIP-FI assertion for the same key. + fn same_key_nip98_and_assertion_headers( + keys: &Keys, + url: &str, + method: &str, + body: &[u8], + ) -> axum::http::HeaderMap { + let mut headers = make_nip98_headers(keys, url, method, body); + let assertion = signed_assertion_for_pubkey(&keys.public_key().to_hex()); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + headers + } + + /// Build an AppState with NIP-FI in Off mode for production-seam regression tests. + /// + /// `require_auth_token = false` so requests without NIP-98 auth still reach + /// the application logic rather than rejecting at the NIP-98 layer. + async fn nip_fi_off_test_state() -> Option> { + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = crate::test_support::database_url(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "wss://nip-fi-test.local".to_string(); + config.require_auth_token = false; + config.require_relay_membership = false; + config.nip_fi.mode = buzz_auth::NipFiMode::Off; + + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + + let (mut state, _audit_shutdown) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Some(Arc::new(state)) + } + + /// Build an AppState with NIP-FI in DenyProtected mode. + async fn nip_fi_deny_protected_test_state() -> Option> { + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = crate::test_support::database_url(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "wss://nip-fi-test.local".to_string(); + config.require_auth_token = true; + config.require_relay_membership = false; + config.nip_fi.mode = buzz_auth::NipFiMode::DenyProtected; + + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + + let (mut state, _audit_shutdown) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Some(Arc::new(state)) + } + + /// Sign a NIP-98 event for a given URL and method, returning a valid + /// `Authorization: Nostr ` header map. + /// + /// Includes a `payload` tag for the given body bytes so the event passes + /// the payload-binding check in NIP-FI Enforce mode. For GET or empty + /// bodies pass `b""` — the SHA-256 of an empty body is included regardless, + /// keeping the event unconditionally valid through `verify_bridge_auth_with_options`. + fn make_nip98_headers( + keys: &Keys, + url: &str, + method: &str, + body: &[u8], + ) -> axum::http::HeaderMap { + use base64::engine::general_purpose::STANDARD as BASE64; + use sha2::{Digest, Sha256}; + let payload_hex = hex::encode(Sha256::digest(body)); + let tags = vec![ + Tag::parse(["u", url]).expect("u tag"), + Tag::parse(["method", method]).expect("method tag"), + Tag::parse(["payload", &payload_hex]).expect("payload tag"), + ]; + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign NIP-98 event"); + let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event"); + let value = format!("Nostr {}", BASE64.encode(event_json.as_bytes())); + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + value.parse().expect("valid header"), + ); + headers + } + + /// Drive a single oneshot request through the full relay router and return + /// the HTTP status. + async fn oneshot_request( + state: Arc, + method: &str, + uri: &str, + host: &str, + headers: axum::http::HeaderMap, + body: &[u8], + ) -> axum::http::StatusCode { + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + let mut builder = Request::builder() + .method(method) + .uri(uri) + .header("host", host); + for (name, value) in &headers { + builder = builder.header(name, value); + } + crate::router::build_router(state) + .oneshot( + builder + .body(Body::from(body.to_vec())) + .expect("build request"), + ) + .await + .expect("router oneshot") + .status() + } + + /// Drive a single oneshot request through the full relay router and return + /// `(status, response_headers, body_bytes)` for exact-byte assertions. + async fn oneshot_request_full( + state: Arc, + method: &str, + uri: &str, + host: &str, + headers: axum::http::HeaderMap, + body: &[u8], + ) -> (axum::http::StatusCode, axum::http::HeaderMap, bytes::Bytes) { + use axum::body::{to_bytes, Body}; + use axum::http::Request; + use tower::ServiceExt; + + let mut builder = Request::builder() + .method(method) + .uri(uri) + .header("host", host); + for (name, value) in &headers { + builder = builder.header(name, value); + } + let resp = crate::router::build_router(state) + .oneshot( + builder + .body(Body::from(body.to_vec())) + .expect("build request"), + ) + .await + .expect("router oneshot"); + let status = resp.status(); + let resp_headers = resp.headers().clone(); + let resp_body = to_bytes(resp.into_body(), 8192).await.unwrap_or_default(); + (status, resp_headers, resp_body) + } + + // ── F4: bridge POST /events — enforce mode, no assertion → 401 ────────── + // + // Exercises the OUTER assertion guard in `router.rs` (not the per-handler + // gate): build_router with no Nostr-Federated-Identity header → guard fires + // before the handler runs → 401 `authentication required\n`. + // + // Falsifying mutation: removing the outer `nip_fi_assertion_guard` layer + // from `build_router` does NOT change this test — the per-handler + // `admit_nip_fi_http_on_state` call in `submit_event` also denies 401 + // `authentication required\n` when no assertion is present. This test + // proves the outer guard fires (and its error path is exercised), not + // that it is the sole denial point for missing-assertion requests. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_bridge_events_no_assertion_is_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let url = format!("https://{host}/events"); + let auth_headers = make_nip98_headers(&keys, &url, "POST", b"{}"); + + let (status, resp_headers, body) = rt.block_on(oneshot_request_full( + state, + "POST", + "/events", + &host, + auth_headers, + b"{}", + )); + + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "NIP-FI enforce mode: POST /events with valid NIP-98 + no assertion MUST deny 401 \ + [FI-TRACE-HTTP-INGRESS]; if this fails the admit_nip_fi_http_on_state gate was \ + removed from submit_event" + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "/events: exact MissingEvidence body must be 'authentication required\\n'" + ); + let ct = resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + ct, "text/plain; charset=utf-8", + "/events: 401 content-type must be text/plain; charset=utf-8" + ); + let www_auth = resp_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, "Nostr", + "/events: 401 must carry WWW-Authenticate: Nostr" + ); + } + + // ── F4: bridge POST /query — enforce mode, no assertion → 401 ─────────── + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_bridge_query_no_assertion_is_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let url = format!("https://{host}/query"); + let auth_headers = make_nip98_headers(&keys, &url, "POST", b"[]"); + + let (status, resp_headers, body) = rt.block_on(oneshot_request_full( + state, + "POST", + "/query", + &host, + auth_headers, + b"[]", + )); + + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "NIP-FI enforce mode: POST /query with valid NIP-98 + no assertion MUST deny 401 \ + [FI-TRACE-HTTP-INGRESS]; if this fails the admit_nip_fi_http_on_state gate was \ + removed from query_events" + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "/query: exact MissingEvidence body must be 'authentication required\\n'" + ); + let ct = resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + ct, "text/plain; charset=utf-8", + "/query: 401 content-type must be text/plain; charset=utf-8" + ); + let www_auth = resp_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, "Nostr", + "/query: 401 must carry WWW-Authenticate: Nostr" + ); + } + + // ── F4: bridge POST /count — enforce mode, no assertion → 401 ─────────── + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_bridge_count_no_assertion_is_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let url = format!("https://{host}/count"); + let auth_headers = make_nip98_headers(&keys, &url, "POST", b"[]"); + + let (status, resp_headers, body) = rt.block_on(oneshot_request_full( + state, + "POST", + "/count", + &host, + auth_headers, + b"[]", + )); + + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "NIP-FI enforce mode: POST /count with valid NIP-98 + no assertion MUST deny 401 \ + [FI-TRACE-HTTP-INGRESS]; if this fails the admit_nip_fi_http_on_state gate was \ + removed from count_events" + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "/count: exact MissingEvidence body must be 'authentication required\\n'" + ); + let ct = resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + ct, "text/plain; charset=utf-8", + "/count: 401 content-type must be text/plain; charset=utf-8" + ); + let www_auth = resp_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, "Nostr", + "/count: 401 must carry WWW-Authenticate: Nostr" + ); + } + + // ── F4: moderation GET — enforce mode, no assertion → 401 ─────────────── + // + // Shared witness for all three moderation routes: they share + // `authorize_moderation_read` which calls `admit_nip_fi_http_on_state`. + // + // The 401 is produced by the OUTER `nip_fi_assertion_guard` layer in + // `build_router`: no `Nostr-Federated-Identity` header → MissingEvidence → + // 401 `authentication required\n`. + // + // Note: removing only the outer guard does NOT change this test — the + // handler's own `admit_nip_fi_http_on_state` also fires 401 on missing + // assertion. This test witnesses the outer guard fires first and its + // error path is exercised; it does not claim the outer guard is the sole + // denial point. The same-key positive (below) is the complement witness. + // + // The exact body/CT/challenge oracles discriminate any implementation that + // returns a different status or body (e.g. application-level 403 if both + // admission layers were removed). + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_moderation_reports_no_assertion_is_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let url = format!("https://{host}/moderation/reports"); + let auth_headers = make_nip98_headers(&keys, &url, "GET", b""); + + let (status, resp_headers, body) = rt.block_on(oneshot_request_full( + state, + "GET", + "/moderation/reports", + &host, + auth_headers, + b"", + )); + + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "NIP-FI enforce mode: GET /moderation/reports with valid NIP-98 + no assertion MUST \ + deny 401 [FI-TRACE-HTTP-INGRESS]; outer nip_fi_assertion_guard fires on missing \ + Nostr-Federated-Identity header → MissingEvidence → 401." + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "/moderation/reports: exact MissingEvidence body must be 'authentication required\\n'" + ); + let ct = resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + ct, "text/plain; charset=utf-8", + "/moderation/reports: 401 content-type must be text/plain; charset=utf-8" + ); + let www_auth = resp_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, "Nostr", + "/moderation/reports: 401 must carry WWW-Authenticate: Nostr" + ); + } + + // ── F4: GIF search — enforce mode, no assertion → 401 ─────────────────── + // + // Shared witness for both GIF routes (search + share both go through + // `authenticate` which calls `admit_nip_fi_http_on_state`). + // + // The 401 is produced by the OUTER `nip_fi_assertion_guard` layer in + // `build_router`: no `Nostr-Federated-Identity` header → MissingEvidence → + // 401 `authentication required\n`. + // + // Note: removing only the outer guard does NOT change this test — the + // handler's own `admit_nip_fi_http_on_state` in `gifs::authenticate` also + // fires 401 on missing assertion. This test witnesses the outer guard fires + // first and its error path is exercised; it does not claim the outer guard is + // the sole denial point. The same-key positive (below) is the complement witness. + // + // The exact body/CT/challenge oracles discriminate any implementation that + // returns a different status or body (e.g. 404 if BOTH admission layers were + // removed and Klipy config was absent). + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_gif_search_no_assertion_is_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let url = format!("https://{host}{}", crate::api::gifs::SEARCH_PATH); + let auth_headers = make_nip98_headers(&keys, &url, "POST", b"{}"); + + let (status, resp_headers, body) = rt.block_on(oneshot_request_full( + state, + "POST", + crate::api::gifs::SEARCH_PATH, + &host, + auth_headers, + b"{}", + )); + + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "NIP-FI enforce mode: POST {} with valid NIP-98 + no assertion MUST deny 401 \ + [FI-TRACE-HTTP-INGRESS]; outer nip_fi_assertion_guard fires on missing \ + Nostr-Federated-Identity header → MissingEvidence → 401.", + crate::api::gifs::SEARCH_PATH + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "GIF search: exact MissingEvidence body must be 'authentication required\\n'. \ + Note: GIF 404 → NIP-FI 401 is a known exception (gifs.rs → bridge → api_error() \ + for the Off path); Enforce must still produce exact NIP-FI bytes." + ); + let ct = resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + ct, "text/plain; charset=utf-8", + "GIF search: 401 content-type must be text/plain; charset=utf-8" + ); + let www_auth = resp_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, "Nostr", + "GIF search: 401 must carry WWW-Authenticate: Nostr" + ); + } + + // ── F4: workflow runs — enforce mode, no assertion → 401 ──────────────── + // + // Shared witness for both workflow routes (`authorize_workflow_read` + // calls `admit_nip_fi_http_on_state`). + // + // The 401 is produced by the OUTER `nip_fi_assertion_guard` layer in + // `build_router`: no `Nostr-Federated-Identity` header → MissingEvidence → + // 401 `authentication required\n`. The per-handler gate is unreachable. + // + // Removing only the outer guard does not change this 401: the request then + // reaches `authorize_workflow_read`, whose `admit_nip_fi_http_on_state` + // (workflows.rs) denies the same missing assertion with the same + // MissingEvidence bytes. The handler-level gate is witnessed separately by + // the same-key positive and mismatched-key controls below. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_workflow_runs_no_assertion_is_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let workflow_id = uuid::Uuid::new_v4(); + let keys = Keys::generate(); + let path = format!("/workflows/{workflow_id}/runs"); + let url = format!("https://{host}{path}"); + let auth_headers = make_nip98_headers(&keys, &url, "GET", b""); + + let (status, resp_headers, body) = rt.block_on(oneshot_request_full( + state, + "GET", + &path, + &host, + auth_headers, + b"", + )); + + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "NIP-FI enforce mode: GET {path} with valid NIP-98 + no assertion MUST deny 401 \ + [FI-TRACE-HTTP-INGRESS]; outer nip_fi_assertion_guard fires on missing \ + Nostr-Federated-Identity header → MissingEvidence → 401." + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "{path}: exact MissingEvidence body must be 'authentication required\\n'" + ); + let ct = resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + ct, "text/plain; charset=utf-8", + "{path}: 401 content-type must be text/plain; charset=utf-8" + ); + let www_auth = resp_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, "Nostr", + "{path}: 401 must carry WWW-Authenticate: Nostr" + ); + } + + // ── GIF search — Enforce mode, same-key admission → reaches handler ─────── + // + // Positive control for `nip_fi_enforce_gif_search_no_assertion_is_401`: + // a valid NIP-FI assertion + valid same-key NIP-98 MUST pass admission and + // reach the GIF handler. The handler returns 404 (GIF search not configured) + // — which is NOT 401/403, proving the NIP-FI gate did not deny the request. + // + // Falsifying mutation: make the NIP-FI verifier always-deny → same-key + // request returns 403 AuthorizationDenied → 404 assertion fires. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_gif_search_same_key_admission_succeeds() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-gif-positive-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + // `nip_fi_enforce_test_state` pins `config.klipy = None`. + let keys = Keys::generate(); + let url = format!("https://{host}{}", crate::api::gifs::SEARCH_PATH); + let headers = same_key_nip98_and_assertion_headers(&keys, &url, "POST", b"{}"); + + let (status, _resp_headers, body) = rt.block_on(oneshot_request_full( + state, + "POST", + crate::api::gifs::SEARCH_PATH, + &host, + headers, + b"{}", + )); + + // Admission passes → handler fires → GIF config absent → exact 404. + // Falsifying mutation: make verifier always-deny → 403 AuthorizationDenied. + assert_eq!( + status, + axum::http::StatusCode::NOT_FOUND, + "GIF search same-key positive: NIP-FI MUST admit and handler MUST return 404 \ + (GIF provider not configured). \ + If 401: NIP-FI MissingEvidence — outer guard or assertion check denying. \ + If 403: NIP-FI AuthorizationDenied — verifier or pairing denying. \ + Body: {body:?}" + ); + // Verify exact body: api_error(NOT_FOUND, "GIF search is not configured") → JSON. + let body_json: serde_json::Value = + serde_json::from_slice(&body).expect("404 body must be valid JSON"); + assert_eq!( + body_json.get("error").and_then(|v| v.as_str()), + Some("GIF search is not configured"), + "GIF search same-key positive: exact 404 body must be JSON \ + {{\"error\":\"GIF search is not configured\"}}. \ + Falsifying mutation: make handler always-deny → 403 body differs." + ); + } + + // ── Moderation reports — Enforce mode, same-key admission → reaches handler ─ + // + // Positive control: a valid NIP-FI assertion + same-key NIP-98 on the + // registered `/moderation/reports` route passes admission and reaches + // `authorize_moderation_action`. The unprivileged caller gets the + // application 403 JSON `{"error":"restricted: moderator access required"}` + // (`authorize_moderation_read` → `api_error`), which is distinguishable + // from the NIP-FI text/plain `authorization denied\n` denial. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_moderation_reports_same_key_admission_succeeds() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-mod-positive-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + // The caller holds no moderation role (`ensure_user` creates a member + // row only), so `authorize_moderation_action(ViewQueue)` fails and + // `authorize_moderation_read` maps it to an application JSON 403. + let keys = Keys::generate(); + rt.block_on(async { + state + .db + .ensure_user( + state + .db + .ensure_configured_community(&host) + .await + .expect("community") + .id, + keys.public_key().as_bytes(), + ) + .await + .expect("ensure_user"); + }); + + let path = "/moderation/reports"; + let url = format!("https://{host}{path}"); + let headers = same_key_nip98_and_assertion_headers(&keys, &url, "GET", b""); + + let (status, resp_headers, body) = rt.block_on(oneshot_request_full( + state, "GET", path, &host, headers, b"", + )); + + // Admission passes — the caller is NOT a moderator so moderation returns + // 403 with exact application body "restricted: moderator access required". + // This is an application-level 403, not a NIP-FI denial. + // + // Distinguishing mutations: + // - NIP-FI always-deny → "authorization denied\n" (text/plain) ≠ JSON body. + // - Remove moderation authz check → 200 with empty results ≠ 403. + assert_eq!( + status, + axum::http::StatusCode::FORBIDDEN, + "Moderation same-key positive: handler MUST reach moderation authz → \ + 403 (caller is not a moderator). \ + If 401: NIP-FI MissingEvidence — assertion check denying. \ + If 200: moderation authz check was removed." + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some("application/json"), + "Moderation same-key positive: application 403 must be JSON" + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "Moderation same-key positive: application 403 carries no challenge" + ); + assert_eq!( + body.as_ref(), + br#"{"error":"restricted: moderator access required"}"#, + "Moderation same-key positive: exact 403 body must be JSON \ + {{\"error\":\"restricted: moderator access required\"}}. \ + If 'authorization denied\\n': NIP-FI AuthDenied — verifier or pairing denying. \ + Falsifying mutation: make verifier always-deny → text/plain body." + ); + } + + // ── Workflow runs — Enforce mode, same-key admission → reaches handler ──── + // + // Positive control for `nip_fi_enforce_workflow_runs_no_assertion_is_401`: + // valid NIP-FI assertion + same-key NIP-98 passes admission and reaches the + // workflow handler. The handler returns 404 (no workflow with this UUID). + // + // Falsifying mutation: make the NIP-FI verifier always-deny → 403 instead + // of 404 → assertion fires. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_workflow_runs_same_key_admission_succeeds() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-wf-positive-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let workflow_id = uuid::Uuid::new_v4(); + let keys = Keys::generate(); + let path = format!("/workflows/{workflow_id}/runs"); + let url = format!("https://{host}{path}"); + let headers = same_key_nip98_and_assertion_headers(&keys, &url, "GET", b""); + + let (status, _resp_headers, _body) = rt.block_on(oneshot_request_full( + state, "GET", &path, &host, headers, b"", + )); + + // Admission passes → workflow not found → 404. + // NIP-FI denial would return 401 or 403 — neither is expected here. + assert_eq!( + status, + axum::http::StatusCode::NOT_FOUND, + "Workflow runs same-key positive: admission MUST pass and handler MUST \ + return 404 (workflow not found). \ + 401 = NIP-FI MissingEvidence; 403 = NIP-FI AuthDenied/Cardinality. \ + Falsifying mutation: make verifier always-deny → 403 instead of 404." + ); + } + + // ── Caller key-pairing witness: GIF mismatched key → 403 AuthorizationDenied ─ + // + // Valid assertion signed for key_a, NIP-98 signed by key_b. The key-pairing + // check in `admit_nip_fi_http_on_state` fires → 403 `authorization denied\n`. + // + // This is the handler-level denial witness: the same-key positive above proves + // admission passes when keys match; this proves the pairing check fires when + // they don't. Together they bound removing the pairing check from both sides. + // + // Falsifying mutation: remove key-pairing check from `admit_nip_fi_http` → + // mismatched keys pass admission → 404 (GIF not configured) ≠ 403. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_gif_search_mismatched_key_is_403() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-gif-mismatch-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + // key_nip98: signs the NIP-98 Authorization header. + // key_assertion: signs the NIP-FI assertion (different pubkey → pairing mismatch). + let key_nip98 = Keys::generate(); + let key_assertion = Keys::generate(); + let url = format!("https://{host}{}", crate::api::gifs::SEARCH_PATH); + let assertion = signed_assertion_for_pubkey(&key_assertion.public_key().to_hex()); + + let mut headers = make_nip98_headers(&key_nip98, &url, "POST", b"{}"); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + + let (status, _resp_headers, body) = rt.block_on(oneshot_request_full( + state, + "POST", + crate::api::gifs::SEARCH_PATH, + &host, + headers, + b"{}", + )); + + assert_eq!( + status, + axum::http::StatusCode::FORBIDDEN, + "GIF mismatched key MUST return 403 AuthorizationDenied. \ + Falsifying mutation: remove key-pairing check → admission passes \ + → 404 (GIF not configured) returned instead." + ); + assert_eq!( + body.as_ref(), + b"authorization denied\n", + "GIF mismatched key 403 MUST carry exact body 'authorization denied\\n'." + ); + } + + // ── Caller key-pairing witness: moderation mismatched key → 403 ───────── + // + // Mirror of the GIF case through the moderation route. + // Falsifying mutation: remove key-pairing check → admission passes → 403 from + // moderation authz (not NIP-FI) with different JSON body. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_moderation_mismatched_key_is_403() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-mod-mismatch-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key_nip98 = Keys::generate(); + let key_assertion = Keys::generate(); + let path = "/moderation/reports"; + let url = format!("https://{host}{path}"); + let assertion = signed_assertion_for_pubkey(&key_assertion.public_key().to_hex()); + + let mut headers = make_nip98_headers(&key_nip98, &url, "GET", b""); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + + let (status, _resp_headers, body) = rt.block_on(oneshot_request_full( + state, "GET", path, &host, headers, b"", + )); + + assert_eq!( + status, + axum::http::StatusCode::FORBIDDEN, + "Moderation mismatched key MUST return 403 AuthorizationDenied. \ + Falsifying mutation: remove key-pairing check → admission passes \ + → 403 from moderation authz (different JSON body)." + ); + assert_eq!( + body.as_ref(), + b"authorization denied\n", + "Moderation mismatched key 403 MUST carry exact body 'authorization denied\\n'. \ + If JSON 403: key-pairing was skipped, moderation authz fired instead." + ); + } + + // ── Caller key-pairing witness: workflow mismatched key → 403 ─────────── + // + // Mirror of the GIF/moderation cases through the workflow route. + // Falsifying mutation: remove key-pairing check → admission passes → 404 (no + // such workflow) rather than 403 AuthorizationDenied. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_workflow_mismatched_key_is_403() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-wf-mismatch-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key_nip98 = Keys::generate(); + let key_assertion = Keys::generate(); + let workflow_id = uuid::Uuid::new_v4(); + let path = format!("/workflows/{workflow_id}/runs"); + let url = format!("https://{host}{path}"); + let assertion = signed_assertion_for_pubkey(&key_assertion.public_key().to_hex()); + + let mut headers = make_nip98_headers(&key_nip98, &url, "GET", b""); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + + let (status, _resp_headers, body) = rt.block_on(oneshot_request_full( + state, "GET", &path, &host, headers, b"", + )); + + assert_eq!( + status, + axum::http::StatusCode::FORBIDDEN, + "Workflow mismatched key MUST return 403 AuthorizationDenied. \ + Falsifying mutation: remove key-pairing check → admission passes \ + → 404 (workflow not found) returned instead." + ); + assert_eq!( + body.as_ref(), + b"authorization denied\n", + "Workflow mismatched key 403 MUST carry exact body 'authorization denied\\n'." + ); + } + + // ── F4: bridge POST /query — off mode, no assertion → reaches application ─ + // + // Regression guard [FI-INV-15]: in Off mode the NIP-FI gate MUST be + // transparent. The request has no assertion header and no auth at all + // (require_auth_token=false in off state). It MUST NOT produce a NIP-FI + // denial (401/403/503). Any application-level response (even 404 or 500) is + // acceptable — the gate was not the source. + // + // Falsifying mutation: enabling NIP-FI mode in the Off state would cause the + // gate to fire; the response would be 401, not the downstream 401 from + // missing auth. Wait — Off state has require_auth_token=false, so an + // anonymous /query without any assertion would reach the application layer + // and produce a non-NIP-FI response (could be 200 [] on an open relay). The + // key observable: the status MUST NOT be produced by the NIP-FI gate in Off + // mode. We verify by checking the response body is NOT the NIP-FI contract + // text ("authentication required\n"). + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_off_bridge_query_no_assertion_is_not_nip_fi_denied() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_off_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + // X-Pubkey dev-mode auth: passes verify_bridge_auth (require_auth_token=false + // in Off state) and reaches admit_nip_fi_http_on_state, which MUST admit + // unconditionally in Off mode. + // + // We cannot use no-auth-at-all because verify_bridge_auth returns 401 + // ("missing Nostr auth") before the NIP-FI gate is reached, making the + // assert_ne!(_, UNAUTHORIZED) trivially falsifiable for the wrong reason. + // X-Pubkey is the correct dev-mode bypass when require_auth_token=false. + let keys = nostr::Keys::generate(); + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + "x-pubkey", + keys.public_key().to_hex().parse().expect("valid header"), + ); + + let status = rt.block_on(oneshot_request( + state, "POST", "/query", &host, headers, b"[]", + )); + + // In Off mode the NIP-FI gate is transparent — any downstream status + // (200, 400, 500) is acceptable. The forbidden outcomes are NIP-FI + // gate denials: 401 (Enforce missing_evidence) and 503 (DenyProtected). + // + // Mutation evidence: changing the test state to Enforce mode causes the + // gate to fire (no Nostr-Federated-Identity header) returning 401, which + // falsifies the first assert_ne. + assert_ne!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "NIP-FI Off mode MUST NOT produce 401 from the gate [FI-INV-15]" + ); + assert_ne!( + status, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "NIP-FI Off mode MUST NOT produce 503 from the gate [FI-INV-15]" + ); + } + + // ── T2-seam: admitted malformed query through real handler → 400 ───────── + // + // Thufir's required seam test: one admitted malformed-query request through + // a real affected handler (`moderation_reports`) asserting 400. + // + // ## What this proves + // + // With the old `.ok().unwrap_or_default()` behavior: `?status=open&limit=abc` + // silently discarded ALL query fields (the entire `ModerationReadQuery` + // became `Default`) and the handler returned 200 with all reports. + // With `parse_query_or_400`: the handler returns 400 after admission. + // + // The test would fail against the old code because the handler would return + // 200 (list all reports) rather than 400. + // + // ## Setup + // + // NIP-FI Off mode + `require_auth_token = false` allows X-Pubkey dev-mode + // auth to bypass NIP-98 and NIP-FI gates, admitting the request to the + // application layer. The actor is seeded as community "owner" so the + // moderation authz check passes without requiring real relay member rows. + // + // ## Falsifying mutation + // + // Revert `parse_query_or_400` to `.ok().unwrap_or_default()` in + // `moderation_reports`. The handler returns 200 (all reports for the + // freshly created community — an empty array `[]`) instead of 400. + // The `assert_eq!(status, BAD_REQUEST)` assertion panics. + #[test] + #[ignore = "requires Postgres"] + fn t2_admitted_malformed_query_through_moderation_reports_is_400() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + // Off mode: NIP-FI gate is transparent; require_auth_token=false allows + // X-Pubkey dev-mode auth to admit the request. + let Some(state) = rt.block_on(nip_fi_off_test_state()) else { + panic!("local Postgres not reachable"); + }; + + let host = format!("t2-seam-{}.local", uuid::Uuid::new_v4().simple()); + let community = rt + .block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + // Seed the test actor as "owner" so moderation authz passes. + let actor_keys = Keys::generate(); + let actor_hex = actor_keys.public_key().to_hex(); + rt.block_on( + state + .db + .add_relay_member(community.id, &actor_hex, "owner", None), + ) + .expect("seed actor as owner"); + + // Build headers: X-Pubkey dev-mode admission (require_auth_token=false). + // No Nostr-Federated-Identity header — NIP-FI is Off, so the guard is + // transparent and the per-handler check admits unconditionally. + let mut headers = axum::http::HeaderMap::new(); + headers.insert("x-pubkey", actor_hex.parse().expect("valid header")); + + // Malformed query: `status=open` is valid but `limit=abc` is not. + // Old behavior: `.ok().unwrap_or_default()` → status=None, limit=None + // (all fields dropped), handler returns 200. + // New behavior: `parse_query_or_400` → 400 BAD_REQUEST. + let status = rt.block_on(oneshot_request( + state, + "GET", + "/moderation/reports?status=open&limit=abc", + &host, + headers, + b"", + )); + + assert_eq!( + status, + axum::http::StatusCode::BAD_REQUEST, + "T2 seam: GET /moderation/reports?status=open&limit=abc after admission MUST \ + return 400; if this returns 200 the handler is still using .ok().unwrap_or_default() \ + which silently discards all query fields on parse error [FI-TRACE-HTTP-INGRESS T2]" + ); + } + + // ── T1-IMP2: POST /internal/git/policy — Enforce mode → NOT 401 ───────── + // + // Verifies that `/internal/git/policy` is exempt from the NIP-FI guard in + // Enforce mode. The pre-receive hook callback carries no + // Nostr-Federated-Identity assertion and must reach the policy handler's + // own authorization layer, not be rejected by the guard. + // + // ## What this proves + // + // In Enforce mode, every non-exempt route without an assertion header gets + // 401 (MissingEvidence) from `nip_fi_assertion_guard`. `/internal/git/policy` + // appears in `NIP_FI_EXEMPT_PREFIXES`, so the guard forwards it instead. + // `require_localhost` then rejects (403) because Tower's `oneshot` does not + // inject `ConnectInfo`. A 403 proves the NIP-FI guard was NOT the rejector; + // a 401 would mean the guard fired and the exempt entry is broken. + // + // ## Falsifying mutation + // + // Remove `"/internal/git/policy"` from `NIP_FI_EXEMPT_PREFIXES` in + // `router.rs`. The guard fires, returns 401, and the `assert_ne!(401)` + // assertion panics. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_git_policy_callback_reaches_own_auth_not_nip_fi_guard() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + + // Minimal syntactically-valid payload — the HMAC will fail (no real + // hook secret), so the policy handler returns 403. We only care that + // the NIP-FI guard does NOT produce a 401 first. + let body = br#"{ + "repo_id": "test-repo", + "repo_owner": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "community_id": "test", + "pusher_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "ref_updates": [], + "timestamp": 1234567890, + "signature": "0000000000000000000000000000000000000000000000000000000000000000" + }"#; + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::CONTENT_TYPE, + "application/json".parse().expect("valid header"), + ); + // No Nostr-Federated-Identity header — the guard must pass this through. + + let status = rt.block_on(oneshot_request( + state, + "POST", + "/internal/git/policy", + "test.local", + headers, + body, + )); + + assert_ne!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "NIP-FI Enforce mode: POST /internal/git/policy with no assertion must NOT \ + be denied by the NIP-FI guard (401); the pre-receive hook does not carry an \ + assertion and must reach the policy handler's own auth layer \ + [FI-TRACE-HTTP-INGRESS T1-IMP2]" + ); + // The policy handler returns 403 (require_localhost check, since + // Tower's oneshot does not inject ConnectInfo) — not 401 from the guard. + // 403 proves the NIP-FI guard was not the rejector. + assert_eq!( + status, + axum::http::StatusCode::FORBIDDEN, + "POST /internal/git/policy must reach its own authorization layer (403), \ + not be blocked at the NIP-FI guard layer (which would return 401)" + ); + } + + // ── F4: bridge POST /query — deny_protected mode → 503 ────────────────── + // + // DenyProtected fires the gate unconditionally before any NIP-98 check, + // returning 503 authorization_unavailable. + // + // Falsifying mutation: switching DenyProtected to Off or Enforce changes the + // status — Off admits (non-401), Enforce needs assertion (401). Either way + // this assert fails. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_deny_protected_bridge_query_is_503() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_deny_protected_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + // The request carries a valid NIP-98 event signed for the community's + // actual URL (https://{host}/query), so the 503 cannot be attributed to + // a proof failure. + // + // In DenyProtected the router's `nip_fi_assertion_guard` returns 503 for + // this non-exempt route before the handler runs; `admit_nip_fi_http` + // would also return 503 as its first step, before NIP-98 or the + // assertion verifier. Neither path runs NIP-98 first. + let keys = Keys::generate(); + let url = format!("https://{host}/query"); + let auth_headers = make_nip98_headers(&keys, &url, "POST", b"[]"); + + let status = rt.block_on(oneshot_request( + state, + "POST", + "/query", + &host, + auth_headers, + b"[]", + )); + + // Mutation evidence: switching DenyProtected to Enforce causes the gate + // to return 401 (no Nostr-Federated-Identity header present); switching + // to Off causes the gate to admit and return a downstream status. Either + // change falsifies this assert_eq. + assert_eq!( + status, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "NIP-FI DenyProtected mode: POST /query MUST deny 503 authorization_unavailable \ + [FI-TRACE-HTTP-INGRESS]; if this fails the admit_nip_fi_http_on_state gate was \ + removed or mode was changed" + ); + } + + // ── T1-IMP1 (final): guard performs crypto verification, not just transport ── + // + // ## What this proves + // + // `nip_fi_assertion_guard` now performs the full offline assertion + // verification — not just transport-level shape validation. A structurally + // valid but cryptographically invalid assertion (wrong signature) MUST be + // denied by the guard with 403 `evidence_rejected`, before the handler fires. + // + // ## Why the test distinguishes guard vs per-handler + // + // The request carries a bad-sig assertion token but NO NIP-98 + // `Authorization: Nostr ...` header. With `require_auth_token = true`: + // + // • Guard intact: `verifier.verify_assertion(bad_token)` → EvidenceRejected + // → 403 (guard denies before handler fires). + // + // • Guard mutated (step 2 removed): guard forwards. Handler's NIP-98 + // auth layer fires first → missing auth → 401. + // + // 403 ≠ 401, so the mutation turns this test RED. + // + // ## What "mandatory wiring" means + // + // The removed wiring in the falsifying mutation is the + // `verifier.verify_assertion(token)` call in `nip_fi_assertion_guard` + // (`router.rs`). Removing it restores the old transport-only guard, which + // forwards any structurally valid token to the handler. That is the + // "forgotten-gate" failure class: a handler that omits + // `admit_nip_fi_http_on_state` would admit with an invalidly-signed + // assertion if the guard doesn't verify. + // + // ## Verifier construction + // + // To get a distinguishable outcome, this test injects a real + // `StaticIssuerKeySource`-backed verifier into the state (rather than + // `nip_fi_verifier = None`), so that a bad-sig token produces a definite + // 403 (not a startup-race 503 that a handler check would also produce). + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_guard_rejects_crypto_invalid_assertion_before_handler_fires() { + use buzz_auth::{ + AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, + IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, + }; + use jsonwebtoken::{jwk::JwkSet, Algorithm}; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + // ── 1. Build the test state with a real injected verifier ───────────── + + let Some(mut state) = rt.block_on(async { + // Clone nip_fi_enforce_test_state setup, but return the state + // before Arc-wrapping so we can inject the verifier. + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = crate::test_support::database_url(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "wss://nip-fi-test.local".to_string(); + config.require_auth_token = true; + config.require_relay_membership = false; + config.nip_fi.mode = buzz_auth::NipFiMode::Enforce; + + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + + let (mut state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Some(state) + }) else { + panic!("local Postgres not reachable"); + }; + + // ── 2. Build the verifier with StaticIssuerKeySource + test key ─────── + // + // The verifier is seeded with a known P-256 public key. Tokens that + // claim `iss=https://issuer.test` will be verified against this key. + // A token with an all-zero signature will fail `InvalidSignatureOrClaims` + // → DenialClass::EvidenceRejected → 403. + // + // Key constants match the canonical test key in buzz-auth + // (verifier/tests.rs): TEST_JWK_X / TEST_JWK_Y / TEST_KID / ISSUER. + const TEST_ISSUER: &str = "https://issuer.example"; + const TEST_AUDIENCE: &str = "https://relay.example"; + const TEST_KID: &str = "test-key-1"; + + let jwks: JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", + "crv": "P-256", + "use": "sig", + "alg": "ES256", + "kid": TEST_KID, + "x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI", + "y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA" + }] + })) + .expect("valid test JWKS"); + + let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); + let key_set = AssertionKeySet::new_for_test(TEST_ISSUER.to_owned(), 1, jwks, hard_deadline) + .expect("valid test key set"); + + let jwks_contract = buzz_auth::JwksSourceContract::new( + format!("{TEST_ISSUER}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid jwks contract"); + + let policy = IssuerPolicy::new( + TEST_ISSUER.to_owned(), + vec![TEST_AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, // skew_seconds + 3600, // max_assertion_age_seconds + None, + jwks_contract, + ) + .expect("valid issuer policy"); + + let mut registry = IssuerRegistry::new(); + registry.insert(policy); + + let verifier: Arc = Arc::new(FederatedAssertionVerifier::new( + registry, + StaticIssuerKeySource::new([key_set]), + )); + + state.nip_fi_verifier = Some(verifier); + let state = Arc::new(state); + + let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + // ── 3. Build a structurally valid but cryptographically invalid token ─ + // + // Header and claims match the verifier's expectations (correct issuer, + // audience, exp, nostr_pubkey). The signature is 64 zero bytes — + // structurally valid base64url for an ES256 DER signature, but + // cryptographically invalid. The verifier will parse through to the + // signature check and fail with EvidenceRejected (403). + const BAD_SIG_TOKEN: &str = concat!( + // Header: {"alg":"ES256","kid":"test-key-1"} + "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5LTEifQ", + ".", + // Claims: {"iss":"https://issuer.example","aud":"https://relay.example", + // "iat":1700000000,"exp":9999999999, + // "nostr_pubkey":"1234...cdef","sub":"test-subject"} + "eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjoiaHR0cHM6Ly9yZWxheS5leGFtcGxlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjk5OTk5OTk5OTksIm5vc3RyX3B1YmtleSI6IjEyMzQ1Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVmMTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTBhYmNkZWYiLCJzdWIiOiJ0ZXN0LXN1YmplY3QifQ", + ".", + // Signature: 64 zero bytes (invalid) + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + ); + + // Verify the token is structurally valid (3 dots, valid base64url segments) + // but is actually rejected by the verifier: + let verifier_check = state + .nip_fi_verifier + .as_deref() + .expect("verifier injected") + .verify_assertion(BAD_SIG_TOKEN); + assert!( + verifier_check.is_err(), + "pre-condition: the bad-sig token MUST be rejected by the verifier; \ + if it passes, the test cannot distinguish guard-deny from handler-deny" + ); + + // ── 4. Send the request through the production router ───────────────── + // + // The request carries: + // • Nostr-Federated-Identity: Bearer (structurally valid, bad sig) + // • NO Authorization: Nostr ... (no NIP-98) + // + // Expected with guard verifying (current code): + // Guard calls verifier.verify_assertion(bad_token) → EvidenceRejected + // → 403 evidence_rejected before handler fires. + // + // Falsifying mutation (remove verifier.verify_assertion from guard): + // Guard forwards (step 2 removed) → handler's NIP-98 auth fires first + // → missing NIP-98 → 401. 403 ≠ 401 → test fails. + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {BAD_SIG_TOKEN}") + .parse() + .expect("valid header"), + ); + // Deliberately NO Authorization header (no NIP-98). + + let status = rt.block_on(oneshot_request( + state, "POST", "/events", &host, headers, b"{}", + )); + + assert_eq!( + status, + axum::http::StatusCode::FORBIDDEN, + "NIP-FI enforce mode: POST /events with cryptographically invalid assertion \ + (bad sig) MUST deny 403 evidence_rejected from the guard before the handler \ + fires [FI-TRACE-AUTHORITY-UNIFORM, T1-IMP1]. \ + Falsifying mutation: remove verifier.verify_assertion from nip_fi_assertion_guard \ + → guard forwards → missing NIP-98 → 401 ≠ 403 → test fails." + ); + } + + // ── R3 cardinality regression: actual-caller (/query) ──────────────────── + // + // Proves that the cardinality gate in `admit_nip_fi_http` fires on actual + // HTTP routes, not just the unit-level `admit_nip_fi_http` tests. + // + // The unit tests in nip_fi_http.rs prove the gate logic; this test proves + // the gate is actually wired into the `/query` route through the full router. + // + // ## Off-mode auth-required compatibility control + // + // Off mode must NOT reject duplicate Authorization headers — `verify_bridge_auth` + // used `.get()` (first-value) before NIP-FI. FI-INV-15 requires that Off mode + // preserves this behavior. The state is built with `require_auth_token = true` + // so the NIP-98 layer is active; single valid NIP-98 succeeds (200 []); a + // valid-first / malformed-second duplicate also uses the first value and + // succeeds (same 200 []). The cardinality gate is bypassed in Off mode: + // neither the single nor the duplicate case returns 403. + // + // Falsifying mutation: add a cardinality check before legacy auth in Off + // mode → duplicate case returns 403 EvidenceRejected → assertion fires. + // + // ## Enforce-mode cardinality denial + // + // Enforce mode + a valid assertion + two Authorization headers must return + // 403 EvidenceRejected from the cardinality gate BEFORE NIP-98 is parsed. + // The assertion guard passes with a valid signed token; the cardinality check + // inside `admit_nip_fi_http` then fires because `auth_count == 2`. + // + // Negative control: without a valid assertion the middleware 401s first and + // the cardinality gate is never reached — the old test exercised the wrong path. + // + // Falsifying mutation: remove the cardinality gate in Enforce mode → the + // two-header request passes cardinality, NIP-98 proceeds with keys2/url2 + // matching → pairing succeeds → handler returns 200 [] (same as single-header + // positive control) → body "evidence rejected\n" assertion fires. + // + // ## Single-header same-key positive control + // + // A single Authorization header with the same valid assertion must NOT produce + // a cardinality denial. Without this, an always-denying implementation passes + // the two-header test. Exact success: status 200, body []. + #[test] + #[ignore = "requires Postgres and Redis"] + fn r3_cardinality_actual_caller_query_off_passes_enforce_denies() { + use buzz_auth::{ + AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, + IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, + }; + use jsonwebtoken::{jwk::JwkSet, Algorithm}; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + // ── Off mode: require_auth_token=true, first-value semantics ───────── + // + // Build an Off state with `require_auth_token = true` so the NIP-98 gate + // is active and can actually validate the token. This differs from the + // shared `nip_fi_off_test_state()` helper which uses `require_auth_token=false`. + // + // With auth required: + // Case 1: single valid NIP-98 → auth passes → handler → 200 []. + // Case 2: valid-first + malformed-second ("Nostr AAAA") → Off mode uses + // first-value semantics (.get() on Authorization) → same 200 []. + // + // Identity of the two results proves first-value semantics preserved. + // Neither is 403: proves cardinality gate is not applied in Off mode. + let Some(off_state) = rt.block_on(async { + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = crate::test_support::database_url(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "wss://nip-fi-test.local".to_string(); + config.require_auth_token = true; + config.require_relay_membership = false; + config.nip_fi.mode = buzz_auth::NipFiMode::Off; + + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + let (mut state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Some(Arc::new(state)) + }) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-cardinality-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(off_state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let url = format!("https://{host}/query"); + // Build a valid single NIP-98 header value. + let nip98_header_value = { + let mut h = make_nip98_headers(&keys, &url, "POST", b"[]"); + h.remove(axum::http::header::AUTHORIZATION) + .expect("authorization header") + }; + // Malformed second header: valid Nostr scheme prefix, invalid payload. + // "Nostr AAAA" decodes as 3 zero bytes — not a valid JSON Nostr event. + let malformed_nostr_header: axum::http::HeaderValue = + "Nostr AAAA".parse().expect("valid header bytes"); + + // Case 1: single valid NIP-98 → auth passes → 200 []. + let (single_off_status, _, single_off_body) = rt.block_on(oneshot_request_full( + Arc::clone(&off_state), + "POST", + "/query", + &host, + { + let mut h = axum::http::HeaderMap::new(); + h.append( + axum::http::header::AUTHORIZATION, + nip98_header_value.clone(), + ); + h + }, + b"[]", + )); + assert_eq!( + single_off_status, + axum::http::StatusCode::OK, + "Off mode: single valid NIP-98 MUST reach the handler and return 200. \ + [FI-INV-15]" + ); + assert_eq!( + single_off_body.as_ref(), + b"[]", + "Off mode: single valid NIP-98 MUST return empty events array for empty filter set." + ); + + // Case 2: valid-first + malformed-second → Off uses first-value → same 200 []. + // Identical values cannot distinguish first-value from last-value selection — + // valid-first/invalid-second proves the first value is used, not the last. + let (dup_off_status, _, dup_off_body) = rt.block_on(oneshot_request_full( + off_state, + "POST", + "/query", + &host, + { + let mut h = axum::http::HeaderMap::new(); + h.append( + axum::http::header::AUTHORIZATION, + nip98_header_value.clone(), + ); + h.append( + axum::http::header::AUTHORIZATION, + malformed_nostr_header.clone(), + ); + h + }, + b"[]", + )); + assert_ne!( + dup_off_status, + axum::http::StatusCode::FORBIDDEN, + "Off mode: duplicate Authorization headers MUST NOT produce 403 EvidenceRejected \ + from the cardinality gate [FI-INV-15]. Off mode must preserve first-value legacy \ + behavior — cardinality denial is an Enforce-only contract. \ + Falsifying mutation: add cardinality check in Off mode → 403 → assertion fires." + ); + assert_eq!( + single_off_status, dup_off_status, + "Off mode: duplicate-header result must equal single-header result — \ + the first valid header is used (first-value semantics), \ + not treated as a cardinality violation." + ); + assert_eq!( + single_off_body, dup_off_body, + "Off mode: single and dup bodies must match — first-value semantics \ + means the malformed second header is silently discarded." + ); + + // ── Enforce mode: build a state with a real injected verifier ───────── + // + // The verifier is required so the assertion guard can validate the signed + // token and forward the request. Without a verifier, the middleware 401s + // before the cardinality gate inside `admit_nip_fi_http` can fire. + let Some(mut enforce_state) = rt.block_on(async { + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = crate::test_support::database_url(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "wss://nip-fi-test.local".to_string(); + config.require_auth_token = true; + config.require_relay_membership = false; + config.nip_fi.mode = buzz_auth::NipFiMode::Enforce; + + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + let (mut state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Some(state) + }) else { + panic!("local Postgres not reachable (enforce)"); + }; + + // Inject the real verifier with the static test key. + const TEST_ISSUER: &str = "https://issuer.example"; + const TEST_AUDIENCE: &str = "https://relay.example"; + const TEST_KID: &str = "test-key-1"; + // PKCS#8 private key matching TEST_JWK_X/Y — same key used by + // nip_fi_guard_rejects_crypto_invalid_assertion_before_handler_fires. + const TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + + let jwks: JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", + "kid": TEST_KID, + "x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI", + "y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA" + }] + })) + .expect("valid test JWKS"); + + let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); + let key_set = AssertionKeySet::new_for_test(TEST_ISSUER.to_owned(), 1, jwks, hard_deadline) + .expect("valid test key set"); + let jwks_contract = buzz_auth::JwksSourceContract::new( + format!("{TEST_ISSUER}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid jwks contract"); + let policy = IssuerPolicy::new( + TEST_ISSUER.to_owned(), + vec![TEST_AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, + 3600, + None, + jwks_contract, + ) + .expect("valid issuer policy"); + let mut registry = IssuerRegistry::new(); + registry.insert(policy); + let verifier: Arc = Arc::new(FederatedAssertionVerifier::new( + registry, + StaticIssuerKeySource::new([key_set]), + )); + enforce_state.nip_fi_verifier = Some(verifier); + let enforce_state = Arc::new(enforce_state); + + let host2 = format!( + "nip-fi-cardinality-enf-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(enforce_state.db.ensure_configured_community(&host2)) + .expect("ensure community"); + + // Mint a valid signed assertion for an arbitrary test pubkey. + let assertion_pubkey_hex = nostr::Keys::generate().public_key().to_hex(); + let valid_assertion = { + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "iat": now, + "exp": now + 600, + "sub": "test-subject", + "nostr_pubkey": assertion_pubkey_hex, + }); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(TEST_KID.to_owned()); + header.typ = Some("nip-fi+jwt".to_owned()); + let key = + EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).expect("valid test EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign assertion") + }; + + // Pre-condition: verifier accepts the token. + assert!( + enforce_state + .nip_fi_verifier + .as_deref() + .expect("verifier injected") + .verify_assertion(&valid_assertion) + .is_ok(), + "pre-condition: valid assertion must be accepted by the verifier" + ); + + // Use the same key for both NIP-98 and the assertion's nostr_pubkey so + // the pairing check succeeds and the request reaches the query handler. + let keys2 = Keys::generate(); + let url2 = format!("https://{host2}/query"); + let nip98_val2 = { + let mut h = make_nip98_headers(&keys2, &url2, "POST", b"[]"); + h.remove(axum::http::header::AUTHORIZATION) + .expect("authorization header") + }; + + // Mint a same-key assertion: nostr_pubkey = keys2's public key. + let same_key_assertion = { + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "iat": now, + "exp": now + 600, + "sub": "test-subject", + "nostr_pubkey": keys2.public_key().to_hex(), + }); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(TEST_KID.to_owned()); + header.typ = Some("nip-fi+jwt".to_owned()); + let key = + EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).expect("valid test EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign same-key assertion") + }; + // Pre-condition: same-key assertion is accepted. + assert!( + enforce_state + .nip_fi_verifier + .as_deref() + .expect("verifier injected") + .verify_assertion(&same_key_assertion) + .is_ok(), + "pre-condition: same-key assertion must be accepted" + ); + + // ── Same-key positive control: 1 Authorization header + same-key assertion ─ + // + // One Authorization header passes the cardinality gate; the NIP-98 key + // matches the assertion's nostr_pubkey → pairing succeeds → handler reached. + // + // Falsifying mutation: always return 403 from cardinality → this test + // returns 403 EvidenceRejected → assertion fires. + let mut single_headers = axum::http::HeaderMap::new(); + single_headers.append(axum::http::header::AUTHORIZATION, nip98_val2.clone()); + single_headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}") + .parse() + .expect("valid header"), + ); + + let single_resp = rt.block_on(async { + use axum::body::{to_bytes, Body}; + use tower::ServiceExt; + let mut builder = axum::http::Request::builder() + .method("POST") + .uri("/query") + .header("host", &host2); + for (name, value) in &single_headers { + builder = builder.header(name, value); + } + let resp = crate::router::build_router(Arc::clone(&enforce_state)) + .oneshot( + builder + .body(Body::from(b"[]".to_vec())) + .expect("build request"), + ) + .await + .expect("router oneshot"); + let status = resp.status(); + let body = to_bytes(resp.into_body(), 4096).await.unwrap_or_default(); + (status, body) + }); + + // Single same-key: cardinality passes, pairing passes; handler reached. + // Exact success: 200 [] (empty filter set on fresh community has no events). + // Falsifying mutation: always-denying cardinality → 403 EvidenceRejected. + assert_eq!( + single_resp.0, + axum::http::StatusCode::OK, + "Single Authorization header + same-key assertion MUST return 200. \ + Falsifying mutation: lower the gate threshold to 1 → 403 EvidenceRejected." + ); + assert_eq!( + single_resp.1.as_ref(), + b"[]", + "Single Authorization header + same-key assertion MUST return empty events array \ + for empty filter set on a fresh community." + ); + + // ── Enforce mode: duplicate header + same-key assertion → cardinality 403 ─ + let mut enforce_headers = axum::http::HeaderMap::new(); + enforce_headers.append(axum::http::header::AUTHORIZATION, nip98_val2.clone()); + enforce_headers.append(axum::http::header::AUTHORIZATION, nip98_val2.clone()); + enforce_headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}") + .parse() + .expect("valid header"), + ); + + let (enforce_status, enforce_resp_headers, enforce_body) = + rt.block_on(oneshot_request_full( + Arc::clone(&enforce_state), + "POST", + "/query", + &host2, + enforce_headers, + b"[]", + )); + + assert_eq!( + enforce_status, + axum::http::StatusCode::FORBIDDEN, + "Enforce mode: duplicate Authorization headers must yield 403 EvidenceRejected \ + from cardinality gate [FI-TRACE-DENIAL-ORACLE]. \ + Falsifying mutation: remove cardinality gate → NIP-98 closure runs → \ + handler returns 200 [] (same as single-header positive control)." + ); + assert_eq!( + enforce_body.as_ref(), + b"evidence rejected\n", + "Enforce mode: cardinality denial body must be exact contract bytes 'evidence rejected\\n'" + ); + let enforce_ct = enforce_resp_headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + enforce_ct, "text/plain; charset=utf-8", + "Enforce mode: cardinality 403 Content-Type must be 'text/plain; charset=utf-8'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + assert!( + enforce_resp_headers + .get(axum::http::header::WWW_AUTHENTICATE) + .is_none(), + "Enforce mode: cardinality 403 MUST NOT emit WWW-Authenticate — \ + the client has a token but it is malformed, not absent. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + } + /// T3c — log fidelity for canvas CAS conflict: the terminal attribution line /// must log `status=409`, not 400, when the relay emits a canvas CAS 409. /// diff --git a/crates/buzz-relay/src/api/gifs.rs b/crates/buzz-relay/src/api/gifs.rs index c29df6746b..871f279fc8 100644 --- a/crates/buzz-relay/src/api/gifs.rs +++ b/crates/buzz-relay/src/api/gifs.rs @@ -15,7 +15,7 @@ use std::time::Duration; use axum::{ extract::State, http::{header, HeaderMap, StatusCode}, - response::Json, + response::{IntoResponse, Json, Response}, }; use futures_util::StreamExt; use serde::Deserialize; @@ -118,12 +118,13 @@ fn klipy_share_request( .json(&serde_json::json!({ "customer_id": request.customer_id }))) } +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers async fn authenticate( state: &Arc, headers: &HeaderMap, path: &str, body: &[u8], -) -> Result<(buzz_core::TenantContext, nostr::PublicKey), (StatusCode, Json)> { +) -> Result<(buzz_core::TenantContext, nostr::PublicKey), Response> { let raw_host = headers .get(header::HOST) .and_then(|value| value.to_str().ok()) @@ -135,23 +136,33 @@ async fn authenticate( StatusCode::NOT_FOUND, "relay: no community is configured for this host", ) + .into_response() })?; let expected_url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); - let bridge::VerifiedBridgeAuth { - pubkey, - event_id_bytes, - signed_created_at, - } = bridge::verify_bridge_auth_with_options( + + // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] + let admission = crate::nip_fi_http::admit_nip_fi_http_on_state( + state, headers, - "POST", - &expected_url, - Some(body), - true, - true, + bridge::make_nip98_closure_for_admission( + headers.clone(), + "POST", + expected_url, + Some(body.to_vec()), + true, + true, + ), )?; - bridge::enforce_http_admission(state, &tenant, &pubkey).await?; - bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + let pubkey = *admission.proven_pubkey(); + let (event_id_bytes, signed_created_at) = admission.into_extra(); + + bridge::enforce_http_admission(state, &tenant, &pubkey) + .await + .map_err(|e| e.into_response())?; + bridge::check_nip98_replay(state, &tenant, event_id_bytes) + .await + .map_err(|e| e.into_response())?; relay_members::enforce_relay_membership( state, tenant.community(), @@ -159,7 +170,8 @@ async fn authenticate( relay_members::extract_auth_tag_header(headers), signed_created_at, ) - .await?; + .await + .map_err(|e| e.into_response())?; Ok((tenant, pubkey)) } @@ -265,20 +277,35 @@ pub async fn search( State(state): State>, headers: HeaderMap, body: axum::body::Bytes, -) -> Result, (StatusCode, Json)> { - let Some(config) = state.config.klipy.as_ref() else { - return Err(api_error( - StatusCode::NOT_FOUND, - "GIF search is not configured", - )); - }; +) -> Response { + search_inner(state, headers, body).await.into_response() +} + +async fn search_inner( + state: Arc, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, Response> { + // Admission first: NIP-98 + NIP-FI must fire before any application-level + // check (including provider availability) so the denial contract wins over + // config or request-validation errors. [FI-TRACE-HTTP-INGRESS] let (tenant, pubkey) = authenticate(&state, &headers, SEARCH_PATH, &body).await?; - let request: SearchRequest = serde_json::from_slice(&body) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid GIF search JSON"))?; - validate_text("query", &request.query, 200, true)?; - validate_text("customer_id", &request.customer_id, 128, false)?; - validate_text("locale", &request.locale, 32, false)?; - enforce_search_admission(&state, &tenant, &pubkey).await?; + + let Some(config) = state.config.klipy.as_ref() else { + return Err( + api_error(StatusCode::NOT_FOUND, "GIF search is not configured").into_response(), + ); + }; + let request: SearchRequest = serde_json::from_slice(&body).map_err(|_| { + api_error(StatusCode::BAD_REQUEST, "invalid GIF search JSON").into_response() + })?; + validate_text("query", &request.query, 200, true).map_err(|e| e.into_response())?; + validate_text("customer_id", &request.customer_id, 128, false) + .map_err(|e| e.into_response())?; + validate_text("locale", &request.locale, 32, false).map_err(|e| e.into_response())?; + enforce_search_admission(&state, &tenant, &pubkey) + .await + .map_err(|e| e.into_response())?; let endpoint = if request.query.trim().is_empty() { "trending" @@ -294,22 +321,30 @@ pub async fn search( if !request.query.trim().is_empty() { query.push(("q", request.query.trim())); } - let url = klipy_url(config.api_key(), &["gifs", endpoint], &query)?; - let response = send_upstream(state.gif_http_client.get(url)).await?; + let url = + klipy_url(config.api_key(), &["gifs", endpoint], &query).map_err(|e| e.into_response())?; + let response = send_upstream(state.gif_http_client.get(url)) + .await + .map_err(|e| e.into_response())?; if !response.status().is_success() { tracing::warn!(status = response.status().as_u16(), "KLIPY search failed"); return Err(api_error( StatusCode::BAD_GATEWAY, "GIF provider rejected the search request", - )); + ) + .into_response()); } // Never forward the provider response wholesale. KLIPY may report an // application-level failure with HTTP 200 and include request details in // its error fields. Allowlist only successful result data so credentials // and provider diagnostics cannot cross the relay boundary. - let upstream = limited_json(response).await?; - Ok(Json(successful_search_payload(&upstream)?)) + let upstream = limited_json(response) + .await + .map_err(|e| e.into_response())?; + Ok(Json( + successful_search_payload(&upstream).map_err(|e| e.into_response())?, + )) } /// Report a selected GIF to KLIPY so the provider can update Recents. @@ -317,31 +352,44 @@ pub async fn share( State(state): State>, headers: HeaderMap, body: axum::body::Bytes, -) -> Result)> { - let Some(config) = state.config.klipy.as_ref() else { - return Err(api_error( - StatusCode::NOT_FOUND, - "GIF search is not configured", - )); - }; - authenticate(&state, &headers, SHARE_PATH, &body).await?; - let request: ShareRequest = serde_json::from_slice(&body) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid GIF share JSON"))?; - validate_text("slug", &request.slug, 200, false)?; - validate_text("customer_id", &request.customer_id, 128, false)?; +) -> Response { + share_inner(state, headers, body).await.into_response() +} - let response = send_upstream(klipy_share_request( - &state.gif_http_client, - config.api_key(), - &request, - )?) - .await?; +async fn share_inner( + state: Arc, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result { + // Admission first: NIP-98 + NIP-FI must fire before provider availability + // check. [FI-TRACE-HTTP-INGRESS] + authenticate(&state, &headers, SHARE_PATH, &body).await?; + + let Some(config) = state.config.klipy.as_ref() else { + return Err( + api_error(StatusCode::NOT_FOUND, "GIF search is not configured").into_response(), + ); + }; + let request: ShareRequest = serde_json::from_slice(&body).map_err(|_| { + api_error(StatusCode::BAD_REQUEST, "invalid GIF share JSON").into_response() + })?; + validate_text("slug", &request.slug, 200, false).map_err(|e| e.into_response())?; + validate_text("customer_id", &request.customer_id, 128, false) + .map_err(|e| e.into_response())?; + + let response = send_upstream( + klipy_share_request(&state.gif_http_client, config.api_key(), &request) + .map_err(|e| e.into_response())?, + ) + .await + .map_err(|e| e.into_response())?; if !response.status().is_success() { tracing::warn!(status = response.status().as_u16(), "KLIPY share failed"); return Err(api_error( StatusCode::BAD_GATEWAY, "GIF provider rejected the share request", - )); + ) + .into_response()); } Ok(StatusCode::NO_CONTENT) diff --git a/crates/buzz-relay/src/api/git/settings.rs b/crates/buzz-relay/src/api/git/settings.rs index 2e1f99811f..ec2250ea08 100644 --- a/crates/buzz-relay/src/api/git/settings.rs +++ b/crates/buzz-relay/src/api/git/settings.rs @@ -29,6 +29,7 @@ use super::{ }; use crate::{ api::{api_error, bridge, relay_members}, + nip_fi_http::admit_nip_fi_http_on_state, state::AppState, }; @@ -174,37 +175,54 @@ async fn authenticate( .await .map_err(|_| error(StatusCode::NOT_FOUND, "repository not found"))?; let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); - let auth = bridge::verify_bridge_auth_with_options( + let method = if body.is_some() { "POST" } else { "GET" }; + let require_payload = body.is_some(); + + // NIP-FI admission: runs NIP-98 extraction, assertion verification, and + // key pairing in fixed order. The control-plane route has strict binding: + // - `require_auth_token = true` (NIP-98 always required; no X-Pubkey fallback) + // - `require_payload = body.is_some()` (POST bodies must be hash-bound) + // + // The router assertion guard already verified the assertion cryptographically; + // this call pairs the proven NIP-98 pubkey with the assertion's claimed key + // and checks the deny map. [FI-TRACE-AUTHORITY-UNIFORM] + let admission = admit_nip_fi_http_on_state( + state, headers, - if body.is_some() { "POST" } else { "GET" }, - &url, - body, - true, - body.is_some(), - ) - .map_err(IntoResponse::into_response)?; - bridge::enforce_http_admission(state, &tenant, &auth.pubkey) + bridge::make_nip98_closure_for_admission( + headers.clone(), + method, + url, + body.map(|b| b.to_vec()), + true, // require_auth_token: always required for control-plane + require_payload, + ), + )?; + let pubkey = *admission.proven_pubkey(); + let (event_id_bytes, signed_created_at) = admission.into_extra(); + + bridge::enforce_http_admission(state, &tenant, &pubkey) .await .map_err(IntoResponse::into_response)?; - bridge::check_nip98_replay(state, &tenant, auth.event_id_bytes) + bridge::check_nip98_replay(state, &tenant, event_id_bytes) .await .map_err(IntoResponse::into_response)?; let tag = relay_members::extract_auth_tag_header(headers); relay_members::enforce_relay_membership( state, tenant.community(), - auth.pubkey.as_bytes(), + pubkey.as_bytes(), tag, - auth.signed_created_at, + signed_created_at, ) .await .map_err(IntoResponse::into_response)?; deny_banned_git_principal( &state.db, tenant.community(), - &auth.pubkey, + &pubkey, tag, - auth.signed_created_at, + signed_created_at, ) .await?; // Admission ignores kind= restrictions by design (NIP-AA). Repository @@ -223,15 +241,11 @@ async fn authenticate( }) }) .and_then(|tag| { - relay_members::extract_nip_oa_owner( - auth.pubkey.as_bytes(), - Some(tag), - auth.signed_created_at, - ) + relay_members::extract_nip_oa_owner(pubkey.as_bytes(), Some(tag), signed_created_at) }); Ok(SettingsAuth { tenant, - caller: auth.pubkey, + caller: pubkey, delegated_owner, }) } diff --git a/crates/buzz-relay/src/api/git/settings_tests.rs b/crates/buzz-relay/src/api/git/settings_tests.rs index 55c04dddcf..3a7318bab2 100644 --- a/crates/buzz-relay/src/api/git/settings_tests.rs +++ b/crates/buzz-relay/src/api/git/settings_tests.rs @@ -1,6 +1,1139 @@ //! Live route/store/clone regressions. Require explicit isolated service URLs; //! never fall back to a developer's Desktop database. +// ── NIP-FI admission seam — settings route ────────────────────────────────── +// +// Proves that `authenticate()` in `git/settings.rs` routes through +// `admit_nip_fi_http_on_state`, not the raw bridge verifier. +// +// Falsifying mutation: replace the `admit_nip_fi_http_on_state(...)` call in +// `authenticate()` with the old raw `verify_bridge_auth_with_options(...)`. +// With that mutation, a valid NIP-98 proof for key B + assertion for key A +// (mismatched keys) would be admitted — the handler never checks key pairing. +// Without the mutation the request is denied 401 `authentication required\n` +// (MissingEvidence: no `Nostr-Federated-Identity` assertion header). +// +// The test here is: valid NIP-98 + Enforce mode + no assertion → 401 from +// `admit_nip_fi_http_on_state` (the same body the guard would produce if the +// guard itself fired). The important invariant is that the HANDLER calls +// admission — the guard also fires, and both 401, so this is observationally +// equivalent to having only the guard. However, the handler call is required +// by NIP-FI.md:516-533 for key pairing, which cannot be verified at the guard. +// The `#[ignore]` comment explains why a full key-pairing test needs JWT infra. +#[cfg(test)] +mod postgres_tests { + use super::super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use base64::Engine; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use tower::ServiceExt; + + struct AlwaysFreshReplayGuard; + + impl buzz_auth::Nip98ReplayGuard for AlwaysFreshReplayGuard { + fn try_mark_in_scope<'a>( + &'a self, + _scope: &'a str, + _event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(async { Ok(true) }) + } + } + + /// Build a minimal Enforce-mode AppState that reaches the settings route. + /// + /// No issuers configured → `nip_fi_verifier = None` (DenyProtected startup path). + /// The test fires before the verifier is needed: missing assertion → 401 + /// `MissingEvidence` before the verifier is consulted. + async fn enforce_state() -> Option> { + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = crate::test_support::database_url(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "ws://nip-fi-settings-test.local".to_string(); + config.require_auth_token = true; + config.require_relay_membership = false; + config.nip_fi.mode = buzz_auth::NipFiMode::Enforce; + + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + + let (mut state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Some(Arc::new(state)) + } + + fn nip98_get_token(keys: &Keys, url: &str) -> String { + let tags = vec![ + Tag::parse(["u", url]).expect("u tag"), + Tag::parse(["method", "GET"]).expect("method tag"), + Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).expect("nonce tag"), + ]; + let event = EventBuilder::new(Kind::Custom(27235), "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign NIP-98 event"); + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event).unwrap()) + ) + } + + // ── R1 NIP-FI admission seam: settings GET, Enforce, no assertion → 401 ── + // + // Falsifying mutation: remove the `admit_nip_fi_http_on_state(...)` call + // from `authenticate()` in `git/settings.rs`, replacing it with the old + // raw bridge verifier. With the old verifier, a valid NIP-98 token for any + // community member would be admitted without key pairing — the response + // would be 200 or a different status. With the NIP-FI call present and no + // assertion header, `admit_nip_fi_http_on_state` maps the absent header to + // MissingEvidence (401, "authentication required\n", `WWW-Authenticate: Nostr`). + // + // Note: the outer router guard also fires on missing assertion, so a + // missing-assertion test is not sufficient to distinguish "handler calls + // admission" from "guard fires first". A full key-pairing test requires a + // real JWT infrastructure with a live JWKS endpoint — that lives in the + // integration test suite. This seam test focuses on the code path change + // (verify_bridge_auth_with_options → admit_nip_fi_http_on_state) and confirms + // the settings route is reachable in Enforce mode with valid NIP-98 auth. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_settings_get_no_assertion_is_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(enforce_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-settings-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + // Use a dummy path (repo won't exist, but NIP-FI admission fires before the repo lookup). + let path = format!( + "/git/{}/test-repo/default-branch", + keys.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + let token = nip98_get_token(&keys, &url); + + let (status, body) = rt.block_on(async { + use axum::body::to_bytes; + let response = super::super::super::transport::git_router(state) + .oneshot( + Request::builder() + .method("GET") + .uri(&path) + .header("host", &host) + .header("authorization", &token) + // No Nostr-Federated-Identity header — this is the no-assertion case. + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("router oneshot"); + let status = response.status(); + let body = to_bytes(response.into_body(), 4096) + .await + .unwrap_or_default(); + (status, body) + }); + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "NIP-FI Enforce: settings GET with valid NIP-98 + no assertion must deny 401 \ + [FI-TRACE-AUTHORITY-UNIFORM]. Falsifying mutation: replace \ + admit_nip_fi_http_on_state() in authenticate() with the raw bridge verifier — \ + a mismatched-key request would then be admitted." + ); + // MissingEvidence body: "authentication required\n" + assert_eq!( + body.as_ref(), + b"authentication required\n", + "missing assertion must produce MissingEvidence body, not a NIP-98 auth challenge \ + or other error" + ); + } + + // ── NIP-FI settings via build_router: key-pairing, OFF, POST protection ── + // + // These tests exercise the settings route through `build_router` (the full + // relay router), which includes the `nip_fi_assertion_guard` middleware. + // The key-pairing tests require a real `FederatedAssertionVerifier` seeded + // with a static test key, so assertions signed by a known PKCS#8 key can + // carry a chosen `nostr_pubkey` claim. + // + // ## Test key constants + // + // Same P-256 key as buzz-auth/src/nip_fi/verifier/tests.rs so + // the construction pattern can be reviewed against a known-good example. + const TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + const TEST_JWK_X: &str = "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI"; + const TEST_JWK_Y: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; + const TEST_KID: &str = "test-key-1"; + const TEST_ISSUER: &str = "https://issuer.test"; + const TEST_AUDIENCE: &str = "https://relay.test"; + + /// Build an Enforce-mode state with a real `FederatedAssertionVerifier` + /// seeded with the static test key. Used for key-pairing tests. + async fn enforce_state_with_verifier() -> Option> { + use buzz_auth::{ + FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, IssuerRegistry, + StaticIssuerKeySource, TokenClass, VerifyAssertion, + }; + use jsonwebtoken::{jwk::JwkSet, Algorithm}; + + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = crate::test_support::database_url(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "ws://nip-fi-settings-pairing-test.local".to_string(); + config.require_auth_token = true; + config.require_relay_membership = false; + config.nip_fi.mode = buzz_auth::NipFiMode::Enforce; + + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + + let (mut state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + + // Build the verifier with a static key. + let jwks: JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", + "crv": "P-256", + "use": "sig", + "alg": "ES256", + "kid": TEST_KID, + "x": TEST_JWK_X, + "y": TEST_JWK_Y + }] + })) + .expect("valid test JWKS"); + + let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); + let key_set = buzz_auth::AssertionKeySet::new_for_test( + TEST_ISSUER.to_owned(), + 1, + jwks, + hard_deadline, + ) + .expect("valid test key set"); + + let jwks_contract = buzz_auth::JwksSourceContract::new( + format!("{TEST_ISSUER}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid jwks contract"); + + let policy = IssuerPolicy::new( + TEST_ISSUER.to_owned(), + vec![TEST_AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, + 3600, + None, + jwks_contract, + ) + .expect("valid issuer policy"); + + let mut registry = IssuerRegistry::new(); + registry.insert(policy); + + let verifier: Arc = Arc::new(FederatedAssertionVerifier::new( + registry, + StaticIssuerKeySource::new([key_set]), + )); + state.nip_fi_verifier = Some(verifier); + + Some(Arc::new(state)) + } + + /// Build an Off-mode state (no verifier needed). + async fn off_state() -> Option> { + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = crate::test_support::database_url(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "ws://nip-fi-settings-off-test.local".to_string(); + config.require_auth_token = false; + config.require_relay_membership = false; + config.nip_fi.mode = buzz_auth::NipFiMode::Off; + + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + + let (mut state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + + Some(Arc::new(state)) + } + + /// Mint a signed ES256 NIP-FI assertion with the given `nostr_pubkey` claim. + /// + /// Uses the same static PKCS#8 PEM and key constants as the verifier above. + fn mint_assertion(nostr_pubkey_hex: &str) -> String { + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "iat": now, + "exp": now + 600, + "sub": "test-subject", + "nostr_pubkey": nostr_pubkey_hex, + }); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(TEST_KID.to_owned()); + // NIP-FI dedicated assertion type + header.typ = Some("nip-fi+jwt".to_owned()); + let key = + EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).expect("valid test EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign assertion") + } + + /// Drive a GET request through `build_router` for the settings path. + /// Returns `(status, response_headers, body)` — helpers that previously + /// discarded headers have been updated so callers can assert the full + /// contract (Content-Type, WWW-Authenticate, etc.). + async fn settings_get_via_build_router( + state: Arc, + host: &str, + path: &str, + auth_token: &str, + assertion: Option<&str>, + ) -> (StatusCode, axum::http::HeaderMap, bytes::Bytes) { + use axum::body::to_bytes; + use axum::http::Request; + use tower::ServiceExt; + let mut builder = Request::builder() + .method("GET") + .uri(path) + .header("host", host) + .header("authorization", auth_token); + if let Some(a) = assertion { + builder = builder.header(buzz_auth::CLIENT_ATTACHED_HEADER, format!("Bearer {a}")); + } + let response = crate::router::build_router(state) + .oneshot( + builder + .body(axum::body::Body::empty()) + .expect("build request"), + ) + .await + .expect("router oneshot"); + let status = response.status(); + let resp_headers = response.headers().clone(); + let body = to_bytes(response.into_body(), 4096) + .await + .unwrap_or_default(); + (status, resp_headers, body) + } + + /// Drive a POST request through `build_router` for the settings path. + /// Returns `(status, response_headers, body)`. + async fn settings_post_via_build_router( + state: Arc, + host: &str, + path: &str, + auth_token: &str, + body_bytes: &[u8], + assertion: Option<&str>, + ) -> (StatusCode, axum::http::HeaderMap, bytes::Bytes) { + use axum::body::to_bytes; + use axum::http::Request; + use tower::ServiceExt; + let mut builder = Request::builder() + .method("POST") + .uri(path) + .header("host", host) + .header("authorization", auth_token) + .header("content-type", "application/json"); + if let Some(a) = assertion { + builder = builder.header(buzz_auth::CLIENT_ATTACHED_HEADER, format!("Bearer {a}")); + } + let response = crate::router::build_router(state) + .oneshot( + builder + .body(axum::body::Body::from(body_bytes.to_vec())) + .expect("build request"), + ) + .await + .expect("router oneshot"); + let status = response.status(); + let resp_headers = response.headers().clone(); + let body = to_bytes(response.into_body(), 4096) + .await + .unwrap_or_default(); + (status, resp_headers, body) + } + + fn nip98_token_for_method(keys: &Keys, url: &str, method: &str, body: Option<&[u8]>) -> String { + use sha2::{Digest, Sha256}; + let mut tags = vec![ + Tag::parse(["u", url]).expect("u tag"), + Tag::parse(["method", method]).expect("method tag"), + Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).expect("nonce tag"), + ]; + if let Some(b) = body { + let hex = hex::encode(Sha256::digest(b)); + tags.push(Tag::parse(["payload", &hex]).expect("payload tag")); + } + let event = EventBuilder::new(Kind::Custom(27235), "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign NIP-98 event"); + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event).unwrap()) + ) + } + + // ── Settings via build_router: Enforce + valid-assertion-key-A + NIP-98-key-B → 403 ── + // + // The assertion claims key-A (`nostr_pubkey = pubkey_a`). The NIP-98 is + // signed by key-B. `admit_nip_fi_http` Step 6 (key pairing) fires → 403 + // authorization_denied. + // + // Falsifying mutation: remove the key-pairing check in `admit_nip_fi_http` + // (the `Some(k) if k == proven_pubkey` match arm). The admission succeeds + // → the handler returns a non-403 response (404 for a missing repo) → + // this test's `assert_eq(403)` fires. + #[test] + #[ignore = "requires Postgres"] + fn settings_build_router_enforce_key_mismatch_denied_403() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(enforce_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-settings-pairing-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key_a = Keys::generate(); + let key_b = Keys::generate(); + + // Assertion claims key-A. + let assertion = mint_assertion(&key_a.public_key().to_hex()); + // NIP-98 signed by key-B. + let path = format!( + "/git/{}/test-repo/default-branch", + key_a.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + let auth = nip98_get_token(&key_b, &url); + + let (status, resp_headers, body) = rt.block_on(settings_get_via_build_router( + state, + &host, + &path, + &auth, + Some(&assertion), + )); + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "NIP-FI Enforce: assertion-for-A + NIP-98-for-B must deny 403 authorization_denied \ + [FI-INV-05]. Falsifying mutation: remove key-pairing check in admit_nip_fi_http → \ + admission succeeds → non-403 response." + ); + assert_eq!( + body.as_ref(), + b"authorization denied\n", + "key mismatch denial MUST produce authorization_denied body bytes" + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "key mismatch 403 Content-Type MUST be text/plain; charset=utf-8." + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "key mismatch 403 MUST NOT carry WWW-Authenticate." + ); + } + + // ── Settings via build_router: Enforce + same-key assertion + NIP-98 → not-403 ── + // + // Positive control: assertion and NIP-98 both prove the same key → key + // pairing passes. The handler proceeds to the repository lookup → 404 + // (no such repo) or 200. Either way, NOT 403 authorization_denied. + // + // Without this positive control an always-denying implementation would + // satisfy the negative tests above while being broken. + #[test] + #[ignore = "requires Postgres"] + fn settings_build_router_enforce_same_key_not_denied() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(enforce_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-settings-samekey-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key = Keys::generate(); + + // Assertion claims the same key that signs the NIP-98. + let assertion = mint_assertion(&key.public_key().to_hex()); + let path = format!( + "/git/{}/test-repo/default-branch", + key.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + let auth = nip98_get_token(&key, &url); + + let (status, _resp_headers, body) = rt.block_on(settings_get_via_build_router( + state, + &host, + &path, + &auth, + Some(&assertion), + )); + + // Admission passes → handler proceeds to repo lookup → repo does not + // exist in the test DB → 404. + assert_eq!( + status, + StatusCode::NOT_FOUND, + "NIP-FI Enforce: same-key assertion + NIP-98 MUST reach handler → 404 \ + (repo does not exist). \ + If 403: key pairing is wrongly denying, or authorize_management denied. \ + If 401: NIP-FI outer guard is wrongly denying a valid assertion. \ + Body: {body:?}" + ); + } + + // ── Settings via build_router: POST — wrong method (GET token) → 403 ─────── + // + // A GET NIP-98 token WITH a payload hash matching the POST body (method=GET, + // payload tag present) on a POST request: method mismatch fails NIP-98 verification. + // In NIP-FI Enforce mode, + // `admit_nip_fi_http` maps a present-but-failing NIP-98 to + // `EvidenceRejected` (403 "evidence rejected\n"), not 401. + // + // Falsifying mutation: change `EvidenceRejected` to `MissingEvidence` in + // `admit_nip_fi_http` → returns 401 → assertion fires. + #[test] + #[ignore = "requires Postgres"] + fn settings_build_router_enforce_post_wrong_method_is_403() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(enforce_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-settings-post-wrong-method-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key = Keys::generate(); + let assertion = mint_assertion(&key.public_key().to_hex()); + let path = format!( + "/git/{}/test-repo/default-branch", + key.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + + // GET token WITH the correct POST body hash: Authorization header IS + // present, payload tag exists (passes bridge.rs require_payload check), + // but method=GET on a POST request fails NIP-98 method verification → + // EvidenceRejected (403). Using nip98_get_token (no payload tag) would + // have the token rejected earlier (missing-payload at bridge.rs:125-137) + // before method validation runs. + let post_body = b"{\"branch\":\"main\",\"expected_manifest\":\"abc\"}"; + let get_token = nip98_token_for_method(&key, &url, "GET", Some(post_body)); + + let (status, resp_headers, body) = rt.block_on(settings_post_via_build_router( + state, + &host, + &path, + &get_token, + post_body, + Some(&assertion), + )); + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "NIP-FI Enforce: GET token on a POST settings request must deny 403 EvidenceRejected \ + (present but invalid Authorization → EvidenceRejected, not MissingEvidence). \ + Falsifying mutation: flip the DenialClass mapping for present-but-failing NIP-98 \ + to MissingEvidence → 401 → assertion fires." + ); + assert_eq!( + body.as_ref(), + b"evidence rejected\n", + "EvidenceRejected body must be exact contract bytes" + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "EvidenceRejected 403 Content-Type MUST be text/plain; charset=utf-8." + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "EvidenceRejected 403 MUST NOT carry WWW-Authenticate." + ); + } + + // ── Settings via build_router: POST — correct method, no payload tag → 403 ─ + // + // A POST NIP-98 token without a payload tag is present but invalid + // (settings POST requires a hash-bound body per NIP-FI.md:619-637). + // In Enforce mode, present-but-failing NIP-98 → EvidenceRejected (403). + // + // Falsifying mutation: set `require_payload = false` in `authenticate()` + // for POST → payload-tag check skipped → NIP-98 succeeds → non-403 status. + #[test] + #[ignore = "requires Postgres"] + fn settings_build_router_enforce_post_missing_payload_tag_is_403() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(enforce_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-settings-post-no-payload-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key = Keys::generate(); + let assertion = mint_assertion(&key.public_key().to_hex()); + let path = format!( + "/git/{}/test-repo/default-branch", + key.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + + // POST token with correct method but no payload tag: settings handler + // requires a hash-bound body; missing tag → NIP-98 fails → 403. + let post_token_no_payload = nip98_token_for_method(&key, &url, "POST", None); + let post_body = b"{\"branch\":\"main\",\"expected_manifest\":\"abc\"}"; + + let (status, resp_headers, body) = rt.block_on(settings_post_via_build_router( + state, + &host, + &path, + &post_token_no_payload, + post_body, + Some(&assertion), + )); + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "NIP-FI Enforce: POST token without payload tag must deny 403 EvidenceRejected \ + (present but missing payload tag → NIP-98 failure → EvidenceRejected). \ + Falsifying mutation: remove require_payload=true from authenticate() → tag \ + check skipped → different status." + ); + assert_eq!( + body.as_ref(), + b"evidence rejected\n", + "EvidenceRejected body must be exact contract bytes" + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "Missing-payload 403 Content-Type MUST be text/plain; charset=utf-8." + ); + } + + // ── Settings via build_router: POST — same-key, valid payload → reaches handler ─ + // + // A correctly signed POST NIP-98 token (method=POST, payload tag matching + // the body) with a matching assertion passes NIP-FI admission and reaches + // the handler. The handler returns a non-NIP-FI error (404 repo not found + // or similar), proving admission succeeded. + // + // Positive control: without this, an always-denying admission implementation + // could pass all three POST tests above without testing real admission. + // + // Falsifying mutation: remove `admit_nip_fi_http_on_state` from + // `authenticate()` → admission skipped → but request still reaches handler + // (non-denial result), so the positive control would not fire. Combined + // with the negative controls above, the full set distinguishes correct + // admission from both always-deny and always-admit implementations. + #[test] + #[ignore = "requires Postgres"] + fn settings_build_router_enforce_post_same_key_valid_payload_reaches_handler() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(enforce_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-settings-post-ok-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key = Keys::generate(); + let assertion = mint_assertion(&key.public_key().to_hex()); + let path = format!( + "/git/{}/test-repo/default-branch", + key.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + let post_body = b"{\"branch\":\"main\",\"expected_manifest\":\"abc\"}"; + + // Correct POST token: method=POST + payload tag hash-bound to the body. + let post_token = nip98_token_for_method(&key, &url, "POST", Some(post_body)); + + let (status, _resp_headers, _body) = rt.block_on(settings_post_via_build_router( + state, + &host, + &path, + &post_token, + post_body, + Some(&assertion), + )); + + // Admission passes; handler reaches `authorize_git_read` which returns + // 404 (no repo in this fresh community). NIP-FI denial codes are 401/403, + // not 404 — so 404 proves the NIP-FI gate passed and the handler ran. + // + // Note: quota checking (`enforce_http_admission`) follows NIP-FI admission + // at settings.rs:204-206. A 503 from Redis/quota outage would follow + // admission, not precede it — but Redis availability is verified by the + // non-503 assertion below. + assert_eq!( + status, + StatusCode::NOT_FOUND, + "NIP-FI Enforce: same-key valid POST token MUST reach the handler and return 404 \ + (repo not found in fresh community). \ + 401 = NIP-FI admission blocked; 403 = key pairing failed; \ + either means admission did not pass. \ + Falsifying mutation: make verifier always-deny → 403 instead of 404." + ); + assert_ne!( + status, + StatusCode::SERVICE_UNAVAILABLE, + "NIP-FI Enforce: same-key valid POST token MUST NOT return 503. \ + 503 means quota or Redis outage after NIP-FI admission — \ + ensure Redis is reachable for this test." + ); + } + + // ── Settings via build_router: Off mode + valid NIP-98 → not blocked ───── + // + // Off mode must not apply NIP-FI admission. A valid NIP-98 GET request + // (no assertion) reaches the handler and gets a non-NIP-FI result. + // + // Falsifying mutation: change Off-mode to Enforce → NIP-FI guard fires → + // 401 MissingEvidence → assertion fires (non-401 expected). + #[test] + #[ignore = "requires Postgres"] + fn settings_build_router_off_mode_valid_nip98_reaches_handler() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(off_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-settings-off-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key = Keys::generate(); + let path = format!( + "/git/{}/test-repo/default-branch", + key.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + let auth = nip98_get_token(&key, &url); + + let (status, _resp_headers, _body) = rt.block_on(settings_get_via_build_router( + state, &host, &path, &auth, + None, // No assertion — Off mode must not require one. + )); + + // In Off mode: NIP-FI guard does not fire; request reaches handler. + // The handler returns 404 (no repo in fresh community) — a non-NIP-FI response. + // 404 proves the request was not blocked by NIP-FI admission. + assert_eq!( + status, + StatusCode::NOT_FOUND, + "Off mode: a valid NIP-98 GET with no assertion MUST reach the handler and \ + return 404 (repo not found in fresh community). \ + 401 means NIP-FI fired (Off mode incorrectly applying active-mode guard). \ + Falsifying mutation: set mode=Enforce → guard fires → 401." + ); + } + + // ── Settings via build_router: Enforce + POST + key-mismatch → 403 ──────── + // + // A valid assertion for key-A, but the Authorization header is NIP-98-signed + // by key-B (different key), is a pairing mismatch. In NIP-FI Enforce mode, + // `admit_nip_fi_http` maps `AuthorizationDenied` to 403 `authorization denied\n`. + // + // This proves the pairing gate fires BEFORE any repo lookup — the path + // `/git/{key-B-hex}/test-repo/default-branch` uses key-B as owner, so + // a mismatch is caught at admission. + // + // Falsifying mutation: remove the pairing check from `admit_nip_fi_http` → + // the request reaches the repo-not-found handler → 404 → assertion fires. + // + // Complement: the `settings_build_router_enforce_key_mismatch_denied_403` + // test above covers GET key-mismatch with the GET proof; this covers POST + // key-mismatch with a payload-bound proof. + #[test] + #[ignore = "requires Postgres"] + fn settings_build_router_enforce_post_key_mismatch_is_403() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(enforce_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-settings-post-mismatch-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + // key_a: the assertion's nostr_pubkey (the "identity" identity). + // key_b: the NIP-98 signer (different key — mismatch). + let key_a = Keys::generate(); + let key_b = Keys::generate(); + let assertion = mint_assertion(&key_a.public_key().to_hex()); + + // Path uses key_a as owner to make it a plausible repo path. + let path = format!( + "/git/{}/test-repo/default-branch", + key_a.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + let post_body = b"{\"branch\":\"main\",\"expected_manifest\":\"abc\"}"; + + // key_b signs the NIP-98 token; assertion claims key_a. Mismatch. + let post_token = nip98_token_for_method(&key_b, &url, "POST", Some(post_body)); + + let (status, resp_headers, body) = rt.block_on(settings_post_via_build_router( + state, + &host, + &path, + &post_token, + post_body, + Some(&assertion), + )); + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "Enforce mode POST: key-mismatch (key_b NIP-98 vs key_a assertion) MUST return 403. \ + Falsifying mutation: remove pairing check → request reaches handler → 404." + ); + assert_eq!( + body.as_ref(), + b"authorization denied\n", + "key-mismatch POST 403 body must be exact 'authorization denied\\n'" + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "key-mismatch POST 403 Content-Type must be text/plain; charset=utf-8" + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "key-mismatch POST 403 MUST NOT carry WWW-Authenticate" + ); + } + + // ── Settings via build_router: Enforce + POST + wrong payload hash → 403 ─ + // + // A valid assertion for key-A, NIP-98 signed by key-A (same key), but the + // NIP-98 token's `payload` tag has a SHA-256 that does NOT match the actual + // request body. `admit_nip_fi_http` enforces payload binding and denies with + // `EvidenceRejected` 403. + // + // This proves the payload-hash verification is active independently of key + // pairing — a wrong hash is caught before any repo lookup. + // + // Falsifying mutation: remove payload-hash verification from + // `make_nip98_closure_for_admission` → wrong hash passes → handler reached + // → 404 instead of 403 → assertion fires. + #[test] + #[ignore = "requires Postgres"] + fn settings_build_router_enforce_post_wrong_payload_hash_is_403() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(enforce_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-settings-post-hash-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key = Keys::generate(); + let assertion = mint_assertion(&key.public_key().to_hex()); + + let path = format!( + "/git/{}/test-repo/default-branch", + key.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + let actual_body = b"{\"branch\":\"main\",\"expected_manifest\":\"abc\"}"; + let wrong_body = b"{\"branch\":\"wrong-branch\",\"expected_manifest\":\"xyz\"}"; + + // Token is signed against wrong_body's hash, but we send actual_body. + // The token claims the hash of wrong_body, so the payload tag doesn't + // match actual_body → EvidenceRejected. + let wrong_hash_token = nip98_token_for_method(&key, &url, "POST", Some(wrong_body)); + + let (status, resp_headers, body) = rt.block_on(settings_post_via_build_router( + state, + &host, + &path, + &wrong_hash_token, + actual_body, + Some(&assertion), + )); + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "Enforce mode POST: wrong payload hash (token bound to different body) MUST return 403. \ + Falsifying mutation: remove payload-hash check → wrong hash passes → \ + request reaches handler → 404 instead of 403." + ); + assert_eq!( + body.as_ref(), + b"evidence rejected\n", + "Wrong payload hash 403 body MUST be exact 'evidence rejected\\n'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "Wrong payload hash 403 Content-Type MUST be text/plain; charset=utf-8." + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "Wrong payload hash 403 MUST NOT carry WWW-Authenticate." + ); + } + + // ── Settings via build_router: Off mode + valid NIP-98 POST → reaches handler ─ + // + // Off mode must not apply NIP-FI admission on POST. A valid NIP-98 POST + // (no assertion) reaches the handler and gets a non-NIP-FI result (404). + // + // Falsifying mutation: change Off mode to Enforce → NIP-FI guard fires → + // 401 MissingEvidence → assertion fires (expected 404). + #[test] + #[ignore = "requires Postgres"] + fn settings_build_router_off_mode_post_reaches_handler() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(off_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-settings-off-post-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key = Keys::generate(); + let path = format!( + "/git/{}/test-repo/default-branch", + key.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + let post_body = b"{\"branch\":\"main\",\"expected_manifest\":\"abc\"}"; + let post_token = nip98_token_for_method(&key, &url, "POST", Some(post_body)); + + let (status, _resp_headers, _body) = rt.block_on(settings_post_via_build_router( + state, + &host, + &path, + &post_token, + post_body, + None, // No assertion — Off mode must not require one. + )); + + // In Off mode: NIP-FI guard does not fire → reaches handler → 404 (no repo). + assert_eq!( + status, + StatusCode::NOT_FOUND, + "Off mode POST: a valid NIP-98 POST with no assertion MUST reach the handler \ + and return 404 (repo not found in fresh community). \ + 401 means NIP-FI fired (Off mode incorrectly applying active-mode guard). \ + Falsifying mutation: set mode=Enforce → guard fires → 401." + ); + } +} // mod postgres_tests + mod external_infra { use super::super::*; use axum::{ @@ -877,4 +2010,313 @@ mod external_infra { assert_ne!(f.set(&f.owner, "main", None).await.0, StatusCode::OK); assert_eq!(f.snapshot().await.digest, before); } + + // ── NIP-FI state re-read: denied assertion does NOT advance stored digest ─ + // + // Verifies that a POST to `set_default_branch` denied by NIP-FI admission + // (key mismatch) leaves the stored snapshot digest unchanged. + // + // Proof structure: + // 1. Snapshot the current digest before any NIP-FI requests. + // 2. POST with a key-mismatch assertion (assertion key ≠ NIP-98 key) → + // 403 EvidenceRejected. The handler is never reached. + // 3. POST with an invalid proof (syntactically malformed token) → + // 403 EvidenceRejected. The handler is never reached. + // 4. Re-read the snapshot → digest is unchanged. + // 5. POST with a same-key assertion (admission passes) → 200 OK (changed/not-changed). + // 6. Re-read the snapshot → digest IS advanced if changed=true. + // + // This is the NIP-FI denial-no-write witness. Source ordering at + // settings.rs:189-207 supports current correctness; this test is the + // regression guard. + // + // Falsifying mutation: call `DefaultBranchSnapshot::set` before NIP-FI + // admission check → denied requests would modify stored state → + // digest changes → step 4 assertion fires. + // + // Uses `build_router` (not `git_router`) so NIP-FI admission is exercised + // at the router level. + #[tokio::test] + #[ignore = "requires isolated Postgres, Redis and MinIO"] + async fn nip_fi_denied_assertion_does_not_advance_snapshot_digest() { + use buzz_auth::{ + AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, + IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, + }; + use jsonwebtoken::{jwk::JwkSet, Algorithm, EncodingKey, Header}; + + let f = Fixture::new().await; + let snapshot_before = f.snapshot().await; + let digest_before = snapshot_before.digest.clone(); + + // ── Inject NIP-FI Enforce + static verifier into the fixture state ── + // + // EC P-256 test key (PKCS#8 PEM) + matching public JWK. + const NIP_FI_ISSUER: &str = "https://nip-fi-settings-test.invalid"; + const NIP_FI_AUDIENCE: &str = "https://relay.settings-test.invalid"; + const NIP_FI_KID: &str = "settings-test-key-1"; + const NIP_FI_EC_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + // Public key coordinates for the JWK (matches the private key above). + const NIP_FI_JWK_X: &str = "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI"; + const NIP_FI_JWK_Y: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; + + let jwks: JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", + "kid": NIP_FI_KID, + "x": NIP_FI_JWK_X, + "y": NIP_FI_JWK_Y + }] + })) + .expect("valid test JWKS"); + let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); + let key_set = + AssertionKeySet::new_for_test(NIP_FI_ISSUER.to_owned(), 1, jwks, hard_deadline) + .expect("valid test key set"); + let jwks_contract = buzz_auth::JwksSourceContract::new( + format!("{NIP_FI_ISSUER}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid jwks contract"); + let policy = IssuerPolicy::new( + NIP_FI_ISSUER.to_owned(), + vec![NIP_FI_AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, + 3600, + None, + jwks_contract, + ) + .expect("valid issuer policy"); + let mut registry = IssuerRegistry::new(); + registry.insert(policy); + let verifier: Arc = Arc::new(FederatedAssertionVerifier::new( + registry, + StaticIssuerKeySource::new([key_set]), + )); + + let mut enforced_state = (*f.state).clone(); + Arc::make_mut(&mut enforced_state.config).nip_fi.mode = buzz_auth::NipFiMode::Enforce; + enforced_state.nip_fi_verifier = Some(verifier); + let enforced_state = Arc::new(enforced_state); + + // Helper: mint a NIP-FI assertion whose `nostr_pubkey` = `pubkey_hex`. + let mint_assertion = |pubkey_hex: &str| -> String { + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": NIP_FI_ISSUER, + "aud": NIP_FI_AUDIENCE, + "iat": now, + "exp": now + 600, + "sub": "test-subject", + "nostr_pubkey": pubkey_hex, + }); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(NIP_FI_KID.to_owned()); + header.typ = Some("nip-fi+jwt".to_owned()); + let key = + EncodingKey::from_ec_pem(NIP_FI_EC_PEM.as_bytes()).expect("valid test EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign assertion") + }; + + // Helper: build a NIP-98 POST token for the settings endpoint. + let settings_path = f.path(); + let settings_url = format!("http://{}{settings_path}", f.tenant.host()); + let post_body = + serde_json::json!({"branch": "main", "expected_manifest": digest_before}).to_string(); + let post_body_bytes = post_body.as_bytes(); + + let build_post_request = |auth_token: String, assertion: Option| { + let mut builder = Request::builder() + .method("POST") + .uri(&settings_path) + .header("host", f.tenant.host()) + .header("authorization", auth_token) + .header("content-type", "application/json"); + if let Some(a) = assertion { + builder = builder.header(buzz_auth::CLIENT_ATTACHED_HEADER, format!("Bearer {a}")); + } + builder + .body(Body::from(post_body_bytes.to_vec())) + .expect("build request") + }; + + // ── Step 2: key-mismatch assertion (key_a vs key_owner) → 403 ──────── + let key_a = Keys::generate(); // assertion identity ≠ NIP-98 signer + let assertion_key_a = mint_assertion(&key_a.public_key().to_hex()); + // NIP-98 signed by owner (f.owner), assertion claims key_a — mismatch. + let mismatch_token = token(&f.owner, "POST", &settings_url, Some(&post_body)); + let (status_mismatch, body_mismatch) = response( + crate::router::build_router(Arc::clone(&enforced_state)) + .oneshot(build_post_request(mismatch_token, Some(assertion_key_a))) + .await + .expect("router oneshot"), + ) + .await; + assert_eq!( + status_mismatch, + StatusCode::FORBIDDEN, + "Key-mismatch assertion MUST deny 403 (AuthorizationDenied). \ + The handler must NOT be reached." + ); + assert_eq!( + body_mismatch["error"].as_str().unwrap_or(""), + "authorization denied\n", + "Key-mismatch 403 body MUST be exact 'authorization denied\\n'. \ + Falsifying mutation: remove key-pairing check → handler reached → \ + different body." + ); + + // ── Step 3: malformed token → 403 EvidenceRejected ─────────────────── + let bad_token = "Nostr !!!bad!!!".to_string(); + let assertion_owner = mint_assertion(&f.owner.public_key().to_hex()); + let (status_malformed, body_malformed) = response( + crate::router::build_router(Arc::clone(&enforced_state)) + .oneshot(build_post_request(bad_token, Some(assertion_owner.clone()))) + .await + .expect("router oneshot"), + ) + .await; + assert_eq!( + status_malformed, + StatusCode::FORBIDDEN, + "Malformed NIP-98 token MUST deny 403 (EvidenceRejected). \ + The handler must NOT be reached." + ); + assert_eq!( + body_malformed["error"].as_str().unwrap_or(""), + "evidence rejected\n", + "Malformed NIP-98 token 403 body MUST be exact 'evidence rejected\\n'. \ + Falsifying mutation: remap EvidenceRejected to MissingEvidence → \ + returns 401 instead of 403." + ); + + // ── Step 3b: wrong-payload-hash token → 403 EvidenceRejected ───────── + // Same key (owner) + valid owner assertion + NIP-98 token whose + // payload hash is computed from a DIFFERENT body ("wrong body"), but + // the request sends `post_body_bytes`. `admit_nip_fi_http` verifies the + // payload tag before reaching the handler: hash mismatch → EvidenceRejected + // 403. + // + // This is the isolated wrong-payload-hash witness. It proves the hash + // check fires independently of key pairing. + // + // Falsifying mutation: remove payload-hash verification from + // `make_nip98_closure_for_admission` → wrong-hash token passes → + // handler reached → non-403 result. + let wrong_hash_token = token( + &f.owner, + "POST", + &settings_url, + Some("wrong body for hash mismatch"), + ); + let assertion_owner_3b = mint_assertion(&f.owner.public_key().to_hex()); + let (status_wrong_hash, body_wrong_hash) = response( + crate::router::build_router(Arc::clone(&enforced_state)) + .oneshot(build_post_request( + wrong_hash_token, + Some(assertion_owner_3b), + )) + .await + .expect("router oneshot"), + ) + .await; + assert_eq!( + status_wrong_hash, + StatusCode::FORBIDDEN, + "Wrong-hash NIP-98 POST MUST deny 403 (EvidenceRejected). \ + Token payload hash is bound to 'wrong body for hash mismatch', \ + but actual request body is post_body_bytes — hash mismatch. \ + Falsifying mutation: remove payload-hash check → handler reached." + ); + assert_eq!( + body_wrong_hash["error"].as_str().unwrap_or(""), + "evidence rejected\n", + "Wrong-hash 403 body MUST be exact 'evidence rejected\\n'. \ + Falsifying mutation: remap payload-hash EvidenceRejected → handler \ + reached → different body." + ); + + // ── Step 4: digest unchanged after all three denials ───────────────── + // Three NIP-FI denials (key-mismatch, malformed, wrong-hash) MUST NOT + // advance the stored snapshot digest. All three are stopped before the + // handler runs. + let digest_after_denials = f.snapshot().await.digest; + assert_eq!( + digest_after_denials, digest_before, + "Snapshot digest MUST be unchanged after NIP-FI denials. \ + Key-mismatch, malformed-token, and wrong-hash denials must NOT advance \ + stored state. Falsifying mutation: call set_default_branch before \ + NIP-FI check → digest changes." + ); + + // ── Step 5: owner POST → 200 OK, changed=true, new digest ────────────── + // Owner NIP-98 + owner assertion → pairing passes → handler reached. + // `authorize_management` at settings.rs:273-292 authorizes the repository + // author OR a named maintainer. The fixture signs the announcement with + // `f.owner` (see fixture:1267-1342), so `named_manager(&auth.caller)` is + // true and the request is authorized. The POST requests `branch: "main"`, + // which exists in the seeded git store, so `set_default_branch` runs and + // returns `changed: true` with a new HEAD digest. + // + // Falsifying mutation A: remove NIP-FI admission from the settings handler + // → the mismatched-key token above would have reached the handler (the outer + // guard verifies but does not pair keys; malformed tokens stay rejected there) + // → set_default_branch called → Step 4's assert_eq!(digest) + // fires before we get here. + // Falsifying mutation B: always-deny pairing → 403 AuthorizationDenied → + // status != 200 → Step-5 assert_eq!(status_ok, OK) fires. + let owner_token = token(&f.owner, "POST", &settings_url, Some(&post_body)); + let assertion_owner_step5 = mint_assertion(&f.owner.public_key().to_hex()); + let (status_ok, body_ok) = response( + crate::router::build_router(Arc::clone(&enforced_state)) + .oneshot(build_post_request(owner_token, Some(assertion_owner_step5))) + .await + .expect("router oneshot"), + ) + .await; + assert_eq!( + status_ok, + StatusCode::OK, + "Owner POST with valid NIP-FI assertion MUST return 200. \ + authorize_management authorizes the repo author; the seeded 'main' \ + branch exists and expected_manifest matches digest_before. \ + If 403: NIP-FI key-pairing or authorize_management is denying the owner. \ + If 401: NIP-FI outer guard is wrongly denying a valid assertion." + ); + let ok_json: serde_json::Value = body_ok.clone(); + assert_eq!( + ok_json.get("changed").and_then(|v| v.as_bool()), + Some(true), + "Owner POST 200 body MUST contain changed=true (branch was updated to 'main'). \ + Falsifying mutation: set_default_branch never called → changed=false." + ); + + // ── Step 6: digest changed after successful owner POST ──────────────── + let after_ok = f.snapshot().await; + assert_eq!( + after_ok.manifest.head, "refs/heads/main", + "Owner POST MUST persist HEAD = refs/heads/main" + ); + assert_eq!( + after_ok.manifest.parent.as_ref(), + Some(&digest_before), + "Owner POST MUST link the new manifest to the pre-POST digest" + ); + let digest_after_ok = after_ok.digest; + assert_ne!( + digest_after_ok, digest_before, + "Digest MUST change after successful owner POST (branch updated to 'main'). \ + The three denials above left digest_before unchanged; the owner POST \ + updated the branch → new digest. \ + Falsifying mutation: set_default_branch skipped → digest unchanged." + ); + } } diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 704bbf1c1d..f872608d05 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -62,7 +62,8 @@ const UPLOAD_PACK_MAX_DECODED_BYTES: u64 = 64 * 1024 * 1024; /// NIP-98 auth extractor for git routes. /// /// Validates the `Authorization: Nostr ` header before the request body -/// is read. Same pattern as `AuthenticatedUpload` in media.rs. +/// is read. Same pattern as `UploadContext` in media.rs: auth is deferred out of +/// the extractor so NIP-FI admission can map failures to canonical denial bytes. /// /// Authorization model: reads (ref advertisement, upload-pack) require the /// caller's *current* active membership in the repo's bound channel — see @@ -79,44 +80,31 @@ pub struct GitAuth { impl axum::extract::FromRequestParts> for GitAuth { type Rejection = Response; + #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers async fn from_request_parts( parts: &mut axum::http::request::Parts, state: &Arc, ) -> Result { let method = parts.method.as_str(); - let auth_header = parts - .headers - .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| { - Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header( - "WWW-Authenticate", - format!("Nostr realm=\"buzz\", method=\"{method}\""), - ) - .body(Body::from("missing Authorization header")) - .unwrap() - })?; - - let token = auth_header.strip_prefix("Nostr ").ok_or_else(|| { - Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header( - "WWW-Authenticate", - format!("Nostr realm=\"buzz\", method=\"{method}\""), - ) - .body(Body::from("expected Authorization: Nostr ")) - .unwrap() - })?; - - let event_bytes = base64::engine::general_purpose::STANDARD - .decode(token) - .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(token)) - .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid base64").into_response())?; - let event_json = String::from_utf8(event_bytes) - .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid utf-8").into_response())?; + // Off mode: parse and validate the Authorization header BEFORE tenant + // lookup. [FI-INV-15] — Off mode preserves pre-NIP-FI error precedence: + // missing credentials → 401 + WWW-Authenticate challenge; + // malformed credentials (bad base64, bad UTF-8) → 401 without challenge; + // regardless of whether the Host resolves to a known community. No + // database work for syntactically bad requests in Off mode. + // + // Enforce: the header syntax is validated + // inside the NIP-FI admission closure below, where proof failures are + // mapped to NIP-FI denial bytes. (DenyProtected returns 503 at the + // start of admission without running the closure.) Tenant lookup still happens before + // admission (immediately after this block) because the signed `u` tag + // must be verified against the tenant-bound host, not a process-global + // domain. + let mode = state.config.nip_fi.mode; + if matches!(mode, buzz_auth::NipFiMode::Off) { + parse_git_auth_header(&parts.headers, method)?; + } // Row zero for Git HTTP: bind the request Host to a server-resolved // tenant before URL verification. We still do not trust forwarded @@ -142,21 +130,15 @@ impl axum::extract::FromRequestParts> for GitAuth { ) .ok_or_else(|| (StatusCode::BAD_REQUEST, "unrecognized git endpoint").into_response())?; - // Repo-root URL verification. + // NIP-FI admission: the NIP-98 extraction closure runs inside + // `admit_nip_fi_http_on_state` so all proof failures (missing header, + // invalid base64, bad signature) are mapped to NIP-FI denial bytes in + // Enforce mode, and cardinality is enforced uniformly. Off mode + // preserves legacy Git 401 responses per [FI-INV-15]; Off-mode header + // syntax was already validated above so the closure cannot fail on the + // syntax cases. + // [FI-TRACE-AUTHORITY-UNIFORM, FI-TRACE-DENIAL-ORACLE] // - // The credential helper signs a NIP-98 token with: - // u = (e.g., http://host/git/{owner}/{repo}) - // - // Git's credential protocol does NOT pass query strings to helpers, so - // service-scoping (`?service=...`) cannot be implemented at the NIP-98 - // level without protocol changes. The token is repo-scoped, not service-scoped. - // - // Security is still provided by: - // - ±60s timestamp window (limits replay) - // - HTTPS in production (prevents token theft) - // - Pre-receive hook for push authorization (role + protection rules) - // - Endpoint routing (clone/push are different HTTP paths) - // Skip HTTP method check for git routes. // // Git's credential helper signs with `method=GET` (the initial /info/refs request) @@ -165,42 +147,58 @@ impl axum::extract::FromRequestParts> for GitAuth { // Security is provided by: service-binding in the URL (clone vs push scoped), // ±60s timestamp, and the pre-receive hook for push authorization. // We pass the method from the event itself so verify_nip98_event always accepts. - let event_method = serde_json::from_str::(&event_json) - .ok() - .and_then(|v| { - v["tags"] - .as_array()? - .iter() - .find(|t| t[0].as_str() == Some("method"))?[1] - .as_str() - .map(str::to_owned) - }) - .unwrap_or_else(|| method.to_owned()); - - // SECURITY: method intentionally not verified for git routes. The tautological - // check (event.method == event.method) is deliberate — see comment block above. - // Git's credential protocol signs once with GET and reuses for POST. The URL tag - // provides the real security boundary (±60s timestamp + URL lock + HTTPS). - + // // body=None: can't buffer streaming pack data to verify payload hash. // Token is time-bounded (±60s) and URL-locked — acceptable trade-off. - let pubkey = - buzz_auth::nip98::verify_nip98_event(&event_json, &expected_url, &event_method, None) + let headers_clone = parts.headers.clone(); + let method_str = method.to_owned(); + let admission = crate::nip_fi_http::admit_nip_fi_http_on_state( + state, + &parts.headers, + move || -> Result, Response> { + // In Off mode `parse_git_auth_header` already ran above and + // succeeded, so re-parsing here is purely for the return value. + // In Enforce mode it runs for the first time inside this closure + // (on the auth-rejection path the NIP-FI layer maps the error). + let (event_json, method_for_verify) = + parse_git_auth_header_full(&headers_clone, &method_str)?; + + // SECURITY: method intentionally not verified for git routes. The tautological + // check (event.method == event.method) is deliberate — see comment block above. + // Git's credential protocol signs once with GET and reuses for POST. The URL tag + // provides the real security boundary (±60s timestamp + URL lock + HTTPS). + let pubkey = buzz_auth::nip98::verify_nip98_event( + &event_json, + &expected_url, + &method_for_verify, + None, + ) .map_err(|e| { - warn!(error = %e, "git NIP-98 auth failed"); - (StatusCode::UNAUTHORIZED, "NIP-98 auth failed").into_response() - })?; + warn!(error = %e, "git NIP-98 auth failed"); + (StatusCode::UNAUTHORIZED, "NIP-98 auth failed").into_response() + })?; - // NOTE: NIP-98 event-ID dedup intentionally NOT implemented here. - // Git's credential protocol reuses one signed token across multiple requests - // in a session (info_refs GET → upload-pack/receive-pack POST). Rejecting - // replayed event IDs would break normal clone/push operations. - // The ±60s timestamp window + URL scoping + HTTPS transport provide sufficient - // replay protection for v1. Per-request signing requires protocol changes. + // NOTE: NIP-98 event-ID dedup intentionally NOT implemented here. + // Git's credential protocol reuses one signed token across multiple requests + // in a session (info_refs GET -> upload-pack/receive-pack POST). Rejecting + // replayed event IDs would break normal clone/push operations. + // The +-60s timestamp window + URL scoping + HTTPS transport provide sufficient + // replay protection for v1. Per-request signing requires protocol changes. - let event: nostr::Event = serde_json::from_str(&event_json) - .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid auth event").into_response())?; - let signed_auth_created_at = event.created_at.as_secs(); + let event: nostr::Event = serde_json::from_str(&event_json).map_err(|_| { + (StatusCode::UNAUTHORIZED, "invalid auth event").into_response() + })?; + let signed_auth_created_at = event.created_at.as_secs(); + + Ok(crate::nip_fi_http::Nip98Proof::new( + pubkey, + (event, signed_auth_created_at), + )) + }, + )?; + + let pubkey = *admission.proven_pubkey(); + let (event, signed_auth_created_at) = admission.into_extra(); // Relay membership gate (NIP-43). Git cannot carry a standalone // x-auth-tag header through the credential-helper protocol, so agents @@ -324,6 +322,86 @@ fn enforce_git_ban_cascade( } } +/// Parse and syntax-validate the `Authorization: Nostr ` header for +/// Git HTTP requests. Returns `Ok(())` on success (the caller only needs to +/// know whether the syntax is valid); on failure returns a `Response` that +/// already carries the correct 401 + `WWW-Authenticate` challenge. +/// +/// Shared by the Off-mode early-exit path and the full extraction below. +/// Keeps the response bytes identical between the two call sites. +/// +/// [FI-INV-15] — Off mode must return 401 + challenge for missing/malformed +/// credentials before any tenant lookup. +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers +fn parse_git_auth_header(headers: &axum::http::HeaderMap, method: &str) -> Result<(), Response> { + parse_git_auth_header_full(headers, method).map(|_| ()) +} + +/// Full extraction: parse the Authorization header and return +/// `(event_json, method_for_verify)`. `method_for_verify` is taken from the +/// NIP-98 event's `method` tag when present, falling back to the HTTP method; +/// this is the "tautological" bypass that lets git clients reuse a GET token +/// for the subsequent POST. +/// +/// Returns `Err(Response)` for missing/malformed-scheme → 401 + `WWW-Authenticate`; +/// bad-base64/bad-utf-8 → 401 without `WWW-Authenticate` (use `into_response()` +/// which does not add the challenge header). +#[allow(clippy::result_large_err)] +fn parse_git_auth_header_full( + headers: &axum::http::HeaderMap, + method: &str, +) -> Result<(String, String), Response> { + let auth_header = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + "WWW-Authenticate", + format!("Nostr realm=\"buzz\", method=\"{method}\""), + ) + .body(Body::from("missing Authorization header")) + .unwrap() + })?; + + let token = auth_header.strip_prefix("Nostr ").ok_or_else(|| { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + "WWW-Authenticate", + format!("Nostr realm=\"buzz\", method=\"{method}\""), + ) + .body(Body::from("expected Authorization: Nostr ")) + .unwrap() + })?; + + let event_bytes = base64::engine::general_purpose::STANDARD + .decode(token) + .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(token)) + .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid base64").into_response())?; + let event_json = String::from_utf8(event_bytes) + .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid utf-8").into_response())?; + + // Extract `method` from the NIP-98 event tag; fall back to HTTP method. + // SECURITY: method intentionally not verified for git routes (see GitAuth + // comment block). Git's credential helper signs once with GET and reuses + // for POST; the tautological self-comparison is deliberate. + let method_for_verify = serde_json::from_str::(&event_json) + .ok() + .and_then(|v| { + v["tags"] + .as_array()? + .iter() + .find(|t| t[0].as_str() == Some("method"))?[1] + .as_str() + .map(str::to_owned) + }) + .unwrap_or_else(|| method.to_owned()); + + Ok((event_json, method_for_verify)) +} + /// Construct the repo-root NIP-98 `u` URL expected for a git HTTP request. /// /// The host is always the server-resolved tenant host. `config_relay_url` only @@ -3783,3 +3861,1483 @@ mod sec005_postgres_tests { assert_eq!(body, "authorization unavailable"); } } + +// ── R4 Off-mode precedence regression tests ─────────────────────────────── +// +// Proves that in Off mode, missing/malformed Authorization headers are rejected +// with 401 + WWW-Authenticate BEFORE any tenant lookup. [FI-INV-15] +// +// The `parse_git_auth_header` helper is the single source of this behavior; +// these tests cover all four cases Thufir specified. +// +// Mutation evidence for all tests: replacing the Off-mode early-exit +// (`if matches!(mode, NipFiMode::Off) { parse_git_auth_header(...)?; }`) +// with a no-op causes the request to proceed to `bind_community()`. On an +// unmapped host that returns 404 `repository not found` — the test's +// status assertion fires (404 ≠ 401). On a mapped host the parse +// runs inside the NIP-FI closure, but at that point the `admit_nip_fi_http` +// wrapper (Off mode) propagates the legacy response — so the status still +// matches. Only the unmapped-host cases truly distinguish the regression. +// Both cases are included so the full invariant (credential-before-DB) is +// visible in the test record. +#[cfg(test)] +mod off_mode_precedence_tests { + use super::*; + use axum::http::HeaderMap; + + fn make_headers(auth: Option<&str>) -> HeaderMap { + let mut h = HeaderMap::new(); + if let Some(a) = auth { + h.insert( + header::AUTHORIZATION, + a.parse().expect("valid header value"), + ); + } + h + } + + // ── Case A: missing Authorization header ───────────────────────────── + + /// Off mode, no Authorization header → 401 + WWW-Authenticate challenge. + /// + /// Scope: this test exercises `parse_git_auth_header` directly, not the + /// full `GitAuth::from_request_parts` path. It proves the parser + /// rejects a missing header with the correct status and challenge. + /// + /// Falsifying mutation: return `Ok(())` from `parse_git_auth_header` + /// when no Authorization header is present → `unwrap_err()` panics. + #[test] + fn off_mode_missing_auth_header_returns_401_with_challenge() { + let headers = make_headers(None); + let err = parse_git_auth_header(&headers, "GET").unwrap_err(); + assert_eq!( + err.status(), + StatusCode::UNAUTHORIZED, + "Off mode: missing Authorization must yield 401, not a tenant-lookup result" + ); + let challenge = err + .headers() + .get("WWW-Authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + challenge.contains("Nostr realm=\"buzz\""), + "Off mode: missing Authorization must include WWW-Authenticate: Nostr challenge; got {challenge:?}" + ); + } + + // ── Case B: wrong scheme (not "Nostr ") ────────────────────────────── + + /// Off mode, wrong Authorization scheme → 401 + challenge. + /// + /// Scope: parser-only test (calls `parse_git_auth_header` directly). + /// Falsifying mutations: return `Ok(())` for non-Nostr schemes, or + /// emit 403 instead of 401 — status assertion fires; or omit the + /// WWW-Authenticate header — challenge assertion fires. + #[test] + fn off_mode_wrong_scheme_returns_401_with_challenge() { + let headers = make_headers(Some("Bearer sometoken")); + let err = parse_git_auth_header(&headers, "GET").unwrap_err(); + assert_eq!( + err.status(), + StatusCode::UNAUTHORIZED, + "Off mode: wrong auth scheme must yield 401" + ); + let challenge = err + .headers() + .get("WWW-Authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + challenge.contains("Nostr realm=\"buzz\""), + "Off mode: wrong scheme must include Nostr challenge" + ); + } + + // ── Case C: invalid base64 ──────────────────────────────────────────── + + /// Off mode, Authorization: Nostr → 401. + /// + /// Scope: parser-only test. Falsifying mutation: accept invalid + /// base64 and return `Ok(())` → `unwrap_err()` panics. + #[test] + fn off_mode_invalid_base64_returns_401() { + let headers = make_headers(Some("Nostr !!!not-base64!!!")); + let err = parse_git_auth_header(&headers, "GET").unwrap_err(); + assert_eq!( + err.status(), + StatusCode::UNAUTHORIZED, + "Off mode: invalid base64 must yield 401" + ); + } + + // ── Case D: valid base64 but invalid UTF-8 bytes ───────────────────── + + /// Off mode, Authorization: Nostr → 401. + /// + /// Scope: parser-only test. Falsifying mutation: skip UTF-8 check, + /// return `Ok(())` → `unwrap_err()` panics. + #[test] + fn off_mode_invalid_utf8_returns_401() { + // 0xC3 0x28 is invalid UTF-8. + let bad_utf8 = base64::engine::general_purpose::STANDARD.encode([0xC3u8, 0x28]); + let headers = make_headers(Some(&format!("Nostr {bad_utf8}"))); + let err = parse_git_auth_header(&headers, "GET").unwrap_err(); + assert_eq!( + err.status(), + StatusCode::UNAUTHORIZED, + "Off mode: non-UTF-8 base64 payload must yield 401" + ); + } + + // ── Positive control ────────────────────────────────────────────────── + + /// Valid Nostr base64 JSON payload passes syntax validation. + /// + /// Scope: parser-only test. A structurally correct credential passes + /// `parse_git_auth_header`, allowing the request to proceed to tenant + /// lookup in the full path. + /// + /// Falsifying mutation: always return `Err(...)` from + /// `parse_git_auth_header` → `is_ok()` fails and the assertion fires. + /// Without this positive control, an always-denying parser could pass + /// all four negative cases above while also breaking valid requests. + #[test] + fn off_mode_valid_nostr_token_passes_syntax_check() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + + let keys = Keys::generate(); + let tags = vec![ + Tag::parse(["u", "http://example.local/git/abc/def"]).unwrap(), + Tag::parse(["method", "GET"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::Custom(27235), "") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + let token = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event).unwrap()) + ); + let headers = make_headers(Some(&token)); + assert!( + parse_git_auth_header(&headers, "GET").is_ok(), + "Off mode: a valid Nostr token must pass syntax validation" + ); + } + + // ── Router-level Off-precedence tests (require Postgres) ───────────── + // + // These tests go through `git_router` → `GitAuth::from_request_parts` + // and prove that the Off-mode early-exit at transport.rs:100-102 fires + // BEFORE `bind_community()`. + // + // Key falsifiability: the unmapped-host cases assert 401. Deleting lines + // 100-102 causes `bind_community()` to run for the unmapped host and + // return 404 — the status assertions fire. Parser-only unit tests above + // cannot prove this ordering because they never call `from_request_parts`. + #[cfg(test)] + mod postgres_tests { + use super::*; + use axum::body::to_bytes; + use tower::ServiceExt; + + const UNMAPPED_HOST: &str = "off-prec-unmapped.git.test.invalid"; + // Valid 64-hex owner (all zeros except last digit = 1) so validate_repo_id + // passes owner validation in the handler body. The NIP-FI gate fires in + // GitAuth::from_request_parts BEFORE validate_repo_id in Enforce mode; + // the valid owner ensures tests that probe post-auth behavior see the + // correct downstream path. + const OWNER_HEX: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + const GIT_PATH: &str = concat!( + "/git/0000000000000000000000000000000000000000000000000000000000000001", + "/myrepo/info/refs?service=git-upload-pack" + ); + + async fn off_mode_state() -> Option> { + let mut config = crate::config::Config::from_env().ok()?; + config.nip_fi.mode = buzz_auth::NipFiMode::Off; + config.require_auth_token = false; + config.require_relay_membership = false; + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 + + let pool = sqlx::PgPool::connect(&config.database_url).await.ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + let (state, _) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Some(Arc::new(state)) + } + + async fn git_request( + state: Arc, + host: &str, + auth: Option<&str>, + ) -> (axum::http::StatusCode, axum::http::HeaderMap, bytes::Bytes) { + let mut builder = axum::http::Request::builder() + .method("GET") + .uri(GIT_PATH) + .header("host", host); + if let Some(a) = auth { + builder = builder.header("authorization", a); + } + let req = builder + .body(axum::body::Body::empty()) + .expect("build request"); + let resp = git_router(Arc::clone(&state)) + .oneshot(req) + .await + .expect("router oneshot"); + let status = resp.status(); + let headers = resp.headers().clone(); + let body = to_bytes(resp.into_body(), 4096).await.unwrap_or_default(); + (status, headers, body) + } + + // ── Unmapped host — missing auth: must be 401 before DB lookup ──── + // + // An unmapped host has no community row. If the Off-mode early-exit + // is removed, `bind_community()` returns 404 for this host. + // The assertion fires because 404 ≠ 401. + // + // Falsifying mutation: delete the `if matches!(mode, Off)` block + // (transport.rs:101-104) → unmapped host proceeds to `bind_community()` → 404. + // + // NOTE: missing-auth and wrong-scheme carry WWW-Authenticate; bad-base64 + // and bad-UTF8 do NOT (those use `into_response()` without the header). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn off_mode_unmapped_host_missing_auth_returns_401_before_db() { + let Some(state) = off_mode_state().await else { + panic!("local Postgres not reachable"); + }; + let (status, headers, body) = git_request(state, UNMAPPED_HOST, None).await; + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "Off mode + unmapped host + missing auth must yield 401 BEFORE \ + bind_community (not 404). \ + Falsifying mutation: delete Off-mode early-exit (transport.rs:101-104) \ + → bind_community returns 404 for unmapped host → assertion fires." + ); + assert_eq!( + body.as_ref(), + b"missing Authorization header", + "missing-auth 401 body must be exact 'missing Authorization header'" + ); + // FI-INV-15: Off-mode bytes match origin/main, whose legacy 401 + // builder sets no Content-Type on this response. + assert!( + headers.get("content-type").is_none(), + "missing-auth 401 must carry no Content-Type (legacy Off bytes); got {:?}", + headers.get("content-type") + ); + let challenge = headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + challenge, + "Nostr realm=\"buzz\", method=\"GET\"", + "missing-auth 401 must carry exact WWW-Authenticate: Nostr realm=\"buzz\", method=\"GET\"; got {challenge:?}" + ); + } + + // ── Unmapped host — wrong scheme: must be 401 before DB lookup ──── + // + // Same falsifiability as the missing-auth case. + // + // Falsifying mutation: delete transport.rs:101-104 → 404. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn off_mode_unmapped_host_wrong_scheme_returns_401_before_db() { + let Some(state) = off_mode_state().await else { + panic!("local Postgres not reachable"); + }; + let (status, headers, body) = + git_request(state, UNMAPPED_HOST, Some("Bearer token")).await; + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "Off mode + unmapped host + wrong auth scheme must yield 401 before \ + bind_community. \ + Falsifying mutation: delete transport.rs:101-104 → 404." + ); + assert_eq!( + body.as_ref(), + b"expected Authorization: Nostr ", + "wrong-scheme 401 body must be 'expected Authorization: Nostr '" + ); + assert!( + headers.get("content-type").is_none(), + "wrong-scheme 401 must carry no Content-Type (legacy Off bytes); got {:?}", + headers.get("content-type") + ); + let challenge_ws = headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + challenge_ws, "Nostr realm=\"buzz\", method=\"GET\"", + "wrong-scheme 401 must carry exact WWW-Authenticate: \ + Nostr realm=\"buzz\", method=\"GET\"; got {challenge_ws:?}" + ); + } + + // ── Unmapped host — invalid base64: must be 401 before DB lookup ── + // + // bad-base64 and bad-UTF8 use `into_response()` — NO WWW-Authenticate. + // Falsifying mutation: delete transport.rs:101-104 → 404. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn off_mode_unmapped_host_invalid_base64_returns_401_before_db() { + let Some(state) = off_mode_state().await else { + panic!("local Postgres not reachable"); + }; + let (status, headers, body) = + git_request(state, UNMAPPED_HOST, Some("Nostr !!!not-base64!!!")).await; + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "Off mode + unmapped host + invalid base64 must yield 401 before \ + bind_community. \ + Falsifying mutation: delete transport.rs:101-104 → 404." + ); + assert_eq!( + body.as_ref(), + b"invalid base64", + "invalid-base64 401 body must be exact 'invalid base64'" + ); + assert!( + headers.get("www-authenticate").is_none(), + "invalid-base64 401 MUST NOT carry WWW-Authenticate \ + (uses into_response(), not the WWW-Authenticate builder path)" + ); + } + + // ── Unmapped host — bad UTF-8 payload: must be 401 before DB lookup ─ + // + // A Nostr token where base64 decodes to non-UTF8 bytes triggers the + // UTF-8 guard in `parse_git_auth_header_full` (transport.rs:379-380). + // Body: "invalid utf-8"; NO WWW-Authenticate (uses `into_response()`). + // + // Falsifying mutation: delete transport.rs:101-104 → 404. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn off_mode_unmapped_host_bad_utf8_returns_401_before_db() { + let Some(state) = off_mode_state().await else { + panic!("local Postgres not reachable"); + }; + // base64-encode non-UTF8 bytes (0xff 0xfe is an invalid UTF-8 start) + let bad_utf8_b64 = base64::engine::general_purpose::STANDARD.encode(b"\xff\xfe\x00"); + let (status, headers, body) = + git_request(state, UNMAPPED_HOST, Some(&format!("Nostr {bad_utf8_b64}"))).await; + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "Off mode + unmapped host + bad-UTF8 payload must yield 401 before \ + bind_community. \ + Falsifying mutation: delete transport.rs:101-104 → 404." + ); + assert_eq!( + body.as_ref(), + b"invalid utf-8", + "bad-UTF8 401 body must be exact 'invalid utf-8'" + ); + assert!( + headers.get("www-authenticate").is_none(), + "bad-UTF8 401 MUST NOT carry WWW-Authenticate \ + (uses into_response(), not the WWW-Authenticate builder path)" + ); + } + + // ── Mapped host — missing auth: compatibility control ────────────── + // + // Proves the same missing-auth behavior holds for mapped hosts. + // Compatibility control: the Off-mode early-exit (`transport.rs:101-104`) + // and the Enforce-mode NIP-FI closure both route through + // `parse_git_auth_header_full()`, which produces the same 401 + challenge. + // + // Falsifying mutation: replace `parse_git_auth_header` with always-pass + // → missing auth is not caught → request proceeds to URL verification + // → different status or body. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn off_mode_mapped_host_missing_auth_returns_401() { + let Some(state) = off_mode_state().await else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "off-prec-mapped-{}.git.test.invalid", + uuid::Uuid::new_v4().simple() + ); + state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community"); + let (status, headers, body) = git_request(state, &host, None).await; + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "Off mode + mapped host + missing auth must yield 401 from Off-mode early-exit \ + (compatibility control: same response via NIP-FI closure in Enforce mode). \ + Falsifying mutation: skip parse_git_auth_header for missing auth → different error." + ); + assert_eq!( + body.as_ref(), + b"missing Authorization header", + "mapped-host missing-auth 401 body must be 'missing Authorization header'" + ); + let challenge = headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + challenge, "Nostr realm=\"buzz\", method=\"GET\"", + "mapped-host missing-auth 401 must carry exact WWW-Authenticate: Nostr realm=\"buzz\", method=\"GET\"; got {challenge:?}" + ); + } + + // ── Mapped host — invalid base64: compatibility control ─────────── + // + // Proves the bad-base64 check holds for mapped hosts. + // `parse_git_auth_header_full()` handles bad base64 and returns 401 (no + // WWW-Authenticate). This is a compatibility assertion: both the Off-mode + // early-exit and the Enforce-mode NIP-FI closure call the same function, so + // the same 401 body is produced in both modes. + // + // Falsifying mutation: remove `parse_git_auth_header` error for bad base64 + // → bad-base64 request passes syntax check → URL verification fails differently. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn off_mode_mapped_host_invalid_base64_returns_401() { + let Some(state) = off_mode_state().await else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "off-prec-mapped-b64-{}.git.test.invalid", + uuid::Uuid::new_v4().simple() + ); + state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community"); + let (status, headers, body) = + git_request(state, &host, Some("Nostr !!!not-base64!!!")).await; + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "Off mode + mapped host + invalid base64 must yield 401. \ + Falsifying mutation: delete transport.rs:101-104 → different error." + ); + assert_eq!( + body.as_ref(), + b"invalid base64", + "mapped-host invalid-base64 401 body must be 'invalid base64'" + ); + assert!( + headers.get("www-authenticate").is_none(), + "invalid-base64 401 MUST NOT carry WWW-Authenticate" + ); + } + + // ── Positive control: valid syntax with unmapped host reaches DB ── + // + // A syntactically valid Nostr token PASSES the Off-mode early-exit + // and proceeds to `bind_community()`. The unmapped host then yields + // 404 — proving the early-exit was NOT the blocker. + // + // Falsifying mutation: always-deny `parse_git_auth_header` regardless + // of input → this control returns 401 instead of 404 → assertion fires. + // The negative cases above prove the opposite direction. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn off_mode_unmapped_host_valid_syntax_reaches_db_and_returns_404() { + let Some(state) = off_mode_state().await else { + panic!("local Postgres not reachable"); + }; + let keys = nostr::Keys::generate(); + let tags = vec![ + nostr::Tag::parse(["u", &format!("http://{UNMAPPED_HOST}{GIT_PATH}")]).unwrap(), + nostr::Tag::parse(["method", "GET"]).unwrap(), + ]; + let event = nostr::EventBuilder::new(nostr::Kind::Custom(27235), "") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + let token = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&event).unwrap()) + ); + let (status, _headers, _body) = git_request(state, UNMAPPED_HOST, Some(&token)).await; + assert_eq!( + status, + axum::http::StatusCode::NOT_FOUND, + "Off mode + unmapped host + valid syntax: parser passes → \ + bind_community fires → 404. \ + Falsifying mutation: always-deny parser → 401 instead of 404." + ); + } + + // ── Enforce mode: missing assertion on info/refs → exact body/CT/challenge ─ + // + // Proves that git routes (info/refs, upload-pack, receive-pack) produce the + // exact contract bytes for MissingEvidence in Enforce mode. All three routes + // share `GitAuth::from_request_parts`; the pack routes are additionally + // tested in `enforce_mode_git_pack_routes_missing_assertion_exact_bytes`. + // + // A Nostr-scheme Authorization header is present (syntactically valid) + // so the Off-mode early-exit passes; no Nostr-Federated-Identity header + // is sent, so `extract_bearer_token` returns MissingEvidence → 401. + // + // Falsifying mutation: remove the `admit_nip_fi_http_on_state` call + // from `GitAuth::from_request_parts` → `GitAuth` falls back to the + // legacy NIP-98 verifier → valid proof is accepted → request reaches + // `validate_repo_id` which succeeds (OWNER_HEX is valid 64-hex), then + // `authorize_git_read` which denies (repo not member of a channel) → + // different status or body → assertion fires. + // + // Why no assertion header: in Enforce mode with no verifier configured + // (startup race) an assertion present + no verifier would return 503. + // The MissingEvidence path (no assertion header) is the correct gate + // test for git routes and is the most discriminating falsifiable case. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn enforce_mode_git_info_refs_missing_assertion_exact_bytes() { + use buzz_auth::NipFiMode; + + let mut config = match crate::config::Config::from_env() { + Ok(c) => c, + Err(_) => panic!("local Postgres not reachable (enforce git): config"), + }; + config.nip_fi.mode = NipFiMode::Enforce; + config.require_auth_token = false; + config.require_relay_membership = false; + // Pin relay_url to a ws:// value so git_expected_url() deterministically + // derives the http:// scheme for the NIP-98 `u` tag. + config.relay_url = "ws://nip-fi-git-test.invalid".to_string(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 + + let pool = match sqlx::PgPool::connect(&config.database_url).await { + Ok(p) => p, + Err(_) => panic!("local Postgres not reachable (enforce git)"), + }; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = + buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (state, _) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + let state = Arc::new(state); + + // Register a mapped host so bind_community succeeds → the NIP-FI + // gate is the first denial point after Off-mode early-exit. + let host = format!( + "nip-fi-git-enf-{}.test.invalid", + uuid::Uuid::new_v4().simple() + ); + state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community"); + + // Build a syntactically valid Nostr token signed for the repo-root URL. + // `git_expected_url()` strips `/info/refs?service=…` and keeps only + // the repository path prefix: `http://{host}/git/{OWNER_HEX}/myrepo`. + // No Nostr-Federated-Identity header → MissingEvidence in Enforce. + let keys = nostr::Keys::generate(); + // GIT_PATH strips "/info/refs?..." suffix → repo root used for URL signing. + // OWNER_HEX is valid 64-hex so validate_repo_id would pass if auth succeeded. + let git_repo_root = format!("/git/{OWNER_HEX}/myrepo"); + let tags = vec![ + nostr::Tag::parse(["u", &format!("http://{host}{git_repo_root}")]).unwrap(), + nostr::Tag::parse(["method", "GET"]).unwrap(), + ]; + let event = nostr::EventBuilder::new(nostr::Kind::Custom(27235), "") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + let auth_token = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&event).unwrap()) + ); + + let (status, headers, body) = git_request(state, &host, Some(&auth_token)).await; + + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "Enforce mode + info/refs + missing assertion MUST deny 401 MissingEvidence. \ + Falsifying mutation: remove admit_nip_fi_http_on_state from GitAuth → \ + legacy NIP-98 verifier accepts the valid proof → request proceeds past \ + the admission gate → different status or body → assertion fires." + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "Enforce mode: git MissingEvidence body must be exact 'authentication required\\n' \ + [FI-TRACE-DENIAL-ORACLE]. Different body means the legacy git error path fired \ + instead of the NIP-FI gate." + ); + let ct = headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + ct, "text/plain; charset=utf-8", + "Enforce mode: git 401 content-type must be text/plain; charset=utf-8" + ); + let www_auth = headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, "Nostr", + "Enforce mode: git 401 must carry WWW-Authenticate: Nostr" + ); + } + + // ── Enforce mode: pack routes (upload-pack + receive-pack) missing assertion ─ + // + // Parameterized test: both POST pack routes share `GitAuth::from_request_parts` + // and must produce the same exact denial bytes as `info/refs` when the + // Nostr-Federated-Identity assertion header is absent. + // + // For each route: + // - missing assertion → 401 MissingEvidence (exact body + CT + challenge) + // - EvidenceRejected (invalid base64 Nostr token) → 401 (NIP-FI maps it) + // - duplicate Authorization headers → 403 cardinality in Enforce mode + // + // Falsifying mutation: remove `admit_nip_fi_http_on_state` from + // `GitAuth::from_request_parts` → NIP-98 validates the token, no assertion + // check, request reaches `validate_repo_id` → succeeds (OWNER_HEX valid) + // → `authorize_git_read` denies (no channel membership) → 403 or 404 + // → 401 assertion fires. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn enforce_mode_git_pack_routes_missing_assertion_exact_bytes() { + use buzz_auth::NipFiMode; + + let mut config = match crate::config::Config::from_env() { + Ok(c) => c, + Err(_) => panic!("local Postgres not reachable (pack routes): config"), + }; + config.nip_fi.mode = NipFiMode::Enforce; + config.require_auth_token = false; + config.require_relay_membership = false; + config.relay_url = "ws://nip-fi-git-pack-test.invalid".to_string(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 + + let pool = match sqlx::PgPool::connect(&config.database_url).await { + Ok(p) => p, + Err(_) => panic!("local Postgres not reachable (pack routes)"), + }; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = + buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (state, _) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + // Inject a static NIP-FI verifier into the Enforce state so that + // every case requiring cryptographic assertion validation (including + // the malformed-assertion Case 3) reaches the verifier rather than + // hitting the absent-verifier 503. Without this, Case 3 sends + // `Bearer !!!not-valid-base64!!!` which passes `extract_bearer_token` + // (scheme/cardinality checks only) and then trips the absent-verifier + // check at nip_fi_http.rs:332-335 → 503, not 403. + // [FI-TRACE-DENIAL-ORACLE: verifier required for all crypto cases] + let state = { + use buzz_auth::{ + AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, + IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, + }; + use jsonwebtoken::{jwk::JwkSet, Algorithm, EncodingKey}; + + const GIT_TEST_ISSUER: &str = "https://git-pack-test.issuer.invalid"; + const GIT_TEST_AUDIENCE: &str = "https://git-pack-test.relay.invalid"; + const GIT_TEST_KID: &str = "git-pack-test-key-1"; + const GIT_TEST_EC_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + + let jwks: JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", + "kid": GIT_TEST_KID, + "x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI", + "y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA" + }] + })) + .expect("valid test JWKS"); + let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); + let key_set = AssertionKeySet::new_for_test( + GIT_TEST_ISSUER.to_owned(), + 1, + jwks, + hard_deadline, + ) + .expect("valid test key set"); + let jwks_contract = buzz_auth::JwksSourceContract::new( + format!("{GIT_TEST_ISSUER}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid jwks contract"); + let policy = IssuerPolicy::new( + GIT_TEST_ISSUER.to_owned(), + vec![GIT_TEST_AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, + 3600, + None, + jwks_contract, + ) + .expect("valid issuer policy"); + let mut registry = IssuerRegistry::new(); + registry.insert(policy); + let verifier: Arc = Arc::new(FederatedAssertionVerifier::new( + registry, + StaticIssuerKeySource::new([key_set]), + )); + + // Encode the test key for use in Case 4 assertions. + // Store in a Mutex so it can be read from the outer scope later. + let enc_key = + EncodingKey::from_ec_pem(GIT_TEST_EC_PEM.as_bytes()).expect("valid EC PEM"); + + let mut s = state; + s.nip_fi_verifier = Some(verifier); + ( + Arc::new(s), + GIT_TEST_ISSUER, + GIT_TEST_AUDIENCE, + GIT_TEST_KID, + enc_key, + ) + }; + let (state, git_test_issuer, git_test_audience, git_test_kid, git_enc_key) = state; + + let host = format!( + "nip-fi-git-pack-{}.test.invalid", + uuid::Uuid::new_v4().simple() + ); + state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community"); + + // Build a valid NIP-98 token signed for the upload-pack repo root. + // Git credential helper signs once with GET and reuses for POST pack requests. + let keys = nostr::Keys::generate(); + let repo_root = format!("/git/{OWNER_HEX}/myrepo"); + let tags = vec![ + nostr::Tag::parse(["u", &format!("http://{host}{repo_root}")]).unwrap(), + nostr::Tag::parse(["method", "GET"]).unwrap(), + ]; + let event = nostr::EventBuilder::new(nostr::Kind::Custom(27235), "") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + let nip98_token = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&event).unwrap()) + ); + + // Helper: send a pack-route POST request and return (status, headers, body). + let send_pack_request = + |state: Arc, + route: &'static str, + auth_headers: Vec<(&'static str, String)>| { + let host = host.clone(); + async move { + let uri = format!("/git/{OWNER_HEX}/myrepo/{route}"); + let mut builder = axum::http::Request::builder() + .method("POST") + .uri(&uri) + .header("host", &host) + .header( + "content-type", + if route == "git-upload-pack" { + "application/x-git-upload-pack-request" + } else { + "application/x-git-receive-pack-request" + }, + ); + for (name, value) in &auth_headers { + builder = builder.header(*name, value); + } + let req = builder + .body(axum::body::Body::empty()) + .expect("build request"); + let resp = git_router(Arc::clone(&state)) + .oneshot(req) + .await + .expect("router oneshot"); + let status = resp.status(); + let headers = resp.headers().clone(); + let body = to_bytes(resp.into_body(), 4096).await.unwrap_or_default(); + (status, headers, body) + } + }; + + // ── Same-key material: built once, shared by Cases 5–7 and Case 4 ── + // + // Hoisted here so Cases 5–7 (inside the per-route loop below) can + // reference `same_key_assertion` without a forward-reference error. + // Case 4 reuses the same variables — no duplication. + { + use jsonwebtoken::{Algorithm, Header}; + let test_keys_outer = nostr::Keys::generate(); + let test_pubkey_hex_outer = test_keys_outer.public_key().to_hex(); + let now_outer = chrono::Utc::now().timestamp(); + let claims_outer = serde_json::json!({ + "iss": git_test_issuer, + "aud": git_test_audience, + "iat": now_outer, + "exp": now_outer + 600, + "sub": "test-subject", + "nostr_pubkey": test_pubkey_hex_outer, + }); + let mut hdr_outer = Header::new(Algorithm::ES256); + hdr_outer.kid = Some(git_test_kid.to_owned()); + hdr_outer.typ = Some("nip-fi+jwt".to_owned()); + let same_key_assertion = + jsonwebtoken::encode(&hdr_outer, &claims_outer, &git_enc_key) + .expect("sign assertion"); + + for route in &["git-upload-pack", "git-receive-pack"] { + let route: &'static str = route; + + // ── Case 1: missing assertion → 401 MissingEvidence ───────── + let (status, headers, body) = send_pack_request( + Arc::clone(&state), + route, + vec![("authorization", nip98_token.clone())], + ) + .await; + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "{route}: missing assertion MUST deny 401 MissingEvidence. \ + Falsifying mutation: remove admit_nip_fi_http_on_state from GitAuth \ + → legacy NIP-98 accepts → reaches validate_repo_id (valid owner) \ + → authorize_git_read denies → different status/body." + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "{route}: MissingEvidence body must be exact 'authentication required\\n'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + let ct = headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + ct, "text/plain; charset=utf-8", + "{route}: 401 content-type must be 'text/plain; charset=utf-8'" + ); + let www_auth = headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, "Nostr", + "{route}: 401 must carry WWW-Authenticate: Nostr" + ); + + // ── Case 2: duplicate Authorization → 403 cardinality ─────── + // Two NIP-98 tokens → cardinality gate fires before NIP-FI assertion check. + // No assertion header needed — cardinality fires first. + let (dup_status, dup_headers, dup_body) = send_pack_request( + Arc::clone(&state), + route, + vec![ + ("authorization", nip98_token.clone()), + ("authorization", nip98_token.clone()), + ], + ) + .await; + assert_eq!( + dup_status, + axum::http::StatusCode::FORBIDDEN, + "{route}: duplicate Authorization headers MUST deny 403 EvidenceRejected \ + (cardinality gate). \ + Falsifying mutation: remove cardinality check from admit_nip_fi_http \ + → request reaches NIP-FI assertion check → different denial." + ); + assert_eq!( + dup_body.as_ref(), + b"evidence rejected\n", + "{route}: cardinality 403 body must be exact 'evidence rejected\\n'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + let dup_ct = dup_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + dup_ct, "text/plain; charset=utf-8", + "{route}: cardinality 403 content-type must be 'text/plain; charset=utf-8'" + ); + assert!( + dup_headers.get("www-authenticate").is_none(), + "{route}: cardinality 403 MUST NOT carry WWW-Authenticate \ + (client has a token, it's malformed — not absent)" + ); + + // ── Case 3: invalid base64 assertion → 403 EvidenceRejected ─ + // A syntactically invalid Nostr-Federated-Identity value (non-base64 + // after the "Nostr " prefix) → EvidenceRejected → 403. + // This is distinct from MissingEvidence (absent header → 401). + // + // Falsifying mutation: skip assertion validation for malformed tokens → + // request reaches handler → different status/body. + let (inv_status, inv_headers, inv_body) = send_pack_request( + Arc::clone(&state), + route, + vec![ + ("authorization", nip98_token.clone()), + ( + buzz_auth::CLIENT_ATTACHED_HEADER, + "Bearer !!!not-valid-base64!!!".to_string(), + ), + ], + ) + .await; + assert_eq!( + inv_status, + axum::http::StatusCode::FORBIDDEN, + "{route}: invalid base64 assertion MUST deny 403 EvidenceRejected. \ + Falsifying mutation: skip assertion parsing on bad input → \ + handler reached → different status/body." + ); + assert_eq!( + inv_body.as_ref(), + b"evidence rejected\n", + "{route}: invalid assertion body must be exact 'evidence rejected\\n'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + let inv_ct = inv_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + inv_ct, "text/plain; charset=utf-8", + "{route}: invalid assertion 403 content-type must be 'text/plain; charset=utf-8'" + ); + assert!( + inv_headers.get("www-authenticate").is_none(), + "{route}: invalid assertion 403 MUST NOT carry WWW-Authenticate \ + (client has a token, it's malformed — not absent)" + ); + + // ── Case 5: missing proof + valid assertion → 401 MissingEvidence ─ + // + // Valid NFI assertion present, but NO Authorization (NIP-98) header. + // NIP-98 extraction closure returns MissingEvidence (no Authorization) → + // maps to 401 `authentication required\n`. + // Proves proof validation is not bypassed by a valid assertion. + // + // Falsifying mutation: make the NIP-98 closure skip missing-auth → + // NIP-98 proves something other than 401 → assertion fires. + let (miss_proof_status, _miss_proof_headers, miss_proof_body) = + send_pack_request( + Arc::clone(&state), + route, + vec![( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}"), + )], + ) + .await; + assert_eq!( + miss_proof_status, + axum::http::StatusCode::UNAUTHORIZED, + "{route}: missing proof + valid assertion MUST deny 401 MissingEvidence. \ + A valid assertion does NOT bypass NIP-98 proof requirement. \ + Falsifying mutation: skip NIP-98 when assertion present → handler reached." + ); + assert_eq!( + miss_proof_body.as_ref(), + b"authentication required\n", + "{route}: missing-proof 401 body must be exact 'authentication required\\n'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + + // ── Case 6: malformed proof + valid assertion → 403 EvidenceRejected ─ + // + // Valid NFI assertion + syntactically malformed NIP-98 (`Nostr !!!bad!!!`). + // NIP-98 closure fails (bad base64) → maps to 403 EvidenceRejected. + // Proves malformed-proof detection is not bypassed by a valid assertion. + // + // Falsifying mutation: accept malformed NIP-98 when assertion present → + // admission bypassed → response is not 403 EvidenceRejected. + let (mal_proof_status, _mal_proof_headers, mal_proof_body) = send_pack_request( + Arc::clone(&state), + route, + vec![ + ("authorization", "Nostr !!!not-valid-base64!!!".to_string()), + ( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}"), + ), + ], + ) + .await; + assert_eq!( + mal_proof_status, + axum::http::StatusCode::FORBIDDEN, + "{route}: malformed proof + valid assertion MUST deny 403 EvidenceRejected. \ + Falsifying mutation: skip NIP-98 validation when assertion present → \ + admission bypassed → not 403." + ); + assert_eq!( + mal_proof_body.as_ref(), + b"evidence rejected\n", + "{route}: malformed-proof 403 body must be exact 'evidence rejected\\n'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + + // ── Case 7: duplicate proof + valid assertion → 403 cardinality ─ + // + // Two Authorization headers + valid NFI assertion. The cardinality gate + // fires before NIP-98 extraction (it runs on Authorization count). + // 403 EvidenceRejected — same result as Case 2 (dup without assertion), + // proving the assertion does not gate the cardinality check. + // + // `nip98_token` is signed by `keys` while `same_key_assertion` names + // `test_keys_outer`, so disabling cardinality predicts key pairing + // denial: 403 `authorization denied\n`. The exact + // `evidence rejected\n` body below distinguishes that mutation. + let (dup_proof_status, _dup_proof_headers, dup_proof_body) = send_pack_request( + Arc::clone(&state), + route, + vec![ + ("authorization", nip98_token.clone()), + ("authorization", nip98_token.clone()), + ( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}"), + ), + ], + ) + .await; + assert_eq!( + dup_proof_status, + axum::http::StatusCode::FORBIDDEN, + "{route}: duplicate proof + valid assertion MUST deny 403 cardinality. \ + Disabling cardinality → key pairing 403 'authorization denied\\n'." + ); + assert_eq!( + dup_proof_body.as_ref(), + b"evidence rejected\n", + "{route}: dup-proof 403 body must be exact 'evidence rejected\\n'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + } + + // ── Case 4: same-key admission → passes NIP-FI, reaches handler ── + // + // The verifier was injected into `state` at the top of this test. + // Same-key: NIP-98 signed by test_keys; assertion nostr_pubkey = + // test_keys.public_key(). Key pairing passes → request reaches + // `validate_repo_id` → `authorize_git_read`. + // + // The repo does not exist in the test database, so `authorize_git_read` + // returns 404 "repository not found" — not a NIP-FI code. + // + // Falsifying mutation: replace the pairing check with always-deny → + // 403 `authorization denied\n` → status/body checks fire. + // + // Also covers `info/refs` (GET) with the same assertion; the route + // shares `GitAuth::from_request_parts` and `authorize_git_read`. + { + use jsonwebtoken::{Algorithm, Header}; + + // Same-key: NIP-98 signed by test_keys; assertion nostr_pubkey = + // test_keys.public_key(). Pairing passes. + let test_keys = nostr::Keys::generate(); + let test_pubkey_hex = test_keys.public_key().to_hex(); + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": git_test_issuer, + "aud": git_test_audience, + "iat": now, + "exp": now + 600, + "sub": "test-subject", + "nostr_pubkey": test_pubkey_hex, + }); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(git_test_kid.to_owned()); + header.typ = Some("nip-fi+jwt".to_owned()); + let same_key_assertion = jsonwebtoken::encode(&header, &claims, &git_enc_key) + .expect("sign assertion"); + + // Build same-key NIP-98 token for upload-pack repo root. + let admitted_nip98_tags = vec![ + nostr::Tag::parse(["u", &format!("http://{host}{repo_root}")]).unwrap(), + nostr::Tag::parse(["method", "GET"]).unwrap(), + ]; + let admitted_event = nostr::EventBuilder::new(nostr::Kind::Custom(27235), "") + .tags(admitted_nip98_tags) + .sign_with_keys(&test_keys) + .unwrap(); + let admitted_nip98_token = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&admitted_event).unwrap()) + ); + + // ── git-upload-pack (POST): same-key admission → 404 ───────── + // + // upload-pack calls authorize_git_read which queries DB for + // kind:30617 announcement. Repo absent → 404 "repository not found". + // + // Falsifying mutation: key-pairing always-deny → 403 + // `authorization denied\n` → body check fires. + { + let (s_up, _h_up, b_up) = send_pack_request( + Arc::clone(&state), + "git-upload-pack", + vec![ + ("authorization", admitted_nip98_token.clone()), + ( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}"), + ), + ], + ) + .await; + assert_eq!( + s_up, + axum::http::StatusCode::NOT_FOUND, + "git-upload-pack: same-key admission MUST reach authorize_git_read \ + → 404 (repo absent). \ + If 401/403: NIP-FI denial — check verifier injection and key pairing. \ + Body: {b_up:?}" + ); + assert_eq!( + b_up.as_ref(), + b"repository not found", + "git-upload-pack: same-key admitted 404 body must be exact \ + 'repository not found'. \ + Falsifying mutation: key pairing always-deny → 403 \ + 'authorization denied\\n'." + ); + } + + // ── git-receive-pack (POST): same-key admission → git busy 503 ─ + // + // Every `git_semaphore` permit is held, so an admitted request + // stops at `receive_pack` → `acquire_git_permit`, which returns + // exactly 503, `Retry-After: 5`, body `git service busy`, no + // Content-Type, no challenge — before hydration, the + // subprocess, or finalize. Any NIP-FI denial (401 + // `authentication required\n`, 403 `evidence rejected\n` / + // `authorization denied\n`, 503 `authorization unavailable\n`) + // happens in `GitAuth` before the handler and cannot produce + // these bytes. + { + let held: Vec<_> = std::iter::from_fn(|| { + Arc::clone(&state.git_semaphore).try_acquire_owned().ok() + }) + .collect(); + assert!( + !held.is_empty(), + "fixture must hold at least one git permit" + ); + let (s_rp, h_rp, b_rp) = send_pack_request( + Arc::clone(&state), + "git-receive-pack", + vec![ + ("authorization", admitted_nip98_token.clone()), + ( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}"), + ), + ], + ) + .await; + drop(held); + assert_eq!( + s_rp, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "git-receive-pack: admitted request MUST reach acquire_git_permit \ + → 503 busy. Body: {b_rp:?}" + ); + assert_eq!( + h_rp.get("retry-after").and_then(|v| v.to_str().ok()), + Some("5"), + "git-receive-pack: busy 503 carries Retry-After: 5" + ); + assert!( + h_rp.get("content-type").is_none(), + "git-receive-pack: busy 503 carries no Content-Type" + ); + assert!( + h_rp.get("www-authenticate").is_none(), + "git-receive-pack: busy 503 carries no challenge" + ); + assert_eq!( + b_rp.as_ref(), + b"git service busy", + "git-receive-pack: exact busy body from acquire_git_permit" + ); + } + + // ── info/refs (GET): shares GitAuth + authorize_git_read ────── + // + // info/refs is a GET with ?service=git-upload-pack sharing + // `GitAuth::from_request_parts` and `authorize_git_read`: missing + // assertion, missing / malformed / duplicate proof with a valid + // assertion, and the same-key positive. + { + let uri = + format!("/git/{OWNER_HEX}/myrepo/info/refs?service=git-upload-pack"); + // ── info/refs Case 1: missing assertion → 401 ──────────── + let (s, _, b) = { + let req = axum::http::Request::builder() + .method("GET") + .uri(&uri) + .header("host", &host) + .header("authorization", &nip98_token) + .body(axum::body::Body::empty()) + .expect("build request"); + let resp = git_router(Arc::clone(&state)) + .oneshot(req) + .await + .expect("router oneshot"); + let st = resp.status(); + let hd = resp.headers().clone(); + let bd = to_bytes(resp.into_body(), 4096).await.unwrap_or_default(); + (st, hd, bd) + }; + assert_eq!( + s, + axum::http::StatusCode::UNAUTHORIZED, + "info/refs: missing assertion MUST deny 401. Body: {b:?}" + ); + assert_eq!( + b.as_ref(), + b"authentication required\n", + "info/refs: missing assertion 401 body must be 'authentication required\\n'." + ); + + // ── info/refs Case 5: missing proof + valid assertion → 401 ─ + let (s5, _, b5) = { + let req = axum::http::Request::builder() + .method("GET") + .uri(&uri) + .header("host", &host) + .header( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}"), + ) + .body(axum::body::Body::empty()) + .expect("build request"); + let resp = git_router(Arc::clone(&state)) + .oneshot(req) + .await + .expect("router oneshot"); + let st = resp.status(); + let bd = to_bytes(resp.into_body(), 4096).await.unwrap_or_default(); + (st, (), bd) + }; + assert_eq!( + s5, + axum::http::StatusCode::UNAUTHORIZED, + "info/refs: missing proof + valid assertion MUST deny 401. Body: {b5:?}" + ); + assert_eq!( + b5.as_ref(), + b"authentication required\n", + "info/refs: missing-proof 401 body must be 'authentication required\\n'." + ); + + // ── info/refs Cases 6/7: malformed / duplicate proof + + // valid same-key assertion → 403 `evidence rejected\n` ─ + for (authorization, case) in [ + ( + vec!["Nostr !!!not-valid-base64!!!".to_string()], + "malformed proof", + ), + ( + vec![admitted_nip98_token.clone(), admitted_nip98_token.clone()], + "duplicate proof", + ), + ] { + let mut builder = axum::http::Request::builder() + .method("GET") + .uri(&uri) + .header("host", &host) + .header( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}"), + ); + for value in &authorization { + builder = builder.header("authorization", value); + } + let resp = git_router(Arc::clone(&state)) + .oneshot(builder.body(axum::body::Body::empty()).expect("build")) + .await + .expect("router oneshot"); + let st = resp.status(); + let hd = resp.headers().clone(); + let bd = to_bytes(resp.into_body(), 4096).await.unwrap_or_default(); + assert_eq!( + st, + axum::http::StatusCode::FORBIDDEN, + "info/refs {case}: MUST deny 403. Body: {bd:?}" + ); + assert_eq!( + hd.get("content-type").and_then(|v| v.to_str().ok()), + Some("text/plain; charset=utf-8"), + "info/refs {case}: 403 Content-Type" + ); + assert!( + hd.get("www-authenticate").is_none(), + "info/refs {case}: 403 carries no challenge" + ); + assert_eq!( + bd.as_ref(), + b"evidence rejected\n", + "info/refs {case}: exact EvidenceRejected body" + ); + } + + // ── info/refs Case 4 (same-key positive) → 404 ─────────── + let (s4, _, b4) = { + let req = axum::http::Request::builder() + .method("GET") + .uri(&uri) + .header("host", &host) + .header("authorization", &admitted_nip98_token) + .header( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}"), + ) + .body(axum::body::Body::empty()) + .expect("build request"); + let resp = git_router(Arc::clone(&state)) + .oneshot(req) + .await + .expect("router oneshot"); + let st = resp.status(); + let bd = to_bytes(resp.into_body(), 4096).await.unwrap_or_default(); + (st, (), bd) + }; + assert_eq!( + s4, + axum::http::StatusCode::NOT_FOUND, + "info/refs: same-key admission MUST reach handler → \ + 404 (repo not found). Body: {b4:?}" + ); + assert_eq!( + b4.as_ref(), + b"repository not found", + "info/refs: same-key admitted 404 body must be 'repository not found'. \ + Falsifying mutation: key pairing always-deny → 403 body." + ); + } + } + } // closes same-key outer block (test_keys_outer / same_key_assertion) + } + } +} diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 6714281f40..8e135ecaae 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -25,6 +25,7 @@ use serde::Deserialize; use serde_json::Value; use crate::handlers::side_effects::{publish_nip43_member_added, publish_nip43_membership_list}; +use crate::nip_fi_http::admit_nip_fi_http_on_state; use buzz_core::invite::{ hash_v2_code, validate_v2_code, DEFAULT_INVITE_TTL_SECS, MAX_INVITE_TTL_SECS, MAX_INVITE_USES, MIN_INVITE_TTL_SECS, V2_PREFIX, @@ -251,14 +252,7 @@ async fn authenticate( pubkey, event_id_bytes, .. - } = bridge::verify_bridge_auth_with_options( - headers, - "POST", - &url, - Some(body), - true, // invites always require NIP-98; no X-Pubkey dev fallback - true, // POST bodies must be covered by a payload tag - )?; + } = bridge::verify_nip98_exempt_invite_claim(headers, "POST", &url, Some(body))?; bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; Ok((tenant, pubkey)) @@ -285,9 +279,75 @@ pub async fn mint_invite( State(state): State>, headers: HeaderMap, body: axum::body::Bytes, -) -> Result, (StatusCode, Json)> { - let (tenant, pubkey) = authenticate(&state, &headers, "/api/invites", &body).await?; +) -> axum::response::Response { + // NIP-FI gate wraps the entire handler so the denial is emitted as exact + // text/plain bytes. [FI-TRACE-AUTHORITY-UNIFORM] + mint_invite_checked(state, headers, body).await +} +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers +async fn mint_invite_checked( + state: Arc, + headers: HeaderMap, + body: axum::body::Bytes, +) -> axum::response::Response { + use axum::response::IntoResponse as _; + + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let tenant = match crate::tenant::bind_community(&state.db, raw_host).await { + Ok(t) => t, + Err(_) => { + return api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + .into_response() + } + }; + + let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, "/api/invites"); + + // NIP-FI admission: NIP-98 extraction runs inside the closure, followed by + // assertion verify → pair → deny-map in fixed order. The proven pubkey is + // only available through the returned NipFiAdmission. [FI-TRACE-AUTHORITY-UNIFORM] + let admission = match admit_nip_fi_http_on_state( + &state, + &headers, + bridge::make_nip98_closure_for_admission( + headers.clone(), + "POST", + url, + Some(body.to_vec()), + true, // invites always require NIP-98; no X-Pubkey dev fallback + true, // POST bodies must be covered by a payload tag + ), + ) { + Ok(a) => a, + Err(resp) => return resp, + }; + let pubkey = *admission.proven_pubkey(); + let (event_id_bytes, _signed_created_at) = admission.into_extra(); + + // Replay detection runs after NIP-98+assertion admission (both proofs verified). + if let Err(e) = bridge::check_nip98_replay(&state, &tenant, event_id_bytes).await { + return e.into_response(); + } + + match mint_invite_inner(&state, body, tenant, pubkey).await { + Ok(json) => json.into_response(), + Err(e) => e.into_response(), + } +} + +async fn mint_invite_inner( + state: &AppState, + body: axum::body::Bytes, + tenant: buzz_core::TenantContext, + pubkey: nostr::PublicKey, +) -> Result, (StatusCode, Json)> { // Authz mirrors kind:9030 (add member): owner or admin only. let sender_hex = pubkey.to_hex(); let member = state @@ -1813,4 +1873,239 @@ mod postgres_tests { let response = get_page(state, "/api/join-policy/privacy").await; assert_eq!(response.status(), StatusCode::NOT_FOUND); } + + // ── NIP-FI seam tests: POST /api/invites ────────────────────────────────── + // + // Gate under test: `admit_nip_fi_http_on_state` in `mint_invite_checked`. + // The router's assertion guard runs first and only verifies the assertion, + // so the handler's own admission is what enforces key pairing. + // + // Infrastructure: `#[ignore = "requires Postgres"]` + tokio::test on the + // invites harness; NIP-FI Enforce is enabled by patching the config after + // state construction. + + // Static P-256 test key, shared with the bridge/media/settings NIP-FI tests. + const NIP_FI_TEST_ISSUER: &str = "https://issuer.example"; + const NIP_FI_TEST_AUDIENCE: &str = "https://relay.example"; + const NIP_FI_TEST_KID: &str = "test-key-1"; + const NIP_FI_TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + + /// Clone `base` into an Enforce-mode state. With `with_verifier`, a real + /// ES256 verifier over the static test key is injected so a signed + /// assertion passes the router guard and reaches the handler. + fn nip_fi_enforce_state(base: &AppState, with_verifier: bool) -> Arc { + use buzz_auth::{ + AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, + IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, + }; + use jsonwebtoken::{jwk::JwkSet, Algorithm}; + + let mut state = base.clone(); + let mut config = (*state.config).clone(); + config.require_auth_token = true; + config.nip_fi.mode = buzz_auth::NipFiMode::Enforce; + state.config = Arc::new(config); + if !with_verifier { + return Arc::new(state); + } + + let jwks: JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", + "kid": NIP_FI_TEST_KID, + "x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI", + "y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA" + }] + })) + .expect("valid test JWKS"); + let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); + let key_set = + AssertionKeySet::new_for_test(NIP_FI_TEST_ISSUER.to_owned(), 1, jwks, hard_deadline) + .expect("valid test key set"); + let jwks_contract = buzz_auth::JwksSourceContract::new( + format!("{NIP_FI_TEST_ISSUER}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid jwks contract"); + let policy = IssuerPolicy::new( + NIP_FI_TEST_ISSUER.to_owned(), + vec![NIP_FI_TEST_AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, + 3600, + None, + jwks_contract, + ) + .expect("valid issuer policy"); + let mut registry = IssuerRegistry::new(); + registry.insert(policy); + let verifier: Arc = Arc::new(FederatedAssertionVerifier::new( + registry, + StaticIssuerKeySource::new([key_set]), + )); + state.nip_fi_verifier = Some(verifier); + Arc::new(state) + } + + /// Mint a signed NIP-FI assertion binding `keys`' pubkey. + fn nip_fi_signed_assertion(keys: &Keys) -> String { + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": NIP_FI_TEST_ISSUER, + "aud": NIP_FI_TEST_AUDIENCE, + "iat": now, + "exp": now + 600, + "sub": "test-subject", + "nostr_pubkey": keys.public_key().to_hex(), + }); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(NIP_FI_TEST_KID.to_owned()); + header.typ = Some("nip-fi+jwt".to_owned()); + let key = EncodingKey::from_ec_pem(NIP_FI_TEST_EC_PKCS8_PEM.as_bytes()) + .expect("valid test EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign assertion") + } + + /// POST `/api/invites` with a NIP-98 proof from `nip98_keys` and, when + /// given, a NIP-FI assertion; returns `(status, body)`. + async fn nip_fi_post_mint( + state: Arc, + host: &str, + nip98_keys: &Keys, + assertion: Option, + ) -> (StatusCode, Vec) { + let url = format!("https://{host}/api/invites"); + let mut request = Request::builder() + .method("POST") + .uri("/api/invites") + .header(header::HOST, host) + .header( + header::AUTHORIZATION, + nip98_auth_header(nip98_keys, &url, b"{}"), + ) + .header(header::CONTENT_TYPE, "application/json"); + if let Some(token) = assertion { + request = request.header("Nostr-Federated-Identity", format!("Bearer {token}")); + } + let response = build_router(state) + .oneshot(request.body(Body::from("{}")).expect("request")) + .await + .expect("response"); + let status = response.status(); + let body = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("read body") + .to_vec(); + (status, body) + } + + /// Valid NIP-98 + no assertion → the router's outer assertion guard denies + /// 401 before the handler runs. This witnesses the guard only; handler + /// pairing is covered by `nip_fi_enforce_mint_invite_key_mismatch_is_403`. + /// + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_enforce_mint_invite_no_assertion_is_401() { + let host = format!("nip-fi-invites-seam-{}.local", Uuid::new_v4().simple()); + let Some(state_base) = invite_test_state(&host).await else { + return; + }; + // No verifier: the guard's missing-assertion check fires before any + // verifier lookup. + let state = nip_fi_enforce_state(&state_base, false); + + let (status, _) = nip_fi_post_mint(state, &host, &Keys::generate(), None).await; + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "NIP-FI enforce mode: POST /api/invites with valid NIP-98 + no assertion MUST be \ + denied 401 by the router's outer assertion guard [FI-TRACE-HTTP-INGRESS]" + ); + } + + /// Valid assertion for key A + NIP-98 proof for key B → the request passes + /// the router guard (which only verifies the assertion) and the handler's + /// `admit_nip_fi_http_on_state` denies on key pairing with exactly 403 + /// `authorization denied\n`. Key B is a seeded owner, so without the + /// handler's admission the mint would succeed. + /// + /// Falsifying mutation: replace the handler's `admit_nip_fi_http_on_state` + /// with NIP-98-only admission (`admit_nip_fi_http` in Off mode) — the + /// owner's mint succeeds with 200. + /// + /// [FI-INV-05] [FI-TRACE-ASSERTION-KEY-MISMATCH] + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_enforce_mint_invite_key_mismatch_is_403() { + let host = format!("nip-fi-invites-pair-{}.local", Uuid::new_v4().simple()); + let state_base = invite_test_state(&host) + .await + .expect("requires reachable Postgres and relay test state"); + let asserted = Keys::generate(); + let owner = Keys::generate(); + seed_nip_fi_owner(&state_base, &host, &owner).await; + let state = nip_fi_enforce_state(&state_base, true); + + let (status, body) = nip_fi_post_mint( + state, + &host, + &owner, + Some(nip_fi_signed_assertion(&asserted)), + ) + .await; + assert_eq!( + (status, body.as_slice()), + (StatusCode::FORBIDDEN, b"authorization denied\n".as_slice()), + "assertion for key A + NIP-98 for key B MUST reach mint_invite_checked and be \ + denied by its NIP-FI key pairing" + ); + } + + /// Same-key control for the mismatch test: assertion and NIP-98 both for + /// the seeded owner → admission succeeds and the handler mints normally. + /// Proves the handler does not deny every assertion-bearing request. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_enforce_mint_invite_same_key_mints() { + let host = format!("nip-fi-invites-ctrl-{}.local", Uuid::new_v4().simple()); + let state_base = invite_test_state(&host) + .await + .expect("requires reachable Postgres and relay test state"); + let owner = Keys::generate(); + seed_nip_fi_owner(&state_base, &host, &owner).await; + let state = nip_fi_enforce_state(&state_base, true); + + let (status, body) = + nip_fi_post_mint(state, &host, &owner, Some(nip_fi_signed_assertion(&owner))).await; + assert_eq!( + status, + StatusCode::OK, + "matching assertion and NIP-98 keys must be admitted and mint: {}", + String::from_utf8_lossy(&body) + ); + let json: Value = serde_json::from_slice(&body).expect("mint response JSON"); + assert!(json.get("code").and_then(Value::as_str).is_some(), "{json}"); + } + + async fn seed_nip_fi_owner(state: &AppState, host: &str, owner: &Keys) { + let community = state + .db + .lookup_community_by_host(host) + .await + .expect("lookup") + .expect("community exists"); + state + .db + .add_relay_member(community.id, &owner.public_key().to_hex(), "owner", None) + .await + .expect("seed owner"); + } } diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 780532ec5d..62e5945b3d 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -24,13 +24,45 @@ use buzz_media::{BlobDescriptor, MediaError, UploadAttribution, UploadNetworkInf use crate::state::AppState; -/// Axum extractor that validates Blossom auth, the BUD-11 hash binding, and -/// relay membership (NIP-43, when enabled) from headers BEFORE the request -/// body is read. This prevents unauthenticated clients from forcing the -/// server to buffer up to 50MB of body data. +/// Lightweight pre-auth upload context: tenant + route mode only. /// -/// Axum processes `FromRequestParts` extractors before `FromRequest` (body) -/// extractors, so auth rejection happens before any body buffering. +/// Used as the first-phase extractor for `upload_blob`. Blossom auth +/// extraction is deliberately NOT done here so it can run inside the +/// NIP-FI admission closure, ensuring that in Enforce mode a missing or +/// malformed Authorization header is mapped to the correct NIP-FI denial +/// bytes (MissingEvidence/EvidenceRejected) rather than legacy +/// `MediaError` JSON 401/403. [FI-TRACE-AUTHORITY-UNIFORM] +// pub(crate) so axum can resolve the extractor from the pub handler signature. +pub(crate) struct UploadContext { + tenant: TenantContext, + route_mode: UploadRouteMode, +} + +impl FromRequestParts> for UploadContext { + type Rejection = MediaError; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let headers = &parts.headers; + + // Row zero: bind tenant from the request host. Fail-closed: + // unmapped host → 404. + let raw_host = headers + .get(header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| MediaError::NotFound)?; + + let route_mode = upload_route_mode(parts.uri.path())?; + + Ok(UploadContext { tenant, route_mode }) + } +} + pub(crate) struct AuthenticatedUpload { auth_event: nostr::Event, /// Community resolved from the request host at extraction time (row zero for @@ -60,10 +92,6 @@ fn upload_route_mode(path: &str) -> Result { } } -struct MediaReadAuth { - tenant: TenantContext, -} - const MEDIA_UPLOAD_RATE_WINDOW: Duration = Duration::from_secs(60); struct UploadPermit { @@ -137,108 +165,6 @@ fn acquire_upload_permit( }) } -impl FromRequestParts> for AuthenticatedUpload { - type Rejection = MediaError; - - async fn from_request_parts( - parts: &mut Parts, - state: &Arc, - ) -> Result { - let headers = &parts.headers; - - // 1. Row zero: bind this upload to its community from the request host, - // identical to the WS door in `router.rs` and the bridge door in - // `bridge.rs`. Fail-closed: an unmapped host or lookup failure is a - // generic `NotFound` (404) — never a default tenant, never echoing the - // host, so an unauthenticated caller cannot probe which communities - // exist on this deployment. - // - // This MUST run before Blossom auth verification (step 2) so the - // `server`-tag check validates against the *bound tenant host*, not a - // process-global domain — a relay process serves many tenant hosts, and - // the stock CLI tags its own configured relay host (conformance row 52). - // Binding only reads the Host header — no request body is buffered — so - // doing it first preserves the pre-body auth-rejection guarantee. - let raw_host = headers - .get(header::HOST) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - let tenant = crate::tenant::bind_community(&state.db, raw_host) - .await - .map_err(|_| MediaError::NotFound)?; - - let route_mode = upload_route_mode(parts.uri.path())?; - - // 2. Extract and validate Blossom auth event against the bound host. - let auth_event = extract_blossom_auth(headers)?; - // Use the permissive window (3600s) here because we don't know the - // content type yet. The upload functions re-verify with the correct - // per-type window (600s for images, 3600s for video) after the body - // has been consumed and the SHA-256 computed. - buzz_media::auth::verify_blossom_auth_event(&auth_event, Some(tenant.host()), 3600)?; - - // 3. Require X-SHA-256 header (BUD-11: mandatory for PUT /upload) - let claimed_hash = headers - .get("x-sha-256") - .and_then(|v| v.to_str().ok()) - .ok_or(MediaError::MissingTag("x-sha-256"))?; - - // Validate format: exactly 64 lowercase hex characters - if claimed_hash.len() != 64 - || !claimed_hash - .chars() - .all(|c| matches!(c, '0'..='9' | 'a'..='f')) - { - return Err(MediaError::HashMismatch); - } - - // 4. Validate X-SHA-256 matches at least one x tag in the auth event - let has_matching_x = auth_event - .tags - .iter() - .any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(claimed_hash))); - if !has_matching_x { - return Err(MediaError::HashMismatch); - } - - // 5. Relay membership gate (NIP-43). Blossom auth proves the signer - // authorized this exact upload hash for this server; NIP-43 answers - // whether that Nostr key may use this community's media store. This is - // the only upload authority: independent of bearer-token / api_tokens - // storage and of `require_auth_token` (which governs the REST API, not - // media). On open relays (membership disabled) any valid Blossom signer - // may upload, matching the WS door's admission policy. - let auth_tag = crate::api::relay_members::extract_auth_tag_header(headers); - crate::api::relay_members::enforce_relay_membership( - state, - tenant.community(), - auth_event.pubkey.as_bytes(), - auth_tag, - Some(auth_event.created_at.as_secs()), - ) - .await - .map_err(|_| MediaError::RelayMembershipRequired)?; - - if upload_rate_limited(state, tenant.community(), &auth_event.pubkey) { - metrics::counter!("buzz_media_upload_rejections_total", "reason" => "rate_limit") - .increment(1); - return Err(MediaError::UploadRateLimitExceeded); - } - let upload_permit = acquire_upload_permit(state, tenant.community(), &auth_event.pubkey) - .inspect_err(|_| { - metrics::counter!("buzz_media_upload_rejections_total", "reason" => "concurrency") - .increment(1); - })?; - - Ok(AuthenticatedUpload { - auth_event, - tenant, - route_mode, - _upload_permit: upload_permit, - }) - } -} - /// Build per-event upload attribution when upload records are enabled /// (`BUZZ_MEDIA_UPLOAD_RECORDS`). Returns `None` when the feature is off — /// the upload pipeline then writes no `_uploads/` record at all. @@ -300,12 +226,9 @@ fn serving_lease_lost(error: anyhow::Error) -> MediaError { /// PUT `/upload` or the temporary media-only `/media/upload` alias. /// -/// Auth is validated via the [`AuthenticatedUpload`] extractor BEFORE the body -/// is read, preventing unauthenticated clients from forcing body buffering. -// AuthenticatedUpload is pub(crate) — it's an internal extractor type, never -// exposed outside this crate. The warning is benign: axum resolves it at -// compile time via trait bounds, not by name. -#[allow(private_interfaces)] +/// Auth is extracted inside the NIP-FI admission closure so that in active +/// modes a missing or malformed Authorization header produces the contract's +/// NIP-FI denial bytes rather than legacy `MediaError` JSON. [FI-TRACE-AUTHORITY-UNIFORM] /// /// Expects: /// - `Authorization: Nostr ` — Blossom auth @@ -317,8 +240,122 @@ fn serving_lease_lost(error: anyhow::Error) -> MediaError { /// Returns a [`BlobDescriptor`] JSON on success. // TODO(v2): Add persistent per-pubkey storage quotas. Admission limits below // bound active parser/storage work, but they do not cap durable bytes stored. +// UploadContext is pub(crate) — it's an internal extractor type, never exposed +// outside this crate. The warning is benign: axum resolves it at compile time +// via trait bounds, not by name. +#[allow(private_interfaces)] +#[allow(clippy::result_large_err)] // Response is the natural error type for axum closures pub async fn upload_blob( State(state): State>, + ctx: UploadContext, + headers: HeaderMap, + body: axum::body::Body, +) -> axum::response::Response { + use crate::nip_fi_http::{admit_nip_fi_http_on_state, Nip98Proof}; + use axum::response::IntoResponse as _; + + // NIP-FI admission with Blossom extraction as the NIP-98 closure. + // In Enforce mode: extraction failure → NIP-FI denial bytes (MissingEvidence/ + // EvidenceRejected). In Off mode: MediaError propagates unchanged [FI-INV-15]. + // + // The closure must verify the auth event against the tenant host BEFORE + // returning the proven pubkey to the admission gate — same ordering invariant + // as the read path. [FI-TRACE-AUTHORITY-UNIFORM] + let tenant_host = ctx.tenant.host().to_owned(); + let headers_clone = headers.clone(); + let admission = match admit_nip_fi_http_on_state(&state, &headers, move || { + let auth_event = extract_blossom_auth(&headers_clone).map_err(|e| e.into_response())?; + // Permissive window (3600s): content type unknown until body arrives. + buzz_media::auth::verify_blossom_auth_event(&auth_event, Some(&tenant_host), 3600) + .map_err(|e| e.into_response())?; + let pubkey = auth_event.pubkey; + Ok(Nip98Proof::new(pubkey, auth_event)) + }) { + Ok(a) => a, + Err(resp) => return resp, + }; + let auth_event = admission.into_extra(); + + // Post-admission: validate X-SHA-256 header and hash binding. + // These are Blossom-protocol checks, not NIP-FI — Off mode still enforces + // them because they protect body integrity, not the assertion boundary. + let claimed_hash = match headers.get("x-sha-256").and_then(|v| v.to_str().ok()) { + Some(h) => h.to_owned(), + None => return MediaError::MissingTag("x-sha-256").into_response(), + }; + if claimed_hash.len() != 64 + || !claimed_hash + .chars() + .all(|c| matches!(c, '0'..='9' | 'a'..='f')) + { + return MediaError::HashMismatch.into_response(); + } + let has_matching_x = auth_event + .tags + .iter() + .any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(&claimed_hash))); + if !has_matching_x { + return MediaError::HashMismatch.into_response(); + } + + // Post-admission: relay membership gate (NIP-43). + let auth_tag = crate::api::relay_members::extract_auth_tag_header(&headers); + if let Err(e) = crate::api::relay_members::enforce_relay_membership( + &state, + ctx.tenant.community(), + auth_event.pubkey.as_bytes(), + auth_tag, + Some(auth_event.created_at.as_secs()), + ) + .await + .map(|_| ()) + .map_err(|_| MediaError::RelayMembershipRequired) + { + return e.into_response(); + } + + // Post-admission: rate limit and concurrency permit. + if upload_rate_limited(&state, ctx.tenant.community(), &auth_event.pubkey) { + metrics::counter!("buzz_media_upload_rejections_total", "reason" => "rate_limit") + .increment(1); + return MediaError::UploadRateLimitExceeded.into_response(); + } + let upload_permit = match acquire_upload_permit( + &state, + ctx.tenant.community(), + &auth_event.pubkey, + ) + .inspect_err(|_| { + metrics::counter!("buzz_media_upload_rejections_total", "reason" => "concurrency") + .increment(1); + }) { + Ok(p) => p, + Err(e) => return e.into_response(), + }; + + let auth = AuthenticatedUpload { + auth_event, + tenant: ctx.tenant, + route_mode: ctx.route_mode, + _upload_permit: upload_permit, + }; + upload_blob_inner(state, auth, headers, body).await +} + +async fn upload_blob_inner( + state: Arc, + auth: AuthenticatedUpload, + headers: HeaderMap, + body: axum::body::Body, +) -> axum::response::Response { + use axum::response::IntoResponse as _; + upload_blob_result(state, auth, headers, body) + .await + .into_response() +} + +async fn upload_blob_result( + state: Arc, auth: AuthenticatedUpload, headers: HeaderMap, body: axum::body::Body, @@ -524,17 +561,41 @@ async fn bind_media_read_tenant( .map_err(|_| MediaError::NotFound) } -async fn authenticate_media_read( - state: &AppState, +/// Extract and signature-verify the Blossom auth event for a GET/HEAD read. +/// +/// This is the NIP-98 extraction step for media reads: it parses the +/// `Authorization: Nostr ` header, decodes and verifies the NIP-98 +/// event, and checks the Blossom GET auth binding (sha256 and server tags). +/// +/// Used as the NIP-98 closure inside `admit_nip_fi_http_on_state` so that in +/// active NIP-FI modes a missing/malformed Authorization header is mapped to +/// the correct NIP-FI DenialClass instead of a legacy `MediaError` JSON 401. +/// Off mode propagates `MediaError` unchanged ([FI-INV-15]). +/// +/// [FI-TRACE-AUTHORITY-UNIFORM] +fn extract_blossom_read_proof( headers: &HeaderMap, - sha256_ext: &str, -) -> Result { - let tenant = bind_media_read_tenant(state, headers).await?; - + sha256: &str, + tenant_host: &str, +) -> Result, MediaError> { let auth_event = extract_blossom_auth(headers)?; - let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext); - buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant.host()), 3600)?; + buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant_host), 3600)?; + let pubkey = auth_event.pubkey; + Ok(crate::nip_fi_http::Nip98Proof::new(pubkey, auth_event)) +} +/// Post-admission membership gate for media reads. +/// +/// Called after `admit_nip_fi_http_on_state` succeeds so that membership +/// is checked against the NIP-FI-verified pubkey rather than a raw +/// header value. Separated from extraction so it can run after admission +/// in both Off and active modes. +async fn enforce_blossom_read_membership( + state: &AppState, + tenant: &TenantContext, + auth_event: &nostr::Event, + headers: &HeaderMap, +) -> Result<(), MediaError> { let auth_tag = crate::api::relay_members::extract_auth_tag_header(headers); crate::api::relay_members::enforce_relay_membership( state, @@ -544,9 +605,8 @@ async fn authenticate_media_read( Some(auth_event.created_at.as_secs()), ) .await - .map_err(|_| MediaError::RelayMembershipRequired)?; - - Ok(MediaReadAuth { tenant }) + .map(|_| ()) + .map_err(|_| MediaError::RelayMembershipRequired) } fn blob_cache_control() -> &'static str { @@ -632,14 +692,39 @@ const MAX_RANGE_CHUNK: u64 = 16 * 1024 * 1024; /// - Chunk capped at 16 MiB; clients request additional ranges for the rest /// /// All responses include `Accept-Ranges: bytes` so video players know seeking is supported. +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers pub async fn get_blob( State(state): State>, Path(sha256_ext): Path, req_headers: HeaderMap, ) -> Result { validate_media_path(&sha256_ext)?; - let media_auth = authenticate_media_read(&state, &req_headers, &sha256_ext).await?; - serve_blob_for_tenant(&state, &media_auth.tenant, &sha256_ext, &req_headers).await + // Row zero: bind tenant. Blossom auth extraction and NIP-FI admission follow + // so that in Enforce mode a missing/malformed Authorization header produces + // NIP-FI denial bytes (not legacy MediaError JSON). [FI-TRACE-AUTHORITY-UNIFORM] + let tenant = bind_media_read_tenant(&state, &req_headers).await?; + let sha256 = sha256_ext + .split('.') + .next() + .unwrap_or(&sha256_ext) + .to_owned(); + let tenant_host = tenant.host().to_owned(); + let headers_clone = req_headers.clone(); + // NIP-FI admission with Blossom extraction as the NIP-98 closure. + // In Enforce mode: extraction failure → NIP-FI denial bytes (MissingEvidence/ + // EvidenceRejected). In Off mode: MediaError propagates unchanged [FI-INV-15]. + use crate::nip_fi_http::admit_nip_fi_http_on_state; + let admission = match admit_nip_fi_http_on_state(&state, &req_headers, move || { + extract_blossom_read_proof(&headers_clone, &sha256, &tenant_host) + .map_err(|e| e.into_response()) + }) { + Ok(a) => a, + Err(resp) => return Ok(resp), + }; + let auth_event = admission.into_extra(); + // Post-admission: membership gate. + enforce_blossom_read_membership(&state, &tenant, &auth_event, &req_headers).await?; + serve_blob_for_tenant(&state, &tenant, &sha256_ext, &req_headers).await } /// Serve a validated blob from an already-authorized tenant context. @@ -897,14 +982,34 @@ fn parse_byte_range(range: &str, total: u64) -> Option<(u64, u64)> { /// Content-type is derived from the validated sidecar only — never from raw S3 /// object metadata — to prevent MIME spoofing via tampered storage. If the sidecar /// is missing, we return 404 rather than fall back to untrusted metadata. +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers pub async fn head_blob( State(state): State>, headers: HeaderMap, Path(sha256_ext): Path, ) -> Result { validate_media_path(&sha256_ext)?; - let media_auth = authenticate_media_read(&state, &headers, &sha256_ext).await?; - let tenant = media_auth.tenant; + // Row zero: bind tenant. Blossom auth extraction and NIP-FI admission follow + // so that in Enforce mode a missing/malformed Authorization header produces + // NIP-FI denial bytes (not legacy MediaError JSON). [FI-TRACE-AUTHORITY-UNIFORM] + let tenant = bind_media_read_tenant(&state, &headers).await?; + let sha256 = sha256_ext + .split('.') + .next() + .unwrap_or(&sha256_ext) + .to_owned(); + let tenant_host = tenant.host().to_owned(); + let headers_clone = headers.clone(); + use crate::nip_fi_http::admit_nip_fi_http_on_state; + let admission = match admit_nip_fi_http_on_state(&state, &headers, move || { + extract_blossom_read_proof(&headers_clone, &sha256, &tenant_host) + .map_err(|e| e.into_response()) + }) { + Ok(a) => a, + Err(resp) => return Ok(resp), + }; + let auth_event = admission.into_extra(); + enforce_blossom_read_membership(&state, &tenant, &auth_event, &headers).await?; let cache_control = blob_cache_control(); // Sidecar gate FIRST — reject before any blob I/O. @@ -1538,4 +1643,1724 @@ mod tests { fn test_parse_byte_range_zero_start() { assert_eq!(parse_byte_range("bytes=0-0", 1000), Some((0, 0))); } + + // ── NIP-FI media regression tests ──────────────────────────────────────── + // + // These postgres-backed tests prove the NIP-FI admission gate is wired into + // the media upload and read routes at the router level. They all require a + // live Postgres + Redis and are tagged #[ignore = "requires Postgres"]. + // + // Verified contract rows (NIP-FI.md rejection table): + // Enforce mode, missing Blossom auth: 401 `authentication required\n` + // text/plain; charset=utf-8 + // WWW-Authenticate: Nostr + // Enforce mode, malformed auth: 403 `evidence rejected\n` + // text/plain; charset=utf-8 + // no challenge + // Enforce mode, duplicate auth header: 403 `evidence rejected\n` (cardinality) + // Off mode, missing Blossom auth: 401 `{"error":"authentication failed"}` + // application/json + // no WWW-Authenticate + // + // [FI-TRACE-AUTHORITY-UNIFORM, FI-INV-15] + #[cfg(test)] + mod postgres_tests { + use super::*; + use std::sync::Arc; + + use axum::body::{to_bytes, Body}; + use axum::http::{Request, StatusCode}; + use buzz_auth::NipFiMode; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use tower::ServiceExt; + + // ── Test infrastructure ──────────────────────────────────────────────── + + /// Always-fresh replay guard (no Redis needed) — same pattern as bridge tests. + struct AlwaysFreshReplayGuard; + impl buzz_auth::Nip98ReplayGuard for AlwaysFreshReplayGuard { + fn try_mark_in_scope<'a>( + &'a self, + _scope: &'a str, + _event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'a, + >, + > { + Box::pin(async { Ok(true) }) + } + } + + /// Constants for the static P-256 test key, matching the cardinality test in bridge.rs. + const TEST_ISSUER: &str = "https://issuer.example"; + const TEST_AUDIENCE: &str = "https://relay.example"; + const TEST_KID: &str = "test-key-1"; + const TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + + /// Build an AppState with NIP-FI Enforce and a real injected P-256 verifier. + async fn media_enforce_test_state() -> Option> { + use buzz_auth::{ + AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, + IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, + }; + use jsonwebtoken::{jwk::JwkSet, Algorithm}; + + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = crate::test_support::database_url(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "wss://nip-fi-media-test.local".to_string(); + config.require_auth_token = false; + config.require_relay_membership = false; + config.nip_fi.mode = NipFiMode::Enforce; + + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + let (mut state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + + // Inject a real P-256 verifier so the assertion guard can forward requests. + let jwks: JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", + "kid": TEST_KID, + "x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI", + "y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA" + }] + })) + .expect("valid test JWKS"); + let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); + let key_set = + AssertionKeySet::new_for_test(TEST_ISSUER.to_owned(), 1, jwks, hard_deadline) + .expect("valid test key set"); + let jwks_contract = buzz_auth::JwksSourceContract::new( + format!("{TEST_ISSUER}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid jwks contract"); + let policy = IssuerPolicy::new( + TEST_ISSUER.to_owned(), + vec![TEST_AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, + 3600, + None, + jwks_contract, + ) + .expect("valid issuer policy"); + let mut registry = IssuerRegistry::new(); + registry.insert(policy); + let verifier: Arc = Arc::new(FederatedAssertionVerifier::new( + registry, + StaticIssuerKeySource::new([key_set]), + )); + state.nip_fi_verifier = Some(verifier); + Some(Arc::new(state)) + } + + /// Build an AppState with NIP-FI Off. + async fn media_off_test_state() -> Option> { + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = crate::test_support::database_url(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "wss://nip-fi-media-off.local".to_string(); + config.require_auth_token = false; + config.require_relay_membership = false; + config.nip_fi.mode = NipFiMode::Off; + + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + let (mut state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Some(Arc::new(state)) + } + + /// Mint a valid Blossom upload auth header value. + fn blossom_upload_auth_value(keys: &Keys, host: &str, sha256_hex: &str) -> String { + use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; + use nostr::JsonUtil as _; + let now = nostr::Timestamp::now().as_secs(); + let exp = now + 300; + let event = EventBuilder::new(Kind::from(24242), "Upload blob") + .tags(vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["expiration", &exp.to_string()]).unwrap(), + Tag::parse(["server", host]).unwrap(), + Tag::parse(["x", sha256_hex]).unwrap(), + ]) + .sign_with_keys(keys) + .expect("sign blossom upload auth"); + format!("Nostr {}", B64.encode(event.as_json().as_bytes())) + } + + /// Mint a valid Blossom get auth header value. + fn blossom_get_auth_value(keys: &Keys, host: &str, sha256_hex: &str) -> String { + use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; + use nostr::JsonUtil as _; + let now = nostr::Timestamp::now().as_secs(); + let exp = now + 300; + let event = EventBuilder::new(Kind::from(24242), "Get blob") + .tags(vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["expiration", &exp.to_string()]).unwrap(), + Tag::parse(["server", host]).unwrap(), + Tag::parse(["x", sha256_hex]).unwrap(), + ]) + .sign_with_keys(keys) + .expect("sign blossom get auth"); + format!("Nostr {}", B64.encode(event.as_json().as_bytes())) + } + + /// Mint a signed NIP-FI assertion whose `nostr_pubkey` matches `keys`. + fn signed_assertion(nostr_pubkey_hex: &str) -> String { + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "iat": now, + "exp": now + 600, + "sub": "test-subject", + "nostr_pubkey": nostr_pubkey_hex, + }); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(TEST_KID.to_owned()); + header.typ = Some("nip-fi+jwt".to_owned()); + let key = + EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).expect("valid test EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign assertion") + } + + /// Drive a oneshot request through the full relay router; return + /// `(status, resp_headers, body_bytes)`. + async fn media_oneshot( + state: Arc, + method: &str, + uri: &str, + host: &str, + headers: axum::http::HeaderMap, + body: &[u8], + ) -> (StatusCode, axum::http::HeaderMap, bytes::Bytes) { + let mut builder = Request::builder() + .method(method) + .uri(uri) + .header("host", host); + for (name, value) in &headers { + builder = builder.header(name, value); + } + let resp = crate::router::build_router(state) + .oneshot( + builder + .body(Body::from(body.to_vec())) + .expect("build request"), + ) + .await + .expect("router oneshot"); + let status = resp.status(); + let resp_headers = resp.headers().clone(); + let resp_body = to_bytes(resp.into_body(), 8192).await.unwrap_or_default(); + (status, resp_headers, resp_body) + } + + /// Upload body that both upload routes reject deterministically before + /// any storage call: the ID3 magic sniffs as `audio/mpeg`, which + /// `validate_file_content` refuses on `/upload` and `upload_blob_result` + /// refuses as `DisallowedContentType` on `/media/upload`. Both map to + /// 415 JSON (`buzz-media/src/error.rs`), after admission, the serving + /// lease, and the body read, but before any storage call. + const AUDIO_BODY: &[u8] = b"ID3\x04\x00\x00\x00\x00\x00\x00"; + const AUDIO_REJECTION: &[u8] = br#"{"error":"disallowed content type: audio/mpeg"}"#; + + fn sha256_hex(bytes: &[u8]) -> String { + use sha2::Digest as _; + hex::encode(sha2::Sha256::digest(bytes)) + } + + /// Assert an exact response: status, Content-Type, challenge, and body. + fn assert_exact_response( + (status, headers, body): &(StatusCode, axum::http::HeaderMap, bytes::Bytes), + expected_status: StatusCode, + expected_content_type: &str, + expected_challenge: Option<&str>, + expected_body: &[u8], + context: &str, + ) { + assert_eq!(*status, expected_status, "{context}: status; body {body:?}"); + assert_eq!( + headers.get("content-type").and_then(|v| v.to_str().ok()), + Some(expected_content_type), + "{context}: Content-Type" + ); + assert_eq!( + headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()), + expected_challenge, + "{context}: WWW-Authenticate" + ); + assert_eq!(body.as_ref(), expected_body, "{context}: body"); + } + + // ── Upload proof matrix: Enforce mode, both upload routes ─────────── + // + // `/upload` and the legacy `/media/upload` alias both route to + // `upload_blob`. Every Enforce case carries a valid assertion, so the + // outer guard forwards the request and the handler's + // `admit_nip_fi_http_on_state` produces the denial. Removing the + // handler admission (or routing an alias around it) lets the legacy + // Blossom extractor answer with `{"error":"authentication failed"}` + // JSON instead of these NIP-FI text/plain bytes. + const UPLOAD_ROUTES: [&str; 2] = ["/upload", "/media/upload"]; + + fn upload_request( + rt: &tokio::runtime::Runtime, + state: &Arc, + route: &str, + host: &str, + authorization: &[&str], + assertion: Option<&str>, + ) -> (StatusCode, axum::http::HeaderMap, bytes::Bytes) { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + "x-sha-256", + sha256_hex(AUDIO_BODY).parse().expect("valid header"), + ); + for value in authorization { + headers.append( + axum::http::header::AUTHORIZATION, + value.parse().expect("valid header bytes"), + ); + } + if let Some(assertion) = assertion { + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + } + rt.block_on(media_oneshot( + Arc::clone(state), + "PUT", + route, + host, + headers, + AUDIO_BODY, + )) + } + + fn media_fixture( + state_fn: impl std::future::Future>>, + ) -> (tokio::runtime::Runtime, Arc, String) { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + let Some(state) = rt.block_on(state_fn) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-up-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + (rt, state, host) + } + + /// Valid assertion, no Authorization → 401 MissingEvidence with challenge. + #[test] + #[ignore = "requires Postgres"] + fn upload_enforce_missing_proof_is_401_nip_fi() { + let (rt, state, host) = media_fixture(media_enforce_test_state()); + let assertion = signed_assertion(&Keys::generate().public_key().to_hex()); + for route in UPLOAD_ROUTES { + assert_exact_response( + &upload_request(&rt, &state, route, &host, &[], Some(&assertion)), + StatusCode::UNAUTHORIZED, + "text/plain; charset=utf-8", + Some("Nostr"), + b"authentication required\n", + &format!("{route}: missing proof"), + ); + } + } + + /// Valid assertion, malformed Authorization → 403 EvidenceRejected. + #[test] + #[ignore = "requires Postgres"] + fn upload_enforce_malformed_proof_is_403_nip_fi() { + let (rt, state, host) = media_fixture(media_enforce_test_state()); + let assertion = signed_assertion(&Keys::generate().public_key().to_hex()); + for route in UPLOAD_ROUTES { + assert_exact_response( + &upload_request( + &rt, + &state, + route, + &host, + &["Nostr !!!not-valid-base64!!!"], + Some(&assertion), + ), + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + None, + b"evidence rejected\n", + &format!("{route}: malformed proof"), + ); + } + } + + /// Valid same-key assertion, the same valid proof twice → 403 from the + /// cardinality gate. Removing the gate admits the first proof and the + /// request reaches the exact 415 of the same-key control below. + #[test] + #[ignore = "requires Postgres"] + fn upload_enforce_duplicate_proof_is_403_cardinality() { + let (rt, state, host) = media_fixture(media_enforce_test_state()); + let keys = Keys::generate(); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let proof = blossom_upload_auth_value(&keys, &host, &sha256_hex(AUDIO_BODY)); + for route in UPLOAD_ROUTES { + assert_exact_response( + &upload_request( + &rt, + &state, + route, + &host, + &[&proof, &proof], + Some(&assertion), + ), + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + None, + b"evidence rejected\n", + &format!("{route}: duplicate proof"), + ); + } + } + + /// Valid assertion for `key_a`, valid proof signed by `key_b` → the + /// handler's key-pairing check denies 403 `authorization denied\n`. + /// Removing key pairing lets the request reach the exact 415 below. + #[test] + #[ignore = "requires Postgres"] + fn upload_enforce_mismatched_key_is_handler_403() { + let (rt, state, host) = media_fixture(media_enforce_test_state()); + let assertion = signed_assertion(&Keys::generate().public_key().to_hex()); + let proof = + blossom_upload_auth_value(&Keys::generate(), &host, &sha256_hex(AUDIO_BODY)); + for route in UPLOAD_ROUTES { + assert_exact_response( + &upload_request(&rt, &state, route, &host, &[&proof], Some(&assertion)), + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + None, + b"authorization denied\n", + &format!("{route}: mismatched key"), + ); + } + } + + /// Same-key assertion + proof → admission passes and the request reaches + /// content validation: exact 415 `AUDIO_REJECTION` on both routes, with + /// no dependence on storage. + #[test] + #[ignore = "requires Postgres"] + fn upload_enforce_same_key_admission_reaches_content_validation() { + let (rt, state, host) = media_fixture(media_enforce_test_state()); + let keys = Keys::generate(); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let proof = blossom_upload_auth_value(&keys, &host, &sha256_hex(AUDIO_BODY)); + for route in UPLOAD_ROUTES { + assert_exact_response( + &upload_request(&rt, &state, route, &host, &[&proof], Some(&assertion)), + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "application/json", + None, + AUDIO_REJECTION, + &format!("{route}: same-key admission"), + ); + } + } + + /// Off mode skips the cardinality gate and `HeaderMap::get` takes the + /// first Authorization value. A single valid proof and a + /// valid-first/malformed-second pair both reach the exact 415; applying + /// cardinality in Off mode would return 403, and last-value selection + /// would return the legacy 401 JSON. + #[test] + #[ignore = "requires Postgres"] + fn upload_off_duplicate_auth_takes_first_value() { + let (rt, state, host) = media_fixture(media_off_test_state()); + let proof = + blossom_upload_auth_value(&Keys::generate(), &host, &sha256_hex(AUDIO_BODY)); + for (authorization, context) in [ + (vec![proof.as_str()], "single proof"), + ( + vec![proof.as_str(), "Nostr !!!not-valid-base64!!!"], + "valid-first/malformed-second", + ), + ] { + assert_exact_response( + &upload_request(&rt, &state, "/upload", &host, &authorization, None), + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "application/json", + None, + AUDIO_REJECTION, + &format!("Off {context}"), + ); + } + } + + // ── Upload: Off mode, missing Blossom auth → legacy MediaError JSON ── + + /// Off mode + PUT /upload with no Authorization header must return the + /// legacy MediaError JSON 401: `{"error":"authentication failed"}`, + /// application/json, and NO `WWW-Authenticate` header. + /// + /// FI-INV-15: Off mode must propagate legacy MediaError responses + /// unchanged. The NIP-FI denial bytes (text/plain + challenge) MUST NOT + /// appear in Off mode. + /// + /// Falsifying mutation: change Off mode to run NIP-FI admission on + /// missing-auth → 401 `authentication required\n` text/plain with + /// WWW-Authenticate → body and content-type assertions fire. + #[test] + #[ignore = "requires Postgres"] + fn upload_off_missing_auth_is_legacy_json_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_off_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-off-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let sha256 = "d".repeat(64); + let mut headers = axum::http::HeaderMap::new(); + headers.insert("x-sha-256", sha256.parse().expect("valid header")); + // No Authorization header. + + let (status, resp_headers, body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "PUT", + "/upload", + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "Off mode + missing Blossom auth MUST return 401 legacy MediaError (FI-INV-15)." + ); + assert_eq!( + body.as_ref(), + br#"{"error":"authentication failed"}"#, + "Off mode 401 body MUST be exact legacy JSON bytes \ + '{{\"error\":\"authentication failed\"}}' [FI-INV-15]. \ + NIP-FI text/plain bytes would indicate Off mode is incorrectly applying \ + active-mode denial." + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "application/json", + "Off mode 401 Content-Type MUST be application/json (legacy MediaError) [FI-INV-15]." + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "Off mode 401 MUST NOT carry WWW-Authenticate [FI-INV-15]." + ); + } + + // ── GET /media: Enforce mode, missing Blossom auth → 401 NIP-FI ───── + + /// Enforce mode + GET /media/{sha256} with no Authorization header must + /// return 401 `authentication required\n` + `WWW-Authenticate: Nostr`. + /// + /// Falsifying mutation: remove `admit_nip_fi_http_on_state` from + /// `get_blob` → the legacy Blossom extractor fires → 401 JSON body, no + /// challenge → body and header assertions fire. + #[test] + #[ignore = "requires Postgres"] + fn get_blob_enforce_missing_proof_is_401_nip_fi() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-enf-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let sha256 = "e".repeat(64); + let path = format!("/media/{sha256}.jpg"); + + // Assertion present but NO Blossom Authorization header. + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + + let (status, resp_headers, body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "GET", + &path, + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "Enforce mode + GET /media + missing Blossom auth MUST return 401 MissingEvidence. \ + Falsifying mutation: remove admit_nip_fi_http_on_state from get_blob → \ + legacy Blossom extractor fires → JSON body, no challenge." + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "GET Enforce 401 body MUST be NIP-FI bytes 'authentication required\\n'." + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "GET Enforce 401 Content-Type MUST be text/plain; charset=utf-8." + ); + assert_eq!( + resp_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "Nostr", + "GET Enforce 401 MUST carry WWW-Authenticate: Nostr." + ); + } + + // ── GET /media: Off mode, missing Blossom auth → legacy JSON 401 ──── + + /// Off mode + GET /media/{sha256} with no Authorization header must + /// return the legacy MediaError JSON 401 (FI-INV-15). + #[test] + #[ignore = "requires Postgres"] + fn get_blob_off_missing_auth_is_legacy_json_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_off_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-off-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let sha256 = "f".repeat(64); + let path = format!("/media/{sha256}.jpg"); + + let (status, resp_headers, body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "GET", + &path, + &host, + Default::default(), + b"", + )); + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "Off GET missing auth → 401" + ); + assert_eq!( + body.as_ref(), + br#"{"error":"authentication failed"}"#, + "Off GET 401 body MUST be legacy JSON bytes [FI-INV-15]." + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "application/json", + "Off GET 401 Content-Type MUST be application/json (legacy MediaError) [FI-INV-15]." + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "Off GET 401 MUST NOT carry WWW-Authenticate [FI-INV-15]." + ); + } + + // ── PUT /upload: Off mode, malformed Authorization → legacy JSON 401 ─ + // + // Off mode does NOT apply NIP-FI cardinality or assertion checks. A + // malformed Nostr token still fails Blossom extraction and the legacy + // MediaError response (application/json 401) is returned unchanged. + // + // This proves Off mode propagates Blossom errors as legacy JSON — not + // the NIP-FI text/plain denial bytes that active modes would produce. + // + // Falsifying mutation A: map Blossom errors to NIP-FI denial bytes in + // Off mode → body changes to "evidence rejected\n" → assertion fires. + // Falsifying mutation B: swap Off → Enforce → cardinality/assertion gates + // fire → NIP-FI 403 body → assertion fires. + #[test] + #[ignore = "requires Postgres"] + fn upload_off_malformed_auth_is_legacy_json_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_off_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-off-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let sha256 = "a".repeat(64); + let mut headers = axum::http::HeaderMap::new(); + headers.insert("x-sha-256", sha256.parse().expect("valid header")); + headers.insert( + axum::http::header::AUTHORIZATION, + "Nostr !!!malformed!!!".parse().expect("valid header bytes"), + ); + + let (status, resp_headers, body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "PUT", + "/upload", + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "Off mode PUT /upload + malformed Authorization MUST return 401 \ + legacy MediaError (FI-INV-15). \ + Falsifying mutation: map Blossom errors to NIP-FI bytes in Off mode \ + → NIP-FI body/CT." + ); + assert_eq!( + body.as_ref(), + br#"{"error":"authentication failed"}"#, + "Off mode malformed-auth 401 body MUST be exact legacy JSON bytes \ + (not NIP-FI text/plain). [FI-INV-15]" + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "application/json", + "Off mode malformed-auth 401 Content-Type MUST be application/json \ + (legacy MediaError). [FI-INV-15]" + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "Off mode malformed-auth 401 MUST NOT carry WWW-Authenticate. [FI-INV-15]" + ); + } + + // ── GET /media: Off mode, malformed Authorization → legacy JSON 401 ─── + // + // Mirror of the upload Off+malformed case for the GET path. + // + // Falsifying mutation: map Blossom errors to NIP-FI bytes in Off mode + // on GET → NIP-FI body/CT → assertion fires. + #[test] + #[ignore = "requires Postgres"] + fn get_blob_off_malformed_auth_is_legacy_json_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_off_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-off-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let sha256 = "c".repeat(64); + let path = format!("/media/{sha256}.jpg"); + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + "Nostr !!!malformed!!!".parse().expect("valid header bytes"), + ); + + let (status, resp_headers, body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "GET", + &path, + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "Off mode GET /media + malformed Authorization MUST return 401 \ + legacy MediaError (FI-INV-15). \ + Falsifying mutation: remap Blossom error to NIP-FI bytes in Off mode." + ); + assert_eq!( + body.as_ref(), + br#"{"error":"authentication failed"}"#, + "Off GET malformed-auth 401 body MUST be exact legacy JSON bytes \ + (not NIP-FI text/plain). [FI-INV-15]" + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "application/json", + "Off GET malformed-auth 401 Content-Type MUST be application/json. [FI-INV-15]" + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "Off GET malformed-auth 401 MUST NOT carry WWW-Authenticate. [FI-INV-15]" + ); + } + + // ── GET/HEAD /media: Off mode, legacy bytes on both read methods ────── + // + // Off mode skips the cardinality gate and `HeaderMap::get` takes the + // first Authorization value, so a single valid get proof and a + // valid-first/malformed-second pair both pass the legacy extractor and + // reach `serve_blob_for_tenant` / `head_blob`, whose sidecar gate + // returns `MediaError::NotFound` for the unstored blob → 404 + // `{"error":"not found"}` JSON (`buzz-media/src/error.rs`). With no + // Authorization at all the legacy extractor returns 401 + // `{"error":"authentication failed"}` JSON. HEAD carries the same + // status and headers with the body stripped by axum. + // + // Applying cardinality in Off mode would return NIP-FI 403 text/plain; + // last-value selection would return the legacy 401 for the duplicate. + fn media_read_off( + rt: &tokio::runtime::Runtime, + state: &Arc, + method: &str, + path: &str, + host: &str, + authorization: &[&str], + ) -> (StatusCode, axum::http::HeaderMap, bytes::Bytes) { + let mut headers = axum::http::HeaderMap::new(); + for value in authorization { + headers.append( + axum::http::header::AUTHORIZATION, + value.parse().expect("valid header bytes"), + ); + } + rt.block_on(media_oneshot( + Arc::clone(state), + method, + path, + host, + headers, + b"", + )) + } + + #[test] + #[ignore = "requires Postgres"] + fn media_read_off_duplicate_auth_takes_first_value() { + let (rt, state, host) = media_fixture(media_off_test_state()); + let sha256 = "d0".repeat(32); + let path = format!("/media/{sha256}.jpg"); + let proof = blossom_get_auth_value(&Keys::generate(), &host, &sha256); + for (method, body) in [ + ("GET", br#"{"error":"not found"}"#.as_slice()), + ("HEAD", b"".as_slice()), + ] { + for (authorization, context) in [ + (vec![proof.as_str()], "single proof"), + ( + vec![proof.as_str(), "Nostr !!!not-valid-base64!!!"], + "valid-first/malformed-second", + ), + ] { + assert_exact_response( + &media_read_off(&rt, &state, method, &path, &host, &authorization), + StatusCode::NOT_FOUND, + "application/json", + None, + body, + &format!("Off {method} {context}"), + ); + } + } + } + + /// Off HEAD witness: missing Authorization → the legacy 401 JSON + /// headers, no challenge, body stripped. Running NIP-FI denial in Off + /// mode would add `WWW-Authenticate: Nostr` and text/plain. + #[test] + #[ignore = "requires Postgres"] + fn head_blob_off_missing_auth_is_legacy_json_401() { + let (rt, state, host) = media_fixture(media_off_test_state()); + let path = format!("/media/{}.jpg", "f".repeat(64)); + assert_exact_response( + &media_read_off(&rt, &state, "HEAD", &path, &host, &[]), + StatusCode::UNAUTHORIZED, + "application/json", + None, + b"", + "Off HEAD missing auth", + ); + } + + // ── HEAD /media: Enforce mode, missing Blossom auth → 401 NIP-FI ──── + + /// Enforce mode + HEAD /media/{sha256} with no Authorization header must + /// return 401 + `WWW-Authenticate: Nostr`. HEAD suppresses the body per + /// RFC 9110; we check status and headers only. + /// + /// Falsifying mutation: remove `admit_nip_fi_http_on_state` from + /// `head_blob` → legacy path fires → 401 but no challenge → assertion fires. + #[test] + #[ignore = "requires Postgres"] + fn head_blob_enforce_missing_proof_is_401_nip_fi() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-enf-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let sha256 = "e".repeat(64); + let path = format!("/media/{sha256}.jpg"); + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + + let (status, resp_headers, head_body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "HEAD", + &path, + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "Enforce mode + HEAD /media + missing Blossom auth MUST return 401. \ + Falsifying mutation: remove admit_nip_fi_http_on_state from head_blob → \ + legacy path fires → no WWW-Authenticate." + ); + assert!( + head_body.is_empty(), + "HEAD MUST suppress the response body (RFC 9110 §9.3.2). \ + Got {} bytes: {:?}", + head_body.len(), + &head_body.as_ref()[..head_body.len().min(64)] + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "HEAD Enforce 401 Content-Type MUST be text/plain; charset=utf-8." + ); + assert_eq!( + resp_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "Nostr", + "HEAD Enforce 401 MUST carry WWW-Authenticate: Nostr." + ); + } + + // ── GET /media: Enforce mode, valid Blossom but NO assertion → 401 ── + + /// Enforce mode + GET /media/{sha256} with a valid Blossom auth but NO + /// `Nostr-Federated-Identity` header must return 401 `authentication + /// required\n` — both the outer guard (`router.rs`) and the per-handler + /// `admit_nip_fi_http_on_state` in `get_blob()` deny at the same point. + /// + /// This is distinct from the "missing Blossom" case: here the Blossom + /// proof IS present, but no assertion was supplied. Either the outer + /// guard or the handler-level check produces MissingEvidence → 401. + /// + /// Falsifying mutation: remove BOTH the outer guard and the handler-level + /// `admit_nip_fi_http_on_state` from `get_blob` → valid Blossom accepted + /// → proceeds to membership/storage → different status → assertion fires. + /// Removing only one layer is insufficient: the other still denies 401. + #[test] + #[ignore = "requires Postgres"] + fn get_blob_enforce_valid_blossom_no_assertion_is_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-enf-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let path = format!("/media/{sha256}.jpg"); + let auth_val = blossom_get_auth_value(&keys, &host, &sha256); + + // Valid Blossom auth but NO Nostr-Federated-Identity header. + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + auth_val.parse().expect("valid header"), + ); + + let (status, resp_headers, body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "GET", + &path, + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "Enforce mode + valid Blossom + NO NIP-FI assertion MUST return 401. \ + Falsifying mutation: remove assertion guard from get_blob → valid Blossom \ + accepted → proceeds to membership/storage → 404 or membership 403." + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "GET Enforce 401 (no assertion) body MUST be NIP-FI bytes." + ); + assert_eq!( + resp_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "Nostr", + "GET Enforce 401 (no assertion) MUST carry WWW-Authenticate: Nostr." + ); + } + + // ── GET /media: Enforce mode, malformed proof → 403 EvidenceRejected ─ + // Authorization header triggers EvidenceRejected before the NIP-FI + // assertion check. 403 + exact body + CT + no challenge. + // + // The fixture supplies a valid assertion so the outer guard forwards + // the request and the handler's Blossom extraction failure is mapped + // to EvidenceRejected. Removing handler admission from `get_blob` + // lets the legacy extractor answer with 401 `{"error":"authentication + // failed"}` JSON instead. + #[test] + #[ignore = "requires Postgres"] + fn get_blob_enforce_malformed_proof_is_403_nip_fi() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-enf-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let sha256 = "c".repeat(64); + let path = format!("/media/{sha256}.jpg"); + + // Malformed: valid Nostr scheme prefix, invalid base64 payload. + // "!!!" is not valid base64 and decodes to an error in the verifier. + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + "Nostr !!!bad!!!".parse().expect("valid header bytes"), + ); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!( + "Bearer {}", + signed_assertion(&Keys::generate().public_key().to_hex()) + ) + .parse() + .expect("valid header"), + ); + + let (status, resp_headers, body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "GET", + &path, + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "GET Enforce + malformed Nostr token MUST return 403 EvidenceRejected. \ + Removing handler admission → legacy 401 JSON instead." + ); + assert_eq!( + body.as_ref(), + b"evidence rejected\n", + "GET Enforce malformed 403 body MUST be exact 'evidence rejected\\n'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "GET Enforce malformed 403 Content-Type MUST be text/plain; charset=utf-8." + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "GET Enforce malformed 403 MUST NOT carry WWW-Authenticate. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + } + + // ── GET /media: Enforce mode, duplicate Authorization → 403 cardinality ─ + // + // Two identical Blossom Authorization headers in Enforce mode trigger + // the cardinality gate inside `admit_nip_fi_http_on_state`. + // 403 + exact body + CT + no challenge. + // + // The fixture supplies a same-key valid assertion so cardinality is the + // only denial source. Removing the cardinality gate admits the first + // proof and the request reaches the sidecar gate → 404 NotFound (the + // same-key control below), not 403 EvidenceRejected. + #[test] + #[ignore = "requires Postgres"] + fn get_blob_enforce_duplicate_proof_is_403_cardinality() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-enf-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let sha256 = "e".repeat(64); + let path = format!("/media/{sha256}.jpg"); + let keys = Keys::generate(); + let blossom_val = blossom_get_auth_value(&keys, &host, &sha256); + let assertion = signed_assertion(&keys.public_key().to_hex()); + + let mut headers = axum::http::HeaderMap::new(); + // Two identical Blossom Authorization headers → cardinality 2. + headers.append( + axum::http::header::AUTHORIZATION, + blossom_val.parse().expect("valid header"), + ); + headers.append( + axum::http::header::AUTHORIZATION, + blossom_val.parse().expect("valid header"), + ); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + + let (status, resp_headers, body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "GET", + &path, + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "GET Enforce + duplicate Authorization MUST return 403 EvidenceRejected. \ + Removing the cardinality gate → admitted → sidecar 404 instead." + ); + assert_eq!( + body.as_ref(), + b"evidence rejected\n", + "GET Enforce cardinality 403 body MUST be exact 'evidence rejected\\n'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "GET Enforce cardinality 403 Content-Type MUST be text/plain; charset=utf-8." + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "GET Enforce cardinality 403 MUST NOT carry WWW-Authenticate. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + } + + // ── GET /media: Enforce mode, same-key success → 404 sidecar not found ─ + // + // A valid NIP-FI assertion + valid Blossom get proof (same key) passes + // admission and proceeds to the sidecar lookup. The sidecar blob does + // not exist in the test state → 404. This proves admission was NOT the + // denial point — an always-denying implementation would return 401/403, + // not 404. + // + // Falsifying mutation: lower the NIP-FI gate to always-deny → + // same-key request returns 401/403 (admission fails) → 404 assertion fires. + #[test] + #[ignore = "requires Postgres"] + fn get_blob_enforce_same_key_admission_succeeds_returns_404() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-enf-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let sha256 = "0".repeat(64); + let path = format!("/media/{sha256}.jpg"); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let blossom_val = blossom_get_auth_value(&keys, &host, &sha256); + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + blossom_val.parse().expect("valid blossom header"), + ); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}") + .parse() + .expect("valid assertion header"), + ); + + let (status, _resp_headers, _body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "GET", + &path, + &host, + headers, + b"", + )); + + // Admission passes → reaches sidecar lookup → blob absent → 404. + // 401 or 403 would indicate admission failure, not sidecar absence. + assert_eq!( + status, + StatusCode::NOT_FOUND, + "GET Enforce same-key admission MUST pass NIP-FI and reach sidecar lookup → 404. \ + 401/403 means admission failed (always-deny implementation). \ + Falsifying mutation: make NIP-FI verifier always-deny → 403 instead of 404." + ); + } + + // ── HEAD /media: Enforce mode, malformed proof → 403 EvidenceRejected ─ + // + // Same contract as GET malformed, but for HEAD. RFC 9110 §9.3.2 suppresses + // the body; we assert status + CT + no challenge only. + #[test] + #[ignore = "requires Postgres"] + fn head_blob_enforce_malformed_proof_is_403_nip_fi() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-enf-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let sha256 = "1".repeat(64); + let path = format!("/media/{sha256}.jpg"); + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + "Nostr !!!bad!!!".parse().expect("valid header bytes"), + ); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!( + "Bearer {}", + signed_assertion(&Keys::generate().public_key().to_hex()) + ) + .parse() + .expect("valid header"), + ); + + let (status, resp_headers, head_body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "HEAD", + &path, + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "HEAD Enforce + malformed Nostr token MUST return 403 EvidenceRejected. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + assert!( + head_body.is_empty(), + "HEAD MUST suppress the response body (RFC 9110 §9.3.2). \ + Got {} bytes.", + head_body.len() + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "HEAD Enforce malformed 403 Content-Type MUST be text/plain; charset=utf-8." + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "HEAD Enforce malformed 403 MUST NOT carry WWW-Authenticate." + ); + } + + // ── HEAD /media: Enforce mode, duplicate Authorization → 403 cardinality ─ + // + // Same contract as GET cardinality, but for HEAD. Body suppressed. + #[test] + #[ignore = "requires Postgres"] + fn head_blob_enforce_duplicate_proof_is_403_cardinality() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-enf-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let sha256 = "2".repeat(64); + let path = format!("/media/{sha256}.jpg"); + let keys = Keys::generate(); + let blossom_val = blossom_get_auth_value(&keys, &host, &sha256); + let assertion = signed_assertion(&keys.public_key().to_hex()); + + let mut headers = axum::http::HeaderMap::new(); + headers.append( + axum::http::header::AUTHORIZATION, + blossom_val.parse().expect("valid header"), + ); + headers.append( + axum::http::header::AUTHORIZATION, + blossom_val.parse().expect("valid header"), + ); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + + let (status, resp_headers, head_body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "HEAD", + &path, + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "HEAD Enforce + duplicate Authorization MUST return 403 EvidenceRejected. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + assert!( + head_body.is_empty(), + "HEAD MUST suppress the response body (RFC 9110 §9.3.2). \ + Got {} bytes.", + head_body.len() + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "HEAD Enforce cardinality 403 Content-Type MUST be text/plain; charset=utf-8." + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "HEAD Enforce cardinality 403 MUST NOT carry WWW-Authenticate." + ); + } + + // ── HEAD /media: Enforce mode, same-key admission → reaches handler ─ + // + // Same-key Blossom get-auth + assertion → admission passes → handler + // attempts sidecar lookup → `read_sidecar_mime` yields `None` → 404. + // + // Falsifying mutation: always-deny key pairing → 403 → assertion fires. + #[test] + #[ignore = "requires Postgres"] + fn head_blob_enforce_same_key_admission_succeeds_returns_404() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-media-enf-hdpos-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let sha256 = "5".repeat(64); + let path = format!("/media/{sha256}.jpg"); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let blossom_token = blossom_get_auth_value(&keys, &host, &sha256); + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + blossom_token.parse().expect("valid header"), + ); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + + let (status, resp_headers, _body_bytes) = rt.block_on(media_oneshot( + Arc::clone(&state), + "HEAD", + &path, + &host, + headers, + b"", + )); + + // Admission passes → handler proceeds to sidecar lookup → blob absent → 404. + // 401/403 would indicate NIP-FI admission failure. + assert_eq!( + status, + StatusCode::NOT_FOUND, + "HEAD /media same-key admission MUST reach handler → 404 (blob not found). \ + If 401: NIP-FI MissingEvidence — outer guard or assertion check denying. \ + If 403: NIP-FI AuthorizationDenied — key pairing denying. \ + Resp headers: {resp_headers:?}. \ + Falsifying mutation: always-deny pairing → 403 instead of 404." + ); + } + + // ── Upload resource witness: handler denial precedes body read and permits ─ + // + // The outer guard (router.rs) forwards any request carrying a + // cryptographically valid assertion, so every denied request below + // carries one (for `key_a`) plus a valid Blossom upload proof signed by + // `key_b`, the real body hash, and the matching host. Only the + // handler's `admit_nip_fi_http_on_state` key-pairing check can deny it + // (403 `authorization denied\n`). + // + // Production order in `upload_blob`: admission → x-sha-256 checks → + // membership → rate limit → `acquire_upload_permit` → body read in + // `upload_blob_result`. + // + // - Denied, instrumented body: zero polls. Moving admission after the + // body read would poll it first. + // - Denied, all global permits held: still 403, not 429. Moving + // admission after `acquire_upload_permit` would return 429. + // - `media_uploads_in_flight` for `key_b` is identical before and after + // each denied request. This proves no leaked per-key accounting; it + // cannot observe a transient acquire-and-release. + // - Admitted controls (assertion for `key_b`, same proof) show the + // resource boundaries are reachable: with permits held → exact 429 + // `upload concurrency limit reached` (`acquire_upload_permit` → + // `buzz-media/src/error.rs`); with permits free → body polled and + // exact 415 `AUDIO_REJECTION`. + #[test] + #[ignore = "requires Postgres"] + fn upload_enforce_denial_does_not_poll_body_or_consume_permit() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct CountingBody { + inner: Option, + polls: Arc, + } + impl http_body::Body for CountingBody { + type Data = bytes::Bytes; + type Error = std::convert::Infallible; + fn poll_frame( + mut self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll, Self::Error>>> + { + self.polls.fetch_add(1, Ordering::SeqCst); + std::task::Poll::Ready(self.inner.take().map(|b| Ok(http_body::Frame::data(b)))) + } + } + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-media-witness-{}.local", + uuid::Uuid::new_v4().simple() + ); + let community = rt + .block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community") + .id; + + let key_a = Keys::generate(); + let key_b = Keys::generate(); + let sha256 = sha256_hex(AUDIO_BODY); + let proof_b = blossom_upload_auth_value(&key_b, &host, &sha256); + let mismatched_assertion = signed_assertion(&key_a.public_key().to_hex()); + let same_key_assertion = signed_assertion(&key_b.public_key().to_hex()); + let accounting_key = (community, key_b.public_key().to_bytes()); + let in_flight = || { + state + .media_uploads_in_flight + .get(&accounting_key) + .map(|count| *count) + }; + + // Send PUT /upload with `assertion`, returning (status, headers, body, polls). + let send = |assertion: &str| { + let polls = Arc::new(AtomicUsize::new(0)); + let request = Request::builder() + .method("PUT") + .uri("/upload") + .header("host", &host) + .header("authorization", &proof_b) + .header("x-sha-256", &sha256) + .header( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}"), + ) + .body(Body::new(CountingBody { + inner: Some(bytes::Bytes::from_static(AUDIO_BODY)), + polls: Arc::clone(&polls), + })) + .expect("build request"); + let response = rt.block_on(async { + let resp = crate::router::build_router(Arc::clone(&state)) + .oneshot(request) + .await + .expect("router oneshot"); + let status = resp.status(); + let headers = resp.headers().clone(); + let body = to_bytes(resp.into_body(), 8192).await.unwrap_or_default(); + (status, headers, body) + }); + (response, polls.load(Ordering::SeqCst)) + }; + let denied = |response: &(StatusCode, axum::http::HeaderMap, bytes::Bytes), + context: &str| { + assert_exact_response( + response, + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + None, + b"authorization denied\n", + context, + ); + }; + + // ── Denied request, permits free: body never polled ───────────── + let before = in_flight(); + let (response, polls) = send(&mismatched_assertion); + denied(&response, "mismatched key, permits free"); + assert_eq!(polls, 0, "handler denial MUST precede the body read"); + assert_eq!( + in_flight(), + before, + "denial MUST leave per-key accounting unchanged" + ); + + // ── All global permits held ───────────────────────────────────── + let semaphore = Arc::clone(&state.media_upload_semaphore); + let held: Vec<_> = + std::iter::from_fn(|| semaphore.clone().try_acquire_owned().ok()).collect(); + assert!( + !held.is_empty(), + "fixture must hold at least one upload permit" + ); + + let before = in_flight(); + let (response, polls) = send(&mismatched_assertion); + denied(&response, "mismatched key, permits exhausted"); + assert_eq!(polls, 0, "handler denial MUST precede the body read"); + assert_eq!( + in_flight(), + before, + "denial MUST leave per-key accounting unchanged" + ); + + let (response, polls) = send(&same_key_assertion); + assert_exact_response( + &response, + StatusCode::TOO_MANY_REQUESTS, + "application/json", + None, + br#"{"error":"upload concurrency limit reached"}"#, + "admitted request, permits exhausted", + ); + assert_eq!(polls, 0, "permit rejection precedes the body read"); + drop(held); + + // ── Admitted request, permits free: body read, exact 415 ──────── + let (response, polls) = send(&same_key_assertion); + assert_exact_response( + &response, + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "application/json", + None, + AUDIO_REJECTION, + "admitted request, permits free", + ); + assert!(polls > 0, "admitted upload MUST read the instrumented body"); + assert_eq!( + in_flight(), + None, + "admitted upload MUST release its per-key slot" + ); + } + } } diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 5745b8d4e5..c84192a6e6 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -32,6 +32,28 @@ pub(crate) fn not_found(msg: &str) -> (StatusCode, Json) { api_error(StatusCode::NOT_FOUND, msg) } +/// Parse a raw query string into `T` after NIP-FI/NIP-98 admission has +/// already succeeded. +/// +/// - Absent or empty query → `Ok(T::default())` (no params is valid). +/// - Non-empty but malformed → `Err(400 bad request)`. +/// +/// **Do not use `.ok().unwrap_or_default()` here.** That pattern silently +/// discards malformed input and changes query semantics — for example, +/// `?status=open&limit=abc` would drop the valid `status=` field together +/// with the bad `limit=`, broadening the query to all statuses. Post- +/// admission a parse failure is the caller's error, not an auth failure. +/// [FI-TRACE-HTTP-INGRESS] +pub(crate) fn parse_query_or_400( + raw: Option<&str>, +) -> Result)> { + match raw { + None | Some("") => Ok(T::default()), + Some(q) => serde_urlencoded::from_str(q) + .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid query: {e}"))), + } +} + /// Relay membership enforcement — single gate for all authenticated entry points. /// /// Moved here from the deleted `relay_members` module. Called by `media.rs`, `bridge.rs`, @@ -383,3 +405,72 @@ pub mod relay_members { } } } + +// ── parse_query_or_400 regression tests ────────────────────────────────────── + +#[cfg(test)] +mod parse_query_tests { + use super::parse_query_or_400; + use serde::Deserialize; + + /// Mirror of `ModerationReadQuery` — independent so this module compiles + /// without pulling in handler dependencies. + #[derive(Debug, Deserialize, Default, PartialEq)] + struct QueryFixture { + status: Option, + limit: Option, + } + + /// Absent query → Default. No params is always valid. + #[test] + fn absent_query_gives_default() { + let result: Result = parse_query_or_400(None); + assert_eq!(result.unwrap(), QueryFixture::default()); + } + + /// Empty string → Default. Same as absent. + #[test] + fn empty_query_gives_default() { + let result: Result = parse_query_or_400(Some("")); + assert_eq!(result.unwrap(), QueryFixture::default()); + } + + /// Well-formed query → parsed correctly. + #[test] + fn valid_query_parses_correctly() { + let result: Result = parse_query_or_400(Some("status=open&limit=50")); + let q = result.unwrap(); + assert_eq!(q.status.as_deref(), Some("open")); + assert_eq!(q.limit, Some(50)); + } + + /// Malformed limit → 400 error, NOT default. + /// + /// Regression for the `.ok().unwrap_or_default()` bug: the old code would + /// silently discard ALL fields on any parse error, so `status=open&limit=abc` + /// would return `QueryFixture::default()` (status=None) instead of 400. + /// That made malformed input yield a BROADER query than intended. + #[test] + fn malformed_limit_is_400_not_default() { + let result: Result = parse_query_or_400(Some("status=open&limit=abc")); + let err = result.unwrap_err(); + assert_eq!( + err.0, + axum::http::StatusCode::BAD_REQUEST, + "malformed ?limit= must return 400, not silently default \ + (old bug: .ok().unwrap_or_default() would drop status= too)" + ); + } + + /// Malformed standalone limit → 400 error, NOT default. + #[test] + fn malformed_standalone_limit_is_400_not_default() { + let result: Result = parse_query_or_400(Some("limit=abc")); + let err = result.unwrap_err(); + assert_eq!( + err.0, + axum::http::StatusCode::BAD_REQUEST, + "malformed ?limit=abc must return 400, not silently default to the cap" + ); + } +} diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index f02fd3e4ea..64095decc1 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -54,6 +54,44 @@ struct TransferCommunityResponse { const OPERATOR_REPLAY_SCOPE: &str = "operator-management"; +#[derive(Debug, Deserialize)] +struct ListenerPubkeysRequest { + pubkeys: Vec, +} + +fn parse_listener_pubkeys(body: &[u8]) -> Result>, (StatusCode, Json)> { + let request: ListenerPubkeysRequest = serde_json::from_slice(body).map_err(|e| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid operator-listener pubkeys JSON: {e}"), + ) + })?; + if request.pubkeys.is_empty() || request.pubkeys.len() > 1_000 { + return Err(api_error( + StatusCode::BAD_REQUEST, + "pubkeys must contain between 1 and 1000 entries", + )); + } + request + .pubkeys + .into_iter() + .map(|value| { + let normalized = validate_pubkey_hex(&value).ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "pubkeys must contain 64-char hex public keys", + ) + })?; + hex::decode(normalized).map_err(|_| { + api_error( + StatusCode::BAD_REQUEST, + "pubkeys must contain 64-char hex public keys", + ) + }) + }) + .collect() +} + /// Shared deployment-global operator auth prelude. The canonical management /// origin and replay namespace are configuration, never tenant registry state /// or an inbound proxy `Host` header. @@ -79,14 +117,7 @@ async fn authorize_operator_request( pubkey, event_id_bytes, .. - } = bridge::verify_bridge_auth_with_options( - headers, - method, - &url, - body, - true, // operator endpoints always require NIP-98; no X-Pubkey dev fallback - body.is_some(), - )?; + } = bridge::verify_nip98_exempt_operator(headers, method, &url, body)?; check_operator_replay(state, event_id_bytes).await?; let pubkey_hex = pubkey.to_hex(); @@ -105,6 +136,86 @@ async fn authorize_operator_request( Ok(pubkey) } +/// Authenticate a deployment-global operator listener using its configured +/// identity and a NIP-98 request signature. +async fn authorize_operator_listener_request( + state: &Arc, + headers: &HeaderMap, + method: &str, + path: &str, + body: &[u8], +) -> Result)> { + let origin = state + .config + .relay_operator_api_origin + .as_deref() + .ok_or_else(|| internal_error("operator API origin is not configured"))?; + let url = format!("{origin}{path}"); + let bridge::VerifiedBridgeAuth { + pubkey, + event_id_bytes, + .. + } = bridge::verify_nip98_exempt_operator(headers, method, &url, Some(body))?; + check_operator_replay(state, event_id_bytes).await?; + if !state + .config + .operator_listener_delivery_urls + .contains_key(&pubkey.to_hex()) + { + return Err(api_error( + StatusCode::FORBIDDEN, + "actor not authorized: not a configured operator listener", + )); + } + Ok(pubkey) +} + +/// Register target pubkeys for the authenticated operator listener. +pub async fn register_listener_pubkeys( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let listener = authorize_operator_listener_request( + &state, + &headers, + "POST", + "/operator/listener/pubkeys", + &body, + ) + .await?; + let target_pubkeys = parse_listener_pubkeys(&body)?; + state + .db + .register_operator_listener_pubkeys(listener.as_bytes(), &target_pubkeys) + .await + .map_err(|e| internal_error(&format!("register operator-listener pubkeys: {e}")))?; + Ok(Json(serde_json::json!({}))) +} + +/// Remove target pubkeys for the authenticated operator listener. +pub async fn remove_listener_pubkeys( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let listener = authorize_operator_listener_request( + &state, + &headers, + "DELETE", + "/operator/listener/pubkeys", + &body, + ) + .await?; + let target_pubkeys = parse_listener_pubkeys(&body)?; + state + .db + .remove_operator_listener_pubkeys(listener.as_bytes(), &target_pubkeys) + .await + .map_err(|e| internal_error(&format!("remove operator-listener pubkeys: {e}")))?; + Ok(Json(serde_json::json!({}))) +} + async fn check_operator_replay( state: &AppState, event_id_bytes: [u8; 32], @@ -646,7 +757,10 @@ pub async fn community_availability( #[cfg(test)] mod postgres_tests { - use std::sync::Arc; + use std::{ + collections::HashSet, + sync::{Arc, Mutex}, + }; use axum::{ body::{to_bytes, Body}, @@ -679,6 +793,26 @@ mod postgres_tests { Box::pin(async { Ok(true) }) } } + + struct SeenOnceReplayGuard(Mutex>); + + impl buzz_auth::Nip98ReplayGuard for SeenOnceReplayGuard { + fn try_mark_in_scope<'a>( + &'a self, + _scope: &'a str, + event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + let inserted = self + .0 + .lock() + .expect("replay set") + .insert(*event_id.as_bytes()); + Box::pin(async move { Ok(inserted) }) + } + } const INGRESS_HOST: &str = "operator-ingress.example"; fn nip98_auth_header(keys: &Keys, url: &str, method: &str, body: Option<&[u8]>) -> String { @@ -779,6 +913,16 @@ mod postgres_tests { ) -> axum::response::Response { let url = format!("http://{INGRESS_HOST}{path}"); let auth = nip98_auth_header(keys, &url, method, body.as_deref().map(str::as_bytes)); + operator_request_with_auth(state, method, path, body, auth).await + } + + async fn operator_request_with_auth( + state: Arc, + method: &str, + path: &str, + body: Option, + auth: String, + ) -> axum::response::Response { let mut request = Request::builder() .method(method) .uri(path) @@ -1471,6 +1615,162 @@ mod postgres_tests { ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn listener_pubkey_routes_register_and_remove_only_for_authenticated_listener() { + let listener = Keys::generate(); + let other_listener = Keys::generate(); + let outsider = Keys::generate(); + let Some(mut state) = operator_test_state(&[]).await else { + return; + }; + Arc::get_mut(&mut state) + .expect("test state has a single owner") + .nip98_replay = Arc::new(SeenOnceReplayGuard(Mutex::new(HashSet::new()))); + let config = Arc::make_mut( + &mut Arc::get_mut(&mut state) + .expect("test state has a single owner") + .config, + ); + config.operator_listener_delivery_urls.insert( + listener.public_key().to_hex(), + url::Url::parse("http://listener.example/deliver").expect("listener URL"), + ); + config.operator_listener_delivery_urls.insert( + other_listener.public_key().to_hex(), + url::Url::parse("http://other-listener.example/deliver").expect("listener URL"), + ); + + let targets = [Keys::generate(), Keys::generate()]; + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect to test DB"); + let target_hex = targets + .iter() + .map(|target| target.public_key().to_hex()) + .collect::>(); + let register_body = serde_json::json!({"pubkeys": target_hex}).to_string(); + let response = signed_operator_request( + Arc::clone(&state), + &listener, + "POST", + "/operator/listener/pubkeys", + Some(register_body), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + let listener_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM operator_listener_pubkeys WHERE listener_pubkey = $1", + ) + .bind(listener.public_key().as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count registered listener targets"); + let other_listener_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM operator_listener_pubkeys WHERE listener_pubkey = $1", + ) + .bind(other_listener.public_key().as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count other listener targets"); + assert_eq!(listener_count, 2); + assert_eq!(other_listener_count, 0); + + let body = serde_json::json!({"pubkeys": [target_hex[0]]}).to_string(); + let missing_payload_auth = nip98_auth_header_without_payload( + &listener, + &format!("http://{INGRESS_HOST}/operator/listener/pubkeys"), + "DELETE", + ); + let missing_payload = operator_request_with_auth( + Arc::clone(&state), + "DELETE", + "/operator/listener/pubkeys", + Some(body), + missing_payload_auth, + ) + .await; + assert_eq!(missing_payload.status(), StatusCode::UNAUTHORIZED); + + let unauthorized = signed_operator_request( + Arc::clone(&state), + &outsider, + "POST", + "/operator/listener/pubkeys", + Some(serde_json::json!({"pubkeys": [target_hex[0]]}).to_string()), + ) + .await; + assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN); + + let malformed = signed_operator_request( + Arc::clone(&state), + &listener, + "POST", + "/operator/listener/pubkeys", + Some(r#"{"pubkeys":["not-a-pubkey"]}"#.to_string()), + ) + .await; + assert_eq!(malformed.status(), StatusCode::BAD_REQUEST); + + let replay_body = serde_json::json!({"pubkeys": [target_hex[1]]}).to_string(); + let replay_auth = nip98_auth_header( + &listener, + &format!("http://{INGRESS_HOST}/operator/listener/pubkeys"), + "DELETE", + Some(replay_body.as_bytes()), + ); + let first_use = operator_request_with_auth( + Arc::clone(&state), + "DELETE", + "/operator/listener/pubkeys", + Some(replay_body.clone()), + replay_auth.clone(), + ) + .await; + assert_eq!(first_use.status(), StatusCode::OK); + let replay = operator_request_with_auth( + Arc::clone(&state), + "DELETE", + "/operator/listener/pubkeys", + Some(replay_body), + replay_auth, + ) + .await; + assert_eq!(replay.status(), StatusCode::UNAUTHORIZED); + let replay_error = read_json(replay).await; + assert!(replay_error["error"] + .as_str() + .unwrap_or_default() + .contains("replay")); + + let remove_body = serde_json::json!({"pubkeys": [target_hex[0]]}).to_string(); + let removed = signed_operator_request( + Arc::clone(&state), + &listener, + "DELETE", + "/operator/listener/pubkeys", + Some(remove_body), + ) + .await; + assert_eq!(removed.status(), StatusCode::OK); + let remaining: Vec> = sqlx::query_scalar( + "SELECT target_pubkey FROM operator_listener_pubkeys WHERE listener_pubkey = $1", + ) + .bind(listener.public_key().as_bytes().as_slice()) + .fetch_all(&pool) + .await + .expect("read remaining listener targets"); + assert!(remaining.is_empty()); + + sqlx::query("DELETE FROM operator_listener_pubkeys WHERE listener_pubkey = $1 OR listener_pubkey = $2") + .bind(listener.public_key().as_bytes().as_slice()) + .bind(other_listener.public_key().as_bytes().as_slice()) + .execute(&pool) + .await + .expect("clean up listener registrations"); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn unmapped_management_host_can_check_availability() { diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index c7fa09bebd..5021c1ee19 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -6,9 +6,9 @@ use std::sync::Arc; use axum::{ - extract::{Path, Query, RawQuery, State}, + extract::{Path, RawQuery, State}, http::{HeaderMap, StatusCode}, - response::Json, + response::{IntoResponse, Json, Response}, }; use chrono::{DateTime, Utc}; use serde::Deserialize; @@ -17,8 +17,11 @@ use uuid::Uuid; use buzz_core::TenantContext; +use buzz_auth::NipFiMode; + use crate::{ - api::{api_error, bridge, internal_error}, + api::{api_error, bridge, internal_error, parse_query_or_400}, + nip_fi_http::admit_nip_fi_http_on_state, state::AppState, }; @@ -40,13 +43,14 @@ fn request_path(path: &str, raw_query: Option<&str>) -> String { } } +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers async fn authorize_workflow_read( state: &Arc, headers: &HeaderMap, path: &str, raw_query: Option<&str>, workflow_id: Uuid, -) -> Result)> { +) -> Result { let raw_host = headers .get(axum::http::header::HOST) .and_then(|value| value.to_str().ok()) @@ -58,17 +62,39 @@ async fn authorize_workflow_read( StatusCode::NOT_FOUND, "relay: no community is configured for this host", ) + .into_response() })?; let path_with_query = request_path(path, raw_query); let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); - let bridge::VerifiedBridgeAuth { - pubkey, - event_id_bytes, - signed_created_at, - } = bridge::verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; - bridge::enforce_http_admission(state, &tenant, &pubkey).await?; - bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + // In NIP-FI enforce/deny-protected mode a real NIP-98 event is mandatory — + // the X-Pubkey dev-mode fallback must never satisfy the pairing requirement. + // [NIP-FI.md:594-607, FI-TRACE-HTTP-INGRESS] + let nip_fi_active = !matches!(state.config.nip_fi.mode, NipFiMode::Off); + + // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] + let require_auth = state.config.require_auth_token || nip_fi_active; + let admission = admit_nip_fi_http_on_state( + state, + headers, + bridge::make_nip98_closure_for_admission( + headers.clone(), + "GET", + url, + None, + require_auth, + false, + ), + )?; + let pubkey = *admission.proven_pubkey(); + let (event_id_bytes, signed_created_at) = admission.into_extra(); + + bridge::enforce_http_admission(state, &tenant, &pubkey) + .await + .map_err(|e| e.into_response())?; + bridge::check_nip98_replay(state, &tenant, event_id_bytes) + .await + .map_err(|e| e.into_response())?; let pubkey_bytes = pubkey.to_bytes().to_vec(); let auth_tag = super::relay_members::extract_auth_tag_header(headers); @@ -79,7 +105,8 @@ async fn authorize_workflow_read( auth_tag, signed_created_at, ) - .await?; + .await + .map_err(|e| e.into_response())?; let workflow = state .db @@ -87,22 +114,21 @@ async fn authorize_workflow_read( .await .map_err(|error| match error { buzz_db::error::DbError::NotFound(_) => { - api_error(StatusCode::NOT_FOUND, "workflow not found") + api_error(StatusCode::NOT_FOUND, "workflow not found").into_response() } - other => internal_error(&format!("get workflow for run read: {other}")), + other => internal_error(&format!("get workflow for run read: {other}")).into_response(), })?; - let channel_id = workflow - .channel_id - .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "workflow is not channel-scoped"))?; + let channel_id = workflow.channel_id.ok_or_else(|| { + api_error(StatusCode::FORBIDDEN, "workflow is not channel-scoped").into_response() + })?; let accessible = state .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) .await - .map_err(|error| internal_error(&format!("workflow channel access lookup: {error}")))?; + .map_err(|error| { + internal_error(&format!("workflow channel access lookup: {error}")).into_response() + })?; if !accessible.contains(&channel_id) { - return Err(api_error( - StatusCode::FORBIDDEN, - "workflow is not accessible", - )); + return Err(api_error(StatusCode::FORBIDDEN, "workflow is not accessible").into_response()); } Ok(tenant) @@ -114,25 +140,47 @@ pub async fn workflow_runs( Path(workflow_id): Path, headers: HeaderMap, RawQuery(raw_query): RawQuery, - Query(query): Query, -) -> Result, (StatusCode, Json)> { +) -> Response { + workflow_runs_inner(state, workflow_id, headers, raw_query) + .await + .into_response() +} + +async fn workflow_runs_inner( + state: Arc, + workflow_id: Uuid, + headers: HeaderMap, + raw_query: Option, +) -> Result, Response> { + // Admission first: NIP-98 + NIP-FI must fire before any application-level + // validation — including query-string parsing — so the denial contract wins + // over request-validation errors. [FI-TRACE-HTTP-INGRESS] + // Raw query is preserved here; parsing happens after admission. + let path = format!("/workflows/{workflow_id}/runs"); + let tenant = + authorize_workflow_read(&state, &headers, &path, raw_query.as_deref(), workflow_id).await?; + + // Parse query string after admission so malformed params (e.g. ?limit=abc) + // cannot 400 before the NIP-FI gate fires. A parse failure after + // admission is a caller error (400); defaulting silently would change + // query semantics. [FI-TRACE-HTTP-INGRESS] + let query: RunsQuery = + parse_query_or_400(raw_query.as_deref()).map_err(|e| e.into_response())?; + if query.before.is_some() != query.before_id.is_some() { return Err(api_error( StatusCode::BAD_REQUEST, "before and before_id must be supplied together", - )); + ) + .into_response()); } let limit = query.limit.unwrap_or(DEFAULT_RUN_LIMIT); if !(1..=MAX_RUN_LIMIT).contains(&limit) { - return Err(api_error( - StatusCode::BAD_REQUEST, - "limit must be between 1 and 100", - )); + return Err( + api_error(StatusCode::BAD_REQUEST, "limit must be between 1 and 100").into_response(), + ); } - let path = format!("/workflows/{workflow_id}/runs"); - let tenant = - authorize_workflow_read(&state, &headers, &path, raw_query.as_deref(), workflow_id).await?; let mut rows = state .db .list_workflow_runs_page( @@ -143,7 +191,7 @@ pub async fn workflow_runs( limit + 1, ) .await - .map_err(|error| internal_error(&format!("list workflow runs: {error}")))?; + .map_err(|error| internal_error(&format!("list workflow runs: {error}")).into_response())?; let has_more = rows.len() > limit as usize; rows.truncate(limit as usize); @@ -169,7 +217,18 @@ pub async fn run_approvals( State(state): State>, Path((workflow_id, run_id)): Path<(Uuid, Uuid)>, headers: HeaderMap, -) -> Result, (StatusCode, Json)> { +) -> Response { + run_approvals_inner(state, workflow_id, run_id, headers) + .await + .into_response() +} + +async fn run_approvals_inner( + state: Arc, + workflow_id: Uuid, + run_id: Uuid, + headers: HeaderMap, +) -> Result, Response> { let path = format!("/workflows/{workflow_id}/runs/{run_id}/approvals"); let tenant = authorize_workflow_read(&state, &headers, &path, None, workflow_id).await?; @@ -179,19 +238,20 @@ pub async fn run_approvals( .await .map_err(|error| match error { buzz_db::error::DbError::NotFound(_) => { - api_error(StatusCode::NOT_FOUND, "workflow run not found") + api_error(StatusCode::NOT_FOUND, "workflow run not found").into_response() } - other => internal_error(&format!("get workflow run for approval read: {other}")), + other => internal_error(&format!("get workflow run for approval read: {other}")) + .into_response(), })?; if run.workflow_id != workflow_id { - return Err(api_error(StatusCode::NOT_FOUND, "workflow run not found")); + return Err(api_error(StatusCode::NOT_FOUND, "workflow run not found").into_response()); } let approvals = state .db .get_run_approvals(tenant.community(), workflow_id, run_id) .await - .map_err(|error| internal_error(&format!("list run approvals: {error}")))?; + .map_err(|error| internal_error(&format!("list run approvals: {error}")).into_response())?; Ok(Json(serde_json::json!({ "approvals": approvals.iter().map(approval_json).collect::>(), }))) diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 5d831b3f65..da69b7cbc2 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -1,11 +1,11 @@ //! Relay configuration from environment variables. -use std::net::SocketAddr; use std::time::Duration; +use std::{collections::HashMap, net::SocketAddr}; use sha2::{Digest, Sha256}; use thiserror::Error; -use tracing::warn; +use tracing::{error, warn}; /// Default maximum inbound WebSocket frame size in bytes. /// @@ -272,6 +272,9 @@ pub struct Config { /// skipped — a typo must not silently disable an operator. pub relay_operator_pubkeys: Vec, + /// Configured operator-listener identities and their HTTPS delivery URLs. + pub operator_listener_delivery_urls: HashMap, + /// Allow NIP-OA owner attestation for relay membership. /// /// When `true` and `require_relay_membership` is also `true`, agents @@ -346,12 +349,14 @@ pub struct Config { pub push_enabled: bool, /// Descriptor key identifier accepted in kind:30350 `exec` tags. pub push_executor_key_id: String, - /// Exact HTTPS gateway endpoint used to submit client-authorized APNs delivery capabilities. + /// Exact HTTP(S) gateway endpoint used to sign and submit APNs delivery capabilities. /// Required while push is enabled. An explicitly empty setting is allowed /// only while push is disabled. pub push_gateway_delivery_url: Option, - /// Hard timeout for one gateway delivery request. + /// Hard timeout for one push gateway delivery request. pub push_gateway_timeout: Duration, + /// Hard timeout for one operator-listener delivery request. + pub operator_listener_timeout: Duration, /// Optional relay-hosted policy shown on join surfaces. Disabled when no /// documents or age attestation are configured. @@ -367,6 +372,14 @@ pub struct Config { /// Whether the configured web bundle serves Git browser routes in addition /// to the public invite landing page. Defaults to false. pub serve_git_web_gui: bool, + + /// NIP-FI federated-identity enforcement configuration. + /// + /// Present when `BUZZ_NIP_FI_MODE` is `enforce` or `deny_protected`; in + /// those modes the relay validates assertions at HTTP ingress and (via S3) + /// at WebSocket upgrade. `Off` mode (the default) leaves all identity + /// enforcement to NIP-42 alone. + pub nip_fi: crate::nip_fi_config::NipFiRelayConfig, } fn parse_bind_addr(raw: &str) -> Result { @@ -453,9 +466,9 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result { "BUZZ_PUSH_GATEWAY_DELIVERY_URL is not a valid URL: {e}" )) })?; - if url.scheme() != "https" + if !matches!(url.scheme(), "http" | "https") || url.host().is_none() - || url.port().is_some() + || (url.scheme() == "https" && url.port().is_some()) || !url.username().is_empty() || url.password().is_some() || url.path() != "/v1/deliveries/apns" @@ -463,13 +476,65 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result { || url.fragment().is_some() { return Err(ConfigError::InvalidValue( - "BUZZ_PUSH_GATEWAY_DELIVERY_URL must be an exact HTTPS /v1/deliveries/apns URL without an explicit port, credentials, query, or fragment" + "BUZZ_PUSH_GATEWAY_DELIVERY_URL must be an exact HTTP(S) /v1/deliveries/apns URL without credentials, query, or fragment; explicit ports are supported only for HTTP" .to_string(), )); } Ok(url) } +fn parse_operator_listener_delivery_urls( + raw: &str, +) -> Result, ConfigError> { + let mut endpoints = HashMap::new(); + let entries: Vec<&str> = raw + .split(';') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .collect(); + if !raw.trim().is_empty() && entries.is_empty() { + return Err(ConfigError::InvalidValue( + "BUZZ_OPERATOR_LISTENERS must contain pubkey:delivery_url pairs".to_string(), + )); + } + for entry in entries { + let (pubkey, delivery_url) = entry.split_once(':').ok_or_else(|| { + ConfigError::InvalidValue( + "BUZZ_OPERATOR_LISTENERS entries must be pubkey:delivery_url pairs".to_string(), + ) + })?; + let pubkey = pubkey.trim().to_ascii_lowercase(); + if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_OPERATOR_LISTENERS entry has an invalid pubkey: {pubkey:?}" + ))); + } + let url = url::Url::parse(delivery_url.trim()).map_err(|e| { + ConfigError::InvalidValue(format!( + "BUZZ_OPERATOR_LISTENERS endpoint is not a valid URL: {e}" + )) + })?; + if url.scheme() != "https" + || url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(ConfigError::InvalidValue( + "BUZZ_OPERATOR_LISTENERS endpoints must be HTTPS URLs without credentials, query, or fragment" + .to_string(), + )); + } + if endpoints.insert(pubkey.clone(), url).is_some() { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_OPERATOR_LISTENERS contains duplicate pubkey: {pubkey}" + ))); + } + } + Ok(endpoints) +} + fn parse_bool(name: &str, default: bool) -> Result { match std::env::var(name) { Err(std::env::VarError::NotPresent) => Ok(default), @@ -789,6 +854,21 @@ impl Config { ); } + let operator_listener_delivery_urls = match std::env::var("BUZZ_OPERATOR_LISTENERS") { + Ok(raw) => parse_operator_listener_delivery_urls(&raw)?, + Err(std::env::VarError::NotPresent) => HashMap::new(), + Err(error) => { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_OPERATOR_LISTENERS must be valid UTF-8: {error}" + ))); + } + }; + if !operator_listener_delivery_urls.is_empty() && relay_operator_api_origin.is_none() { + error!( + "BUZZ_OPERATOR_LISTENERS is set but RELAY_OPERATOR_API_ORIGIN is not — operator-listener registration requests will reject every request until RELAY_OPERATOR_API_ORIGIN is set" + ); + } + let auth = buzz_auth::AuthConfig { rate_limits: rate_limit_config_from_env()?, }; @@ -1004,6 +1084,33 @@ impl Config { Err(_) => 2_000, }; let push_gateway_timeout = Duration::from_millis(push_gateway_timeout_millis); + let operator_listener_timeout_millis = match std::env::var( + "BUZZ_OPERATOR_LISTENER_TIMEOUT_MS", + ) { + Ok(raw) => match raw + .parse::() + .ok() + .filter(|millis| (100..=10_000).contains(millis)) + { + Some(millis) => millis, + None => { + error!( + value = %raw, + "invalid BUZZ_OPERATOR_LISTENER_TIMEOUT_MS; using default 5000ms (expected integer in 100..=10000)" + ); + 5_000 + } + }, + Err(std::env::VarError::NotPresent) => 5_000, + Err(err) => { + error!( + error = %err, + "invalid BUZZ_OPERATOR_LISTENER_TIMEOUT_MS; using default 5000ms" + ); + 5_000 + } + }; + let operator_listener_timeout = Duration::from_millis(operator_listener_timeout_millis); const MAX_POLICY_MARKDOWN_BYTES: usize = 256 * 1024; let read_policy_markdown = |name: &str| -> Result, ConfigError> { @@ -1240,6 +1347,7 @@ impl Config { relay_owner_pubkey, relay_operator_api_origin, relay_operator_pubkeys, + operator_listener_delivery_urls, allow_nip_oa_auth, klipy, media, @@ -1261,10 +1369,12 @@ impl Config { push_executor_key_id, push_gateway_delivery_url, push_gateway_timeout, + operator_listener_timeout, join_policy, admin, web_dir, serve_git_web_gui, + nip_fi: crate::nip_fi_config::NipFiRelayConfig::from_env()?, }) } } @@ -1469,6 +1579,20 @@ mod tests { (config, captured) } + fn config_with_operator_listeners(raw: Option<&str>) -> Result { + let previous = std::env::var_os("BUZZ_OPERATOR_LISTENERS"); + match raw { + Some(value) => std::env::set_var("BUZZ_OPERATOR_LISTENERS", value), + None => std::env::remove_var("BUZZ_OPERATOR_LISTENERS"), + } + let result = Config::from_env(); + match previous { + Some(value) => std::env::set_var("BUZZ_OPERATOR_LISTENERS", value), + None => std::env::remove_var("BUZZ_OPERATOR_LISTENERS"), + } + result + } + /// Assert `captured` contains a WARN naming the removal of `BUZZ_ADMIN_TOKEN` /// so the migration breadcrumb Will's ruling preserved cannot silently regress. fn assert_admin_token_removal_warning(captured: &str) { @@ -2259,8 +2383,19 @@ mod tests { #[test] fn push_gateway_url_is_exact_and_fail_closed() { assert!(parse_push_gateway_delivery_url("https://push.example/v1/deliveries/apns").is_ok()); + let delivery_url = "http://push.example:8080/v1/deliveries/apns"; + assert_eq!( + parse_push_gateway_delivery_url(delivery_url) + .unwrap() + .as_str(), + delivery_url + ); + assert!(parse_push_gateway_delivery_url("http://push-gateway/v1/deliveries/apns").is_ok()); for invalid in [ - "http://push.example/v1/deliveries/apns", + "ftp://push.example/v1/deliveries/apns", + "http://push-gateway:8080/v1/deliveries/apns?token=x", + "http://user@push-gateway:8080/v1/deliveries/apns", + "http://push-gateway:8080/v1/deliveries/apns#fragment", "https://push.example:8443/v1/deliveries/apns", "https://push.example/v1/deliveries/apns/", "https://push.example/v1/deliveries/apns?token=x", @@ -2273,6 +2408,93 @@ mod tests { } } + #[test] + fn operator_listener_routes_parse_and_normalize() { + let routes = parse_operator_listener_delivery_urls( + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:https://one.example/mentions;bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:https://two.example/mentions", + ) + .expect("valid operator-listener routes"); + assert_eq!(routes.len(), 2); + assert!( + routes.contains_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ); + assert!( + routes.contains_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + ); + } + + #[test] + fn operator_listener_routes_reject_unsafe_or_duplicate_entries() { + let key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + for raw in [ + format!("{key}:http://listener.example/mentions"), + format!("{key}:https://user:pass@listener.example/mentions"), + format!("{key}:https://listener.example/mentions?token=secret"), + format!("{key}:https://listener.example/mentions;{key}:https://other.example"), + ] { + assert!( + parse_operator_listener_delivery_urls(&raw).is_err(), + "unsafe or duplicate route accepted: {raw}" + ); + } + } + + #[test] + fn malformed_operator_listener_config_prevents_startup() { + let _guard = ENV_MUTEX.lock().unwrap(); + let result = config_with_operator_listeners(Some(";")); + + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_OPERATOR_LISTENERS") + )); + } + + #[cfg(unix)] + #[test] + fn non_utf8_operator_listener_config_prevents_startup() { + use std::os::unix::ffi::OsStringExt; + + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_OPERATOR_LISTENERS"); + std::env::set_var( + "BUZZ_OPERATOR_LISTENERS", + std::ffi::OsString::from_vec(vec![0xff]), + ); + + let result = Config::from_env(); + + match previous { + Some(value) => std::env::set_var("BUZZ_OPERATOR_LISTENERS", value), + None => std::env::remove_var("BUZZ_OPERATOR_LISTENERS"), + } + + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_OPERATOR_LISTENERS must be valid UTF-8") + )); + } + + #[test] + fn valid_and_absent_operator_listener_config_preserve_routes() { + let _guard = ENV_MUTEX.lock().unwrap(); + let key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let raw = format!("{key}:https://listener.example/mentions"); + let config = config_with_operator_listeners(Some(&raw)).expect("valid listener config"); + assert_eq!( + config + .operator_listener_delivery_urls + .get(key) + .map(url::Url::as_str), + Some("https://listener.example/mentions") + ); + + let config = config_with_operator_listeners(None).expect("absent listener config"); + assert!(config.operator_listener_delivery_urls.is_empty()); + } + #[test] fn invalid_push_gateway_timeout_is_not_silently_defaulted() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 18ea187fc7..6991ebd368 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -33,6 +33,14 @@ pub mod mesh_boot; pub mod metrics; /// NIP-11 relay information document. pub mod nip11; +mod nip98; +/// NIP-FI relay configuration: mode, issuer registry, JWKS warm/refresh. +pub mod nip_fi_config; +/// NIP-FI HTTP ingress enforcement: assertion extraction, verification, +/// key-pairing check, and deny-map gate for every protected HTTP surface. +pub(crate) mod nip_fi_http; +/// Deployment-global operator-listener mention delivery worker. +pub mod operator_listener; /// NIP-01 client/relay message parsing. pub mod protocol; /// Durable NIP-PL matcher and delivery worker. diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 114b51ee7f..6adea419da 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -528,6 +528,45 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { ); let state = Arc::new(app_state); + // NIP-FI JWKS warm + background refresh. + // + // Per [FI-TRACE-DEPENDENCY-FAIL-CLOSED]: a JWKS warm failure at startup + // MUST NOT abort the relay. The relay starts and HTTP-protected routes deny + // with `authorization_unavailable` (503) until a snapshot lands. The + // background loop retries automatically. + // + // The background task is cancelled cleanly on shutdown via a + // CancellationToken so it does not outlive the process. + let nip_fi_jwks_cancel = tokio_util::sync::CancellationToken::new(); + if let Some(ref jwks_source) = state.nip_fi_jwks_source.clone() { + let jwks_configs = state.config.nip_fi.jwks_configs.clone(); + info!( + issuer_count = jwks_configs.len(), + "NIP-FI: warming JWKS snapshots for HTTP enforcement" + ); + let issuer_ids: Vec = jwks_configs.iter().map(|c| c.issuer.clone()).collect(); + warm_nip_fi_jwks_snapshots(jwks_source, &issuer_ids).await; + // Background refresh loop: independent per-issuer cadence. + let refresh_source = Arc::clone(jwks_source); + let refresh_configs = jwks_configs.clone(); + let refresh_cancel = nip_fi_jwks_cancel.clone(); + tokio::spawn(async move { + nip_fi_jwks_refresh_loop( + refresh_configs + .iter() + .map(|c| (c.issuer.clone(), c.contract.refresh_interval_seconds())) + .collect(), + move |issuer| { + let src = Arc::clone(&refresh_source); + let iss = issuer.to_owned(); + Box::pin(async move { src.get_snapshot(&iss).await.is_some() }) + }, + refresh_cancel, + ) + .await; + }); + } + // Inter-relay mesh (BUZZ_MESH seam). `boot_mesh` returns None when the // kill switch is off — nothing is bound, published, or spawned, so the // relay behaves byte-identically to a build without the mesh. When @@ -818,6 +857,24 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { info!("NIP-PL push disabled by BUZZ_PUSH_ENABLED"); } + // Registration cleanup is independent of configured routes. Delivery is + // opt-in, while accepted events populate the outbox transactionally. + tokio::spawn(buzz_relay::operator_listener::run_reaper(Arc::clone( + &state, + ))); + if !state.config.operator_listener_delivery_urls.is_empty() { + tokio::spawn(buzz_relay::operator_listener::run_delivery_worker( + Arc::clone(&state), + )); + info!( + listeners = state.config.operator_listener_delivery_urls.len(), + "operator-listener mention delivery worker started" + ); + } else { + info!("operator-listener mention delivery disabled by BUZZ_OPERATOR_LISTENERS"); + } + info!("operator-listener registration cleanup started"); + // Admin outbox delivery worker — drives `relay_admin_outbox` rows. // Uses DB-level leases (held_by / lease_expires_at) so multiple pods can // run the worker concurrently without double-delivery. @@ -1280,6 +1337,104 @@ mod env_filter_tests { } } +/// Warm NIP-FI JWKS snapshots for all configured issuers at startup. +/// +/// Calls `source.get_snapshot(issuer)` once per configured issuer and logs +/// the outcome. On success, the snapshot is cached and the relay is ready to +/// validate federated assertions. On failure, the relay starts and HTTP-protected +/// routes deny with 503 until the background loop delivers a snapshot. +/// +/// Raw `iss` values are never logged (NIP-FI.md:777-779); only the +/// `issuer_index` diagnostic code appears in log output. +/// +/// Extracted from `run_relay_main` for unit-testability. +/// [FI-TRACE-DEPENDENCY-FAIL-CLOSED] +async fn warm_nip_fi_jwks_snapshots( + source: &buzz_auth::ProductionJwksSource, + issuer_ids: &[String], +) { + for (idx, issuer) in issuer_ids.iter().enumerate() { + match source.get_snapshot(issuer).await { + Some(_) => { + // issuer_index is a non-identifying diagnostic code. + // Raw `iss` is excluded from logs per NIP-FI.md:777-779. + info!(issuer_index = idx, "NIP-FI: JWKS snapshot warmed"); + } + None => { + warn!( + issuer_index = idx, + "NIP-FI: JWKS warm failed — HTTP ingress will deny 503 until \ + a snapshot lands; background refresh will retry" + ); + } + } + } +} + +/// Background JWKS refresh loop for NIP-FI issuers. +/// +/// Sleeps until the nearest due issuer, runs the fetch for each overdue issuer, +/// then records the post-fetch instant as the new baseline. Scheduling from +/// the post-fetch instant keeps the interval at least `interval_secs` even under +/// nonzero network latency (pre-fetch scheduling would drift the interval +/// backward by the fetch latency on every cycle). +/// +/// `fetch` returns `true` if the snapshot was successfully refreshed, `false` +/// on fetch failure (the loop continues either way; hard-deadline enforcement +/// lives in the JWKS source itself). +/// +/// Extracted from `run_relay_main` for unit-testability. [FI-TRACE-DEPENDENCY-FAIL-CLOSED] +async fn nip_fi_jwks_refresh_loop( + // `(issuer_id, interval_seconds)` pairs, one per configured issuer. + issuers: Vec<(String, u64)>, + // Async fetch callback: `issuer → true (success) / false (failure)`. + mut fetch: F, + cancel: CancellationToken, +) where + F: FnMut(&str) -> Fut, + Fut: std::future::Future, +{ + let mut intervals: Vec<(String, u64, tokio::time::Instant)> = issuers + .into_iter() + .map(|(issuer, interval)| (issuer, interval, tokio::time::Instant::now())) + .collect(); + + loop { + // Sleep until the next scheduled refresh across all issuers. + let next = intervals + .iter() + .map(|(_, interval, last)| *last + std::time::Duration::from_secs(*interval)) + .min() + .unwrap_or_else(|| tokio::time::Instant::now() + std::time::Duration::from_secs(300)); + tokio::select! { + _ = tokio::time::sleep_until(next) => {} + _ = cancel.cancelled() => break, + } + let now = tokio::time::Instant::now(); + for (idx, (issuer, interval, last)) in intervals.iter_mut().enumerate() { + if now >= *last + std::time::Duration::from_secs(*interval) { + if !fetch(issuer).await { + // issuer_index is a non-identifying diagnostic code. + // Raw `iss` is excluded from logs per NIP-FI.md:777-779. + warn!( + issuer_index = idx, + "NIP-FI: background JWKS refresh returned no snapshot" + ); + } + // Schedule the NEXT refresh from when this fetch completed, + // not from the instant captured before the await. Scheduling + // from the pre-fetch snapshot drifts the interval backward by + // the fetch latency on every cycle; scheduling from post-fetch + // keeps the interval at least `interval_secs` even under + // nonzero network latency. The hard-deadline contract + // (jwks_hard_deadline_seconds) is enforced by the JWKS source + // itself, not by this timer. + *last = tokio::time::Instant::now(); + } + } + } +} + async fn run_community_revalidator( state: Arc, period: std::time::Duration, @@ -2106,7 +2261,7 @@ mod tests { use super::{ buzz_auto_migrate_enabled, connect_audit_pool, dropped_in_memory_keys, idle_timeout_secs, - refresh_legacy_active_gauge_recency, relay_keypair_from_config, + nip_fi_jwks_refresh_loop, refresh_legacy_active_gauge_recency, relay_keypair_from_config, run_periodic_until_cancelled, EmissionScope, InMemoryMetricKey, }; use buzz_db::DbConfig; @@ -2319,4 +2474,1019 @@ mod tests { assert_eq!(idle_timeout_secs(None, 300), 900); assert_eq!(idle_timeout_secs(Some(10), 1_000), 3_000); } + + // ── F4: JWKS refresh-interval anchoring ─────────────────────────────────── + // + // The fix: `*last = tokio::time::Instant::now()` is called AFTER the fetch + // awaits, not before (where `now` was captured pre-fetch). Under nonzero + // fetch latency, scheduling from pre-fetch would drift the interval backward + // on every cycle. + // + // Test matrix: + // A. Nonzero fetch latency: a 10s fetch inside a 60s interval → the next + // refresh is scheduled 60s after the fetch completes (70s from start), + // not 60s after the pre-fetch `now` (which would be ≈60s from start). + // B. Fetch failure still advances `last`, preventing a tight-loop. + // The loop continues; the third cycle fires at the correct deadline. + // C. Hard deadline with early-cache: when interval=60 and hard_deadline=90s, + // the loop fires at T=60; at T=70 (post-fetch) last=70, next due at + // T=130. The `hard_deadline` is enforced by `ProductionJwksSource`, NOT + // by the timer loop — the loop only tracks refresh cadence. + // D. The production adapter's `.is_some()` contract: "a live snapshot + // exists" (not "the last fetch succeeded"). After a failed refresh the + // previously-cached snapshot may still be live; `src.get_snapshot().is_some()` + // returns true in that case. The timer loop treats the boolean as an + // opaque "notify" / "warn" signal, not as a freshness oracle. + // + // Falsifying mutation: change `*last = tokio::time::Instant::now()` to + // `*last = now` (where `now` is the pre-await snapshot). Test A fails + // because the second refresh fires at T≈60s rather than T≈70s. + + /// Nonzero fetch latency: second refresh must be anchored to post-fetch instant. + #[tokio::test(start_paused = true)] + async fn jwks_refresh_interval_anchored_to_post_fetch_instant() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let fetch_count = Arc::new(AtomicUsize::new(0)); + let second_fetch_instant: Arc>> = + Arc::new(std::sync::Mutex::new(None)); + + let count_clone = Arc::clone(&fetch_count); + let instant_clone = Arc::clone(&second_fetch_instant); + let cancel = CancellationToken::new(); + let cancel_task = cancel.clone(); + + // 10s simulated fetch latency, 60s interval. + let fetch_latency = Duration::from_secs(10); + let interval_secs = 60u64; + + let task = tokio::spawn(async move { + nip_fi_jwks_refresh_loop( + vec![("issuer-a".to_string(), interval_secs)], + move |_issuer| { + let n = count_clone.fetch_add(1, Ordering::SeqCst); + let instant_ref = Arc::clone(&instant_clone); + let latency = fetch_latency; + Box::pin(async move { + // Simulate nonzero fetch latency. + tokio::time::sleep(latency).await; + if n == 1 { + // Record when the second fetch completes. + *instant_ref.lock().unwrap() = Some(tokio::time::Instant::now()); + } + true // success + }) + }, + cancel_task, + ) + .await; + }); + + // Time T=0: loop starts with last=now. + tokio::task::yield_now().await; + + // Advance to T=60s: first refresh becomes due. + tokio::time::advance(Duration::from_secs(60)).await; + tokio::task::yield_now().await; + + // Advance through the 10s fetch latency to T=70s. + tokio::time::advance(Duration::from_secs(10)).await; + tokio::task::yield_now().await; + + // At T=70 the first fetch completes; last is now ~70s. + // A second refresh is due 60s later, at T=130. Verify it does NOT fire at T=120. + tokio::time::advance(Duration::from_secs(59)).await; // T=129 + tokio::task::yield_now().await; + assert_eq!( + fetch_count.load(Ordering::SeqCst), + 1, + "second refresh must NOT fire before post-fetch last + interval_secs; \ + at T=129 only one fetch should have completed. \ + Falsifying mutation: use pre-fetch `now` for `last` update → second fetch fires at T≈120" + ); + + // Advance to T=131: second refresh is now overdue (post-fetch last + 60 ≤ 131). + tokio::time::advance(Duration::from_secs(2)).await; // T=131 + tokio::task::yield_now().await; + // Sleep through the 10s fetch latency. + tokio::time::advance(Duration::from_secs(10)).await; + tokio::task::yield_now().await; + + assert_eq!( + fetch_count.load(Ordering::SeqCst), + 2, + "second refresh must have fired by T=141 (post-fetch last ~70 + 60 + 10 fetch latency)" + ); + + cancel.cancel(); + task.await.expect("refresh loop task"); + } + + /// Fetch failure still advances `last`: no tight-loop and the third cycle fires + /// at the correct deadline. + #[tokio::test(start_paused = true)] + async fn jwks_refresh_interval_advances_last_on_failure() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let fetch_count = Arc::new(AtomicUsize::new(0)); + let count_for_fetch = Arc::clone(&fetch_count); + let cancel = CancellationToken::new(); + let cancel_task = cancel.clone(); + + let task = tokio::spawn(async move { + nip_fi_jwks_refresh_loop( + vec![("issuer-b".to_string(), 60)], + move |_| { + let c = Arc::clone(&count_for_fetch); + Box::pin(async move { + c.fetch_add(1, Ordering::SeqCst); + false // always fails + }) + }, + cancel_task, + ) + .await; + }); + + tokio::task::yield_now().await; + + // First fire at T=60. + tokio::time::advance(Duration::from_secs(60)).await; + tokio::task::yield_now().await; + assert_eq!( + fetch_count.load(Ordering::SeqCst), + 1, + "first refresh at T=60" + ); + + // Second fire at T=120: failure advances `last` so no tight-loop. + tokio::time::advance(Duration::from_secs(60)).await; + tokio::task::yield_now().await; + assert_eq!( + fetch_count.load(Ordering::SeqCst), + 2, + "second refresh at T=120; failure must still advance last. \ + Falsifying mutation: omit `*last = Instant::now()` on failure → tight-loop" + ); + + cancel.cancel(); + task.await.expect("refresh loop task"); + } + + // ── Test C: hard-deadline is enforced by ProductionJwksSource, not the timer ── + // + // The timer loop is cadence-only: it fires at `last + interval_secs`. + // The `hard_deadline` in `ProductionJwksSource` is a separate contract that + // the timer loop does not enforce directly. This test proves the timer loop + // fires at T=60 (interval) and then again at approximately T=130 + // (post-fetch last ~70 + interval 60), with no spurious fires in between. + // + // A "cache-returns-early" fetch is simulated by the fetch returning `true` + // (a live snapshot is available). This is the `.is_some()` contract: the + // production adapter returns `true` when a snapshot exists, which may be a + // cached snapshot even after a transient failure — NOT "fetch succeeded". + // + // Falsifying mutation (timer): advancing the interval check to use + // `last + hard_deadline_secs` instead of `last + interval_secs` would + // cause the first fire to happen at T=90 instead of T=60; the T=60 assert + // would fire. This confirms the timer does not conflate hard_deadline with + // interval. + #[tokio::test(start_paused = true)] + async fn jwks_refresh_interval_is_cadence_only_not_hard_deadline() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let fetch_count = Arc::new(AtomicUsize::new(0)); + let count_clone = Arc::clone(&fetch_count); + let cancel = CancellationToken::new(); + let cancel_task = cancel.clone(); + + // interval=60s, simulating a hard_deadline of 90s at the source level. + // The timer loop receives only (issuer, interval=60) — it knows nothing + // about hard_deadline. The hard_deadline enforcement belongs to + // ProductionJwksSource, not here. + let task = tokio::spawn(async move { + nip_fi_jwks_refresh_loop( + vec![("issuer-c".to_string(), 60)], + move |_| { + let c = Arc::clone(&count_clone); + Box::pin(async move { + c.fetch_add(1, Ordering::SeqCst); + // Returns true = "a live snapshot exists" (cache-hit). + // This is the production .is_some() contract. + true + }) + }, + cancel_task, + ) + .await; + }); + + tokio::task::yield_now().await; + + // First fire at T=60 (interval). + tokio::time::advance(Duration::from_secs(60)).await; + tokio::task::yield_now().await; + assert_eq!( + fetch_count.load(Ordering::SeqCst), + 1, + "first refresh fires at T=60 (interval boundary). \ + Falsifying mutation: if the loop used hard_deadline instead of interval → fires at T=90" + ); + + // No spurious fire at T=89 (before the hard-deadline would matter). + tokio::time::advance(Duration::from_secs(29)).await; // T=89 + tokio::task::yield_now().await; + assert_eq!( + fetch_count.load(Ordering::SeqCst), + 1, + "no spurious fire at T=89; next scheduled fire is at T=60+60=120 (cadence only)" + ); + + // Next fire at T=120 (post-fetch last=60 + interval=60). + tokio::time::advance(Duration::from_secs(31)).await; // T=120 + tokio::task::yield_now().await; + assert_eq!( + fetch_count.load(Ordering::SeqCst), + 2, + "second refresh fires at T=120; cadence-only scheduling confirmed" + ); + + cancel.cancel(); + task.await.expect("refresh loop task"); + } +} + +// ── F5: Composition — production timer + ProductionJwksSource ───────────────── +// +// Tests that `nip_fi_jwks_refresh_loop` and `ProductionJwksSource` compose +// correctly across four scenarios that individual tests cannot cover separately: +// +// 1. **Nonzero fetch latency**: a 10s simulated fetch inside a 60s interval; +// the second refresh is anchored to post-fetch instant (T≈130, not T≈120). +// 2. **Not-due cache hit**: at T=59 (one second before the interval) the source +// returns the cached snapshot immediately without fetching; fetch_count stays +// at 1. The timer loop's boolean return (`is_some()`) correctly reflects a +// live snapshot from the cache. +// 3. **Failure does not extend snapshot freshness**: after a failed refresh +// the source's hard_deadline is unchanged (no new snapshot was committed); +// the previous snapshot remains valid until its original deadline. +// 4. **Hard-deadline cleared by source**: the source clears an expired snapshot +// on the next `get_snapshot()` call; the timer loop then fires a second +// fetch and the source commits the fresh snapshot. +// +// ProductionJwksSource uses a controlled clock (`new_with_clock`) so tests +// advance time without wall-clock sleeps. The timer loop uses tokio's paused +// clock for its own `sleep_until`. +// +// Falsifying mutations: +// - Remove clock injection → test times out (wall time, unpaused). +// - Remove `*last = Instant::now()` post-fetch → second refresh fires at T≈120 +// (before T=130 assertion) — test A fails. +// - Return `true` from the failure path → false positive on the failure test. +// +// These tests are in `mod composition_tests` to isolate their `use` declarations. +#[cfg(test)] +mod composition_tests { + use buzz_auth::{ + IssuerJwksConfig, JwksFetchError, JwksSourceContract, ProductionJwksSource, + ScriptedJwksFetcher, + }; + use std::sync::atomic::Ordering; + use std::sync::Arc; + use std::time::Duration; + use tokio_util::sync::CancellationToken; + + use super::nip_fi_jwks_refresh_loop; + use super::warm_nip_fi_jwks_snapshots; + + fn test_jwks(kid: &str) -> String { + format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","use":"sig","alg":"ES256","kid":"{kid}"}}]}}"# + ) + } + + fn make_source_config(issuer: &str, refresh: u64, hard_deadline: u64) -> IssuerJwksConfig { + IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + format!("https://{issuer}/.well-known/jwks.json"), + refresh, + hard_deadline, + ) + .expect("valid test contract"), + } + } + + // ── Composition A: nonzero fetch latency, not-due cache hit ───────────── + // + // Scenario: + // T=0: source has no snapshot. Timer fires at T=60, fetch takes 10s. + // T=59: cache NOT due (age < refresh=60) — source returns None (not yet + // populated) but the timer's boolean treats None as "no snapshot". + // Actually we advance to T=70 to pass the first fetch, then test + // the not-due case at T=129 (post-fetch last ≈70, next due ≈130). + // T=129: cache is NOT due (age = 129-70 = 59 < 60); timer has not re-fired. + // T=131: cache is due; second fetch fires and completes at T=141. + // + // The not-due case proves `ProductionJwksSource.get_snapshot()` returns the + // cached snapshot without fetching when `age < refresh_interval`. After the + // first fetch at T=70, the timer advances `last` to T=70; the next sleep + // waits until T=70+60=130. So at T=129 the timer has not re-fired: + // `callback_start_count` stays at 1 and `fetch_count` stays at 1. + // The test proves fetch_count stays at 1 at T=129 and advances to 2 by T=141. + #[tokio::test(start_paused = true)] + async fn composition_nonzero_latency_and_not_due_cache_hit() { + const ISSUER: &str = "comp-a.issuer.test"; + const REFRESH: u64 = 60; + const HARD_DEADLINE: u64 = 90; + const FETCH_LATENCY_SECS: u64 = 10; + + // Controlled clock for the source: starts at T0. + let t0_secs = chrono::Utc::now().timestamp(); + let clock_secs = Arc::new(std::sync::atomic::AtomicI64::new(t0_secs)); + let clock2 = Arc::clone(&clock_secs); + let clock3 = Arc::clone(&clock_secs); // for the task closure + let now_fn: Arc chrono::DateTime + Send + Sync> = + Arc::new(move || { + chrono::DateTime::from_timestamp(clock2.load(Ordering::SeqCst), 0) + .unwrap_or(chrono::DateTime::UNIX_EPOCH) + }); + + // Two responses: first succeeds (populates cache), second succeeds. + let fetcher = ScriptedJwksFetcher::new([Ok(test_jwks("key-a1")), Ok(test_jwks("key-a2"))]); + let fetcher_count = Arc::clone(&fetcher.call_count); + + // Track how many times the timer callback is *entered* (not just how many + // fetches complete). This distinguishes the pre-fetch-last timing mutation: + // restoring `last = now` before the fetch would cause the second callback to + // fire at T≈120 (not T≈130), because pre-fetch `last=T60` yields + // next_due = T60+60=T120; post-fetch `last=T70` yields next_due = T70+60=T130. + // At T=129 the second callback has already entered under the mutation → + // callback_start_count == 2. + let callback_start_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let callback_start_count_task = Arc::clone(&callback_start_count); + + let source = Arc::new( + ProductionJwksSource::new_with_clock( + vec![make_source_config(ISSUER, REFRESH, HARD_DEADLINE)], + fetcher, + Arc::clone(&now_fn), + ) + .expect("valid source"), + ); + + let cancel = CancellationToken::new(); + let cancel_task = cancel.clone(); + let source_task = Arc::clone(&source); + + let task = tokio::spawn(async move { + nip_fi_jwks_refresh_loop( + vec![(ISSUER.to_owned(), REFRESH)], + move |issuer| { + let s = Arc::clone(&source_task); + let issuer = issuer.to_owned(); + let clock_ref = Arc::clone(&clock3); + let start_ctr = Arc::clone(&callback_start_count_task); + Box::pin(async move { + // Record callback entry before any fetch work. + start_ctr.fetch_add(1, Ordering::SeqCst); + // Simulate nonzero fetch latency by advancing tokio time + // and the source clock by FETCH_LATENCY_SECS. + // The source clock advances so `fetched_at` is set correctly. + tokio::time::sleep(Duration::from_secs(FETCH_LATENCY_SECS)).await; + clock_ref.fetch_add(FETCH_LATENCY_SECS as i64, Ordering::SeqCst); + s.get_snapshot(&issuer).await.is_some() + }) + }, + cancel_task, + ) + .await; + }); + + // T=0: loop starts. + tokio::task::yield_now().await; + + // Advance to T=60: first refresh due. + tokio::time::advance(Duration::from_secs(60)).await; + clock_secs.store(t0_secs + 60, Ordering::SeqCst); + tokio::task::yield_now().await; + + // Advance through 10s fetch latency to T=70. + tokio::time::advance(Duration::from_secs(10)).await; + tokio::task::yield_now().await; + + // At T=70: first fetch completed; post-fetch last ≈70; next due ≈130. + // cache has a snapshot with fetched_at ≈70. + assert_eq!( + fetcher_count.load(Ordering::SeqCst), + 1, + "composition A: exactly one fetch at T=70" + ); + + // T=129: NOT due (age = 129-70 = 59 < 60). No second fetch. + tokio::time::advance(Duration::from_secs(59)).await; // T=129 + clock_secs.store(t0_secs + 129, Ordering::SeqCst); + tokio::task::yield_now().await; + + // T=129: assert both timing and cache-hit claims. + assert_eq!( + callback_start_count.load(Ordering::SeqCst), + 1, + "composition A: timer callback must NOT have been entered a second time at T=129. \ + Falsifying mutation: restore pre-fetch `last = now` in the refresh loop → \ + second callback fires at T≈120 (not T≈130: pre-fetch `last=T60` yields \ + next_due = T60+60=T120; post-fetch `last=T70` yields next_due = T70+60=T130; \ + the mutation makes the callback enter at T≈120, before T=129) → \ + callback_start_count == 2 at T=129." + ); + assert_eq!( + fetcher_count.load(Ordering::SeqCst), + 1, + "composition A: cache not-due at T=129 — no second fetch. Falsifying mutation: remove the not-due short-circuit from get_snapshot → second fetcher call at T=129 → count == 2." + ); + + // Verify the source serves the cached snapshot without fetching. + // get_snapshot advances the source clock by 0 (no latency here). + let snap = source.get_snapshot(ISSUER).await; + assert!( + snap.is_some(), + "composition A: cached snapshot must be live at T=129 (hard_deadline is T≈160)" + ); + assert_eq!( + fetcher_count.load(Ordering::SeqCst), + 1, + "composition A: get_snapshot at T=129 must NOT trigger a new fetch" + ); + + // T=131: second refresh is due (post-fetch last ≈70 + 60 = 130 ≤ 131). + tokio::time::advance(Duration::from_secs(2)).await; // T=131 + clock_secs.store(t0_secs + 131, Ordering::SeqCst); + tokio::task::yield_now().await; + + // Advance through 10s fetch latency. + tokio::time::advance(Duration::from_secs(10)).await; // T=141 + clock_secs.store(t0_secs + 141, Ordering::SeqCst); + tokio::task::yield_now().await; + + assert_eq!( + fetcher_count.load(Ordering::SeqCst), + 2, + "composition A: second fetch by T=141 (post-fetch last ≈70 + interval 60 + fetch 10)" + ); + + cancel.cancel(); + task.await.expect("refresh loop task"); + } + + // ── Composition B: fetch failure does not extend snapshot freshness ────── + // + // A successful first fetch populates the snapshot (hard_deadline = T0+90). + // Verified claims (Claims 1-2 via direct source; Claim 3 via timer callback; + // Claim 4 via direct source recovery): + // + // 1. After a failed refresh at T=61, the previous snapshot is still served + // (it is before its hard_deadline: T0+61 < T0+90). + // 2. The generation is unchanged (no new snapshot committed on failure). + // 3. At T=122 (past hard_deadline T0+90), the timer callback returns false + // (source returns None — snapshot cleared, continuing failure cannot revive it). + // Confirmed via both the timer callback signal and a direct source call. + // 4. After a successful fetch at T=123, the snapshot is recovered — Some. + // + // Clock design: both layers share a single Tokio paused clock. + // - `nip_fi_jwks_refresh_loop` uses `tokio::time::sleep_until`. + // - `ProductionJwksSource` uses a `now_fn` bridged to the same paused clock + // via `t0_instant` / `t0_utc` offsets — advancing Tokio time drives both. + // + // Response queue (5 total): + // response[0]: ok — warm at T=0 (direct call) + // response[1]: fail — stale at T=61 (direct call, snapshot still live) + // response[2]: fail — timer callback due T=121, observes T=122 (source T=122 > deadline T=90 + // → snapshot cleared → fetch → None → callback returns false) + // response[3]: fail — direct call at T=122 (confirms still None; Claim 3 source) + // response[4]: ok — direct call at T=123 (recovery; Claim 4) + // + // Sequence: + // T=0: warm (response[0]). Timer NOT yet spawned (Claims 1-2 use direct calls). + // T=61: direct call → stale fail (response[1]) → live snapshot → Claims 1+2. + // T=61: spawn timer. Timer `last = T=61`. First callback due at T=61+60=T=121. + // T=122: advance Tokio past the T=121 due time; callback observes T=122. Source: T=122 > deadline=90 + // → snapshot cleared → fetch response[2]=fail → None → callback returns + // false → warn! emitted. callback_count=1. + // Wait for callback_count >= 1, then cancel. + // T=122: direct call (response[3]=fail) → None. Claim 3 confirmed. + // T=123: direct call (response[4]=ok) → Some. Claim 4 confirmed. + // + // Falsifying mutation: "store snapshot on failure with extended deadline" + // sets deadline to T0+61+90=T0+151. At observed T=122: + // - now=T0+122 >= deadline=T0+151 is FALSE → snapshot live, NOT cleared. + // - age_secs = T0+122 - T0+61 = 61 >= 60 → stale → fetch response[2]=fail. + // - Snapshot NOT cleared → fetch fails → but old snapshot kept (live) → Some. + // - Callback returns TRUE (not false) → callback_returned_false stays false + // - assertion fires: callback_returned_false must be true. + // At T=122 direct call: same logic → Some → Claim 3 (is_none()) assertion fires. + #[tokio::test(start_paused = true)] + async fn composition_failure_does_not_extend_snapshot_freshness() { + const ISSUER: &str = "comp-b.issuer.test"; + const REFRESH: u64 = 60; + const HARD_DEADLINE: u64 = 90; + + // Bridge the source clock to the Tokio paused clock so both layers see + // identical time when tokio::time::advance() is called. + let t0_instant = tokio::time::Instant::now(); + let t0_utc = chrono::Utc::now(); // stable: paused runtime + let t0_instant_b = t0_instant; + let t0_utc_b = t0_utc; + let now_fn: Arc chrono::DateTime + Send + Sync> = + Arc::new(move || { + let elapsed_secs = tokio::time::Instant::now() + .duration_since(t0_instant_b) + .as_secs() as i64; + t0_utc_b + + chrono::Duration::try_seconds(elapsed_secs) + .unwrap_or(chrono::Duration::zero()) + }); + + // Five responses: [ok, fail, fail, fail, ok] — see sequence above. + let fetcher = ScriptedJwksFetcher::new([ + Ok(test_jwks("key-b1")), + Err(JwksFetchError::NetworkError), + Err(JwksFetchError::NetworkError), + Err(JwksFetchError::NetworkError), + Ok(test_jwks("key-b2")), + ]); + let fetcher_count = Arc::clone(&fetcher.call_count); + + let source = Arc::new( + ProductionJwksSource::new_with_clock( + vec![make_source_config(ISSUER, REFRESH, HARD_DEADLINE)], + fetcher, + Arc::clone(&now_fn), + ) + .expect("valid source"), + ); + + // ── T=0: warm the cache ───────────────────────────────────────────── + // Direct call: source sees T=0, no snapshot → fetch response[0]=ok → Some. + let snap_before = source.get_snapshot(ISSUER).await.expect("initial snapshot"); + let generation_before = snap_before.generation(); + assert_eq!( + fetcher_count.load(Ordering::SeqCst), + 1, + "composition B: one fetch for initial warm (response[0]=ok)" + ); + + // ── T=61: stale direct call (Claims 1+2) ─────────────────────────── + // Age = 61 >= 60 → stale → fetch response[1]=fail. + // Snapshot still live (T=61 < hard_deadline=T=90) → Some returned. + tokio::time::advance(Duration::from_secs(61)).await; + tokio::task::yield_now().await; + + let snap_after_fail = source.get_snapshot(ISSUER).await; + + // Claim 1: failed refresh at T=61 still returns the previous live snapshot. + assert!( + snap_after_fail.is_some(), + "composition B: failed refresh at T=61 MUST still serve the cached snapshot \ + (T=61 < hard_deadline T=90). \ + Falsifying mutation: clear snapshot on failure → None at T=61." + ); + assert_eq!( + fetcher_count.load(Ordering::SeqCst), + 2, + "composition B: second fetch attempted (response[1]=fail)" + ); + + // Claim 2: generation unchanged (no new snapshot committed on failure). + let generation_after_fail = snap_after_fail.unwrap().generation(); + assert_eq!( + generation_before, generation_after_fail, + "composition B: fetch failure MUST NOT advance the snapshot generation — \ + the cached snapshot is unchanged. \ + Falsifying mutation: commit a new snapshot on failure → generation changes." + ); + + // ── T=61: spawn timer loop ────────────────────────────────────────── + // Spawned at T=61; timer records `last = T=61`. First callback due at T=121. + // + // The timer callback calls source.get_snapshot() and returns its is_some(). + // At T=121 (past hard_deadline T=90) it must return false (snapshot absent). + let callback_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let callback_count_task = Arc::clone(&callback_count); + let callback_returned_false = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let callback_returned_false_task = Arc::clone(&callback_returned_false); + + let cancel = CancellationToken::new(); + let cancel_task = cancel.clone(); + let source_task = Arc::clone(&source); + + let task = tokio::spawn(async move { + nip_fi_jwks_refresh_loop( + vec![(ISSUER.to_owned(), REFRESH)], + move |iss| { + let s = Arc::clone(&source_task); + let iss = iss.to_owned(); + let ctr = Arc::clone(&callback_count_task); + let flag = Arc::clone(&callback_returned_false_task); + Box::pin(async move { + let result = s.get_snapshot(&iss).await.is_some(); + if !result { + flag.store(true, Ordering::SeqCst); + } + ctr.fetch_add(1, Ordering::SeqCst); + result + }) + }, + cancel_task, + ) + .await; + }); + + // Yield once so the spawned task initializes and records `last = T=61`. + tokio::task::yield_now().await; + + // ── Advance to T=122 ──────────────────────────────────────────────── + // Timer is due at T=121 (last=T=61, next=T=61+60=T=121 ≤ T=122), but + // `tokio::time::advance` jumps to T=122 before the task runs, so the + // callback observes T=122 > hard_deadline=T=90 → snapshot cleared → + // fetch response[2]=fail → None. Callback returns false. + // callback_returned_false=true. callback_count=1. + // + // Falsifying mutation: extend deadline to T=61+90=T=151 on failure. + // At T=122: T=122 < T=151 → snapshot NOT cleared; age_secs=122-61=61 >= 60 + // → stale → fetch response[2]=fail → snapshot not cleared (still live) → Some. + // Callback returns true → callback_returned_false stays false → assertion fires. + tokio::time::advance(Duration::from_secs(61)).await; // T=61 → T=122 + // Bounded yield: let the spawned timer task run its callback (observed at T=122). + // At most 10_000 yields; if the callback never fires this diagnostic fails + // rather than hanging forever. A virtual tokio::time::timeout would also + // create a fake timer that never fires while the clock is paused — hence + // the explicit iteration bound. + for i in 0..10_000usize { + if callback_count.load(Ordering::SeqCst) >= 1 { + break; + } + if i == 9_999 { + panic!( + "composition B: callback_count never reached 1 after 10_000 yields. \ + The spawned refresh loop task may have panicked or stalled." + ); + } + tokio::task::yield_now().await; + } + + // Claim 3 via timer: callback_returned_false proves the timer observed None. + assert!( + callback_returned_false.load(Ordering::SeqCst), + "composition B: timer callback at T=122 MUST return false (source returns None \ + when snapshot is past hard_deadline=T=90 and fetch keeps failing). \ + Falsifying mutation: extend deadline on failure (T=61+90=T=151) → \ + snapshot still live at T=122 → callback returns true → this assertion fires." + ); + + cancel.cancel(); + task.await.expect("refresh loop task"); + + // ── Claim 3 via direct source call ────────────────────────────────── + // T=122: past hard_deadline=T=90 → snapshot cleared → fetch response[3]=fail → None. + // Confirms the source still returns None after the timer observed expiry. + let snap_at_expiry = source.get_snapshot(ISSUER).await; + assert!( + snap_at_expiry.is_none(), + "composition B: at T=122 (past hard_deadline T=90), get_snapshot MUST return None. \ + Snapshot must be cleared and continuing fetch failure cannot revive it. \ + Falsifying mutation: extend deadline on failure → snapshot live at T=122 → Some." + ); + assert_eq!( + fetcher_count.load(Ordering::SeqCst), + 4, // warm(1) + stale-fail(2) + timer-expiry(3) + direct-expiry(4) + "composition B: fourth fetch at T=122 (direct expiry verification, response[3]=fail)" + ); + + // ── Claim 4: recovery ──────────────────────────────────────────────── + // Advance to T=123 (still past hard_deadline=T=90, snapshot absent). + // get_snapshot forces a fetch → response[4]=ok → Some. + tokio::time::advance(Duration::from_secs(1)).await; // T=122 → T=123 + tokio::task::yield_now().await; + let snap_after_recovery = source.get_snapshot(ISSUER).await; + assert!( + snap_after_recovery.is_some(), + "composition B: recovery fetch at T=123 MUST return a new snapshot \ + (response[4]=ok). \ + Falsifying mutation: make get_snapshot always return Some (never trigger a fetch) \ + → response[4]=ok never consumed → generation unchanged → assert_ne!(generation) \ + below fires." + ); + assert_eq!( + fetcher_count.load(Ordering::SeqCst), + 5, + "composition B: fifth fetch at T=123 (recovery, response[4]=ok)" + ); + let generation_after_recovery = snap_after_recovery.unwrap().generation(); + // New JWKS content "key-b2" ≠ "key-b1" → generation must have advanced. + assert_ne!( + generation_before, generation_after_recovery, + "composition B: recovery fetch with different JWKS MUST advance the generation. \ + Falsifying mutation: never commit a new snapshot → generation unchanged." + ); + } + + // ── Composition C: privacy — issuer URL must not appear in log output ──── + // + // `ProductionJwksSource` logs `warn!(error = %err, ...)` on fetch failure + // and the timer loop logs `warn!(issuer_index = idx, ...)` on no-snapshot. + // The startup warm writers log `info!(issuer_index = idx, ...)` on success + // and `warn!(issuer_index = idx, ...)` on failure. + // None of these paths must echo the raw issuer URL or JWKS URI. + // + // This test drives four paths: + // 1. `warm_nip_fi_jwks_snapshots()` success → startup `info!` (no URI). + // 2. `warm_nip_fi_jwks_snapshots()` failure → startup `warn!` (no URI). + // 3. `ProductionJwksSource::get_snapshot()` fetch fail → + // library `warn!(error = %err, "nip-fi jwks fetch failed...")` (no URI). + // 4. `nip_fi_jwks_refresh_loop` no-snapshot → + // `warn!(issuer_index = idx, "NIP-FI: background JWKS refresh returned no snapshot")` + // (no URI). Requires source clock past hard_deadline so get_snapshot + // returns None (snapshot cleared) and the callback returns false. + // + // Falsifying mutation (timer path): add `issuer_uri = config.contract.jwks_uri()` + // to any warn! → sentinel appears in captured output → assertion fires. + // + // Clock design: both layers share a single paused Tokio clock. + // - `nip_fi_jwks_refresh_loop` uses `tokio::time::sleep_until` and + // `tokio::time::Instant::now()` — controlled by `start_paused` runtime. + // - `ProductionJwksSource` uses an injected `now_fn` — bridged to the + // same paused Tokio clock via `t0_instant` / `t0_utc` offsets so both + // layers observe the same time when Tokio time is advanced. + // + // Sequence (all times are Tokio-paused clock offsets from T=0): + // T=0: spawn timer loop; yield so it records `last = Instant::now() = T0`. + // First callback due at T=60. + // T=0: warm (paths 1+2) consumes responses[0]=ok, [1]=fail. + // Snapshot for issuer_ok: fetched_at=T0, hard_deadline=T90. + // All subsequent fetcher calls return NetworkError (queue exhausted). + // T=61: advance Tokio → timer due at T=60 runs at T=61 (still live: T61 < T90, + // stale age=61 → fetch → NetworkError → live snapshot returned; post-fetch + // last=T61). + // Path 3 direct call at T=61: same result → fetch-fail warn! ✓. + // T=121 (T=61+60): advance Tokio → timer fires at T=121 (second fire: + // last=T61, next_due=T121). Source clock at T=121: now=T121 > deadline=T90 + // → snapshot cleared → fetch fails → None → callback returns false → + // path-4 warn! ✓. Wait for callback count >= 2 then cancel. + // + // Falsifying mutation (path 4): bridge now_fn to a fixed clock at T=61 → + // at timer-fire T=121, source sees T=61 < T=90 → snapshot live → callback + // returns true → no warn! → path-4 assertion fails. + // + // Uses `#[test]` + manual runtime so `with_default` wraps all async execution. + #[test] + fn composition_log_does_not_leak_issuer_url() { + use std::io::Write; + + // Sentinel must be lowercase: JwksSourceContract::new() canonicalizes + // the URI and lowercases the host component. An uppercase sentinel would + // never match the canonicalized output even when the URI leaks. + const SENTINEL: &str = "sentinel-issuer-url-9f4e2b1a"; + const REFRESH: u64 = 60; + const HARD_DEADLINE: u64 = 90; + + // Two issuers: one warm-success, one warm-fail. + let issuer_ok = format!("https://{SENTINEL}.ok.example.invalid"); + let issuer_fail = format!("https://{SENTINEL}.fail.example.invalid"); + let jwks_uri_ok = format!("https://{SENTINEL}.cdn-ok.example.invalid/jwks.json"); + let jwks_uri_fail = format!("https://{SENTINEL}.cdn-fail.example.invalid/jwks.json"); + + // Capture log output. + let buf = Arc::new(std::sync::Mutex::new(Vec::::new())); + #[derive(Clone)] + struct MakeCapturing(Arc>>); + struct CapturingWriter(Arc>>); + impl Write for CapturingWriter { + fn write(&mut self, data: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(data); + Ok(data.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for MakeCapturing { + type Writer = CapturingWriter; + fn make_writer(&'a self) -> CapturingWriter { + CapturingWriter(Arc::clone(&self.0)) + } + } + let subscriber = tracing_subscriber::fmt() + .with_writer(MakeCapturing(Arc::clone(&buf))) + .with_ansi(false) + .finish(); + + tracing::subscriber::with_default(subscriber, || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_time() + .start_paused(true) + .build() + .expect("runtime"); + + rt.block_on(async { + // Anchor the source clock to Tokio's paused time. + // Both layers (timer loop + source) share this clock: + // advancing Tokio time drives the source clock identically. + let t0_instant = tokio::time::Instant::now(); + let t0_utc = chrono::Utc::now(); // stable: paused runtime + let t0_instant_c = t0_instant; + let t0_utc_c = t0_utc; + let now_fn: Arc chrono::DateTime + Send + Sync> = + Arc::new(move || { + let elapsed_secs = tokio::time::Instant::now() + .duration_since(t0_instant_c) + .as_secs() as i64; + t0_utc_c + + chrono::Duration::try_seconds(elapsed_secs) + .unwrap_or(chrono::Duration::zero()) + }); + + // Queue: [warm-ok, warm-fail]; all subsequent calls → NetworkError. + let fetcher = ScriptedJwksFetcher::new([ + Ok(r#"{"keys":[{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","use":"sig","alg":"ES256","kid":"k1"}]}"#.to_string()), + Err(JwksFetchError::NetworkError), + ]); + + let source = Arc::new( + ProductionJwksSource::new_with_clock( + vec![ + IssuerJwksConfig { + issuer: issuer_ok.clone(), + contract: JwksSourceContract::new( + jwks_uri_ok.clone(), + REFRESH, + HARD_DEADLINE, + ) + .expect("valid test contract"), + }, + IssuerJwksConfig { + issuer: issuer_fail.clone(), + contract: JwksSourceContract::new( + jwks_uri_fail.clone(), + REFRESH, + HARD_DEADLINE, + ) + .expect("valid test contract"), + }, + ], + fetcher, + Arc::clone(&now_fn), + ) + .expect("valid source"), + ); + + // Count callback completions so we know when path 4 has fired. + // Callback 1 (at T=61): snapshot live → true (no warn!). + // Callback 2 (at T=121): snapshot past deadline → false → path-4 warn!. + let callback_count = + Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let callback_count_task = Arc::clone(&callback_count); + + // Spawn timer loop at T=0. `last = Instant::now() = T0`. + // First callback due at T=60 (runs at T=61); second at T=121 (post-fetch + // last=T61). + let cancel = tokio_util::sync::CancellationToken::new(); + let cancel_task = cancel.clone(); + let source_task = Arc::clone(&source); + let issuer_task = issuer_ok.clone(); + let task = tokio::spawn(async move { + nip_fi_jwks_refresh_loop( + vec![(issuer_task.clone(), REFRESH)], + move |iss| { + let s = Arc::clone(&source_task); + let iss = iss.to_owned(); + let ctr = Arc::clone(&callback_count_task); + Box::pin(async move { + let result = s.get_snapshot(&iss).await.is_some(); + ctr.fetch_add(1, Ordering::SeqCst); + result + }) + }, + cancel_task, + ) + .await; + }); + + // Yield once: spawned task initializes, records `last = T0`. + tokio::task::yield_now().await; + + // Paths 1+2: startup warm writers. + // Consumes responses[0]=ok (issuer_ok) and [1]=fail (issuer_fail). + // Emits: info!(issuer_index=0, "NIP-FI: JWKS snapshot warmed") + // warn!(issuer_index=1, "NIP-FI: JWKS warm failed…") + // After warm: issuer_ok snapshot has fetched_at=T0, hard_deadline=T0+90. + // Queue exhausted; all subsequent fetcher calls → NetworkError. + let issuer_ids = vec![issuer_ok.clone(), issuer_fail.clone()]; + warm_nip_fi_jwks_snapshots(&*source, &issuer_ids).await; + + // Path 3: library fetch-fail warn!. + // Advance to T=61 (past refresh interval=60, before hard_deadline=90). + // During the advance, the timer fires at T=60 but resolves at T=61: + // source clock=T61, age=61 >= 60 → stale → fetch → NetworkError + // → fetch-fail warn! [path 3 precursor] → live snapshot returned + // → callback 1 returns true (no path-4 warn!); last=T61. + // Then path 3 direct call at T=61 also produces fetch-fail warn! ✓. + tokio::time::advance(std::time::Duration::from_secs(61)).await; + // Bounded yield: allow the T=60 timer callback to run. + for i in 0..10_000usize { + if callback_count.load(Ordering::SeqCst) >= 1 { + break; + } + if i == 9_999 { + panic!( + "privacy C: callback_count never reached 1 after 10_000 yields at T=61. \ + The spawned privacy timer task may have panicked or stalled." + ); + } + tokio::task::yield_now().await; + } + // Path 3: direct call; produces `warn!(error = %err, "nip-fi jwks fetch failed…")`. + let _ = source.get_snapshot(&issuer_ok).await; + + // Path 4: timer loop no-snapshot warn!. + // Advance from T=61 to T=121 (60 more seconds). + // Timer fires at T=121 (post-fetch last=T61, next_due=T61+60=T121). + // Source clock via now_fn = T=121 >= hard_deadline=T=90: + // → snapshot cleared + // → fetch fails (NetworkError) → None + // → callback 2 returns false + // → `warn!(issuer_index=idx, "NIP-FI: background JWKS refresh + // returned no snapshot")` ✓. + // + // Falsifying mutation: bridge now_fn to a fixed clock at T=61 → + // at T=121 source sees T=61 < T=90 → snapshot live → callback true + // → no warn! → path-4 assertion fires. + tokio::time::advance(std::time::Duration::from_secs(60)).await; + // Bounded yield: wait for callback 2 (count >= 2) to confirm path-4 has run. + for i in 0..10_000usize { + if callback_count.load(Ordering::SeqCst) >= 2 { + break; + } + if i == 9_999 { + panic!( + "privacy C: callback_count never reached 2 after 10_000 yields at T=121. \ + The spawned privacy timer task may have panicked or stalled." + ); + } + tokio::task::yield_now().await; + } + cancel.cancel(); + let _ = task.await; + }); + }); + + let captured = String::from_utf8(buf.lock().unwrap().clone()).unwrap_or_default(); + + // Assert startup warm-success was captured (path 1). + assert!( + captured.contains("JWKS snapshot warmed"), + "Expected info! 'NIP-FI: JWKS snapshot warmed' from startup warm success path. \ + Captured (first 500 chars):\n{}", + &captured[..captured.len().min(500)] + ); + // Assert startup warm-fail was captured (path 2). + assert!( + captured.contains("JWKS warm failed"), + "Expected warn! 'NIP-FI: JWKS warm failed' from startup warm failure path. \ + Captured (first 500 chars):\n{}", + &captured[..captured.len().min(500)] + ); + // Assert library fetch-fail warn! was captured (path 3). + // Fired by the T=60 timer callback and/or the T=61 direct call — either path + // produces `warn!(error = %err, "nip-fi jwks fetch failed…")`. + assert!( + captured.contains("nip-fi jwks fetch failed"), + "Expected warn! 'nip-fi jwks fetch failed' from ProductionJwksSource. \ + The log capture infrastructure may be broken. \ + Captured (first 500 chars):\n{}", + &captured[..captured.len().min(500)] + ); + // Assert timer background warn! was captured (path 4). + // Fired by callback 2 at Tokio T=121: source clock T=121 > deadline T=90 + // → get_snapshot returns None → callback returns false → warn! emitted. + // + // Falsifying mutation: bridge now_fn to a fixed T=61 clock → + // source at T=121 still sees T=61 < T=90 → snapshot live → callback true + // → no warn! → this assertion fails. + assert!( + captured.contains("background JWKS refresh returned no snapshot"), + "Expected timer warn! 'NIP-FI: background JWKS refresh returned no snapshot'. \ + Fired when callback 2 (Tokio T=121) finds source clock T=121 > hard_deadline T=90. \ + Captured (first 500 chars):\n{}", + &captured[..captured.len().min(500)] + ); + // Assert no sentinel in any log output. + assert!( + !captured.contains(SENTINEL), + "NIP-FI logs MUST NOT contain the raw issuer URL or JWKS URI. \ + Sentinel '{SENTINEL}' found in captured output. \ + Falsifying mutation: add issuer_uri to any warn! call → sentinel appears.\n\ + Captured (first 500 chars):\n{}", + &captured[..captured.len().min(500)] + ); + } } diff --git a/crates/buzz-relay/src/nip98.rs b/crates/buzz-relay/src/nip98.rs new file mode 100644 index 0000000000..117131396d --- /dev/null +++ b/crates/buzz-relay/src/nip98.rs @@ -0,0 +1,22 @@ +//! Shared NIP-98 signing helpers for relay-originated HTTP requests. + +use base64::Engine as _; +use nostr::{EventBuilder, Kind, Tag}; +use sha2::{Digest as _, Sha256}; + +/// Build a NIP-98 authorization header for a signed JSON `POST` request. +pub(crate) fn nip98_header(keys: &nostr::Keys, url: &str, body: &[u8]) -> anyhow::Result { + let hash = hex::encode(Sha256::digest(body)); + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags([ + Tag::parse(["u", url])?, + Tag::parse(["method", "POST"])?, + Tag::parse(["payload", &hash])?, + Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()])?, + ]) + .sign_with_keys(keys)?; + Ok(format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event)?) + )) +} diff --git a/crates/buzz-relay/src/nip_fi_config.rs b/crates/buzz-relay/src/nip_fi_config.rs new file mode 100644 index 0000000000..f60572f7ad --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_config.rs @@ -0,0 +1,619 @@ +//! NIP-FI relay-level configuration: issuer set, session lifetime, and JWKS +//! warm/refresh. +//! +//! All env-var parsing lives here so `config.rs` stays focused on the top-level +//! `Config` struct. This module is `pub` — `config.rs` constructs it, and the +//! relay reads it as `config.nip_fi`. +//! +//! # Environment variables +//! +//! | Variable | Required | Description | +//! |---|---|---| +//! | `BUZZ_NIP_FI_MODE` | No | `off` (default), `enforce`, or `deny_protected`. | +//! | `BUZZ_NIP_FI_ISSUERS` | If enforce | JSON array of issuer configs (see [`IssuerEnvConfig`]). | +//! | `BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS` | If enforce | Per-partition limit on session lifetime. | +//! +//! `maximum_assertion_age` is per-issuer only (field `maximum_assertion_age_seconds` in +//! the issuer JSON array), not a relay-level env var. A relay-level duplicate that could +//! disagree with the enforced per-issuer value was removed in this PR. +//! +//! Absent or empty `BUZZ_NIP_FI_MODE` defaults to `off`, keeping the relay +//! backward-compatible until an operator explicitly enables enforcement. + +use std::time::Duration; + +use buzz_auth::{ + validate_nip_fi_config, FreshnessClass, IssuerJwksConfig, IssuerPolicy, IssuerPolicyError, + IssuerRegistry, JwksSourceContract, NipFiMode, NipFiStartupError, TokenClass, +}; +use jsonwebtoken::Algorithm; + +use crate::config::ConfigError; + +/// Maximum accepted `max_connection_lifetime` in seconds (30 days). +const MAX_CONNECTION_LIFETIME_SECS: u64 = 30 * 24 * 3600; + +// ── Per-issuer JSON config shape ───────────────────────────────────────────── + +/// One entry in the `BUZZ_NIP_FI_ISSUERS` JSON array. +/// +/// **Example** (one issuer, `nip-fi+jwt` dedicated assertions): +/// ```json +/// [ +/// { +/// "issuer": "https://login.example.com", +/// "audiences": ["https://relay.example.com"], +/// "token_class": "nip-fi+jwt", +/// "algorithms": ["ES256"], +/// "skew_seconds": 30, +/// "maximum_assertion_age_seconds": 3600, +/// "jwks_uri": "https://login.example.com/.well-known/jwks.json", +/// "jwks_refresh_interval_seconds": 300, +/// "jwks_hard_deadline_seconds": 86400 +/// } +/// ] +/// ``` +/// The `require_attested_key` field is not part of this schema; S2 removed it +/// from buzz-auth. S3 enforces key pairing structurally for every issuer. +#[derive(Debug, serde::Deserialize)] +pub(super) struct IssuerEnvConfig { + /// Exact `iss` value. + pub issuer: String, + /// One or more accepted `aud` values. + pub audiences: Vec, + /// `"at+jwt"` or `"nip-fi+jwt"`. + pub token_class: TokenClassEnvConfig, + /// Algorithm names, e.g. `["ES256", "RS256"]`. + pub algorithms: Vec, + /// Accepted clock skew in seconds (≤ 300). + #[serde(default)] + pub skew_seconds: u64, + /// `iat + maximum_assertion_age` residual bound in seconds. + pub maximum_assertion_age_seconds: u64, + /// HTTPS endpoint serving the JWK Set for this issuer. + pub jwks_uri: String, + /// Seconds between JWKS refreshes. + pub jwks_refresh_interval_seconds: u64, + /// Hard deadline for accepting a JWKS snapshot in seconds. + pub jwks_hard_deadline_seconds: u64, +} + +/// Token-class discriminant in the issuer config JSON. +#[derive(Debug, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(super) enum TokenClassEnvConfig { + #[serde(rename = "nip-fi+jwt")] + DedicatedNipFi, + #[serde(rename = "at+jwt")] + AccessTokenAtJwt, +} + +// ── Relay-level NIP-FI config ───────────────────────────────────────────────── + +/// The relay-level NIP-FI configuration produced by `Config::from_env`. +/// +/// Carries the validated `NipFiMode`, the full `IssuerRegistry`, +/// the parallel `IssuerJwksConfig` slice for `ProductionJwksSource`, and the +/// session-lifetime bound. +#[derive(Debug, Clone)] +pub struct NipFiRelayConfig { + /// The enforcement mode selected by `BUZZ_NIP_FI_MODE`. + pub mode: NipFiMode, + /// Validated per-issuer assertion-policy registry. + pub registry: IssuerRegistry, + /// Parallel JWKS configs for `ProductionJwksSource` construction. + pub jwks_configs: Vec, + /// Hard upper bound on a single connection lease, in seconds. + /// Required in enforce mode per spec (NIP-FI.md §Request and session + /// bounds): every deployment MUST configure a positive finite value. + pub max_connection_lifetime_secs: u64, +} + +impl NipFiRelayConfig { + /// Parse NIP-FI relay configuration from the process environment. + /// + /// Returns `Err` when `BUZZ_NIP_FI_MODE=enforce` but required config is + /// missing or invalid (fail-closed: no token is accepted until this passes). + pub fn from_env() -> Result { + let mode = parse_mode()?; + + if let NipFiMode::Off | NipFiMode::DenyProtected = mode { + return Ok(Self { + mode, + registry: IssuerRegistry::new(), + jwks_configs: Vec::new(), + max_connection_lifetime_secs: 0, + }); + } + + // Enforce mode: all fields required. + let issuers_json = std::env::var("BUZZ_NIP_FI_ISSUERS").map_err(|_| { + ConfigError::InvalidValue( + "BUZZ_NIP_FI_MODE=enforce but BUZZ_NIP_FI_ISSUERS is not set; \ + set it to a JSON array of issuer configs" + .to_string(), + ) + })?; + if issuers_json.trim().is_empty() { + return Err(ConfigError::InvalidValue( + "BUZZ_NIP_FI_ISSUERS must not be empty in enforce mode".to_string(), + )); + } + + let issuer_entries: Vec = + serde_json::from_str(&issuers_json).map_err(|e| { + // Do not embed raw `e` — serde_json type-error messages can + // include the unexpected field value verbatim (issuer URLs, etc). + // Use classify() and positional info only. [NIP-FI.md:777-779] + ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_ISSUERS is not valid JSON: {:?} at line {} column {}", + e.classify(), + e.line(), + e.column(), + )) + })?; + + if issuer_entries.is_empty() { + return Err(ConfigError::InvalidValue( + "BUZZ_NIP_FI_ISSUERS must contain at least one issuer in enforce mode".to_string(), + )); + } + + // `BUZZ_NIP_FI_MAXIMUM_ASSERTION_AGE_SECS` is intentionally NOT parsed + // here. The authoritative `maximum_assertion_age` comes from each issuer's + // JSON config entry (field `maximum_assertion_age_seconds`). A relay-level + // duplicate that could disagree with the per-issuer value is a config-drift + // trap — removed in this PR. + + let max_connection_lifetime_secs = parse_u64_bounded( + "BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", + 1, + MAX_CONNECTION_LIFETIME_SECS, + )? + .ok_or_else(|| { + ConfigError::InvalidValue( + "BUZZ_NIP_FI_MODE=enforce but \ + BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS is not set; \ + every enforce deployment must configure a positive finite value" + .to_string(), + ) + })?; + + let mut registry = IssuerRegistry::new(); + let mut jwks_configs = Vec::with_capacity(issuer_entries.len()); + + for (issuer_idx, entry) in issuer_entries.iter().enumerate() { + let (policy, jwks_config) = build_issuer(entry).map_err(|e| { + ConfigError::InvalidValue(format!( + // issuer_idx is a non-identifying diagnostic code. + // Raw `iss` is excluded per NIP-FI.md:777-779. + "BUZZ_NIP_FI_ISSUERS: issuer at index {issuer_idx}: {e}" + )) + })?; + registry.insert(policy); + jwks_configs.push(jwks_config); + } + + // Delegate final validation to buzz-auth startup gate. + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks_configs).map_err( + |e: NipFiStartupError| ConfigError::InvalidValue(format!("NIP-FI config invalid: {e}")), + )?; + + Ok(Self { + mode, + registry, + jwks_configs, + max_connection_lifetime_secs, + }) + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn parse_mode() -> Result { + match std::env::var("BUZZ_NIP_FI_MODE") + .ok() + .as_deref() + .map(str::trim) + { + None | Some("") | Some("off") => Ok(NipFiMode::Off), + Some("enforce") => Ok(NipFiMode::Enforce), + Some("deny_protected") => Ok(NipFiMode::DenyProtected), + Some(other) => Err(ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_MODE must be \"enforce\", \"deny_protected\", or \"off\"; got {other:?}" + ))), + } +} + +/// Parse an optional positive `u64` env var bounded to `[min_val, max_val]`. +/// Returns `None` when the variable is absent or empty. +fn parse_u64_bounded(name: &str, min_val: u64, max_val: u64) -> Result, ConfigError> { + match std::env::var(name) { + Err(_) => Ok(None), + Ok(raw) if raw.trim().is_empty() => Ok(None), + Ok(raw) => { + let v: u64 = raw.trim().parse().map_err(|_| { + ConfigError::InvalidValue(format!("{name} must be a positive integer")) + })?; + if v < min_val || v > max_val { + return Err(ConfigError::InvalidValue(format!( + "{name} must be in {min_val}..={max_val}" + ))); + } + Ok(Some(v)) + } + } +} + +/// Parse a `jsonwebtoken::Algorithm` from a case-sensitive string. +fn parse_algorithm(s: &str) -> Result { + match s { + "ES256" => Ok(Algorithm::ES256), + "ES384" => Ok(Algorithm::ES384), + "RS256" => Ok(Algorithm::RS256), + "RS384" => Ok(Algorithm::RS384), + "RS512" => Ok(Algorithm::RS512), + "PS256" => Ok(Algorithm::PS256), + "PS384" => Ok(Algorithm::PS384), + "PS512" => Ok(Algorithm::PS512), + "EdDSA" => Ok(Algorithm::EdDSA), + other => Err(format!( + "unknown or non-asymmetric algorithm (got {} chars); \ + supported: ES256 ES384 RS256 RS384 RS512 PS256 PS384 PS512 EdDSA", + other.len() + )), + } +} + +fn build_issuer(entry: &IssuerEnvConfig) -> Result<(IssuerPolicy, IssuerJwksConfig), String> { + let algorithms: Vec = entry + .algorithms + .iter() + .map(|s| parse_algorithm(s)) + .collect::>()?; + + let token_class = match entry.token_class { + TokenClassEnvConfig::DedicatedNipFi => TokenClass::DedicatedNipFi, + TokenClassEnvConfig::AccessTokenAtJwt => { + // at+jwt requires a SubjectClassContract; for simplicity in the + // initial deployment, dedicated nip-fi+jwt is the expected class. + // at+jwt support is left for a follow-up — fail closed with a + // clear message so operators know the required fields. + return Err("\"at+jwt\" token class requires a subject-class contract; \ + use \"nip-fi+jwt\" for initial deployments or add \ + subject_class fields to the issuer config" + .to_string()); + } + }; + + let jwks_contract = JwksSourceContract::new( + entry.jwks_uri.clone(), + entry.jwks_refresh_interval_seconds, + entry.jwks_hard_deadline_seconds, + ) + .ok_or_else(|| { + "invalid JWKS source contract (check jwks_uri is HTTPS, \ + refresh_interval < hard_deadline, and both are positive)" + .to_string() + })?; + + let policy = IssuerPolicy::new( + entry.issuer.clone(), + entry.audiences.clone(), + token_class, + FreshnessClass::OfflineJwt, + algorithms, + entry.skew_seconds, + entry.maximum_assertion_age_seconds, + None, // offline-jwt: no status age + jwks_contract.clone(), + ) + .map_err(|e: IssuerPolicyError| e.to_string())?; + + let jwks_config = IssuerJwksConfig { + issuer: entry.issuer.clone(), + contract: jwks_contract, + }; + + Ok((policy, jwks_config)) +} + +// ── Duration helpers ────────────────────────────────────────────────────────── + +impl NipFiRelayConfig { + /// Returns the configured `max_connection_lifetime` as a `Duration`. + /// Returns `None` in `Off`/`DenyProtected` mode (sentinel value 0). + pub fn max_connection_lifetime(&self) -> Option { + if self.max_connection_lifetime_secs == 0 { + None + } else { + Some(Duration::from_secs(self.max_connection_lifetime_secs)) + } + } + + /// Returns `true` when the relay is in `Enforce` mode. + pub fn is_enforce(&self) -> bool { + matches!(self.mode, NipFiMode::Enforce) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // Env vars are process-global — serialize tests that mutate them to prevent + // cross-test races when the suite runs with multiple threads. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + /// RAII guard: removes a set of env vars when dropped, restoring a clean + /// state even on test panic. + struct EnvGuard(Vec<&'static str>); + impl EnvGuard { + fn new(keys: &[&'static str]) -> Self { + Self(keys.to_vec()) + } + } + impl Drop for EnvGuard { + fn drop(&mut self) { + for key in &self.0 { + std::env::remove_var(key); + } + } + } + + const NIP_FI_VARS: &[&str] = &[ + "BUZZ_NIP_FI_MODE", + "BUZZ_NIP_FI_ISSUERS", + "BUZZ_NIP_FI_MAXIMUM_ASSERTION_AGE_SECS", + "BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", + ]; + + #[test] + fn off_mode_requires_no_other_config() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + // NipFiMode::Off is the default: no issuers, no age limit. + std::env::remove_var("BUZZ_NIP_FI_MODE"); + let cfg = NipFiRelayConfig::from_env().expect("Off mode must not fail"); + assert!(matches!(cfg.mode, NipFiMode::Off)); + assert!(cfg.registry.is_empty()); + } + + #[test] + fn deny_protected_requires_no_other_config() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + std::env::set_var("BUZZ_NIP_FI_MODE", "deny_protected"); + let cfg = NipFiRelayConfig::from_env().expect("DenyProtected mode must not fail"); + assert!(matches!(cfg.mode, NipFiMode::DenyProtected)); + } + + #[test] + fn enforce_without_issuers_fails_closed() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::remove_var("BUZZ_NIP_FI_ISSUERS"); + std::env::remove_var("BUZZ_NIP_FI_MAXIMUM_ASSERTION_AGE_SECS"); + let err = NipFiRelayConfig::from_env() + .expect_err("enforce without issuers must be a config error"); + let msg = err.to_string(); + assert!( + msg.contains("BUZZ_NIP_FI_ISSUERS"), + "error names the missing var: {msg}" + ); + } + + /// A complete, valid Enforce issuer entry. Tests derive negative fixtures + /// from it by removing exactly one field. + fn valid_enforce_issuer() -> serde_json::Value { + serde_json::json!({ + "issuer": "https://issuer.test", + "audiences": ["https://relay.test"], + "token_class": "nip-fi+jwt", + "algorithms": ["ES256"], + "skew_seconds": 30, + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://issuer.test/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 3600 + }) + } + + #[test] + fn enforce_without_assertion_age_fails_closed() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::set_var("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"); + + // Success control: the complete fixture is accepted. + let valid = valid_enforce_issuer(); + std::env::set_var( + "BUZZ_NIP_FI_ISSUERS", + serde_json::json!([valid]).to_string(), + ); + NipFiRelayConfig::from_env().expect("complete Enforce issuer config must be accepted"); + + // Same fixture minus only the per-issuer age bound. + let mut missing_age = valid_enforce_issuer(); + missing_age + .as_object_mut() + .unwrap() + .remove("maximum_assertion_age_seconds"); + std::env::set_var( + "BUZZ_NIP_FI_ISSUERS", + serde_json::json!([missing_age]).to_string(), + ); + let err = NipFiRelayConfig::from_env() + .expect_err("Enforce issuer without maximum_assertion_age_seconds must fail closed"); + // The parser deliberately reports only the serde error class (never + // the message) so config values cannot leak; with the control above + // passing, the one-field difference is what produced this rejection. + // It must be the deserialization rejection, not a later policy-build + // failure (which a defaulted age would hit instead). + let msg = err.to_string(); + assert!( + msg.contains("BUZZ_NIP_FI_ISSUERS is not valid JSON: Data"), + "missing maximum_assertion_age_seconds must be rejected at deserialization: {msg}" + ); + } + + #[test] + fn unknown_mode_is_rejected() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + std::env::set_var("BUZZ_NIP_FI_MODE", "permissive"); + let err = NipFiRelayConfig::from_env().expect_err("unknown mode must error"); + assert!(err.to_string().contains("BUZZ_NIP_FI_MODE")); + } + + // ── R5 privacy sentinel tests ───────────────────────────────────────────── + // + // These tests prove that parse errors on BUZZ_NIP_FI_ISSUERS do NOT echo + // raw issuer config values (URLs, audience strings, issuer identifiers) in + // the error messages. [NIP-FI.md:777-779] + // + // The test input embeds a unique sentinel string that should never appear in + // any error message. Failing this invariant would mean serde_json or another + // parser is leaking operator-supplied field values into error text. + // + // Falsifying mutation for all tests: remove the `.classify()` / `other.len()` + // wrapping in `from_env()` / `parse_algorithm()` and restore a raw `{e}` or + // `{s}` interpolation. The sentinel strings would appear in the error + // message and the assertion fires. + + /// Malformed issuer JSON: wrong-typed field must not leak the sentinel value. + /// + /// We use a valid JSON array with `skew_seconds` as a string (where the + /// deserializer expects a number). Raw serde would echo the actual string + /// value in a type-error message like `expected u64, got string "SENTINEL..."`. + /// The test asserts the sentinel does NOT appear — proving the code strips or + /// classifies the error rather than forwarding serde's message. + /// + /// This is stronger than using outright-malformed JSON, which serde never + /// echoes in the first place. A non-discriminating malformed-JSON sentinel + /// passes even if the code leaks values from valid-but-wrong-typed fields. + #[test] + fn malformed_issuer_json_error_does_not_leak_raw_value() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + // A sentinel that serde would echo in a type-mismatch error if not suppressed. + const SENTINEL: &str = "SENTINEL_SKEW_VALUE_abc123xyz"; + // Valid array with `skew_seconds` as a string — deserializer expects u64. + // Raw serde error would be something like: + // "invalid type: string \"SENTINEL_SKEW_VALUE_abc123xyz\", expected u64" + let issuers_json = serde_json::json!([{ + "issuer": "https://issuer.test", + "audiences": ["https://relay.test"], + "token_class": "nip-fi+jwt", + "algorithms": ["ES256"], + "skew_seconds": SENTINEL, // wrong type: serde echoes this value + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://issuer.test/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 3600 + }]) + .to_string(); + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::set_var("BUZZ_NIP_FI_ISSUERS", &issuers_json); + std::env::set_var("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"); + + let err = NipFiRelayConfig::from_env().expect_err("wrong-typed field must fail"); + let msg = err.to_string(); + + assert!( + !msg.contains(SENTINEL), + "parse error MUST NOT echo the raw field value (privacy sentinel leaked): {msg}" + ); + // The error must still be non-empty and identify the config variable. + assert!( + msg.contains("BUZZ_NIP_FI_ISSUERS"), + "error must name the config variable: {msg}" + ); + } + + /// Invalid algorithm string error must not echo the raw value. + #[test] + fn invalid_algorithm_error_does_not_leak_raw_value() { + // parse_algorithm is private; we test it indirectly by passing a full + // issuer config with a sentinel algorithm name. + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + const SENTINEL_ALG: &str = "SENTINEL_ALGORITHM_HS256_SECRET"; + let issuers_json = serde_json::json!([{ + "issuer": "https://issuer.test", + "audiences": ["https://relay.test"], + "token_class": "nip-fi+jwt", + "algorithms": [SENTINEL_ALG], + "skew_seconds": 30, + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://issuer.test/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 3600 + }]) + .to_string(); + + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::set_var("BUZZ_NIP_FI_ISSUERS", &issuers_json); + std::env::set_var("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"); + + let err = NipFiRelayConfig::from_env().expect_err("unknown algorithm must fail"); + let msg = err.to_string(); + + assert!( + !msg.contains(SENTINEL_ALG), + "algorithm error MUST NOT echo the raw algorithm value: {msg}" + ); + // The error must indicate what went wrong (non-empty, contains hint). + assert!(!msg.is_empty(), "error must be non-empty"); + } + + /// Policy-build rejection error must not leak the issuer URL. + #[test] + fn policy_build_rejection_error_does_not_leak_issuer_url() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + const SENTINEL_ISSUER: &str = "https://sentinel-issuer-secret.example"; + // An issuer config with an empty audiences list → IssuerPolicy::new fails. + let issuers_json = serde_json::json!([{ + "issuer": SENTINEL_ISSUER, + "audiences": [], // empty → IssuerPolicy::new must fail + "token_class": "nip-fi+jwt", + "algorithms": ["ES256"], + "skew_seconds": 30, + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://sentinel-issuer-secret.example/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 3600 + }]) + .to_string(); + + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::set_var("BUZZ_NIP_FI_ISSUERS", &issuers_json); + std::env::set_var("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"); + + let err = NipFiRelayConfig::from_env() + .expect_err("empty audiences must cause a policy build failure"); + let msg = err.to_string(); + + // The error must not leak the sentinel issuer URL. + assert!( + !msg.contains(SENTINEL_ISSUER), + "policy-build error MUST NOT echo the raw issuer URL: {msg}" + ); + // The error must be non-empty and mention the issuer index. + assert!( + msg.contains("index"), + "error must reference the issuer by index, not URL: {msg}" + ); + } +} diff --git a/crates/buzz-relay/src/nip_fi_http.rs b/crates/buzz-relay/src/nip_fi_http.rs new file mode 100644 index 0000000000..cd74a2d28c --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_http.rs @@ -0,0 +1,1280 @@ +//! NIP-FI HTTP ingress enforcement. +//! +//! Every protected HTTP surface in enforce mode MUST call +//! [`admit_nip_fi_http`] (or its state-convenience wrapper +//! [`admit_nip_fi_http_on_state`]) which is the single authority for the +//! complete NIP-FI admission decision for one HTTP request: +//! +//! 1. Run the caller's NIP-98 extraction closure → `proven_pubkey`. +//! 2. Extract the `Nostr-Federated-Identity: Bearer ` assertion. +//! 3. Verify it offline against the configured issuer JWKS. +//! 4. Confirm the assertion's `nostr_pubkey` equals `proven_pubkey`. [FI-INV-05] +//! 5. Check the deny map for the proven pubkey. [FI-INV-14] +//! +//! HTTP is sessionless: every request re-verifies. There is no lifetime- +//! partition concept — the session-bounds section of NIP-FI.md is WS-only. +//! +//! ## Structural authority +//! +//! [`NipFiAdmission`] has a private constructor. The only way to produce +//! one is via [`admit_nip_fi_http`]. This does not force a handler to call +//! it. In Enforce, a handler that skips the call and does its own NIP-98 is +//! still subject to the router's assertion guard, but a request with a valid +//! assertion passes without key pairing or a deny-map check. (Off skips the +//! guard entirely; DenyProtected denies without verifying.) +//! +//! ## Carrier / precedence +//! +//! Per NIP-FI.md §Client-attached transport: +//! - Assertion: `Nostr-Federated-Identity: Bearer ` (this +//! module's responsibility). +//! - Nostr proof: `Authorization: Nostr ` (NIP-98, owned by +//! the NIP-98 closure passed to `admit_nip_fi_http`). +//! - `Authorization` is RESERVED for NIP-98; the assertion MUST NOT appear +//! there. Mixing the two fields is an `EvidenceRejected` (403) denial. +//! +//! ## Deny map +//! +//! The deny map is S4 (Duncan). Until S4 lands this module stubs it as a +//! fail-open no-op: [`HttpDenyMap::is_denied`] always returns false. When S4 +//! adds the real implementation, replace the stub in `admit_nip_fi_http_on_state` +//! with a reference to the real map. The integration is a one-liner. +//! +//! ## Off-mode regression +//! +//! When `NipFiMode::Off`, `admit_nip_fi_http` still calls the NIP-98 closure +//! (preserving whatever auth the surface required before NIP-FI), then returns +//! `Ok(NipFiAdmission { assertion: None, ... })` immediately without the +//! assertion/pairing/deny steps. Pre-NIP-FI behavior is fully preserved for +//! OSS deployments. +//! +//! [FI-TRACE-DENIAL-ORACLE]: exact HTTP response bytes are fixed in NIP-FI.md. +//! [FI-TRACE-TRANSPORT-CLOSED]: assertion transport is exactly one header. +//! [FI-TRACE-AUTHORITY-UNIFORM]: all protected surfaces call this function. + +use axum::{ + body::Body, + http::{HeaderMap, Response, StatusCode}, +}; +use buzz_auth::{ + DenialClass, NipFiMode, VerifiedAssertion, VerifyAssertion, CLIENT_ATTACHED_HEADER, +}; +use chrono::{DateTime, Utc}; +use nostr::PublicKey; +use std::fmt; + +// ── Deny-map seam (S4 stub) ─────────────────────────────────────────────────── + +/// Narrow interface consumed by HTTP enforcement. S4 (Duncan) will provide +/// the real implementation; until then, `AlwaysAdmitStubDenyMap` stubs it +/// fail-open (admits unconditionally). +/// +/// Signature mirrors `NipFiDenyMap::is_denied` from S4 so integration is a +/// one-liner: replace `AlwaysAdmitStubDenyMap` with the shared map. +/// +/// `(issuer, pubkey, now)` are required because the deny set is issuer- +/// scoped per `NIP-FI.md:624-627`. Passing only pubkey would collide +/// across issuers — a deny for `(iss-A, k)` must not block `(iss-B, k)`. +/// +/// Sealed: only implementations in this crate are accepted. +pub(crate) trait HttpDenyMap: sealed::Sealed { + /// Returns `true` when `(issuer, pubkey)` has an active deny entry at + /// `now` (`now < until`). A poisoned or unavailable backing store MUST + /// return `false` (admits) only when an explicit availability guarantee is + /// established; the S4 real map currently admits on poisoned lock. The + /// S4 integration commit is expected to resolve the fail-closed story + /// before S5 merges; the interface contract here is the agreed shape. + fn is_denied(&self, issuer: &str, pubkey: &PublicKey, now: DateTime) -> bool; +} + +pub(crate) mod sealed { + pub(crate) trait Sealed {} +} + +/// Stub deny map that always admits. Used until S4 provides the real map. +/// +/// Name is explicit: this is **fail-open**, not fail-closed. The stub phase +/// is intentional — deny-map enforcement defers to S4 landing. The name +/// `AlwaysAdmitStubDenyMap` prevents a future integrator from assuming this +/// stub is safe for production use. +pub(crate) struct AlwaysAdmitStubDenyMap; +impl sealed::Sealed for AlwaysAdmitStubDenyMap {} +impl HttpDenyMap for AlwaysAdmitStubDenyMap { + /// Always admits: the deny map is not yet wired (S4 pending). + fn is_denied(&self, _issuer: &str, _pubkey: &PublicKey, _now: DateTime) -> bool { + false + } +} + +// ── Admission type ──────────────────────────────────────────────────────────── + +/// Opaque NIP-98 proof produced by a NIP-98 extraction closure. +/// +/// The `pubkey` field is private to this module. Code that calls +/// `bridge::make_nip98_closure_for_admission`—or any other closure that yields +/// this type—cannot read the proven key directly; it must pass the closure to +/// [`admit_nip_fi_http`], which opens the proof internally and returns the key +/// only through the private-constructor `NipFiAdmission`. +/// +/// ## Falsifier +/// +/// Invoking the closure directly (`make_nip98_closure_for_admission(...)()`) +/// returns `Ok(Nip98Proof { .. })`. Without the private `pubkey` accessor, +/// the call site cannot project the key — any attempt to destructure or call +/// `.pubkey` fails to compile. +/// +/// `X` is caller-supplied side-data (e.g. replay-detection fields). +pub(crate) struct Nip98Proof { + /// Private: only `admit_nip_fi_http` may read this field. + pubkey: PublicKey, + /// Side-data threaded through from the extraction closure. + pub(crate) extra: X, +} + +impl Nip98Proof { + /// Construct a proof. `pub(crate)` so that both bridge-internal closures + /// and the media/git surfaces (which already hold a proven pubkey from a + /// prior extractor) can build the token without leaking the key. + pub(crate) fn new(pubkey: PublicKey, extra: X) -> Self { + Self { pubkey, extra } + } +} + +/// Proof that the mode-appropriate NIP-FI admission path completed for one +/// HTTP request. +/// +/// Construction is private to [`admit_nip_fi_http`]. **No other code path +/// produces this type.** A value means the path for the configured mode ran: +/// +/// Off: NIP-98 extraction → admit (`assertion: None`, no pairing) +/// Enforce: NIP-98 extraction → assertion extraction → verify → pair → +/// deny-map → admit +/// +/// DenyProtected never produces one. Key pairing is guaranteed only in +/// Enforce. +/// +/// `X` is caller-supplied side-data returned by the NIP-98 extraction closure +/// (e.g. replay-detection fields). Use `()` when no side-data is needed. +/// +/// [FI-TRACE-AUTHORITY-UNIFORM] Every protected HTTP surface produces this +/// type via `admit_nip_fi_http`; there is no other source. +#[must_use] +pub(crate) struct NipFiAdmission { + /// The pubkey proven by NIP-98 (and, in Enforce, confirmed by assertion + /// pairing). + /// + /// Private: obtain via [`NipFiAdmission::proven_pubkey`]. + /// Only set from within [`admit_nip_fi_http`]. + proven_pubkey: PublicKey, + /// The verified federation assertion (Some in Enforce mode, None in Off). + assertion: Option, + /// Caller-supplied side-data from the NIP-98 extraction closure. + extra: X, +} + +impl fmt::Debug for NipFiAdmission { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("NipFiAdmission") + .field("proven_pubkey", &self.proven_pubkey) + .field("assertion", &self.assertion) + .finish_non_exhaustive() + } +} + +impl NipFiAdmission { + /// The pubkey proven by NIP-98 (and, in Enforce, by assertion pairing). + /// + /// This is the only way to obtain an authoritative pubkey for downstream + /// authorization checks. It is equal to the NIP-98 `pubkey` (what the + /// request proved); in Enforce it also equals the assertion's + /// `nostr_pubkey` (what the federation identity bound). + pub(crate) fn proven_pubkey(&self) -> &PublicKey { + &self.proven_pubkey + } + + /// The verified federation assertion, if NIP-FI was in Enforce mode. + /// + /// `None` in Off mode — the assertion was not required. + #[allow(dead_code)] + pub(crate) fn assertion(&self) -> Option<&VerifiedAssertion> { + self.assertion.as_ref() + } + + /// Caller-supplied side-data from the NIP-98 extraction closure. + #[allow(dead_code)] + pub(crate) fn extra(&self) -> &X { + &self.extra + } + + /// Consume the admission, returning ownership of the side-data. + pub(crate) fn into_extra(self) -> X { + self.extra + } +} + +// ── Main admission function ─────────────────────────────────────────────────── + +/// Run the full NIP-FI admission sequence for one HTTP request. +/// +/// ## Sequence (per NIP-FI.md §Admission procedure) +/// +/// 1. DenyProtected mode: unconditional 503, before the `Authorization` +/// cardinality check, the NIP-98 closure, or the verifier run. +/// 2. Enforce mode: reject more than one `Authorization` field (403). +/// 3. Run `extract_nip98` — the caller's NIP-98 extraction closure. Returns +/// `(proven_pubkey, X)` on success, or a `Response` to emit on failure. +/// Off mode returns `Ok(NipFiAdmission { proven_pubkey, assertion: None, +/// extra: X })` here; Off-mode behavior is identical to pre-NIP-FI (no +/// assertion requirement). [FI-INV-15] +/// 4. Enforce mode: extract `Nostr-Federated-Identity: Bearer `. +/// 5. Verify assertion (signature, issuer, expiry, claims). +/// 6. Assert `assertion.asserted_key == proven_pubkey`. [FI-INV-05] +/// 7. Check deny map for `(iss, proven_pubkey)`. [FI-INV-14] +/// 8. Return `Ok(NipFiAdmission { proven_pubkey, assertion: Some(...), extra: X })`. +/// +/// ## NIP-98 failure remapping in Enforce mode +/// +/// When the NIP-98 closure fails in Enforce mode, the closure typically returns +/// a legacy JSON 401/403 (`api_error`). NIP-FI.md §Admission procedure step 3 +/// requires NIP-FI DenialClass responses instead: +/// - Absent `Authorization` header → `MissingEvidence` (401 "authentication required") +/// - Present but malformed/invalid `Authorization` → `EvidenceRejected` (403) +/// +/// In Off mode the legacy response is returned unchanged ([FI-INV-15]). +/// [FI-TRACE-DENIAL-ORACLE] +/// +/// ## What the private constructor guarantees +/// +/// [`NipFiAdmission`] has a private constructor, so the only source of a +/// `NipFiAdmission` value is this function. It does not force a handler to +/// call this function. In Enforce, a handler that skips it and runs its own +/// NIP-98 is still subject to the router's assertion guard, but a request with +/// a valid assertion passes without key pairing or a deny-map check. Off skips +/// the guard entirely; DenyProtected denies without verifying. +/// +/// ## Off-mode semantics +/// +/// In Off mode the NIP-98 closure is always called (step 3). In Off mode the closure +/// result still gates entry — if NIP-98 auth is required for non-NIP-FI +/// reasons (e.g. `require_auth_token`), the closure encodes that. NIP-FI +/// layers (assertion/pairing/deny) are skipped entirely. +/// +/// [FI-TRACE-AUTHORITY-UNIFORM] +// Response is intentionally large (axum's design); boxing it here +// would add allocation without architectural benefit. The large Err variant +// is load-bearing: it IS the HTTP response, returned directly by handlers. +#[allow(clippy::result_large_err)] +pub(crate) fn admit_nip_fi_http( + headers: &HeaderMap, + extract_nip98: F, + verifier: Option<&dyn VerifyAssertion>, + mode: NipFiMode, + deny_map: &D, +) -> Result, Response> +where + D: HttpDenyMap, + F: FnOnce() -> Result, Response>, +{ + // Step 1 — DenyProtected mode: unconditional 503. Checked first so no + // request shape (duplicate, missing, or invalid `Authorization`) can turn + // it into a 401/403, and so neither NIP-98 nor the verifier runs. + if matches!(mode, NipFiMode::DenyProtected) { + return Err(http_denial(DenialClass::AuthorizationUnavailable)); + } + + // Step 2 — cardinality gate: Enforce mode requires exactly one Authorization + // field per NIP-FI.md:695-700. Off mode preserves legacy first-value behavior + // (`.get()` silently takes the first) so no regression for Off deployments. + // + // Axum / hyper de-duplicates most header fields during HTTP/1.1 parsing, but + // RFC 7230 permits comma-separated combining or multiple header lines; + // `HeaderMap::get` silently takes only the FIRST value. Rejecting duplicates + // closes the attack where a relay-aware adversary slips a second credential + // past the NIP-98 verifier. [FI-INV-15] + if !matches!(mode, NipFiMode::Off) { + let auth_count = headers.get_all("authorization").iter().count(); + if auth_count > 1 { + return Err(http_denial(DenialClass::EvidenceRejected)); + } + } + + // Step 3: run NIP-98 extraction (Off and Enforce). + let nip98_result = extract_nip98(); + + // Off mode: NIP-FI not required. Return admission immediately. + // The NIP-98 closure already enforced whatever auth the surface required. + // [FI-INV-15 exemption] + if matches!(mode, NipFiMode::Off) { + // Off mode: propagate the closure result unchanged (legacy behavior). + let Nip98Proof { + pubkey: proven_pubkey, + extra, + } = nip98_result?; + return Ok(NipFiAdmission { + proven_pubkey, + assertion: None, + extra, + }); + } + + // Enforce mode: NIP-98 closure failure MUST + // produce a NIP-FI DenialClass response, not a legacy JSON error. + // [NIP-FI.md §Admission procedure step 3; FI-TRACE-DENIAL-ORACLE] + let Nip98Proof { + pubkey: proven_pubkey, + extra, + } = nip98_result.map_err(|_legacy| { + // Determine the appropriate denial class from Authorization header presence. + // Absent header → MissingEvidence (401); present-but-invalid → EvidenceRejected (403). + // [FI-TRACE-DENIAL-ORACLE] + let class = if headers.contains_key("authorization") { + DenialClass::EvidenceRejected + } else { + DenialClass::MissingEvidence + }; + http_denial(class) + })?; + + // Steps 4–8 — Enforce mode. + + // Step 4: extract the assertion token. + let token = extract_bearer_token(headers).map_err(http_denial)?; + + // Step 5: cryptographic verification (signature, issuer, expiry, claims). + let verifier = verifier.ok_or_else(|| { + // Verifier not yet constructed (startup race); fail closed. + http_denial(DenialClass::AuthorizationUnavailable) + })?; + let assertion = verifier.verify_assertion(token).map_err(|e| { + tracing::debug!(code = e.code(), "nip-fi assertion denied at http ingress"); + http_denial(e.denial_class()) + })?; + + // Step 6: key pairing — assertion.asserted_key MUST equal proven NIP-98 key. + // A claimless assertion (no nostr_pubkey) is also a denial. [FI-INV-05] + match assertion.asserted_key() { + Some(k) if k == proven_pubkey => {} + _ => { + metrics::counter!( + "buzz_auth_failures_total", + "reason" => "nip_fi_http_key_mismatch" + ) + .increment(1); + tracing::debug!( + proven = %proven_pubkey.to_hex(), + "NIP-FI HTTP key pairing mismatch" + ); + // Key mismatch is a private-state denial: authorization_denied (403). + // [FI-TRACE-DENIAL-ORACLE] + return Err(http_denial(DenialClass::AuthorizationDenied)); + } + } + + // Step 7: deny-map check — (iss, pubkey) must not be in an active deny window. + // [FI-INV-14] [NIP-FI.md:624-627] + let issuer = assertion.identity().issuer(); + if deny_map.is_denied(issuer, &proven_pubkey, Utc::now()) { + metrics::counter!( + "buzz_auth_failures_total", + "reason" => "nip_fi_http_denied_pubkey" + ) + .increment(1); + // Denied-pubkey is a private-state denial. [FI-TRACE-DENIAL-ORACLE] + return Err(http_denial(DenialClass::AuthorizationDenied)); + } + + // Step 8: admit. + Ok(NipFiAdmission { + proven_pubkey, + assertion: Some(assertion), + extra, + }) +} + +// ── Transport extraction ────────────────────────────────────────────────────── + +/// Extract the single `Bearer ` from the `Nostr-Federated-Identity` +/// header. +/// +/// Rejects all forms the spec prohibits: +/// - Absent → `MissingEvidence` +/// - Repeated (multiple header values) → `EvidenceRejected` +/// - Comma-combined (`,` in a single value) → `EvidenceRejected` +/// - Empty after `Bearer ` stripping → `EvidenceRejected` +/// - Non-`Bearer ` prefix → `EvidenceRejected` +/// - Whitespace in the token (after scheme) → `EvidenceRejected` +/// +/// [FI-TRACE-TRANSPORT-CLOSED] +pub(crate) fn extract_bearer_token(headers: &HeaderMap) -> Result<&str, DenialClass> { + let mut values = headers.get_all(CLIENT_ATTACHED_HEADER).iter(); + let first = match values.next() { + Some(v) => v, + None => return Err(DenialClass::MissingEvidence), + }; + // Repeated header fields deny. [FI-TRACE-TRANSPORT-CLOSED] + if values.next().is_some() { + return Err(DenialClass::EvidenceRejected); + } + let raw = first.to_str().map_err(|_| DenialClass::EvidenceRejected)?; + // Comma-combined values deny. + if raw.contains(',') { + return Err(DenialClass::EvidenceRejected); + } + let token = raw + .strip_prefix("Bearer ") + .ok_or(DenialClass::EvidenceRejected)?; + // Empty or whitespace-containing token denies. + if token.is_empty() || token.contains(ascii_whitespace) { + return Err(DenialClass::EvidenceRejected); + } + Ok(token) +} + +fn ascii_whitespace(c: char) -> bool { + c.is_ascii_whitespace() +} + +// ── HTTP denial response ────────────────────────────────────────────────────── + +/// Build the exact HTTP denial response for the given class. +/// +/// The response contract is fixed by NIP-FI.md rejection table: +/// - Status, Content-Type, WWW-Authenticate (for 401), and body bytes are the +/// closed contract. No other fields are added that depend on the private +/// condition. [FI-TRACE-DENIAL-ORACLE] +pub(crate) fn http_denial(class: DenialClass) -> Response { + let mut builder = Response::builder() + .status(StatusCode::from_u16(class.http_status()).expect("valid status")) + .header("Content-Type", class.content_type()); + if let Some(challenge) = class.www_authenticate() { + builder = builder.header("WWW-Authenticate", challenge); + } + builder + .body(Body::from(class.http_body())) + .expect("valid denial response") +} + +// ── State-convenience wrapper ───────────────────────────────────────────────── + +/// Convenience wrapper: pull mode + verifier from `AppState` and call +/// [`admit_nip_fi_http`]. +/// +/// `extract_nip98` is a closure that performs NIP-98 authentication and +/// returns `(proven_pubkey, X)`. This wrapper supplies `deny_map = +/// &AlwaysAdmitStubDenyMap`; S4 can replace the stub without touching call +/// sites by changing this wrapper. +/// +/// This is the single entry-point every NIP-FI-protected surface calls. It +/// delegates to [`admit_nip_fi_http`], which alone constructs a +/// [`NipFiAdmission`]. +/// +/// [FI-TRACE-AUTHORITY-UNIFORM] +// Response is intentionally large (axum's design); see admit_nip_fi_http. +#[allow(clippy::result_large_err)] +pub(crate) fn admit_nip_fi_http_on_state( + state: &crate::state::AppState, + headers: &HeaderMap, + extract_nip98: F, +) -> Result, Response> +where + F: FnOnce() -> Result, Response>, +{ + let mode = state.config.nip_fi.mode; + let verifier = state.nip_fi_verifier.as_deref(); + admit_nip_fi_http( + headers, + extract_nip98, + verifier, + mode, + &AlwaysAdmitStubDenyMap, + ) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + // Response is 128 bytes by axum's design; the large Err is intentional + // throughout this module — it IS the HTTP response returned from tests. + #![allow(clippy::result_large_err)] + use super::*; + use axum::http::HeaderValue; + use buzz_auth::{NipFiMode, VerifyAssertion}; + use chrono::Utc; + + // Helper: read the body bytes synchronously (tests only). + fn body_bytes(resp: Response) -> Vec { + use http_body_util::BodyExt as _; + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + resp.into_body() + .collect() + .await + .expect("body") + .to_bytes() + .to_vec() + }) + } + + fn any_pubkey() -> PublicKey { + nostr::Keys::generate().public_key() + } + + // ── extract_bearer_token ───────────────────────────────────────────────── + + // Absent header → MissingEvidence (401). + // + // Mutation evidence: returning EvidenceRejected instead makes the + // `assert_eq!(class, DenialClass::MissingEvidence)` assertion panic. + #[test] + fn missing_header_is_missing_evidence() { + let headers = HeaderMap::new(); + let class = extract_bearer_token(&headers).unwrap_err(); + assert_eq!(class, DenialClass::MissingEvidence); + } + + // Repeated header → EvidenceRejected (403). + // + // Mutation evidence: keeping the first value instead of rejecting makes + // `unwrap_err()` panic. + #[test] + fn repeated_header_is_evidence_rejected() { + let mut headers = HeaderMap::new(); + headers.append( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer token1"), + ); + headers.append( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer token2"), + ); + let class = extract_bearer_token(&headers).unwrap_err(); + assert_eq!(class, DenialClass::EvidenceRejected); + } + + // Comma-combined → EvidenceRejected. + #[test] + fn comma_combined_is_evidence_rejected() { + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer a, Bearer b"), + ); + let class = extract_bearer_token(&headers).unwrap_err(); + assert_eq!(class, DenialClass::EvidenceRejected); + } + + // Empty token after Bearer prefix → EvidenceRejected. + #[test] + fn empty_token_is_evidence_rejected() { + let mut headers = HeaderMap::new(); + headers.insert(CLIENT_ATTACHED_HEADER, HeaderValue::from_static("Bearer ")); + let class = extract_bearer_token(&headers).unwrap_err(); + assert_eq!(class, DenialClass::EvidenceRejected); + } + + // Wrong prefix (non-Bearer) → EvidenceRejected. + #[test] + fn wrong_prefix_is_evidence_rejected() { + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Token xyz"), + ); + let class = extract_bearer_token(&headers).unwrap_err(); + assert_eq!(class, DenialClass::EvidenceRejected); + } + + // Whitespace in token → EvidenceRejected. + #[test] + fn whitespace_in_token_is_evidence_rejected() { + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer foo bar"), + ); + let class = extract_bearer_token(&headers).unwrap_err(); + assert_eq!(class, DenialClass::EvidenceRejected); + } + + // Valid Bearer token → extracted. + #[test] + fn valid_bearer_token_extracted() { + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer a.b.c"), + ); + let token = extract_bearer_token(&headers).unwrap(); + assert_eq!(token, "a.b.c"); + } + + // ── http_denial ────────────────────────────────────────────────────────── + + // MissingEvidence → 401, exact body, WWW-Authenticate: Nostr. + // + // Mutation evidence: changing status to 403 makes the status assert panic. + #[test] + fn missing_evidence_denial_is_401_with_nostr_challenge() { + let resp = http_denial(DenialClass::MissingEvidence); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + resp.headers() + .get("WWW-Authenticate") + .and_then(|v| v.to_str().ok()), + Some("Nostr"), + "MissingEvidence MUST carry WWW-Authenticate: Nostr" + ); + assert_eq!(body_bytes(resp), b"authentication required\n"); + } + + // EvidenceRejected → 403, exact body, no WWW-Authenticate. + // + // Mutation evidence: changing status to 401 or body to "denied" makes + // corresponding assertions panic. + #[test] + fn evidence_rejected_denial_is_403_exact_bytes() { + let resp = http_denial(DenialClass::EvidenceRejected); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert!( + resp.headers().get("WWW-Authenticate").is_none(), + "EvidenceRejected must not carry a WWW-Authenticate header" + ); + assert_eq!(body_bytes(resp), b"evidence rejected\n"); + } + + // AuthorizationDenied → 403, exact body. + // + // Mutation evidence: body check. + #[test] + fn authorization_denied_is_403_exact_bytes() { + let resp = http_denial(DenialClass::AuthorizationDenied); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert_eq!(body_bytes(resp), b"authorization denied\n"); + } + + // AuthorizationUnavailable → 503, exact body. + // + // Mutation evidence: status and body checks. + #[test] + fn authorization_unavailable_is_503_exact_bytes() { + let resp = http_denial(DenialClass::AuthorizationUnavailable); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body_bytes(resp), b"authorization unavailable\n"); + } + + // Private-state conditions are byte-identical at the admission boundary: + // a key-pairing mismatch and a deny-map hit must produce the same status, + // headers and body, so a client cannot tell which private state denied it. + // A matching-key request with a non-denying map is admitted, proving the + // deny-map fixture is what produced the second denial. + // [FI-TRACE-DENIAL-ORACLE] + // + // Mutation evidence: changing the deny-map branch to any other denial + // class makes the status/body equality assertion fail. + #[test] + fn authorization_denied_rows_are_byte_identical() { + use buzz_auth::VerifiedAssertion; + + struct FixedKeyVerifier(PublicKey); + impl VerifyAssertion for FixedKeyVerifier { + fn verify_assertion( + &self, + _token: &str, + ) -> Result { + Ok(VerifiedAssertion::new_for_test(self.0)) + } + } + struct FixedDenyMap(bool); + impl sealed::Sealed for FixedDenyMap {} + impl HttpDenyMap for FixedDenyMap { + fn is_denied(&self, _: &str, _: &PublicKey, _: DateTime) -> bool { + self.0 + } + } + + let pubkey_a = any_pubkey(); + let pubkey_b = any_pubkey(); + let verifier = FixedKeyVerifier(pubkey_a); + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer any.valid.looking.token"), + ); + let admit = |proven: PublicKey, deny_map: &FixedDenyMap| { + admit_nip_fi_http( + &headers, + || Ok(Nip98Proof::new(proven, ())), + Some(&verifier as &dyn VerifyAssertion), + NipFiMode::Enforce, + deny_map, + ) + }; + let snapshot = |resp: Response| { + let status = resp.status(); + let headers = resp.headers().clone(); + (status, headers, body_bytes(resp)) + }; + + let Err(mismatch) = admit(pubkey_b, &FixedDenyMap(false)) else { + panic!("key mismatch must be denied"); + }; + let Err(denied) = admit(pubkey_a, &FixedDenyMap(true)) else { + panic!("deny-map hit must be denied"); + }; + let mismatch = snapshot(mismatch); + assert_eq!(mismatch.0, StatusCode::FORBIDDEN); + assert_eq!(mismatch.2, b"authorization denied\n"); + assert_eq!( + mismatch, + snapshot(denied), + "key-mismatch and deny-map denials must be byte-identical (status, headers, body)" + ); + + assert!( + admit(pubkey_a, &FixedDenyMap(false)).is_ok(), + "matching keys with a non-denying map must be admitted" + ); + } + + // ── admit_nip_fi_http — off mode ───────────────────────────────────────── + + // Off mode → Ok(NipFiAdmission) with assertion=None regardless of headers. + // The NIP-98 closure is still called; its pubkey is forwarded. + // + // Mutation evidence: returning Err from off mode makes `unwrap()` panic. + #[test] + fn off_mode_admits_unconditionally() { + let headers = HeaderMap::new(); // no assertion + let expected_pubkey = any_pubkey(); + let ep = expected_pubkey; + let outcome = admit_nip_fi_http( + &headers, + || Ok(Nip98Proof::new(ep, ())), + None::<&dyn VerifyAssertion>, + NipFiMode::Off, + &AlwaysAdmitStubDenyMap, + ); + let admission = + outcome.expect("Off mode MUST not require NIP-FI assertion — OSS default regression"); + assert_eq!(*admission.proven_pubkey(), expected_pubkey); + assert!(admission.assertion().is_none()); + } + + // Off mode: NIP-98 closure failure propagates even in off mode. + // + // Mutation evidence: if off-mode short-circuits before the closure, the + // returned Err is swallowed → `unwrap_err()` panics. + #[test] + fn off_mode_propagates_nip98_closure_failure() { + let headers = HeaderMap::new(); + let deny_resp = http_denial(DenialClass::MissingEvidence); + let deny_status = deny_resp.status(); + let outcome = admit_nip_fi_http::<_, (), _>( + &headers, + || Err(deny_resp), + None::<&dyn VerifyAssertion>, + NipFiMode::Off, + &AlwaysAdmitStubDenyMap, + ); + let resp = outcome.unwrap_err(); + assert_eq!(resp.status(), deny_status); + } + + // ── F3: NIP-98 failure remapping in Enforce mode ───────────────────────── + // + // In Enforce mode, NIP-98 closure failure MUST produce NIP-FI DenialClass + // responses (not legacy JSON). The class depends on whether the + // Authorization header was present: + // - Absent header → MissingEvidence (401) + // - Present-but-invalid → EvidenceRejected (403) + // + // In Off mode the legacy response is propagated unchanged. + // + // Mutation evidence (absent-header path): replacing MissingEvidence with + // EvidenceRejected makes the `assert_eq!(status, 401)` assertion panic. + // Mutation evidence (present-header path): replacing EvidenceRejected with + // MissingEvidence makes the `assert_eq!(status, 403)` assertion panic. + + #[test] + fn enforce_nip98_failure_absent_auth_yields_missing_evidence() { + // Authorization header absent → NIP-98 closure fails → MissingEvidence (401). + let headers = HeaderMap::new(); // no Authorization header + let legacy_resp = http_denial(DenialClass::EvidenceRejected); // would be 403 if propagated + let outcome = admit_nip_fi_http::<_, (), _>( + &headers, + || Err(legacy_resp), + None::<&dyn VerifyAssertion>, + NipFiMode::Enforce, + &AlwaysAdmitStubDenyMap, + ); + let resp = outcome.unwrap_err(); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "absent-header NIP-98 failure MUST yield 401 MissingEvidence in Enforce mode" + ); + assert_eq!(body_bytes(resp), b"authentication required\n"); + } + + #[test] + fn enforce_nip98_failure_present_auth_yields_evidence_rejected() { + // Authorization header present (but NIP-98 fails) → EvidenceRejected (403). + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + HeaderValue::from_static("Nostr invalid_base64!!!"), + ); + let legacy_resp = http_denial(DenialClass::MissingEvidence); // would be 401 if propagated + let outcome = admit_nip_fi_http::<_, (), _>( + &headers, + || Err(legacy_resp), + None::<&dyn VerifyAssertion>, + NipFiMode::Enforce, + &AlwaysAdmitStubDenyMap, + ); + let resp = outcome.unwrap_err(); + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "present-but-invalid Authorization MUST yield 403 EvidenceRejected in Enforce mode" + ); + assert_eq!(body_bytes(resp), b"evidence rejected\n"); + } + + #[test] + fn off_mode_nip98_failure_propagates_legacy_response() { + // Off mode: legacy response is returned unchanged ([FI-INV-15]). + // If this test breaks, Off mode is remapping errors it should leave alone. + let headers = HeaderMap::new(); // no Authorization header + let legacy_status = StatusCode::UNAUTHORIZED; + let legacy_resp = http_denial(DenialClass::MissingEvidence); + let outcome = admit_nip_fi_http::<_, (), _>( + &headers, + || Err(legacy_resp), + None::<&dyn VerifyAssertion>, + NipFiMode::Off, + &AlwaysAdmitStubDenyMap, + ); + let resp = outcome.unwrap_err(); + assert_eq!( + resp.status(), + legacy_status, + "Off mode MUST propagate legacy NIP-98 failure response unchanged" + ); + } + + // ── R6(a) regression: Off-mode preserves exact legacy JSON bytes / content-type ── + // + // Thufir R6 / Carl F3: the Off test in the existing suite checks only status, + // so it cannot establish that Off mode preserves the JSON body bytes and + // content-type header that the pre-NIP-FI paths produce. This test uses a + // synthetic "legacy JSON 401" response (matching what `api_error` in bridge.rs + // produces) and verifies the exact body bytes and content-type survive Off mode. + // + // Mutation evidence: if Off mode remapped the error to `http_denial()` format + // (`text/plain; charset=utf-8`), the content-type assertion fires. If it + // remapped the body to NIP-FI denial bytes, the body assertion fires. + #[test] + fn off_mode_preserves_exact_legacy_json_body_and_content_type() { + use axum::http::header::CONTENT_TYPE; + // Build a synthetic legacy JSON error response, as `api_error` does. + let legacy_body = b"{\"error\":\"NIP-98: missing Authorization\"}"; + let legacy_resp = axum::http::Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header(CONTENT_TYPE, "application/json") + .body(axum::body::Body::from(legacy_body.as_ref())) + .unwrap(); + let headers = HeaderMap::new(); + let outcome = admit_nip_fi_http::<_, (), _>( + &headers, + || Err(legacy_resp), + None::<&dyn VerifyAssertion>, + NipFiMode::Off, + &AlwaysAdmitStubDenyMap, + ); + let resp = outcome.unwrap_err(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + resp.headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()), + Some("application/json"), + "Off mode MUST preserve the legacy application/json content-type" + ); + assert_eq!( + body_bytes(resp), + legacy_body, + "Off mode MUST preserve exact legacy JSON error body bytes" + ); + } + + // ── admit_nip_fi_http — deny_protected ─────────────────────────────────── + + // DenyProtected → Err(503 authorization unavailable) for every request + // shape, without running the NIP-98 closure or the verifier. Every case + // carries a syntactically valid assertion bearer so the verifier would be + // reachable if the mode check moved below token extraction. + // + // Mutation evidence: moving the DenyProtected check below the cardinality + // gate makes the duplicate case return 403 (status assertion fails). + // Moving it below the closure makes the closure counter non-zero; moving + // it below the closure *and* the error remap also turns the failed-closure + // cases into 401/403. Moving it below verification makes the verifier + // counter non-zero on the successful-closure case. + #[test] + fn deny_protected_returns_503_before_nip98_or_verifier() { + use std::cell::Cell; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct CountingVerifier(AtomicUsize); + impl VerifyAssertion for CountingVerifier { + fn verify_assertion( + &self, + _token: &str, + ) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(VerifiedAssertion::new_for_test(any_pubkey())) + } + } + + let auth = |headers: &mut HeaderMap, value: &'static str| { + headers.append("authorization", HeaderValue::from_static(value)); + }; + let with_bearer = || { + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer header.payload.signature"), + ); + headers + }; + let mut duplicate = with_bearer(); + auth(&mut duplicate, "Nostr first"); + auth(&mut duplicate, "Nostr second"); + let missing = with_bearer(); + let mut present = with_bearer(); + auth(&mut present, "Nostr invalid"); + + // (case, headers, closure succeeds) + let cases: [(&str, &HeaderMap, bool); 4] = [ + ("duplicate Authorization", &duplicate, true), + ("missing Authorization", &missing, false), + ("failed NIP-98 closure", &present, false), + ("successful NIP-98 closure", &present, true), + ]; + for (case, headers, closure_ok) in cases { + let closure_calls = Cell::new(0u32); + let verifier = CountingVerifier(AtomicUsize::new(0)); + let outcome = admit_nip_fi_http::<_, (), _>( + headers, + || { + closure_calls.set(closure_calls.get() + 1); + if closure_ok { + Ok(Nip98Proof::new(any_pubkey(), ())) + } else { + Err(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Body::from("legacy")) + .unwrap()) + } + }, + Some(&verifier as &dyn VerifyAssertion), + NipFiMode::DenyProtected, + &AlwaysAdmitStubDenyMap, + ); + let Err(resp) = outcome else { + panic!("{case}: DenyProtected must deny with 503"); + }; + assert_eq!( + resp.status(), + StatusCode::SERVICE_UNAVAILABLE, + "{case}: DenyProtected status" + ); + assert_eq!( + body_bytes(resp), + b"authorization unavailable\n", + "{case}: DenyProtected body" + ); + assert_eq!( + closure_calls.get(), + 0, + "{case}: NIP-98 closure must not run" + ); + assert_eq!( + verifier.0.load(Ordering::SeqCst), + 0, + "{case}: verifier must not run" + ); + } + } + + // ── admit_nip_fi_http — enforce, missing assertion ─────────────────────── + + // Enforce + missing assertion header → Err(401). + // + // Mutation evidence: the status assertion on the response panics if the + // missing-header path returns 403 instead of 401. + #[test] + fn enforce_missing_assertion_is_401() { + let headers = HeaderMap::new(); + let pubkey = any_pubkey(); + let outcome = admit_nip_fi_http( + &headers, + || Ok(Nip98Proof::new(pubkey, ())), + None::<&dyn VerifyAssertion>, + NipFiMode::Enforce, + &AlwaysAdmitStubDenyMap, + ); + // Missing header → MissingEvidence before verifier check. + match outcome { + Err(resp) => { + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + assert_eq!(body_bytes(resp), b"authentication required\n"); + } + _ => panic!("Missing assertion must deny with 401"), + } + } + + // ── admit_nip_fi_http — enforce, no verifier (startup race) ───────────── + + // Enforce + valid-looking header but no verifier (startup race) → Err(503). + // + // Mutation evidence: returning 403 from the None-verifier path makes the + // status assertion panic. + #[test] + fn enforce_no_verifier_returns_503() { + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer eyJhbGciOiJFUzI1NiJ9.e30.sig"), + ); + let pubkey = any_pubkey(); + let outcome = admit_nip_fi_http( + &headers, + || Ok(Nip98Proof::new(pubkey, ())), + None::<&dyn VerifyAssertion>, + NipFiMode::Enforce, + &AlwaysAdmitStubDenyMap, + ); + match outcome { + Err(resp) => { + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body_bytes(resp), b"authorization unavailable\n"); + } + _ => panic!("Missing verifier must deny with 503"), + } + } + + // ── admit_nip_fi_http — key pairing falsifier ──────────────────────────── + + // Enforce mode: valid assertion for key-A + NIP-98 proving key-B → Err(403 + // authorization_denied). + // + // This is the **pairing-wiring falsifier** Thufir required (Round 4). + // The test uses a mock verifier that returns a VerifiedAssertion whose + // asserted_key is key-A, while the NIP-98 closure returns key-B. + // + // Mutation evidence (pairing branch): + // Remove the `Some(k) if k == proven_pubkey` branch (replace with + // `Some(_)`) → function admits instead of denying → `unwrap_err()` panics. + // + // [FI-INV-05] [FI-TRACE-ASSERTION-KEY-MISMATCH] + #[test] + fn enforce_key_mismatch_is_denied() { + use buzz_auth::{VerifiedAssertion, VerifyAssertion}; + + let key_a = nostr::Keys::generate(); + let key_b = nostr::Keys::generate(); + let pubkey_a = key_a.public_key(); + let pubkey_b = key_b.public_key(); + + // Mock verifier: always succeeds, always claims pubkey_a as asserted_key. + struct PairingMockVerifier(nostr::PublicKey); + impl VerifyAssertion for PairingMockVerifier { + fn verify_assertion( + &self, + _token: &str, + ) -> Result { + Ok(VerifiedAssertion::new_for_test(self.0)) + } + } + let verifier = PairingMockVerifier(pubkey_a); + + // NIP-98 closure returns key-B; assertion claims key-A → mismatch. + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer any.valid.looking.token"), + ); + + let outcome = admit_nip_fi_http( + &headers, + || Ok(Nip98Proof::new(pubkey_b, ())), + Some(&verifier as &dyn VerifyAssertion), + NipFiMode::Enforce, + &AlwaysAdmitStubDenyMap, + ); + match outcome { + Err(resp) => { + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "key mismatch MUST deny with 403 authorization_denied" + ); + assert_eq!(body_bytes(resp), b"authorization denied\n"); + } + Ok(_) => panic!( + "assertion-for-A + NIP-98-for-B MUST be denied; \ + pairing branch removal would cause this panic" + ), + } + } + + // ── admit_nip_fi_http — deny map stub admits ───────────────────────────── + + // The stub deny map always admits (never denies). + // + // Mutation evidence: if `is_denied` returned true, the deny path would + // fire and the test would receive a Denied outcome instead of reaching + // the verifier check (which would deny for a different reason — invalid + // token). The distinction is observable: 401 vs 403. + #[test] + fn stub_deny_map_never_denies() { + let pubkey = any_pubkey(); + assert!( + !AlwaysAdmitStubDenyMap.is_denied("https://idp.example.com", &pubkey, Utc::now()), + "stub deny map MUST admit unconditionally until S4 provides the real map" + ); + } + + // ── R3 regression: Authorization cardinality ───────────────────────────── + // + // Thufir R3 / Carl F2: duplicate Authorization headers must be rejected in + // Enforce mode, and must be ACCEPTED in Off mode (FI-INV-15: + // Off behavior must match pre-NIP-FI base, which used `.get()` first-value). + // + // The cardinality gate is now in `admit_nip_fi_http`, not in + // `verify_bridge_auth_with_options`, ensuring Off-mode callers are never + // affected regardless of their `require_auth_token` flag. + // + // Mutation evidence (enforce branch): removing the cardinality gate makes + // a duplicate-header request proceed to NIP-98 extraction, which either + // succeeds (if both tokens are valid — impossible in these tests with a + // None verifier) or fails with a different status code. The test would + // still 403 in Enforce (extraction failure) but for the wrong reason; in + // DenyProtected it would 503; in Off it would either 401 (missing NIP-98) + // or pass. The combination uniquely identifies the gate. + // + // Mutation evidence (Off branch): if Off-mode also checked cardinality, the + // Off duplicate test would receive 403 instead of the legacy NIP-98 closure + // result (401 from the always-failing closure below). The assert fires. + + #[test] + fn enforce_duplicate_authorization_header_denied_403() { + // Enforce mode + two Authorization fields → 403 EvidenceRejected before + // NIP-98 extraction runs. + // + // Mutation: removing the `auth_count > 1` gate means the closure runs, + // extraction fails (invalid token), and admission maps the failure to + // 403 EvidenceRejected (header is present). Status is the same (403) + // but the body is different — the gate produces the standard + // `evidence rejected\n` bytes; NIP-98 failure in Enforce mode also + // produces `evidence rejected\n`. To distinguish, we verify the body + // comes from cardinality (gate fires before closure) rather than from + // the NIP-98 path: the closure must NEVER be called. + use std::sync::atomic::{AtomicBool, Ordering}; + let closure_ran = std::sync::Arc::new(AtomicBool::new(false)); + let closure_ran_clone = closure_ran.clone(); + let pubkey = any_pubkey(); + let mut headers = HeaderMap::new(); + headers.append( + axum::http::header::AUTHORIZATION, + HeaderValue::from_static("Nostr first.token"), + ); + headers.append( + axum::http::header::AUTHORIZATION, + HeaderValue::from_static("Nostr second.token"), + ); + + let outcome = admit_nip_fi_http::<_, (), _>( + &headers, + || { + closure_ran_clone.store(true, Ordering::SeqCst); + Ok(Nip98Proof::new(pubkey, ())) + }, + None::<&dyn VerifyAssertion>, + NipFiMode::Enforce, + &AlwaysAdmitStubDenyMap, + ); + let resp = outcome.unwrap_err(); + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "duplicate Authorization in Enforce MUST yield 403 EvidenceRejected" + ); + assert_eq!(body_bytes(resp), b"evidence rejected\n"); + assert!( + !closure_ran.load(Ordering::SeqCst), + "NIP-98 closure must NOT run when cardinality gate fires" + ); + } + + #[test] + fn off_mode_duplicate_authorization_header_passes_to_closure() { + // Off mode + two Authorization fields → closure runs, legacy behavior. + // + // FI-INV-15: Off mode must preserve pre-NIP-FI base behavior exactly. + // The base parser used `.get()` which silently accepted the first + // value from a multi-value header map. Off mode must NOT reject on + // cardinality — that would be a behavioral regression. + // + // Mutation evidence: adding a cardinality check in Off mode makes the + // closure never run and returns 403. The `closure_ran` assert fires. + use std::sync::atomic::{AtomicBool, Ordering}; + let closure_ran = std::sync::Arc::new(AtomicBool::new(false)); + let closure_ran_clone = closure_ran.clone(); + let pubkey = any_pubkey(); + let mut headers = HeaderMap::new(); + headers.append( + axum::http::header::AUTHORIZATION, + HeaderValue::from_static("Nostr first.token"), + ); + headers.append( + axum::http::header::AUTHORIZATION, + HeaderValue::from_static("Nostr second.token"), + ); + + // Closure succeeds → Off mode should admit. + let outcome = admit_nip_fi_http::<_, (), _>( + &headers, + || { + closure_ran_clone.store(true, Ordering::SeqCst); + Ok(Nip98Proof::new(pubkey, ())) + }, + None::<&dyn VerifyAssertion>, + NipFiMode::Off, + &AlwaysAdmitStubDenyMap, + ); + let admission = match outcome { + Ok(a) => a, + Err(resp) => panic!( + "Off mode MUST admit when closure succeeds, even with duplicate auth header; \ + got {} response", + resp.status() + ), + }; + assert!( + closure_ran.load(Ordering::SeqCst), + "NIP-98 closure MUST run in Off mode; cardinality gate must not fire" + ); + assert_eq!( + *admission.proven_pubkey(), + pubkey, + "proven_pubkey must be the one returned by the closure" + ); + } +} diff --git a/crates/buzz-relay/src/operator_listener.rs b/crates/buzz-relay/src/operator_listener.rs new file mode 100644 index 0000000000..a21292d2ae --- /dev/null +++ b/crates/buzz-relay/src/operator_listener.rs @@ -0,0 +1,776 @@ +//! Deployment-global operator-listener mention matching and notification delivery. + +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use chrono::{DateTime, TimeDelta, Utc}; +use futures_util::future::join_all; +use serde::Serialize; +use tracing::{error, warn}; +use uuid::Uuid; + +use crate::{nip98::nip98_header, state::AppState}; + +use reqwest::StatusCode; + +const CLAIM_SECS: i64 = 30; +const DELIVERY_BATCH_LIMIT: i64 = 10; +const IDLE_POLL_FLOOR: Duration = Duration::from_millis(250); +const IDLE_POLL_CEILING: Duration = Duration::from_secs(2); +const OUTBOX_REAP_INTERVAL: Duration = Duration::from_secs(5 * 60); +const REGISTRATION_REAP_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); + +/// Notification sent to the configured operator-listener endpoint. +#[derive(Debug, Serialize)] +struct MentionNotification { + v: u8, + pubkey: String, + community_host: String, + event_id: String, + event_kind: i32, + event_created_at: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WorkerIteration { + Worked, + Idle, + Failed, +} + +/// Reap stale deliveries every five minutes and expired registrations daily. +pub async fn run_reaper(state: Arc) { + tokio::join!( + async { + loop { + if let Err(error) = state.db.delete_expired_operator_listener_pubkeys().await { + warn!(%error, "operator-listener registration cleanup failed"); + } + tokio::time::sleep(REGISTRATION_REAP_INTERVAL).await; + } + }, + async { + loop { + if let Err(error) = state.db.reap_operator_listener_deliveries().await { + warn!(%error, "operator-listener outbox reap failed"); + } + tokio::time::sleep(OUTBOX_REAP_INTERVAL).await; + } + } + ); +} + +/// Continuously deliver operator-listener notification rows with bounded concurrency and retries. +pub async fn run_delivery_worker(state: Arc) { + let http = match build_delivery_http_client(state.config.operator_listener_timeout) { + Ok(http) => http, + Err(error) => { + error!(%error, "operator-listener HTTP client initialization failed"); + return; + } + }; + let mut idle_delay = IDLE_POLL_FLOOR; + loop { + match run_delivery_once(&state, &http).await { + WorkerIteration::Worked => idle_delay = IDLE_POLL_FLOOR, + WorkerIteration::Idle => { + tokio::time::sleep(idle_delay).await; + idle_delay = (idle_delay * 2).min(IDLE_POLL_CEILING); + } + WorkerIteration::Failed => tokio::time::sleep(Duration::from_secs(2)).await, + } + } +} + +fn build_delivery_http_client(timeout: Duration) -> Result { + reqwest::Client::builder() + .timeout(timeout) + .redirect(reqwest::redirect::Policy::none()) + .build() +} + +async fn run_delivery_once(state: &AppState, http: &reqwest::Client) -> WorkerIteration { + let transport = ReqwestDeliveryTransport { http }; + run_delivery_once_with_transport(state, &transport).await +} + +#[async_trait::async_trait] +trait DeliveryTransport: Sync { + async fn post( + &self, + url: &url::Url, + authorization: &str, + body: Vec, + ) -> Result; +} + +#[async_trait::async_trait] +trait DeliveryStore: Sync { + async fn release(&self, id: Uuid, claim_id: Uuid, next: DateTime) -> Result; + async fn complete(&self, id: Uuid, claim_id: Uuid) -> Result; + async fn retry(&self, id: Uuid, claim_id: Uuid, next: DateTime) -> Result; + async fn fail(&self, id: Uuid, claim_id: Uuid) -> Result; +} + +struct DbDeliveryStore<'a> { + db: &'a buzz_db::Db, +} + +#[async_trait::async_trait] +impl DeliveryStore for DbDeliveryStore<'_> { + async fn release(&self, id: Uuid, claim_id: Uuid, next: DateTime) -> Result { + self.db + .release_operator_listener_delivery(id, claim_id, next) + .await + .map_err(|error| error.to_string()) + } + + async fn complete(&self, id: Uuid, claim_id: Uuid) -> Result { + self.db + .complete_operator_listener_delivery(id, claim_id) + .await + .map_err(|error| error.to_string()) + } + + async fn retry(&self, id: Uuid, claim_id: Uuid, next: DateTime) -> Result { + self.db + .retry_operator_listener_delivery(id, claim_id, next) + .await + .map_err(|error| error.to_string()) + } + + async fn fail(&self, id: Uuid, claim_id: Uuid) -> Result { + self.db + .fail_operator_listener_delivery(id, claim_id) + .await + .map_err(|error| error.to_string()) + } +} + +struct ReqwestDeliveryTransport<'a> { + http: &'a reqwest::Client, +} + +#[async_trait::async_trait] +impl DeliveryTransport for ReqwestDeliveryTransport<'_> { + async fn post( + &self, + url: &url::Url, + authorization: &str, + body: Vec, + ) -> Result { + self.http + .post(url.clone()) + .header("Authorization", authorization) + .header("Content-Type", "application/json") + .body(body) + .send() + .await + .map(|response| response.status()) + .map_err(|error| error.to_string()) + } +} + +async fn run_delivery_once_with_transport( + state: &AppState, + transport: &T, +) -> WorkerIteration { + let claimed = match state + .db + .claim_operator_listener_deliveries( + DELIVERY_BATCH_LIMIT, + Utc::now() + TimeDelta::seconds(CLAIM_SECS), + ) + .await + { + Ok(claimed) => claimed, + Err(error) => { + error!(%error, "operator-listener delivery claim failed"); + return WorkerIteration::Failed; + } + }; + if claimed.is_empty() { + return WorkerIteration::Idle; + } + let store = DbDeliveryStore { db: &state.db }; + join_all(claimed.into_iter().map(|delivery| { + deliver_one( + &state.config.operator_listener_delivery_urls, + &state.relay_keypair, + &store, + transport, + delivery, + ) + })) + .await; + WorkerIteration::Worked +} + +async fn deliver_one( + routes: &HashMap, + relay_keypair: &nostr::Keys, + store: &S, + transport: &T, + delivery: buzz_db::operator_listener::ClaimedDelivery, +) { + let listener_hex = hex::encode(&delivery.listener_pubkey); + let Some(url) = routes.get(&listener_hex) else { + warn!( + delivery=%delivery.id, + listener=%listener_hex, + "operator-listener delivery has no route on this pod; releasing claim" + ); + if let Err(error) = store + .release( + delivery.id, + delivery.claim_id, + Utc::now() + TimeDelta::seconds(CLAIM_SECS), + ) + .await + { + error!(%error, delivery=%delivery.id, "failed to release unroutable operator-listener delivery"); + } + metrics::counter!("buzz_operator_listener_deliveries_total", "outcome" => "unroutable") + .increment(1); + return; + }; + let body = match serde_json::to_vec(&MentionNotification { + v: 1, + pubkey: hex::encode(&delivery.target_pubkey), + community_host: delivery.community_host.clone(), + event_id: hex::encode(&delivery.event_id), + event_kind: delivery.event_kind, + event_created_at: delivery.event_created_at.timestamp(), + }) { + Ok(body) => body, + Err(error) => { + fail_permanently( + store, + &delivery, + &format!("notification encoding failed: {error}"), + ) + .await; + return; + } + }; + let auth = match nip98_header(relay_keypair, url.as_str(), &body) { + Ok(auth) => auth, + Err(error) => { + fail_permanently( + store, + &delivery, + &format!("notification auth failed: {error}"), + ) + .await; + return; + } + }; + let response = transport.post(url, &auth, body).await; + match response { + Ok(status) if status.is_success() => { + match store.complete(delivery.id, delivery.claim_id).await { + Ok(true) => {} + Ok(false) => warn!( + delivery=%delivery.id, + "operator-listener delivery completion lost its claim" + ), + Err(error) => error!( + delivery=%delivery.id, + %error, + "failed to persist operator-listener delivery completion" + ), + } + metrics::counter!("buzz_operator_listener_deliveries_total", "outcome" => "accepted") + .increment(1); + } + Ok(status) + if status == StatusCode::REQUEST_TIMEOUT + || status == StatusCode::TOO_MANY_REQUESTS + || status.is_server_error() => + { + retry_or_fail(store, &delivery, format!("HTTP {status}")).await + } + Ok(status) => { + fail_permanently(store, &delivery, &format!("HTTP {status}")).await; + } + Err(error) => retry_or_fail(store, &delivery, error.to_string()).await, + } +} + +async fn fail_permanently( + store: &S, + delivery: &buzz_db::operator_listener::ClaimedDelivery, + reason: &str, +) { + error!(delivery=%delivery.id, %reason, "operator-listener delivery failed permanently"); + if let Err(error) = store.fail(delivery.id, delivery.claim_id).await { + error!(delivery=%delivery.id, %error, "failed to delete terminal operator-listener delivery"); + } + metrics::counter!("buzz_operator_listener_deliveries_total", "outcome" => "failed") + .increment(1); +} + +async fn retry_or_fail( + store: &S, + delivery: &buzz_db::operator_listener::ClaimedDelivery, + reason: String, +) { + if delivery.attempt >= buzz_db::operator_listener::MAX_DELIVERY_ATTEMPTS { + fail_permanently(store, delivery, &format!("retries exhausted: {reason}")).await; + return; + } + let delay = 2_i64.pow((delivery.attempt - 1).clamp(0, 7) as u32); + warn!( + delivery=%delivery.id, + attempt=delivery.attempt, + retry_in_seconds=delay, + %reason, + "operator-listener delivery failed; retrying" + ); + if let Err(error) = store + .retry( + delivery.id, + delivery.claim_id, + Utc::now() + TimeDelta::seconds(delay), + ) + .await + { + error!(delivery=%delivery.id, %error, "failed to persist operator-listener retry"); + } + metrics::counter!("buzz_operator_listener_deliveries_total", "outcome" => "retry").increment(1); +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use tokio::{ + io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}, + net::{TcpListener, TcpStream}, + }; + + use super::*; + use buzz_core::tenant::CommunityId; + use nostr::Keys; + + #[test] + fn reapers_use_their_expected_intervals() { + assert_eq!(OUTBOX_REAP_INTERVAL, Duration::from_secs(5 * 60)); + assert_eq!( + REGISTRATION_REAP_INTERVAL, + Duration::from_secs(24 * 60 * 60) + ); + } + + #[derive(Debug, Clone, PartialEq, Eq)] + enum StoreCall { + Release(Uuid, Uuid, DateTime), + Complete(Uuid, Uuid), + Retry(Uuid, Uuid, DateTime), + Fail(Uuid, Uuid), + } + + #[derive(Default)] + struct MockStore { + calls: Mutex>, + } + + impl MockStore { + fn calls(&self) -> Vec { + self.calls.lock().expect("store calls lock").clone() + } + } + + #[async_trait::async_trait] + impl DeliveryStore for MockStore { + async fn release( + &self, + id: Uuid, + claim_id: Uuid, + next: DateTime, + ) -> Result { + self.calls + .lock() + .expect("store calls lock") + .push(StoreCall::Release(id, claim_id, next)); + Ok(true) + } + + async fn complete(&self, id: Uuid, claim_id: Uuid) -> Result { + self.calls + .lock() + .expect("store calls lock") + .push(StoreCall::Complete(id, claim_id)); + Ok(true) + } + + async fn retry( + &self, + id: Uuid, + claim_id: Uuid, + next: DateTime, + ) -> Result { + self.calls + .lock() + .expect("store calls lock") + .push(StoreCall::Retry(id, claim_id, next)); + Ok(true) + } + + async fn fail(&self, id: Uuid, claim_id: Uuid) -> Result { + self.calls + .lock() + .expect("store calls lock") + .push(StoreCall::Fail(id, claim_id)); + Ok(true) + } + } + + struct SentRequest { + url: url::Url, + authorization: String, + body: Vec, + } + + struct MockTransport { + response: Mutex>>, + request: Mutex>, + request_count: Mutex, + } + + impl MockTransport { + fn new(response: Result) -> Self { + Self { + response: Mutex::new(Some(response)), + request: Mutex::new(None), + request_count: Mutex::new(0), + } + } + + fn take_request(&self) -> Option { + self.request.lock().expect("transport request lock").take() + } + + fn request_count(&self) -> usize { + *self + .request_count + .lock() + .expect("transport request count lock") + } + } + + #[async_trait::async_trait] + impl DeliveryTransport for MockTransport { + async fn post( + &self, + url: &url::Url, + authorization: &str, + body: Vec, + ) -> Result { + *self + .request_count + .lock() + .expect("transport request count lock") += 1; + *self.request.lock().expect("transport request lock") = Some(SentRequest { + url: url.clone(), + authorization: authorization.to_owned(), + body, + }); + self.response + .lock() + .expect("transport response lock") + .take() + .expect("delivery should make one request") + } + } + + fn delivery_fixture( + attempt: i32, + ) -> ( + HashMap, + Keys, + buzz_db::operator_listener::ClaimedDelivery, + ) { + let listener = Keys::generate(); + let listener_pubkey = listener.public_key().to_bytes().to_vec(); + let route = url::Url::parse("https://listener.example/mentions").expect("test URL"); + let routes = HashMap::from([(hex::encode(&listener_pubkey), route)]); + ( + routes, + Keys::generate(), + buzz_db::operator_listener::ClaimedDelivery { + id: Uuid::new_v4(), + claim_id: Uuid::new_v4(), + listener_pubkey, + target_pubkey: vec![0x11; 32], + community: CommunityId::from_uuid(Uuid::new_v4()), + community_host: "community.example".to_owned(), + event_id: vec![0x22; 32], + event_kind: 9, + event_created_at: DateTime::from_timestamp(1_700_000_000, 0) + .expect("test timestamp"), + attempt, + }, + ) + } + + #[tokio::test] + async fn unroutable_delivery_releases_claim_without_http_request() { + let (_, relay_keypair, delivery) = delivery_fixture(1); + let store = MockStore::default(); + let transport = MockTransport::new(Ok(StatusCode::NO_CONTENT)); + let before = Utc::now() + TimeDelta::seconds(CLAIM_SECS); + + deliver_one( + &HashMap::new(), + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + let after = Utc::now() + TimeDelta::seconds(CLAIM_SECS); + assert!(transport.take_request().is_none()); + let calls = store.calls(); + assert_eq!(calls.len(), 1); + match &calls[0] { + StoreCall::Release(id, claim_id, next) => { + assert_eq!(*id, delivery.id); + assert_eq!(*claim_id, delivery.claim_id); + assert!((before..=after).contains(next)); + } + call => panic!("expected claim release, got {call:?}"), + } + } + + #[tokio::test] + async fn successful_delivery_posts_payload_and_completes_claim() { + let (routes, relay_keypair, delivery) = delivery_fixture(1); + let store = MockStore::default(); + let transport = MockTransport::new(Ok(StatusCode::NO_CONTENT)); + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + let request = transport.take_request().expect("delivery request"); + assert_eq!(request.url, routes[&hex::encode(&delivery.listener_pubkey)]); + assert!(request.authorization.starts_with("Nostr ")); + let body: serde_json::Value = serde_json::from_slice(&request.body).expect("JSON body"); + assert_eq!(body["v"], 1); + assert_eq!(body["pubkey"], hex::encode(&delivery.target_pubkey)); + assert_eq!(body["community_host"], delivery.community_host); + assert_eq!(body["event_id"], hex::encode(&delivery.event_id)); + assert_eq!(body["event_kind"], delivery.event_kind); + assert_eq!( + body["event_created_at"], + delivery.event_created_at.timestamp() + ); + assert_eq!( + store.calls(), + [StoreCall::Complete(delivery.id, delivery.claim_id)] + ); + } + + #[tokio::test] + async fn rate_limit_and_server_errors_retry_with_exponential_delay() { + for status in [ + StatusCode::TOO_MANY_REQUESTS, + StatusCode::SERVICE_UNAVAILABLE, + ] { + let (routes, relay_keypair, delivery) = delivery_fixture(3); + let store = MockStore::default(); + let transport = MockTransport::new(Ok(status)); + let before = Utc::now() + TimeDelta::seconds(4); + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + let after = Utc::now() + TimeDelta::seconds(4); + assert_eq!(transport.request_count(), 1, "HTTP {status}"); + assert!(transport.take_request().is_some()); + let calls = store.calls(); + assert_eq!(calls.len(), 1, "HTTP {status}"); + match &calls[0] { + StoreCall::Retry(id, claim_id, next) => { + assert_eq!(*id, delivery.id); + assert_eq!(*claim_id, delivery.claim_id); + assert!((before..=after).contains(next)); + } + call => panic!("expected HTTP {status} retry, got {call:?}"), + } + } + } + + #[tokio::test] + async fn deterministic_client_error_fails_without_retrying() { + let (routes, relay_keypair, delivery) = delivery_fixture(1); + let store = MockStore::default(); + let transport = MockTransport::new(Ok(StatusCode::BAD_REQUEST)); + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + assert_eq!(transport.request_count(), 1); + assert!(transport.take_request().is_some()); + assert_eq!( + store.calls(), + [StoreCall::Fail(delivery.id, delivery.claim_id)] + ); + } + + async fn read_http_request(stream: &mut TcpStream) -> (String, Vec) { + let mut reader = BufReader::new(stream); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .await + .expect("read request line"); + + let mut content_length = 0; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await.expect("read header"); + if line == "\r\n" || line.is_empty() { + break; + } + if let Some((name, value)) = line.split_once(':') { + if name.eq_ignore_ascii_case("content-length") { + content_length = value.trim().parse().expect("valid content length"); + } + } + } + + let mut body = vec![0; content_length]; + reader + .read_exact(&mut body) + .await + .expect("read request body"); + (request_line, body) + } + + #[tokio::test] + async fn redirect_response_fails_delivery_without_retrying() { + let source = TcpListener::bind("127.0.0.1:0") + .await + .expect("source listener"); + let source_addr = source.local_addr().expect("source address"); + let target = TcpListener::bind("127.0.0.1:0") + .await + .expect("target listener"); + let target_addr = target.local_addr().expect("target address"); + let target_url = format!("http://{target_addr}/redirected"); + let target_task = tokio::spawn(async move { + match tokio::time::timeout(Duration::from_secs(1), target.accept()).await { + Ok(Ok((mut stream, _))) => { + let request = read_http_request(&mut stream).await; + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await + .expect("write target success response"); + Some(request) + } + Ok(Err(error)) => panic!("accept target request: {error}"), + Err(_) => None, + } + }); + let (_, relay_keypair, delivery) = delivery_fixture(1); + let source_url = + url::Url::parse(&format!("http://{source_addr}/mentions")).expect("source URL"); + let routes = HashMap::from([(hex::encode(&delivery.listener_pubkey), source_url)]); + let store = MockStore::default(); + let http = build_delivery_http_client(Duration::from_secs(2)).expect("delivery client"); + let transport = ReqwestDeliveryTransport { http: &http }; + + let source_task = tokio::spawn(async move { + let (mut stream, _) = source.accept().await.expect("accept source request"); + let request = read_http_request(&mut stream).await; + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: {target_url}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write redirect response"); + let second_request = tokio::time::timeout(Duration::from_millis(250), source.accept()) + .await + .is_ok(); + (request, second_request) + }); + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + let (request, second_source_request) = + tokio::time::timeout(Duration::from_secs(2), source_task) + .await + .expect("source server completed") + .expect("source server task"); + assert!(request.0.starts_with("POST /mentions HTTP/1.1")); + assert!(!request.1.is_empty()); + assert!( + !second_source_request, + "source received more than one request" + ); + let target_request = tokio::time::timeout(Duration::from_secs(2), target_task) + .await + .expect("target server completed") + .expect("target server task"); + assert_eq!( + store.calls(), + [StoreCall::Fail(delivery.id, delivery.claim_id)] + ); + assert!( + target_request.is_none(), + "redirect target unexpectedly received a request" + ); + } + + #[tokio::test] + async fn exhausted_delivery_attempts_fail_permanently() { + let (routes, relay_keypair, delivery) = + delivery_fixture(buzz_db::operator_listener::MAX_DELIVERY_ATTEMPTS); + let store = MockStore::default(); + let transport = MockTransport::new(Err("connection reset".to_owned())); + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + assert!(transport.take_request().is_some()); + assert_eq!( + store.calls(), + [StoreCall::Fail(delivery.id, delivery.claim_id)] + ); + } +} diff --git a/crates/buzz-relay/src/push_runtime.rs b/crates/buzz-relay/src/push_runtime.rs index 246997aac2..d47dcc9d61 100644 --- a/crates/buzz-relay/src/push_runtime.rs +++ b/crates/buzz-relay/src/push_runtime.rs @@ -5,15 +5,13 @@ use std::{ time::{Duration, Instant}, }; -use base64::Engine as _; use buzz_core::filter::{filters_match, reader_authorized_for_event}; use chrono::{TimeDelta, Utc}; -use nostr::{EventBuilder, Filter, Kind, Tag}; +use nostr::Filter; use serde::{Deserialize, Serialize}; -use sha2::{Digest as _, Sha256}; use tracing::{error, warn}; -use crate::{handlers::push_lease::Subscription, state::AppState}; +use crate::{handlers::push_lease::Subscription, nip98::nip98_header, state::AppState}; const CLAIM_SECS: i64 = 30; const EVENT_USEFUL_SECS: i64 = 3600; @@ -666,22 +664,6 @@ async fn retry_or_fail( } } -fn nip98_header(keys: &nostr::Keys, url: &str, body: &[u8]) -> anyhow::Result { - let hash = hex::encode(Sha256::digest(body)); - let event = EventBuilder::new(Kind::HttpAuth, "") - .tags([ - Tag::parse(["u", url])?, - Tag::parse(["method", "POST"])?, - Tag::parse(["payload", &hash])?, - Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()])?, - ]) - .sign_with_keys(keys)?; - Ok(format!( - "Nostr {}", - base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event)?) - )) -} - fn class_rank(_: &str) -> u8 { 1 } @@ -689,7 +671,14 @@ fn class_rank(_: &str) -> u8 { #[cfg(test)] mod tests { use super::*; - use axum::{extract::State, routing::post, Json, Router}; + use axum::{ + body::Bytes, + extract::{OriginalUri, State}, + http::HeaderMap, + routing::post, + Json, Router, + }; + use nostr::{EventBuilder, Kind, Tag}; use serde_json::Value; use std::{future::IntoFuture, sync::Arc}; use tokio::sync::Mutex; @@ -729,9 +718,25 @@ mod tests { async fn capture( State(seen): State>>>, - Json(body): Json, + OriginalUri(uri): OriginalUri, + headers: HeaderMap, + body: Bytes, ) -> Json { - seen.lock().await.push(body); + let url: url::Url = format!("http://{}{}", headers["host"].to_str().unwrap(), uri) + .parse() + .unwrap(); + assert!(!headers.contains_key("x-forwarded-proto")); + nostr::nips::nip98::verify_auth_header( + headers["authorization"].to_str().unwrap(), + &url, + nostr::nips::nip98::HttpMethod::POST, + nostr::Timestamp::now(), + Some(&body), + ) + .unwrap(); + seen.lock() + .await + .push(serde_json::from_slice(&body).unwrap()); Json(serde_json::json!({"status":"accepted"})) } @@ -744,12 +749,14 @@ mod tests { axum::serve( listener, Router::new() - .route("/deliver", post(capture)) + .route("/v1/deliveries/apns", post(capture)) .with_state(seen.clone()), ) .into_future(), ); - let url: url::Url = format!("http://{address}/deliver").parse().unwrap(); + let url: url::Url = format!("http://{address}/v1/deliveries/apns") + .parse() + .unwrap(); let http = reqwest::Client::new(); let keys = nostr::Keys::generate(); let request_id = uuid::Uuid::new_v4(); diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 5c9bf04db6..d09bcb1bb0 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -24,9 +24,238 @@ use crate::audio; use crate::connection::handle_connection; use crate::metrics::track_metrics; use crate::nip11::{nip11_document, relay_info_handler}; +use crate::nip_fi_http::http_denial; use crate::readiness::{self, ReadinessEvaluation, ReadinessReason}; use crate::state::AppState; +// ── NIP-FI fail-closed assertion guard ─────────────────────────────────────── +// +// ## Purpose +// +// This middleware is the crypto backstop for NIP-FI route classification. +// It runs *over the entire merged router*: in Enforce or DenyProtected mode, +// any request whose path does not start with a prefix in +// `NIP_FI_EXEMPT_PREFIXES` must carry the +// `Nostr-Federated-Identity: Bearer …` assertion header with a +// cryptographically valid signature — or it is denied before reaching the +// handler. +// +// The handler-level admission authority is `admit_nip_fi_http_on_state` in +// `nip_fi_http.rs`. Protected handlers call it with a NIP-98 extraction +// closure and it delegates to `admit_nip_fi_http`, which runs NIP-98 +// extraction → assertion verify → pairing → deny-map (Enforce) and alone +// constructs a `NipFiAdmission`. The private constructor does not force a +// handler to make the call. +// +// What is guaranteed: in Enforce, this guard rejects a missing or invalid +// assertion on every non-exempt route; in DenyProtected it returns 503 +// without verifying; in Off it is transparent. Key pairing +// (`asserted_key == proven_pubkey`) and the deny map run only in handlers +// that call `admit_nip_fi_http_on_state`. In Enforce, a handler that omits +// the call and does its own NIP-98 is still subject to the assertion guard, +// but a request with a valid assertion passes without pairing or a deny check. +// +// ## Adding a new route +// +// * **Protected (NIP-98-authenticated):** call `admit_nip_fi_http_on_state` +// with a NIP-98 extraction closure. No action needed here. +// +// * **Public / exempt (no NIP-FI requirement):** add the path or prefix to +// `NIP_FI_EXEMPT_PREFIXES` below. Failure to do so will deny the route in +// Enforce mode, which is intentional: the default is DENY; public status is +// explicit. +// +// ## Relationship to Off mode +// +// When `NipFiMode::Off` the guard is fully transparent — no request is +// touched. [FI-INV-15] +// +// ## What this guard checks (and does NOT check) +// +// The guard performs the full offline assertion verification (transport +// extraction + JWT signature + issuer + expiry + claims). This means: +// +// • Absent header → 401 MissingEvidence +// • Junk / non-Bearer value → 403 EvidenceRejected +// • Repeated / comma-combined fields → 403 EvidenceRejected +// • Structurally malformed / bad sig → 403 EvidenceRejected +// • Unknown issuer / expired / bad claims → 403 EvidenceRejected +// • No verifier yet (startup race) → 503 AuthorizationUnavailable +// • Cryptographically valid assertion → forward to handler +// +// The guard does NOT check key pairing or deny-map: those require the NIP-98 +// `proven_pubkey` from each handler's closure, which is not available in +// middleware. `admit_nip_fi_http_on_state` performs the full sequence. +// +// [FI-TRACE-AUTHORITY-UNIFORM] Both the guard and `admit_nip_fi_http_on_state` +// delegate to `nip_fi_http.rs`; the guard fires first. + +/// Path prefixes that are exempt from NIP-FI assertion enforcement. +/// +/// Every route in this relay is NIP-FI-protected by default. Routes that +/// should NOT require the `Nostr-Federated-Identity` header in Enforce mode +/// MUST appear in this list; omission means the guard denies the route. +/// +/// **Matching rules:** +/// - Entries ending with `/` match any path with that prefix (subtree match). +/// - All other entries match exactly (the request path must equal the entry +/// or start with the entry followed by `/`, `?`, or `#`). +/// +/// When adding a new public or pre-auth route, add its path or prefix here +/// and include the NIP-FI classification comment in `build_router`. +const NIP_FI_EXEMPT_PREFIXES: &[&str] = &[ + // WebSocket upgrade + NIP-11 relay info (public; WS-NIP-FI governs WS) + "/", + // NIP-11 relay info — exact path + "/info", + // NIP-05 — exact path + "/.well-known/nostr.json", + // K8s / health probes (no auth) — exact paths + "/health", + "/_liveness", + "/_readiness", + // Pre-membership enrollment door — identity not yet issued + "/api/invites/claim", + // Pre-membership policy gate — no NIP-98 principal yet + "/api/invites/accept-policy", + // Public policy documents — exact path + subtree + "/api/join-policy", + // Webhook trigger — secret-header auth; subtree for /hooks/{id} + "/hooks/", + // Huddle audio WebSocket — WS-NIP-FI governs WebSocket; subtree + "/huddle/", + // Testbed-only mesh probe — no auth; subtree + "/_mesh/", + // Operator admin plane — keypair-in-config auth; subtree + "/operator/", + // Admin SPA backend — operator-credential gated; subtree + "/api/admin/", + // Static assets served by the SPA fallback; subtree + "/assets/", + "/favicon.svg", + // Invite landing page (SPA) — subtree + "/invite/", + // Git web GUI (SPA) — exact + subtree + "/repos", + // Internal HMAC/localhost control-plane endpoint for the pre-receive hook. + // Already protected by `require_localhost` middleware + signed operation + // payload; does not carry a NIP-FI assertion. Listed by exact path — + // sub-paths (if any) are equally harmless since no routes exist there. + "/internal/git/policy", +]; + +/// Middleware: full offline assertion guard for NIP-FI protected paths. +/// +/// Fires before any handler. In Enforce mode, if the request path is not +/// covered by [`NIP_FI_EXEMPT_PREFIXES`] the guard performs the full offline +/// NIP-FI assertion verification (transport extraction + JWT signature + +/// issuer + expiry + claims) via the relay's `FederatedAssertionVerifier`: +/// +/// - Absent header → 401 `authentication required\n` +/// - Junk / non-Bearer value → 403 `evidence rejected\n` +/// - Repeated / comma-combined → 403 `evidence rejected\n` +/// - Bad signature / claims → 403 `evidence rejected\n` +/// - No verifier (startup race) → 503 `authorization unavailable\n` +/// - Cryptographically valid → forward to handler +/// +/// A "forgotten gate" handler — one that omits its own +/// `admit_nip_fi_http_on_state` call — cannot admit with an invalidly signed +/// assertion because the guard rejects it here before the handler fires. +/// Only a cryptographically verified assertion reaches the handler; the +/// handler then performs the key pairing and deny-map checks via +/// `admit_nip_fi_http_on_state`. +/// +/// In Off mode the middleware is fully transparent. +async fn nip_fi_assertion_guard( + State(state): State>, + request: Request, + next: middleware::Next, +) -> axum::response::Response { + use crate::nip_fi_http::extract_bearer_token; + use buzz_auth::NipFiMode; + + // Off mode: fully transparent. [FI-INV-15] + if matches!(state.config.nip_fi.mode, NipFiMode::Off) { + return next.run(request).await; + } + + let path = request.uri().path(); + + // Exempt paths bypass the assertion-token check. + let exempt = NIP_FI_EXEMPT_PREFIXES.iter().any(|pattern| { + if *pattern == "/" { + // Exact root match only. + return path == "/"; + } + if pattern.ends_with('/') { + // Subtree match: path must start with this prefix. + return path.starts_with(pattern); + } + // Exact-or-subtree match: path equals the pattern, or path starts + // with the pattern followed by a path separator or query character. + // This prevents "/info" from matching "/info-extra". + if path == *pattern { + return true; + } + if let Some(rest) = path.strip_prefix(pattern) { + return rest.starts_with('/') || rest.starts_with('?') || rest.starts_with('#'); + } + false + }); + + if exempt { + return next.run(request).await; + } + + // Admin SPA document routes are exempt when the request is on the admin + // host. The admin SPA serves its own documents at bare paths (`/reports`, + // `/reports/`, `/feedback`) — the browser navigates there directly. + // These paths carry no NIP-FI-protected tenant data; the actual data calls + // go to `/api/admin/v1/...` (already exempt via the `/api/admin/` prefix). + // + // The exemption is host-qualified: `/reports` on a tenant host is NOT + // exempt and stays protected. Off mode is already handled above. + // [FI-TRACE-AUTHORITY-UNIFORM] + if is_admin_spa_path(path) && api::admin::is_admin_host(&state, request.headers()) { + return next.run(request).await; + } + + // Non-exempt path in Enforce or DenyProtected mode. + // + // DenyProtected: unconditional 503 regardless of assertion presence. + // (`admit_nip_fi_http_on_state` also does this; the guard is the backstop.) + if matches!(state.config.nip_fi.mode, NipFiMode::DenyProtected) { + return http_denial(buzz_auth::DenialClass::AuthorizationUnavailable); + } + + // Enforce mode: full offline assertion verification. + // + // Step 1 — transport: extract the Bearer token. Rejects absent, junk, + // repeated, comma-combined, empty, and whitespace-containing values. + // [FI-TRACE-TRANSPORT-CLOSED] + let token = match extract_bearer_token(request.headers()) { + Ok(t) => t, + Err(class) => return http_denial(class), + }; + + // Step 2 — cryptographic: verify signature, issuer, expiry, and claims. + // A forgotten-gate handler that omits `admit_nip_fi_http_on_state` can + // only be reached with a cryptographically valid assertion. Key pairing + // and deny-map are performed by `admit_nip_fi_http_on_state` in the + // handler, not here. [FI-TRACE-AUTHORITY-UNIFORM] + let verifier = match state.nip_fi_verifier.as_deref() { + Some(v) => v, + None => { + // Verifier not yet constructed (startup race); fail closed. + return http_denial(buzz_auth::DenialClass::AuthorizationUnavailable); + } + }; + match verifier.verify_assertion(token) { + Ok(_) => next.run(request).await, + Err(e) => http_denial(e.denial_class()), + } +} + /// Build the axum [`Router`] with all relay routes, middleware, and CORS configuration. /// /// Pure Nostr protocol: WebSocket (NIP-01), HTTP bridge (NIP-98), media (Blossom), @@ -88,6 +317,11 @@ pub fn build_router(state: Arc) -> Router { "/operator/communities", get(api::operator::list_owned_communities).post(api::operator::provision_community), ) + .route( + "/operator/listener/pubkeys", + post(api::operator::register_listener_pubkeys) + .delete(api::operator::remove_listener_pubkeys), + ) .route( "/operator/communities/archive", post(api::operator::archive_community), @@ -206,6 +440,10 @@ pub fn build_router(state: Arc) -> Router { } merged + .layer(middleware::from_fn_with_state( + state.clone(), + nip_fi_assertion_guard, + )) .layer(middleware::from_fn(track_metrics)) .layer(http_trace_layer()) .layer(build_cors_layer(&state.config.cors_origins)) @@ -1161,6 +1399,19 @@ mod tests { std::fs::write(dir.join("favicon.svg"), "").expect("favicon"); } + /// Write a distinct admin bundle with a unique sentinel so tests can + /// assert the exact expected bytes and distinguish admin from public HTML. + fn write_admin_bundle(dir: &std::path::Path) { + std::fs::create_dir_all(dir.join("assets")).expect("assets dir"); + std::fs::write( + dir.join("index.html"), + "", + ) + .expect("admin index.html"); + std::fs::write(dir.join("assets/app.js"), "export {};").expect("bundle asset"); + std::fs::write(dir.join("favicon.svg"), "").expect("favicon"); + } + async fn spa_response( state: Arc, host: &str, @@ -1380,4 +1631,656 @@ mod tests { "oversized messages must be rejected by the WebSocket parser before the handler sees them" ); } + + // ── nip_fi_assertion_guard: fail-closed classification tests ───────────── + // + // ## What these tests prove + // + // `nip_fi_assertion_guard` is the crypto backstop for NIP-FI route + // classification. These unit tests directly verify the exempt-prefix + // matching logic that determines whether a request is guarded or not. + // + // The key property: a non-exempt path with no assertion header must be + // denied in Enforce mode, even if the handler does NOT call + // `admit_nip_fi_http_on_state`. This is the belt — a handler that omits + // its gate cannot bypass the missing/invalid-assertion rejection. + // Key pairing and the deny map are NOT covered by this guard: they run + // only in handlers that call `admit_nip_fi_http_on_state`. + // + // ## Dummy-route failure-mode demonstration (for code review) + // + // To confirm the failure mode is dead: + // 1. In `build_router` add a handler with no NIP-FI gate: + // `.route("/dummy-unclassified", get(|| async { "hello" }))` + // (Do NOT add "/dummy-unclassified" to NIP_FI_EXEMPT_PREFIXES.) + // 2. Deploy with NIP-FI in Enforce mode. + // 3. Send `GET /dummy-unclassified` with valid NIP-98 but no assertion. + // 4. Response: 401 `authentication required\n` from the guard. + // 5. Revert the dummy route. + // + // This is the mechanism the tests below exercise at the unit level. + + /// Returns true when `path` is exempt per `NIP_FI_EXEMPT_PREFIXES`. + /// Mirrors the matching logic in `nip_fi_assertion_guard`. + fn is_exempt(path: &str) -> bool { + NIP_FI_EXEMPT_PREFIXES.iter().any(|pattern| { + if *pattern == "/" { + return path == "/"; + } + if pattern.ends_with('/') { + return path.starts_with(pattern); + } + if path == *pattern { + return true; + } + if let Some(rest) = path.strip_prefix(pattern) { + return rest.starts_with('/') || rest.starts_with('?') || rest.starts_with('#'); + } + false + }) + } + + // Exempt paths — guard must pass these through in Enforce mode. + #[test] + fn exempt_paths_are_recognized() { + // Root exact match + assert!(is_exempt("/"), "/ must be exempt (WS + NIP-11)"); + assert!(!is_exempt("/events"), "POST /events must NOT be exempt"); + // Exact-match entries must not bleed into adjacent paths + assert!( + !is_exempt("/info-extra"), + "/info-extra must NOT match /info" + ); + assert!(!is_exempt("/healthz"), "/healthz must NOT match /health"); + + // Probes + assert!(is_exempt("/health")); + assert!(is_exempt("/_liveness")); + assert!(is_exempt("/_readiness")); + + // Pre-membership + assert!(is_exempt("/api/invites/claim")); + assert!(is_exempt("/api/invites/accept-policy")); + + // Public docs + assert!(is_exempt("/api/join-policy")); + assert!(is_exempt("/api/join-policy/terms")); + assert!(is_exempt("/api/join-policy/privacy")); + + // Webhook (prefix) + assert!(is_exempt("/hooks/abc123")); + assert!(!is_exempt("/hooksnot"), "/hooksnot must not match /hooks/"); + + // Operator / admin subtrees + assert!(is_exempt("/operator/communities")); + assert!(is_exempt("/api/admin/v1/something")); + + // SPA / assets + assert!(is_exempt("/assets/main.js")); + assert!(is_exempt("/invite/abc")); + assert!(is_exempt("/repos")); + assert!(is_exempt("/repos/owner/name")); + } + + // Protected paths — guard must deny these in Enforce mode. + #[test] + fn protected_paths_are_not_exempt() { + assert!(!is_exempt("/events"), "POST /events must be protected"); + assert!(!is_exempt("/query"), "POST /query must be protected"); + assert!(!is_exempt("/count"), "POST /count must be protected"); + assert!( + !is_exempt("/gifs/search"), + "POST /gifs/search must be protected" + ); + assert!( + !is_exempt("/gifs/share"), + "POST /gifs/share must be protected" + ); + assert!( + !is_exempt("/workflows/abc/runs"), + "GET /workflows must be protected" + ); + assert!( + !is_exempt("/moderation/reports"), + "GET /moderation/reports must be protected" + ); + assert!( + !is_exempt("/moderation/audit"), + "GET /moderation/audit must be protected" + ); + assert!( + !is_exempt("/moderation/restricted"), + "GET /moderation/restricted must be protected" + ); + assert!( + !is_exempt("/api/invites"), + "POST /api/invites (mint) must be protected" + ); + assert!(!is_exempt("/upload"), "PUT /upload must be protected"); + assert!( + !is_exempt("/media/upload"), + "PUT /media/upload must be protected" + ); + assert!( + !is_exempt("/media/deadbeef.bin"), + "GET /media/{{sha}} must be protected" + ); + } + + // Regression: a newly added unclassified path must NOT be exempt by default. + // If a developer adds a route and forgets to add it to NIP_FI_EXEMPT_PREFIXES, + // `is_exempt` returns false → the guard denies in Enforce mode. + // This test proves that the default is DENY, not ADMIT. + #[test] + fn unclassified_path_is_not_exempt_by_default() { + // A path that looks plausibly authenticated but was just added: + assert!( + !is_exempt("/api/new-feature/data"), + "newly added unclassified path must default to NOT exempt; \ + if this fails, NIP_FI_EXEMPT_PREFIXES has an overly broad entry" + ); + assert!( + !is_exempt("/api/invites/new-endpoint"), + "a new invite sub-path must not be exempt just because /api/invites/ exists; \ + only /api/invites/claim and /api/invites/accept-policy are explicitly exempt" + ); + } + + // ── F6: admin SPA document paths are NOT broadly exempt ───────────────── + // + // Admin SPA document routes (`/reports`, `/reports/`, `/feedback`) are + // served by the SPA fallback on the admin host. They are NOT in + // `NIP_FI_EXEMPT_PREFIXES` — the broad exempt list would make `/reports` + // exempt on tenant hosts too, which is unintentional. Instead, the guard + // exempts them conditionally via a host-qualified `is_admin_spa_path` + + // `is_admin_host` check (see `nip_fi_assertion_guard`). + // + // This test proves two things: + // 1. `is_admin_spa_path` recognises the admin document routes. + // 2. These paths are NOT broadly exempt (no entry in NIP_FI_EXEMPT_PREFIXES) + // so tenant hosts remain protected. + // + // Mutation evidence: adding "/reports" to NIP_FI_EXEMPT_PREFIXES makes + // `is_exempt("/reports")` return true and the assertion below panics. + #[test] + fn admin_spa_paths_are_not_broadly_exempt_but_are_admin_spa_paths() { + // /reports and /feedback ARE admin SPA document paths. + assert!( + is_admin_spa_path("/reports"), + "/reports must be an admin SPA path (for host-qualified exemption)" + ); + assert!( + is_admin_spa_path("/reports/abc-123"), + "/reports/ must be an admin SPA path" + ); + assert!( + is_admin_spa_path("/feedback"), + "/feedback must be an admin SPA path" + ); + assert!( + is_admin_spa_path("/feedback/abc"), + "/feedback/ must be an admin SPA path" + ); + + // But they are NOT in NIP_FI_EXEMPT_PREFIXES (not broadly exempt). + // The guard exempts them only when the request is on the admin host. + assert!( + !is_exempt("/reports"), + "/reports must NOT be broadly exempt; exemption is host-qualified in the guard" + ); + assert!( + !is_exempt("/reports/abc-123"), + "/reports/ must NOT be broadly exempt" + ); + assert!( + !is_exempt("/feedback"), + "/feedback must NOT be broadly exempt" + ); + assert!( + !is_exempt("/feedback/abc"), + "/feedback/ must NOT be broadly exempt" + ); + } + + // ── F6: build_router guard navigation — host-qualified admin exemption ─── + // + // Proves that the `is_admin_spa_path(path) && is_admin_host(...)` check in + // `nip_fi_assertion_guard` (router.rs:215-217) does exactly what it says: + // + // • `/reports` on the admin host → HTML (guard exempts it, SPA fallback serves it) + // • `/reports` on a tenant host → 503 (guard NOT exempted; DenyProtected denies it) + // + // Falsifying mutation: remove the `is_admin_spa_path(path) && ...` branch at + // router.rs:215-217. The admin-host request then reaches the DenyProtected + // branch and returns 503 — the assertion below panics instead of returning + // HTML. The path-classification tests (`admin_spa_paths_are_not_broadly_exempt_*`) + // would still pass because they only test the helper functions, not the guard. + // + // DenyProtected is used here because it denies unconditionally without + // needing a verifier, making the test self-contained and infrastructure-free. + #[tokio::test] + async fn build_router_admin_spa_path_exempt_on_admin_host_denied_on_tenant_host() { + use buzz_auth::NipFiMode; + + let admin_dir = tempfile::tempdir().expect("admin bundle dir"); + let web_dir = tempfile::tempdir().expect("public bundle dir"); + write_bundle(admin_dir.path()); + write_bundle(web_dir.path()); + + // Build a DenyProtected-mode state using the same SPA helper, but with + // the NIP-FI mode overridden after config construction. + let mut config = crate::config::Config::from_env().expect("default config loads"); + config.require_relay_membership = false; + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.web_dir = Some(web_dir.path().to_path_buf()); + config.admin = Some(crate::config::AdminConfig { + host: "admin.example".to_string(), + auth: crate::config::AdminAuth::Disabled, + web_dir: Some(admin_dir.path().to_path_buf()), + }); + config.nip_fi.mode = NipFiMode::DenyProtected; + + let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + let state = Arc::new(state); + + // Admin host: /reports must be exempted (guard lets it through → SPA serves HTML). + let admin_response = spa_response(state.clone(), "admin.example", "/reports").await; + assert_ne!( + admin_response.status(), + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "/reports on the admin host must NOT be denied 503 in DenyProtected mode; \ + the host-qualified admin SPA exemption in nip_fi_assertion_guard must fire. \ + Falsifying mutation: remove `is_admin_spa_path(path) && is_admin_host(...)` at router.rs:215-217" + ); + // The SPA fallback serves the index document. + assert_eq!( + admin_response.status(), + axum::http::StatusCode::OK, + "/reports on the admin host must be served as an SPA document" + ); + + // Tenant host: /reports is NOT exempt (guard denies it with 503 DenyProtected). + let tenant_response = spa_response(state.clone(), "tenant.example", "/reports").await; + assert_eq!( + tenant_response.status(), + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "/reports on a tenant host must be denied 503 in DenyProtected mode; \ + the guard exemption must NOT fire for non-admin hosts. \ + Falsifying mutation: remove the is_admin_host check — tenant host would \ + then match is_admin_spa_path and bypass the guard" + ); + } + + // ── F6 (extended): complete admin SPA matrix ───────────────────────────── + // + // Proves that the `is_admin_spa_path(path) && is_admin_host(...)` exemption + // covers all admin document routes (`/reports`, `/reports/`, `/feedback`) + // in both Enforce and DenyProtected modes, and that none of these are + // accidentally exempted on tenant hosts. + // + // The existing `build_router_admin_spa_path_exempt_on_admin_host_denied_on_tenant_host` + // test covers `/reports` in DenyProtected. This test fills the matrix. + // + // Falsifying mutation (coverage of all paths): replacing `is_admin_spa_path` + // with a hardcoded `/reports`-only check would cause the `/reports/` and + // `/feedback` rows to return 503 on the admin host → assertions fire. + #[tokio::test] + async fn build_router_admin_spa_full_matrix() { + use buzz_auth::NipFiMode; + + // Helper: build a state with a given mode. + // Returns (state, _admin_dir, _web_dir) — callers must keep the TempDirs + // alive for the lifetime of the test; they are dropped at end of scope. + async fn state_with_mode( + mode: NipFiMode, + admin_dir: &std::path::Path, + web_dir: &std::path::Path, + ) -> Arc { + write_admin_bundle(admin_dir); + write_bundle(web_dir); + + let mut config = crate::config::Config::from_env().expect("default config loads"); + config.require_relay_membership = false; + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.web_dir = Some(web_dir.to_path_buf()); + config.admin = Some(crate::config::AdminConfig { + host: "admin.matrix.example".to_string(), + auth: crate::config::AdminAuth::Disabled, + web_dir: Some(admin_dir.to_path_buf()), + }); + config.nip_fi.mode = mode; + + let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = + buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Arc::new(state) + } + + // Admin SPA paths to test. + let admin_paths = ["/reports", "/reports/abc-123-id", "/feedback"]; + + // ── DenyProtected matrix ────────────────────────────────────────────── + // + // Admin host + DenyProtected: guard exempts admin SPA paths → 200 HTML. + // Tenant host + DenyProtected: guard NOT exempted → 503 + exact body. + let deny_admin_dir = tempfile::tempdir().expect("deny admin bundle dir"); + let deny_web_dir = tempfile::tempdir().expect("deny public bundle dir"); + let deny_state = state_with_mode( + NipFiMode::DenyProtected, + deny_admin_dir.path(), + deny_web_dir.path(), + ) + .await; + for path in &admin_paths { + let admin_resp = spa_response(deny_state.clone(), "admin.matrix.example", path).await; + let admin_status = admin_resp.status(); + let admin_ct = admin_resp + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned(); + let admin_body = axum::body::to_bytes(admin_resp.into_body(), 8192) + .await + .unwrap_or_default(); + assert_eq!( + admin_status, + axum::http::StatusCode::OK, + "DenyProtected: {path} on admin host must be 200 (SPA exemption). \ + Falsifying mutation: remove is_admin_spa_path || restrict to /reports only → 503" + ); + assert_eq!( + admin_body.as_ref(), + b"", + "DenyProtected: {path} on admin host 200 must serve the exact admin HTML body; \ + distinct content distinguishes admin bundle from public bundle." + ); + assert_eq!( + admin_ct, "text/html; charset=utf-8", + "DenyProtected: {path} on admin host 200 Content-Type must be \ + 'text/html; charset=utf-8'; got '{admin_ct}'" + ); + + let tenant_resp = spa_response(deny_state.clone(), "tenant.matrix.example", path).await; + let tenant_status = tenant_resp.status(); + let tenant_resp_headers = tenant_resp.headers().clone(); + let tenant_body = axum::body::to_bytes(tenant_resp.into_body(), 8192) + .await + .unwrap_or_default(); + assert_eq!( + tenant_status, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "DenyProtected: {path} on tenant host must be 503. \ + Exemption must not apply to non-admin hosts." + ); + assert_eq!( + tenant_body.as_ref(), + b"authorization unavailable\n", + "DenyProtected: {path} on tenant host 503 must have exact body \ + 'authorization unavailable\\n' (AuthorizationUnavailable contract)." + ); + let tenant_ct = tenant_resp_headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + tenant_ct, "text/plain; charset=utf-8", + "DenyProtected: {path} on tenant host 503 Content-Type must be \ + 'text/plain; charset=utf-8'; got '{tenant_ct}'" + ); + assert!( + tenant_resp_headers.get("www-authenticate").is_none(), + "DenyProtected: {path} on tenant host 503 MUST NOT carry WWW-Authenticate \ + (DenyProtected unconditionally denies; challenge absent)" + ); + } + + // ── Enforce matrix ──────────────────────────────────────────────────── + // + // Admin host + Enforce + no verifier (None) → NIP-FI admission guard + // fires for non-exempt paths. Admin SPA paths are host-qualified exempt + // → 200 HTML. Tenant host → 401 (missing assertion, no verifier configured) + // with exact body "authentication required\n" and WWW-Authenticate: Nostr. + // + // Important: asserting only `!= 200` for the tenant case is insufficient — + // the fallthrough public 404 path also returns non-200. Exact 401 + header + // + body distinguishes the NIP-FI guard from a public 404 fallback. + let enforce_admin_dir = tempfile::tempdir().expect("enforce admin bundle dir"); + let enforce_web_dir = tempfile::tempdir().expect("enforce public bundle dir"); + let enforce_state = state_with_mode( + NipFiMode::Enforce, + enforce_admin_dir.path(), + enforce_web_dir.path(), + ) + .await; + for path in &admin_paths { + let admin_resp = + spa_response(enforce_state.clone(), "admin.matrix.example", path).await; + let admin_status = admin_resp.status(); + let admin_ct = admin_resp + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned(); + let admin_body = axum::body::to_bytes(admin_resp.into_body(), 8192) + .await + .unwrap_or_default(); + assert_eq!( + admin_status, + axum::http::StatusCode::OK, + "Enforce: {path} on admin host must be 200 (SPA exemption active in Enforce too). \ + Falsifying mutation: remove is_admin_spa_path exemption from Enforce branch → non-200" + ); + assert_eq!( + admin_body.as_ref(), + b"", + "Enforce: {path} on admin host 200 must serve the exact admin HTML body." + ); + assert_eq!( + admin_ct, "text/html; charset=utf-8", + "Enforce: {path} on admin host 200 Content-Type must be \ + 'text/html; charset=utf-8'; got '{admin_ct}'" + ); + + let tenant_resp = + spa_response(enforce_state.clone(), "tenant.matrix.example", path).await; + let tenant_status = tenant_resp.status(); + let tenant_headers = tenant_resp.headers().clone(); + let tenant_body = axum::body::to_bytes(tenant_resp.into_body(), 8192) + .await + .unwrap_or_default(); + // Must be 401, not just != 200. A 404 fallback would also satisfy != 200 + // but would indicate the NIP-FI guard was bypassed. + assert_eq!( + tenant_status, + axum::http::StatusCode::UNAUTHORIZED, + "Enforce: {path} on tenant host must be 401 MissingEvidence (not 200, not 404). \ + The NIP-FI guard must fire and produce an exact denial, not fall through to \ + the public 404 route. \ + Falsifying mutation: remove the guard call → 404 → assertion fires." + ); + assert_eq!( + tenant_body.as_ref(), + b"authentication required\n", + "Enforce: {path} on tenant host 401 must have exact body \ + 'authentication required\\n' (MissingEvidence contract)." + ); + let www_auth = tenant_headers + .get("WWW-Authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, + "Nostr", + "Enforce: {path} on tenant host 401 must have WWW-Authenticate: Nostr header. \ + Falsifying mutation: remove challenge from MissingEvidence denial → assertion fires." + ); + let tenant_ct = tenant_headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + tenant_ct, "text/plain; charset=utf-8", + "Enforce: {path} on tenant host 401 Content-Type must be \ + 'text/plain; charset=utf-8'; got '{tenant_ct}'" + ); + } + } + + // ── T1-IMP1: adversarial guard — junk/non-Bearer assertion is denied ────── + // + // Before this fix the guard called `headers.contains_key(CLIENT_ATTACHED_HEADER)`, + // so `Nostr-Federated-Identity: junk` would pass because the header is + // present. After the fix the guard performs full offline assertion + // verification (transport extraction + JWT signature + issuer + expiry): + // + // • Junk / non-Bearer value → transport extraction fails → 403 + // • Empty Bearer token → transport extraction fails → 403 + // • Structurally valid token → transport extraction passes → crypto verify → 403 if bad sig + // + // This test proves the transport-extraction cases. The crypto-verification + // case (structurally valid but bad signature) is proven by the production- + // router test `nip_fi_guard_rejects_crypto_invalid_assertion_before_handler_fires` + // in bridge.rs, which has a falsifying mutation: removing + // `verifier.verify_assertion(token)` from the guard turns the expected + // 403 into 401 (handler's NIP-98 auth fires instead). + #[test] + fn guard_rejects_junk_assertion_not_just_absent_header() { + use crate::nip_fi_http::extract_bearer_token; + use axum::http::HeaderMap; + use buzz_auth::CLIENT_ATTACHED_HEADER; + + // Case 1: bare junk value (not Bearer-prefixed). + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + "junk".parse().expect("valid header value"), + ); + assert!( + extract_bearer_token(&headers).is_err(), + "guard calls extract_bearer_token: bare 'junk' must be rejected (EvidenceRejected)" + ); + + // Case 2: valid-looking Bearer prefix but empty token. + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + "Bearer ".parse().expect("valid header value"), + ); + assert!( + extract_bearer_token(&headers).is_err(), + "guard calls extract_bearer_token: 'Bearer ' with empty token must be rejected" + ); + + // Case 3: structurally valid compact JWS (three Base64url-separated dots) + // passes transport extraction. The guard then calls + // `verifier.verify_assertion()` which would reject it as + // InvalidSignatureOrClaims → EvidenceRejected (403) in the full guard. + // This test only exercises transport extraction; the full-guard crypto + // falsifier is in bridge.rs::nip_fi_guard_rejects_crypto_invalid_assertion_before_handler_fires. + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + "Bearer a.b.c".parse().expect("valid header value"), + ); + assert!( + extract_bearer_token(&headers).is_ok(), + "structurally-valid compact JWS passes transport extraction; \ + guard then proceeds to crypto verification" + ); + } + + // ── T1-IMP2 exemption classification ───────────────────────────────────── + // + // `/internal/git/policy` must be exempt so the pre-receive hook callback + // reaches its own `require_localhost` + HMAC authorization layer in active + // NIP-FI mode. Only the exact path and sub-paths are exempt — the broader + // `/internal/` subtree is NOT exempted (no catch-all entry exists). + // + // Matching semantics: a non-`/`-ending pattern matches exact OR sub-paths + // (path equals pattern, or path starts with `pattern/`). This is safe + // because no routes exist under `/internal/git/policy/*` — any sub-path + // passes through the guard to Axum, which returns 404. + // + // Falsifying mutation: remove the "/internal/git/policy" entry from + // NIP_FI_EXEMPT_PREFIXES. `is_exempt("/internal/git/policy")` returns + // false, and the guard would return 401 in Enforce mode (every git push + // would be rejected by the hook callback failing). + #[test] + fn internal_git_policy_is_exempt_but_internal_subtree_is_not() { + assert!( + is_exempt("/internal/git/policy"), + "/internal/git/policy must be exempt: pre-receive hook calls it without \ + a NIP-FI assertion; blocking it breaks git push in Enforce/DenyProtected mode" + ); + // No catch-all /internal/ entry exists — only the specific path is + // listed, so unrelated /internal/* paths are not exempt. + assert!( + !is_exempt("/internal/"), + "the /internal/ subtree must NOT be broadly exempt; \ + only the specific hook-callback path is exempted" + ); + assert!( + !is_exempt("/internal/other"), + "/internal/other must NOT be exempt (no /internal/ subtree entry)" + ); + } } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index cbfe7b7b30..7237640200 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -778,6 +778,27 @@ pub struct AppState { /// byte-identically to a relay without the mesh. Access via /// [`AppState::mesh`]. pub mesh: Arc>, + + /// NIP-FI federated-identity assertion verifier, shared across all HTTP + /// ingress checks. + /// + /// `None` when `config.nip_fi.mode` is `Off`. When present, the verifier + /// is the single offline authority for assertion validation on every + /// protected HTTP surface. The backing `ProductionJwksSource` is also + /// shared and performs bounded periodic JWKS refresh internally. + /// + /// The field uses `dyn VerifyAssertion` (type erasure) so that + /// integration tests can inject a `StaticIssuerKeySource`-backed verifier + /// without requiring a live JWKS fetch. Production code always stores a + /// `FederatedAssertionVerifier>` here; the type + /// erased form costs one vtable dispatch per request, which is negligible + /// relative to the JWT crypto. + pub nip_fi_verifier: Option>, + + /// The shared JWKS source backing `nip_fi_verifier`, exposed so `main.rs` + /// can warm it at startup and drive the background refresh loop. + /// `None` iff `nip_fi_verifier` is `None`. + pub nip_fi_jwks_source: Option>, } impl AppState { @@ -866,6 +887,8 @@ impl AppState { let gif_http_client = crate::api::gifs::build_gif_http_client(); let admission_rate_limiter = Arc::new(RedisRateLimiter::new(redis_pool.clone())); let audit_enabled = audit_arc.is_some(); + // Build NIP-FI components before moving config into the state Arc. + let (nip_fi_verifier, nip_fi_jwks_source) = build_nip_fi_components(&config); let state = Self { config: Arc::new(config), db, @@ -955,6 +978,8 @@ impl AppState { // `crates/buzz-test-client` once those land). tracer: Arc::new(crate::conformance::NoopTracer), mesh: Arc::new(std::sync::OnceLock::new()), + nip_fi_verifier, + nip_fi_jwks_source, }; ( state, @@ -1369,6 +1394,51 @@ impl AuditShutdownHandle { } } +/// Construct the NIP-FI assertion verifier + JWKS source from `config.nip_fi`. +/// +/// Returns `(None, None)` when the mode is `Off` or `DenyProtected` (the +/// verifier is never consulted there; admission always returns 503). In +/// `Enforce` mode, constructs a `ProductionJwksSource` (shared via `Arc`) +/// and a `FederatedAssertionVerifier` over a clone of that `Arc`. +/// The source starts empty; HTTP admission returns `authorization_unavailable` +/// (503) until the startup warm in `main.rs` succeeds. [FI-TRACE-DEPENDENCY-FAIL-CLOSED] +type NipFiComponents = ( + Option>, + Option>, +); + +fn build_nip_fi_components(config: &crate::config::Config) -> NipFiComponents { + use buzz_auth::{FederatedAssertionVerifier, HttpJwksFetcher, NipFiMode, ProductionJwksSource}; + + if matches!( + config.nip_fi.mode, + NipFiMode::Off | NipFiMode::DenyProtected + ) { + // Off: no enforcement. DenyProtected: verifier never consulted (always 503). + return (None, None); + } + + let source = + match ProductionJwksSource::new(config.nip_fi.jwks_configs.clone(), HttpJwksFetcher::new()) + { + Some(s) => Arc::new(s), + None => { + tracing::error!( + "nip-fi: ProductionJwksSource construction returned None despite \ + passing startup validation — HTTP enforcement unavailable" + ); + return (None, None); + } + }; + + let verifier: Arc = Arc::new(FederatedAssertionVerifier::new( + config.nip_fi.registry.clone(), + Arc::clone(&source), + )); + + (Some(verifier), Some(source)) +} + /// Log a single audit entry with metrics. Extracted so the normal loop /// and the post-cancel drain share the same logic. async fn log_audit_entry(audit: &buzz_audit::AuditService, entry: buzz_audit::NewAuditEntry) { diff --git a/deploy/charts/buzz-push-gateway/templates/deployment.yaml b/deploy/charts/buzz-push-gateway/templates/deployment.yaml index 5eb8293967..4a3e6d3b6a 100644 --- a/deploy/charts/buzz-push-gateway/templates/deployment.yaml +++ b/deploy/charts/buzz-push-gateway/templates/deployment.yaml @@ -32,7 +32,6 @@ spec: env: - { name: BUZZ_PUSH_BIND_ADDR, value: "0.0.0.0:8080" } - { name: BUZZ_PUSH_HEALTH_ADDR, value: "0.0.0.0:8081" } - - { name: BUZZ_PUSH_GATEWAY_ORIGIN, value: {{ required "gatewayOrigin is required" .Values.gatewayOrigin | quote }} } {{/* Schema bounds this integer; normalize YAML numbers before quote can emit scientific notation. */}} - { name: BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS, value: {{ .Values.maxGrantLifetimeSeconds | int64 | quote }} } - { name: BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH, value: /run/buzz/app-attest/root.pem } diff --git a/deploy/charts/buzz-push-gateway/tests/grant-lifetime.sh b/deploy/charts/buzz-push-gateway/tests/grant-lifetime.sh index 301a382f27..7a5dea7797 100755 --- a/deploy/charts/buzz-push-gateway/tests/grant-lifetime.sh +++ b/deploy/charts/buzz-push-gateway/tests/grant-lifetime.sh @@ -8,7 +8,7 @@ trap 'rm -f "$fixture"' EXIT env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -rjson -ryaml -ropen3 \ - "$fixture" <<'RUBY' chart = 'deploy/charts/buzz-push-gateway' -command = ['helm', 'template', 'push', chart, '--set', 'gatewayOrigin=https://push.example'] +command = ['helm', 'template', 'push', chart] cases = [] render = lambda do |label, expected, args, values| output, error, status = Open3.capture3(*command, *args, stdin_data: values) diff --git a/deploy/charts/buzz-push-gateway/tests/render.sh b/deploy/charts/buzz-push-gateway/tests/render.sh index ac41c5714c..efba0c0943 100755 --- a/deploy/charts/buzz-push-gateway/tests/render.sh +++ b/deploy/charts/buzz-push-gateway/tests/render.sh @@ -5,15 +5,14 @@ out=$(mktemp); production_out=$(mktemp); route_out=$(mktemp); datadog_out=$(mkte trap 'rm -f "$out" "$production_out" "$route_out" "$datadog_out" "${monitoring_out:-}"' EXIT gateway_origin_arg=(--set 'gatewayOrigin=https://push.example') -# Generic values require the deployment-owned gateway origin. -helm lint deploy/charts/buzz-push-gateway "${gateway_origin_arg[@]}" >/dev/null -helm template push deploy/charts/buzz-push-gateway "${gateway_origin_arg[@]}" >"$out" +# Generic values work without an application origin. +helm lint deploy/charts/buzz-push-gateway >/dev/null +helm template push deploy/charts/buzz-push-gateway >"$out" # Production values support a platform-owned ingress without rendering an # HTTPRoute. The environment-owned inputs remain mandatory. production_args=( -f deploy/charts/buzz-push-gateway/values-production.yaml --set 'image.digest=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' - --set 'gatewayOrigin=https://push.example' --set 'profiles.dogfood.appAttestAppId=REALTEAM.xyz.block.buzz.dogfood.mobile' --set 'networkPolicy.postgresEgressCidrs[0]=10.42.0.0/16' ) @@ -79,12 +78,9 @@ required = Set.new(%w[ DATABASE_URL BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH BUZZ_PUSH_DOGFOOD_APNS_TOPIC BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID BUZZ_PUSH_GRANT_KEYS BUZZ_PUSH_TOKEN_KEYS BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS - BUZZ_PUSH_GATEWAY_ORIGIN ]) assert!(required.subset?(env_names)) -gateway_origin = d.dig("spec", "template", "spec", "containers", 0, "env") - .find { |entry| entry["name"] == "BUZZ_PUSH_GATEWAY_ORIGIN" } -assert!(gateway_origin["value"] == "https://push.example") +assert!(!env_names.include?("BUZZ_PUSH_GATEWAY_ORIGIN")) assert!(!env_names.any? { |name| name.include?("APP_STORE") }) apns_volume = d.dig("spec", "template", "spec", "volumes").find { |volume| volume["name"] == "apns-dogfood" } assert!(apns_volume.dig("secret", "defaultMode") == 0o400, apns_volume.inspect) @@ -123,9 +119,10 @@ assert!(!route.dig("spec", "parentRefs").empty?) assert!(route.dig("spec", "hostnames") == ["push.example"]) RUBY -# A gateway origin is a required deployment input, even when HTTPRoute is off. -if helm template push deploy/charts/buzz-push-gateway >/dev/null 2>&1; then - echo 'expected missing gatewayOrigin to fail' >&2 +# An origin is needed only to produce the optional routing hostname. +if helm template push deploy/charts/buzz-push-gateway \ + --set httpRoute.enabled=true --set 'httpRoute.parentRefs[0].name=gateway' >/dev/null 2>&1; then + echo 'expected enabled HTTPRoute without gatewayOrigin to fail' >&2 exit 1 fi for invalid_origin in 'http://push.example' 'https://push.example:8443' 'https://push.example/base'; do diff --git a/deploy/charts/buzz-push-gateway/values-production.yaml b/deploy/charts/buzz-push-gateway/values-production.yaml index 3882e935e6..513a64adf0 100644 --- a/deploy/charts/buzz-push-gateway/values-production.yaml +++ b/deploy/charts/buzz-push-gateway/values-production.yaml @@ -3,6 +3,7 @@ image: tag: "" digest: "" +# Optional routing input, required only when httpRoute.enabled=true. gatewayOrigin: "" profiles: dogfood: diff --git a/deploy/charts/buzz-push-gateway/values.schema.json b/deploy/charts/buzz-push-gateway/values.schema.json index 5f83965368..b2e785e33f 100644 --- a/deploy/charts/buzz-push-gateway/values.schema.json +++ b/deploy/charts/buzz-push-gateway/values.schema.json @@ -13,8 +13,8 @@ }, "gatewayOrigin": { "type": "string", - "format": "uri", - "pattern": "^https://[^/?#@:]+/?$" + "description": "Routing origin for the optional HTTPRoute; not passed to the gateway binary.", + "pattern": "^(https://[^/?#@:]+/?)?$" }, "maxGrantLifetimeSeconds": { "type": "integer", @@ -306,7 +306,6 @@ "required": [ "replicaCount", "existingSecret", - "gatewayOrigin", "maxGrantLifetimeSeconds", "profiles", "httpRoute", diff --git a/deploy/charts/buzz-push-gateway/values.yaml b/deploy/charts/buzz-push-gateway/values.yaml index 904cfbe788..237bc874ee 100644 --- a/deploy/charts/buzz-push-gateway/values.yaml +++ b/deploy/charts/buzz-push-gateway/values.yaml @@ -18,8 +18,7 @@ migration: resources: requests: {cpu: 50m, memory: 64Mi} limits: {cpu: 250m, memory: 128Mi} -# Exact externally reachable HTTPS origin. The gateway derives transport routes -# from this value; NIP-PL v1 App Attest audiences remain registered constants. +# Routing origin used only when httpRoute.enabled=true. Not passed to the binary. gatewayOrigin: "" maxGrantLifetimeSeconds: 2592000 profiles: diff --git a/desktop/tests/e2e/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts index d5680b6c90..2d8e5f8769 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -166,25 +166,49 @@ test("opening edit during an immediate photo upload preserves the draft", async page, }) => { await page.goto("/"); - await page.evaluate(() => { - const e2e = ( - window as Window & { - __BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } }; - } - ).__BUZZ_E2E__; - if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000; - }); await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + // Hold the upload at the IPC boundary until the edit assertion completes. + // A fixed delay races menu interaction and CI scheduling, so it can test + // an already-completed upload instead of the in-flight draft guard. + await page.evaluate(() => { + const w = window as Window & { + __RELEASE_PHOTO_UPLOAD__?: () => void; + __TAURI_INTERNALS__: { + invoke: ( + command: string, + payload: unknown, + options: unknown, + ) => Promise; + }; + }; + const original = w.__TAURI_INTERNALS__.invoke.bind(w.__TAURI_INTERNALS__); + const gate = new Promise((resolve) => { + w.__RELEASE_PHOTO_UPLOAD__ = resolve; + }); + w.__TAURI_INTERNALS__.invoke = async (command, payload, options) => { + if (command === "upload_media_bytes_raw") await gate; + return original(command, payload, options); + }; + }); await choosePhoto(page); await expect(page.getByTestId("upload-progress")).toBeVisible(); await openMoreActionsMenu(page, "mock-general-welcome"); await page.getByTestId("edit-message-mock-general-welcome").click(); + // Edit is dispatched by onCloseAutoFocus after Radix unmounts the menu, + // not by the click itself. Keep the upload held through that handoff. + await expect(page.getByRole("menu")).toHaveCount(0); // Edit entry is rejected while the compacted draft cannot represent the // reserved upload slot. The upload remains current and lands in the draft. await expect(page.getByTestId("edit-target")).toHaveCount(0); await expect(page.getByTestId("upload-progress")).toBeVisible(); + await page.evaluate(() => { + ( + window as Window & { __RELEASE_PHOTO_UPLOAD__: () => void } + ).__RELEASE_PHOTO_UPLOAD__(); + }); await expect(page.getByTestId("upload-progress")).toHaveCount(0, { timeout: 5_000, }); diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index d1174132c7..db7354873f 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -601,15 +601,23 @@ test("the mention button opens settings and can undo an address", async ({ await expect( composer.getByRole("button", { name: "Mention someone" }), ).toBeVisible(); - await input.fill(""); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + // Clear through editor transactions before selecting a manual mention. + await input.press("ControlOrMeta+A"); + await input.press("Backspace"); + await expect(input).toHaveText(""); await menu .getByRole("button", { name: "Mention Morgarita", exact: true }) .click(); await expect(input).toHaveText("@Morgarita "); + // Explicitly opting out keeps subsequent mentions manual. The old avatar's + // exit animation must not be mistaken for a newly pinned recipient. await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); + ).toHaveCount(0); await input.type("later"); await input.press("Enter"); diff --git a/docs/data-backfills.md b/docs/data-backfills.md new file mode 100644 index 0000000000..83e3931967 --- /dev/null +++ b/docs/data-backfills.md @@ -0,0 +1,511 @@ +# Durable data backfills: a specification + +`draft` + +## Overview + +This section is non-normative. It gives the mental model for the requirements +that follow. + +A Buzz data backfill repairs existing PostgreSQL rows after a compatible schema +is available. It may start automatically with the relay or manually while the +relay serves traffic. If a worker stops, another worker resumes from committed +progress. + +Think of each backfill as one durable PostgreSQL row keyed by a stable ID: + +1. The application registers the stable ID. +2. Starting the backfill captures a finite upper bound. +3. A worker takes an exclusive claim. +4. Each transaction changes target rows and advances the checkpoint together. +5. Workers repeat bounded batches until they reach the upper bound. +6. Validation proves the result before the row becomes `completed`. + +PostgreSQL is the authority throughout this flow. Queues and timers may wake a +worker, but they do not own work or progress. The admin API and client display +the PostgreSQL state; they do not keep another copy of the lifecycle. + +The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, +and **MAY** are to be interpreted as described in BCP 14 when they appear in +all capitals. + +## Purpose and scope + +This specification covers: + +- durable registration and execution of bounded data backfills; +- exclusive claims, crash recovery, retry, pause, and validation; +- atomic target mutations and checkpoint progress; +- independent schema-migration and automatic-backfill controls; +- readiness behavior in automatic and manual modes; +- an authorized, auditable admin API and client; and +- conformance tests at production transaction, startup, and admin seams. + +It does not define: + +- schema migration mechanics or a generic migration framework; +- a Nostr event kind or other wire protocol; +- a distributed queue as durable state; +- exactly-once worker code outside a PostgreSQL transaction; +- one universal batch size, timeout, or retry schedule; +- a way for a backfill to make an incompatible application schema safe; or +- destructive controls that erase, rewind, or force completion. + +Backfill definitions are application code, not runtime-authored migrations. + +## Core model + +### Terms + +| Term | Meaning | +|---|---| +| **Definition** | Application code that declares an ID, ordering key, batch bound, mutation, and validation. | +| **Stable ID** | The only identity for execution, administration, validation, completion, and readiness. | +| **Upper bound** | The inclusive, definition-typed end of the finite source set. It is captured once. | +| **Checkpoint** | The greatest source position whose required mutation is durably committed or was deterministically unnecessary. | +| **Claim** | Temporary, exclusive PostgreSQL authority for one worker to advance a backfill. | +| **Generation** | A monotonically increasing value that fences work from a stale claim. | +| **Validation** | Definition-specific proof that work through the upper bound has the required postcondition. | +| **Required automatic backfill** | A stable ID that the deployed application includes in automatic startup readiness. | + +### PostgreSQL authority + +PostgreSQL MUST be the sole authority for lifecycle, ownership, progress, +retry eligibility, diagnostics, validation, and completion. A queue, timer, +cache, notification, or in-process registry MAY wake a worker. Loss, delay, +duplication, or replay of that signal MUST NOT affect correctness. A worker +MUST re-read PostgreSQL before acting. + +The row for a stable ID MUST remain compact. It records the lifecycle, immutable +bound, monotonic checkpoint, claim and generation, bounded retry and diagnostic +state, validation, timestamps, and audit correlation. Bounded definition +metadata MAY be stored for diagnostics. Detailed history belongs in the audit +facility, not an unbounded per-batch journal. Telemetry is a projection, not +another state store. + +### Stable identity and registration + +Each definition MUST declare a stable ID. A stable ID MUST map to exactly one +durable row and at most one readiness obligation across builds and deployments. +Validated completion MUST be permanent for that ID. + +Registration and reconciliation MUST be idempotent upserts by stable ID. +Repeated registration MUST NOT create another execution. Registration signals +may arrive late, more than once, or out of order; the durable state MUST still +converge on one row and one readiness obligation. For registration-owned +diagnostic metadata, the last registration wins. + +Registration order, revision strings, build metadata, and version metadata +MUST NOT select a runnable definition or establish execution precedence. They +MUST NOT create another row, checkpoint, completion, or readiness obligation. + +Every process allowed to work on an active ID MUST implement the same behavior: +the same source set, ordering and checkpoint meaning, mutation, and validation +contract. A material change to any of those semantics MUST use a new stable ID. +Registering different behavior under an active ID is operator error; the +orchestrator MUST NOT compare or choose between definitions. + +### Rolling deployments + +A process MUST reconcile its definitions by stable ID before claiming work. +Any process with a compatible local definition MAY claim eligible work, +regardless of registration order or diagnostic metadata. A process MUST fail +closed if it does not know the definition or cannot safely interpret the row's +bound, checkpoint, lifecycle, and completion contract. + +Deploying another compatible process does not revoke an existing claim. Its +owner MAY commit while the claim and generation remain valid. If the deployment +cannot accept that commit, its semantics are materially different and require a +new stable ID. Deployment order, binary age, and metadata MUST NOT act as fences. + +An exclusive claim authorizes one compatible worker. Generations reject stale +owners. These mechanisms MUST NOT establish definition precedence. + +Before starting a backfill, deploy and fully roll out the normal write path +that maintains the desired postcondition. Operators MUST ensure old writers +and in-flight old behavior no longer produce actionable rows, then start the +bounded historical repair. This ordering is an operator and developer +responsibility, not a condition the backfill framework verifies. If it is +violated, the existing checkpoint may have advanced past writes that were +never repaired, and no rerun under the same stable ID can revisit them. +Operators must run a new repair under a new stable ID, and may need to repeat +that process until the normal write path is safe. + +## Lifecycle and operator actions + +One state machine governs automatic and manual execution: + +| State | Meaning | Next states | +|---|---|---| +| `pending` | Registered but not started; no bound exists. | `running` | +| `running` | Started and eligible for a worker claim, subject to retry backoff. | `paused`, `blocked`, `failed`, `validating` | +| `paused` | Operator intent prevents claims and fences current work. | `running`, `validating` | +| `blocked` | A prerequisite or data condition needs operator remediation. | `running`, `validating` | +| `failed` | Execution exhausted bounded retries, or validation failed. | `running`, `validating` | +| `validating` | Mutation reached the bound and is eligible for validation. | `paused`, `completed`, `failed`, `blocked` | +| `completed` | Validation succeeded. This state is terminal. | none | + +The supported operator actions are: + +| Action | Contract | +|---|---| +| `start` | From `pending`, atomically capture the upper bound and enter `running` before dispatching work. | +| `pause` | From `running` or `validating`, atomically enter `paused`, advance the generation, and invalidate the claim. | +| `resume` | From `paused`, enter `running`, or `validating` when no admitted work remains. | +| `retry` | From `blocked` or `failed`, enter `running`, or `validating` when no admitted work remains, at the existing bound and checkpoint. | +| `validate` | For `pending`, return `current-state`. For any other row, use the read-only validation contract below. | + +Repeated `pause` and `resume` requests MUST converge on the requested state. +Neither action changes the bound or checkpoint. There is no separate retrying +or cancellation state. Retry eligibility and delay are attributes of `running`; +`pause` is the safe interruption command. + +An in-flight batch either commits before `pause` or fails the generation fence +and rolls back. Previously committed batches remain committed. + +### Retry, blockage, and failure + +The engine MUST durably classify an execution error before reporting it as +handled. Retryable errors remain `running` without a claim until bounded +backoff permits another claim. Automatic retries MUST be bounded and +end in a terminal `failed` or `blocked` disposition instead of a hot loop. + +An unmet prerequisite or data invariant enters `blocked` with a bounded, +actionable diagnostic. Exhausted execution errors enter `failed`. Validation +failure enters `failed`, unless it identifies an unmet prerequisite and enters +`blocked` instead. + +`retry` is fresh operator intent. It MAY clear bounded attempt accounting and +stale diagnostics. It MUST preserve history, the bound, and the checkpoint, and +MUST NOT bypass validation. + +### Validation and completion + +Reaching the upper bound MUST enter `validating`, not `completed`. Validation +MUST read authoritative PostgreSQL state, cover the postcondition through the +bound, and be safe to repeat after a crash. + +Only successful validation MAY transition the row to `completed`. The +completion transaction MUST verify the lifecycle and generation so a stale +validator cannot complete a paused or retried run. Operators cannot select +`completed`, and completed state MUST remain immutable. + +## Execution safety + +### Finite source set and checkpoint + +Before the first mutation, `start` MUST capture an upper bound in the same +transaction that initiates execution. The definition MUST state how the bound +represents an empty source set. Once captured, the bound MUST NOT be changed or +reinterpreted for that stable ID. + +The definition MUST use a checkpoint type with a total, stable ordering. The +checkpoint MUST be inclusive. Before the first source item commits, the +checkpoint MAY be absent; absence means a position before all admitted work, +not a worker-supplied text sentinel. + +The checkpoint MUST NOT move backward or beyond the upper bound. A worker MUST +select positions strictly after the checkpoint and no later than the bound. If +the ordering key is not unique, the definition MUST use a stable compound key +that cannot ambiguously skip or repeat tied rows. + +Rows created beyond the upper bound MUST be handled by normal write paths or a +new backfill ID. Application reads and writes MUST remain correct for both +repaired and unrepaired rows during execution. + +Rationale: the upper bound makes historical repair finite and separates it from +live writes. + +### Bounded batches + +Each definition MUST declare a finite maximum amount of work per batch. The +production selection and mutation path MUST enforce it. Any work that could +grow with the source set MUST have that bound or a stricter finite bound. + +Batch transactions and lock-holding intervals MUST have bounded duration. +Workers MUST observe cancellation, timeout, and pause intent at transaction and +batch boundaries. They MUST NOT start another batch after observing that intent, +and an interrupted transaction MUST roll back. + +If a work, transaction, lock, or interruption bound cannot be honored, the +engine MUST commit neither partial mutations nor a checkpoint. It MUST apply +the bounded failure policy instead of widening the batch or continuing without +a bound. + +### Atomic mutation and progress + +Every batch MUST commit its target mutations and checkpoint advance in one +PostgreSQL transaction. A mutation, checkpoint, ownership, commit, +cancellation, or timeout failure MUST roll back both. + +Rationale: this transaction keeps committed mutations from being hidden behind +an older checkpoint and keeps the checkpoint from claiming rolled-back work. +Definitions SHOULD still make mutations idempotent as defense in depth. + +Side effects that cannot join the transaction are outside the backfill commit. +They MUST NOT be required for the checkpoint to mean complete. + +### Exclusive claims and generation fencing + +A backfill MUST NOT have more than one current claim. Acquisition, renewal, +release, and takeover MUST be PostgreSQL state transitions. Each new claim or +takeover MUST advance the generation. An admin transition that invalidates +in-flight work MUST also advance it. + +Every transaction that changes target data or progress, or commits a validation +or lifecycle result that can advance execution, MUST verify before commit that: + +- the lifecycle permits the operation; and +- the presented generation equals the current generation. + +A claim-authorized worker execution commit MUST additionally verify that: + +- the presented owner holds the current claim; and +- the claim remains valid. + +Operator transitions and diagnostic validation do not require a claim. Operator +actions remain lifecycle- and generation-fenced, as do validation or completion +results that advance execution. + +Failure of any applicable check MUST reject the entire transaction. For a +claim-authorized worker, checking only before work starts is insufficient +because a paused, expired, or replaced owner may finish after takeover. + +Claims MUST have bounded validity and require renewal. Takeover MUST eventually +be possible after renewal stops. PostgreSQL time and state decide validity; +worker-local clocks do not grant authority. + +### Release, crash, and takeover + +A worker MAY release a claim while the row remains `running`. A crash leaves +either a committed batch or no batch effect. After the abandoned claim expires, +another worker advances the generation and resumes strictly after the committed +checkpoint. + +The old worker may keep computing, but its later transaction MUST fail the +generation check. Takeover MUST NOT rewind the checkpoint, recapture the bound, +or infer progress from worker memory. + +## Configuration and readiness + +Schema auto-migration and automatic backfills are independent controls. All +four configurations are valid: + +| Schema auto-migration | Automatic backfills | Required behavior | +|---|---|---| +| off | off | The relay MUST run no migrations, MUST NOT start `pending` backfills, and MUST NOT add a backfill readiness gate. Already-started manual work MUST remain recoverable. The installed schema and application MUST serve safely with incomplete data. | +| off | on | The relay MUST run no migrations. With a compatible installed schema, it MUST start required backfills and remain unready until they reach `completed`. Missing required schema is a schema-compatibility failure; a backfill MUST NOT replace migration. | +| on | off | The relay MUST run migrations, but MUST NOT start `pending` backfills or add a backfill readiness gate. Operators run them manually while the application serves mixed repaired and unrepaired data. Already-started work MUST remain recoverable. | +| on | on | The relay MUST run migrations, then required backfills, and remain unready until every required backfill reaches `completed`. | + +Automatic mode assumes the rollout prerequisite above is already satisfied. A +required automatic backfill MUST ship in a release after its prerequisite write +path is fully rolled out and old writer behavior is drained. A definition that +ships with its prerequisite write-path change MUST NOT auto-start in that +release; an operator MUST start it manually only after rollout completion. + +In automatic mode, every required `pending`, `running`, `paused`, `blocked`, +`failed`, or `validating` row MUST keep the serving gate closed. The relay MUST +register the complete set of required stable IDs before evaluating that gate. +A restart MUST reconstruct the gate from PostgreSQL; late discovery MUST NOT +create a ready interval. + +The authorized deployment-admin boundary MUST remain reachable while the +ordinary serving readiness gate is closed so operators can inspect and recover +incomplete backfills. + +In manual mode, incomplete backfills MUST NOT make the relay unready. This is +safe only when application reads and writes work correctly before, during, and +after the repair. A schema change that cannot serve safely until its backfill +finishes does not conform to manual mode. + +Turning automatic backfills off prevents automatic initiation of `pending` +rows. It does not abandon `running` or `validating` work. Workers MUST recover +or take over already-started work after failure without adding a readiness gate; +an operator MAY pause it. + +Backfill readiness is only one input to the serving decision. Database, cache, +deletion-fence, shutdown, and schema-safety checks remain independent. + +## Admin and client contract + +The relay admin API is the external boundary. The client and operator tools +MUST project its PostgreSQL-backed state and invoke the lifecycle actions above; +they MUST NOT keep another lifecycle or synthesize outcomes. + +### Read model and honest projection + +Authorized list and detail reads MUST expose: + +- stable IDs and bounded informational definition metadata; +- lifecycle, bound, checkpoint-derived progress, ownership, retry eligibility, + and timestamps; +- bounded execution and validation diagnostics; +- the latest explicit validation disposition, outcome, and time; and +- participation in the automatic readiness gate. + +Unknown totals, unavailable diagnostics, and failed or stale reads MUST remain +unknown. The UI MUST NOT render them as zero, complete, healthy, or offline. +The server MUST derive current status from PostgreSQL on each read. A client MAY +cache for presentation, but a failed refresh does not make cached data current. + +### Control idempotency + +The API and client MUST support exactly the lifecycle actions listed above. +They MUST be safe under retries, duplicate delivery, concurrent operators, and +a response lost after commit. Repeating one logical request MUST return its +original result or the converged state without duplicating a transition, +recapturing a bound, reusing a generation, or starting another owner. +Conflicting requests MUST return a typed conflict with current server state. + +In manual mode, `start` is the durable intent that separates recoverable work +from registered `pending` work that automatic configuration MUST leave alone. + +The normal API MUST NOT expose reset, checkpoint rewind, bound changes, record +deletion, force completion, unfenced claim release, or generic cancellation. +Incompatible work uses a new stable ID. Destructive recovery requires a +separate specification. + +### Explicit validation + +`validate` requests definition-owned, read-only validation against PostgreSQL. +It is not a second lifecycle or an operator-selected completion transition. + +The API MUST return exactly one of these typed outcomes: + +| Outcome | Meaning | +|---|---| +| `success` | Validation ran and the postcondition held. | +| `failure` | Validation ran and rejected the state, with a bounded diagnostic. | +| `current-state` | Validation did not run or has no result yet; the response includes the lifecycle and reason without implying success or failure. | + +A `pending` row MUST return `current-state` without running validation. Any +other row MAY be validated. When the row is `validating` and +completion preconditions hold, the request MAY drive the same generation-fenced +completion transition used by the lifecycle. In every other state, validation +is diagnostic and MUST NOT change the lifecycle, bound, checkpoint, generation, +or target data. + +Revalidating a completed row MUST report a current failure as `failure` while +leaving immutable completion and progress unchanged. + +Only one validation invocation per stable ID MAY run at a time. Duplicate +delivery of one logical request MUST return its original bounded outcome or a +correlated `current-state` result. Concurrent requests MUST join the active +invocation or return `current-state`. After a lost response, retrying the same +request MUST recover the outcome. The client MUST retry or refresh; it MUST NOT +infer the outcome. + +Diagnostic validation MUST persist only its bounded outcome, request +correlation, and audit record. It MUST NOT require or create an execution claim. +It MUST remain serialized, operator-authorized, read-only, and unable to change +immutable execution state. + +Accepted, rejected, coalesced, and completed validation requests MUST be audited +with request correlation, actor, stable ID, evaluated lifecycle, and bounded +outcome. Audit recording MUST NOT turn validation into a target-data write. + +### Authorization and audit + +Backfills are deployment-wide operational state. Reads and controls MUST +require an explicit deployment-operator capability at the relay admin boundary. +Community ownership, channel administration, and membership MUST NOT grant this +authority. A protected network boundary MAY supply equivalent authority, but +the deployment MUST NOT claim individual attribution it does not have. + +Every accepted or rejected control attempt MUST be audited with the actor or +authority source, stable ID, request correlation, prior and resulting state, +claim generation when relevant, time, and a bounded outcome. An accepted state +change and its audit record, or durable intent to append that record, MUST commit +atomically. An unaudited change is not success. + +Diagnostics in the API, audit, and logs MUST be bounded and redacted. They MUST +NOT expose raw source rows, credentials, unbounded database errors, or query +text. Detailed diagnostic reads SHOULD be observable without logging row data. + +### Observability + +Operators MUST be able to distinguish: + +- lifecycle counts and readiness-gating disposition; +- claim acquisition, renewal, loss, takeover, and stale-owner rejection; +- attempted and committed work, checkpoint movement, and lack of progress; +- retryable errors, blockage, exhausted failure, and validation outcomes; and +- execution and validation duration. + +Metric labels MUST use bounded vocabularies. Stable IDs, checkpoints, row +values, diagnostics, and actor IDs belong in authorized views and structured +logs, not labels. Missing telemetry is unknown. Metrics and logs MUST reconcile +with PostgreSQL state and MUST NOT authorize transitions. + +## Conformance tests + +Tests MUST exercise production transaction, startup, admin, and client seams. +They MUST be falsifiable: removing the relevant bound, atomic write, fence, +authorization check, or readiness input MUST fail a test. Test-only lifecycle +helpers do not prove conformance. + +At minimum, the suite covers: + +1. **Claim race.** Concurrent workers contend for one row. Only one worker owns + the current claim and commits under its generation; eventual takeover + preserves the checkpoint. +2. **Bounded batch.** A source set larger than one batch advances only within + the declared batch bound before releasing the transaction. Cancellation or + timeout rolls back target mutations and checkpoint. Removing production + bound enforcement fails the test. +3. **Rollback.** Failure after mutation but before commit, including checkpoint + failure, leaves target data and checkpoint unchanged. +4. **Restart.** Termination before and after batch commit, and during validation, + rebuilds progress from PostgreSQL without recapturing the bound. +5. **Stale owner.** Pause, expiry, or takeover while a worker is in flight + rejects its mutation, checkpoint, validation, and completion at commit. +6. **Rolling deployment.** Two compatible processes register the same stable ID + in either order and retain one row and readiness obligation. Either MAY win + an exclusive claim. After takeover, generation fencing rejects the prior + owner's writes. A process that does not know the stable ID cannot claim. The + test MUST NOT choose by registration order or metadata, or assert definition + precedence. +7. **Validation.** Reaching the bound cannot complete without validation. + Failure is durable and repeatable; only success reaches immutable completion. +8. **Bounded failure.** Persistent errors reach `failed` or `blocked` instead of + retrying forever. Operator retry preserves the bound and checkpoint. +9. **Admin authorization.** Unauthorized reads and controls return no protected + state and perform no mutation. Community roles grant no authority. +10. **Admin idempotency.** Duplicate, concurrent, and lost-response requests do + not duplicate starts, reuse generations, or recapture bounds. Conflicts + return current state. +11. **Explicit validation.** Start with a `completed` row and no execution claim. + Through the production API and client, run validation, persist and display + its current typed outcome, and write its audit record. It creates no claim + and does not change the generation, target data, lifecycle, bound, + checkpoint, or completion. The API and client also render all typed outcomes + for non-`pending` rows. Unauthorized, duplicate, concurrent, and + lost-response requests follow the contract above. Removing serialization, + read-only behavior, or an immutable-state guard fails the test. +12. **Client projection.** The UI preserves unknown and failure states and sends + only supported controls. +13. **Configuration matrix.** All four configurations run through real startup + and readiness paths. Automatic mode gates until validation, manual mode does + not add the gate, and schema safety remains independent. Automatic mode + also exercises authorized inspection and recovery from `paused`, `blocked`, + and `failed` through the production admin boundary while ordinary serving + remains gated. + +PostgreSQL concurrency tests SHOULD use controlled transaction barriers so +losing and stale commits are observed rather than inferred from timing. + +## Component mapping + +| Responsibility | Buzz component | +|---|---| +| Stable-ID registration, lifecycle, durable store, claims, fencing, checkpoints, retry, and validation | The new `buzz-backfill` crate owns the domain and persistence. | +| Database and transaction primitives for atomic mutation and checkpoint progress | `buzz-db` provides narrow primitives. It does not own backfill records, policy, or orchestration, and it does not expose its connection pool. | +| Discovery, automatic execution, configuration, startup ordering, and readiness | `buzz-relay` composes the engine with startup and serving coordination. | +| Authorized reads, lifecycle actions, validation, audit, conflicts, and diagnostics | Relay deployment-admin interfaces expose the contract; tools use them instead of accessing tables. | +| List, detail, progress, diagnostics, readiness, and controls | The desktop/admin client projects relay state and never stores another lifecycle. | + +This keeps the repository's focused-crate architecture: the relay orchestrates, +the backfill crate owns its domain, and the database crate provides general +transaction infrastructure. Backfill administration remains an operator admin +concern; it does not add a Nostr event or NIP. diff --git a/docs/nips/NIP-AR.md b/docs/nips/NIP-AR.md new file mode 100644 index 0000000000..6277fb4396 --- /dev/null +++ b/docs/nips/NIP-AR.md @@ -0,0 +1,109 @@ +NIP-AR +====== + +Channel Artifacts +----------------- + +`draft` `optional` `relay` + +Defines `kind:45010` for editable records called **artifacts**. Each artifact has one home channel and may be attached to a thread. Its home determines who can read it. Any number of artifacts, including of the same type, may share a channel or thread. + +The relay manages identity, access, and revisions. Clients define the content types, such as `buzz.task` or `buzz.project`. + +## Event format + +Each event contains a complete snapshot. For example, a task created directly in a channel (standard signing fields omitted): + +```json +{ + "kind": 45010, + "tags": [ + ["ar", "1"], + ["d", "04737c81-e5e8-4412-bb47-f446813cfeba"], + ["h", "9b353519-f4fe-4757-aef4-bec6cc0ae54c"], + ["type", "buzz.task"], + ["title", "Fix the payment timeout"], + ["op", "create"], + ["assignee", ""], + ["project", "17cfe3ca-b3d0-4a76-a2a4-4eaf0d3a4109"] + ], + "content": "{\"version\":1,\"status\":\"open\"}" +} +``` + +The `assignee` and `project` tags and the payload are illustrative, not required fields; this NIP does not define a task schema. + +| Tag | Meaning | +| --- | --- | +| `ar` | Envelope version; `1`. | +| `d` | Stable artifact UUID. | +| `h` | Home channel UUID. | +| `type` | Namespaced content type. | +| `title` | Absent on `delete`; otherwise required, nonblank display title, at most 512 UTF-8 bytes. | +| `op` | `create`, `update`, `move`, `delete`, or `restore`. | +| `root` | Optional conversation anchor event ID within `h`. | +| `prev` | Previous accepted revision ID; required except on creation. | + +Each listed tag has exactly two string elements and occurs once, except optional tags may be absent and `title` MUST be absent on `delete`. UUIDs are lowercase, hyphenated, and non-nil; event IDs are 64 lowercase hex characters. Type names are dot-separated lowercase ASCII components, each starting with a letter and otherwise using letters, digits, `_`, or `-`, up to 128 bytes total. `buzz.*` is reserved for published Buzz client contracts. + +Relays MUST reject invalid envelopes, unsupported envelope versions, and events with more than 256 tags. Normal event-size and write quotas apply. + +Clients place fields needed for filtering in client-defined tags. The relay matches tag names and values without interpreting their meaning; `content` remains opaque text. Client-defined tags cannot override the envelope or grant access. + +## Identity and edits + +An artifact is identified by `(community, d)`, independently of its author or home. `d` and `type` are immutable. Kind 45010 is a regular stored event, not a NIP-01 addressable event. It falls in Buzz's forum and social kind range (45000–45999) but is not a forum post and follows none of the forum rules. + +Creation requires an unused `d`, `op=create`, and no `prev`. Every later revision MUST name the current accepted event in `prev`. The relay checks authorization and advances the current revision atomically: two competing edits cannot both succeed. Timestamps do not choose the winner. + +Resubmitting an already accepted event succeeds without applying it again, even if a later revision has since become current, so a client that lost an acknowledgement never sees a false conflict. The response reveals nothing the sender can no longer read. A different event whose `prev` is not the current revision is a conflict. A conflict response MAY name the current revision only when the sender can read it. On conflict, clients fetch the current accessible revision and reconcile their changes rather than blindly retrying. An `update` keeps `h`; a `move` changes it. + +A `create` whose `d` is already in use fails. When the sender cannot read the existing artifact, the error reveals only that the identity is taken, never its home, type, title, or current revision. Clients MAY derive `d` deterministically, for example from the message an artifact tracks, and accept that disclosure. + +On creation or when changed from the previous accepted revision, `root` MUST identify an existing, undeleted conversation anchor in `h`. Later loss of that anchor does not prevent edits. Replies belong to that conversation, not to an artifact revision. + +## Access + +Channel read permission governs every artifact read, including lookups, history, search, previews, counts, and live updates. Threads inherit their channel's audience. Membership and visibility changes take effect on subsequent reads and deliveries. + +Write permission means permission to post a kind-9 message in the channel, including authentication, token restrictions, moderation, and archive checks. This includes agents and, where channel policy permits, nonmembers of open channels. Artifact writes use `messages:write`, the scope that admits kind-9 posting. + +| Operation | Required permission at commit time | +| --- | --- | +| Create or update | Write in the home channel. | +| Move | Write in both source and destination. | +| Delete or restore | Write in the home channel, and either being the artifact's author (the signer of its `create` revision) or holding the channel's owner or admin role. In a DM, write alone suffices. | + +These rules apply to every type. Apart from deletion and restoration, they do not depend on who created the artifact. DM participants are peers. The relay determines channel type from the stored channel, not from an artifact tag. + +Relationship tags organize work without changing access. Linking a task to a project does not move it or share it. References to repositories or other services retain those services' access and action permissions. Errors MUST NOT disclose inaccessible artifact details. + +## Moving and deleting + +A move publishes the current snapshot into the destination under the same `d`. Its `root` must be absent or belong to the destination. Clients MUST show the destination audience and the information being shared before confirmation. + +The relay MUST atomically advance the current revision and durably record both arrival in the destination and removal from the source. The source receives a separate relay-authenticated removal identifying the artifact, source scope, and replay position, without destination metadata or content. Both changes must survive disconnects and crashes. Relays unable to provide this MUST reject moves. + +Earlier revisions remain under their original channels' access rules; a move never lets the destination read revisions from the source. The destination can load current state without reading earlier revisions. Conversation messages stay where they are. + +Deletion is a soft delete: a revision with `op=delete`, empty content, and no `title` tag. It preserves the current `type`, `h`, and `root`, and contains only envelope tags and NIP-OA `auth` tags the relay has verified. It removes the artifact from current-state queries and active views. Its revisions stay readable under their channels' access rules until redacted or expired by retention, so a lookup by `d` returns the deletion. After deletion, only `restore` is accepted: it names the deletion in `prev`, keeps its `h`, and carries a complete snapshot that becomes the current state again. A deleted `d` stays reserved and is never reused for another artifact. Completing or archiving work is a content change, not deletion or channel archival. + +## Moderation and retention + +Relays MUST reject kind-5 deletion requests targeting artifact revisions with an explicit reason. A NIP-29 kind-9005 removal targeting a revision redacts it instead of deleting it: the relay withholds that revision's title, content, and client-defined tags from every surface, including history, search, and live delivery, and serves a relay-authenticated removal marker that keeps its `d`, `h`, `type`, `op`, and `prev`. Redaction never changes which revision is current, so a redacted current revision can still be edited, deleted, or restored with a new revision. Revision history is subject to the community's retention policy; expiring earlier revisions MUST NOT remove the current revision or break `prev` checks. + +## Current state and queries + +An artifact's current state is its latest accepted revision, as determined by the `prev` rules above. Current-state queries MUST return only that revision and omit deleted artifacts. For example, a task whose current revision belongs to Project B no longer appears in Project A's list, even if an earlier revision belonged there. Revision history is queried separately. This NIP does not define a public acceptance order or history pagination; relays record the order in which they accept revisions, and a later sync specification defines how clients observe it. + +Relays MUST support querying current artifacts across channels the reader can access using exact tag-name/value matches, including multi-character tag names. Conditions on different tag names are combined with AND; requested values for one name are combined with OR. A match compares the tag name and its first value in the same tag. Access checks and tag matching apply before pagination and any counts. Older revisions do not contribute matches. + +Relays MUST advertise and enforce limits on tag-name/value sizes, total tag bytes, query predicates and values, and page size. Queries MUST run within an enforced execution budget. Unsupported or over-limit requests fail explicitly; predicates MUST NOT be silently ignored or incomplete results presented as complete. + +Artifact revisions are state changes, not conversation messages. Receiving one MUST NOT increase chat or reply counts, add a chat unread, mark a conversation read, or repeat mention or push notifications for tags carried over from the previous revision. + +## Client behavior + +Clients validate content before interpreting it as a known type. Unknown types or malformed content render a safe fallback using the title, type, and home link. Editors MUST preserve unfamiliar fields and annotation tags or decline the edit. References may be unavailable or cyclic; clients must handle them safely without assuming relay validation. + +Artifact text is untrusted. A type name, relationship tag, or payload cannot authorize a privileged action. Moving, deleting, or revoking access cannot erase copies already downloaded. diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index c45f900e82..1ee9ed2f16 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -395,13 +395,13 @@ Success is `200 {"status":"revoked"}`. The revocation atomically invalidates the ### Relay delivery -`POST /v1/deliveries/apns` has the exact externally configured URL `https://push.buzz.xyz/v1/deliveries/apns`. Request: +The relay sends requests to the configured HTTP(S) delivery URL ending in `/v1/deliveries/apns`. Delivery authentication uses the NIP-98 event format with method/path binding rather than full request-URL matching. The signed method MUST be `POST` and the signed HTTP(S) URL path MUST be exactly `/v1/deliveries/apns`, without query, fragment or credentials. The gateway does not compare scheme, host or port to the incoming request and rejects incoming request queries. `https://push.buzz.xyz/v1/deliveries/apns` is an example. Request: ```json {"v":1,"endpoint_grant":"","request_id":"","expires_at":} ``` -The relay supplies a NIP-98 `Authorization: Nostr ` header for method `POST`, the exact URL above, and the SHA-256 payload hash of the **received request body bytes**. The gateway verifies the NIP-98 event signature, timestamp under NIP-98 rules, method, URL, and payload; the event pubkey is the relay identity. It decrypts `endpoint_grant`, requires that signer, current installation/delegation, endpoint epoch and generation, and both `now <= request.expires_at <= grant.expires_at`. Every NIP-98 event id is burned at admission. +The relay supplies a NIP-98 `Authorization: Nostr ` header for method `POST`, the configured delivery URL, and the SHA-256 payload hash of the **received request body bytes**. The gateway verifies the event ID and signature, event kind, timestamp under NIP-98 rules, method, signed URL path, and payload; the event pubkey is the relay identity. It decrypts `endpoint_grant`, requires that signer, current installation/delegation, endpoint epoch and generation, and both `now <= request.expires_at <= grant.expires_at`. Every NIP-98 event id is burned at admission. The relay's durable job UUID is `request_id` and becomes the stable APNs `apns-id`. Delivery replay/quota reservation is one transaction. The commit of that transaction is send-begin: a revocation or rotation commit that completes first prevents the old-capability send; a send admitted first may finish. Terminal outcomes retain the `(relay_pubkey, request_id)` reservation; transient/configuration outcomes release it only after provider processing so a fresh NIP-98 event may retry the same job. Endpoint quota is charged once per admitted attempt and never refunded. A crash before transient cleanup can reject that id until its bounded request expiry; exactly-once provider delivery is not guaranteed. diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index 75af0e2c5f..65a3c8cf3d 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -4,7 +4,7 @@ ## Network and health -- Public listener: `BUZZ_PUSH_BIND_ADDR` (default `0.0.0.0:8080`). Route the configured `BUZZ_PUSH_GATEWAY_ORIGIN` to this port. +- Public listener: `BUZZ_PUSH_BIND_ADDR` (default `0.0.0.0:8080`). Route the deployment hostname to this port; the binary needs no public-origin setting. - Private health listener: `BUZZ_PUSH_HEALTH_ADDR` (default `0.0.0.0:8081`). Probe `/_liveness` and `/_readiness`; do not expose this port publicly. The chart has no pod-ingress allowance for 8081; Kubernetes node/kubelet-origin probe traffic is exempt from NetworkPolicy. Add a narrowly selected monitoring source only if the target CNI requires pod-origin health scraping. - Readiness fails when PostgreSQL authority is unavailable. Graceful shutdown stops accepting new requests before draining in-flight APNs calls. @@ -13,7 +13,6 @@ | Variable | Purpose | |---|---| | `DATABASE_URL` | PostgreSQL authority/admission store. Runtime credentials need DML on the six gateway tables, not DDL. | -| `BUZZ_PUSH_GATEWAY_ORIGIN` | Exact externally reachable HTTPS origin. No credentials, port, path, query, or fragment. The gateway derives its transport routes from it; NIP-PL v1 App Attest audiences remain the registered `https://push.buzz.xyz/v1/...` constants. | | `BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS` | Maximum delegation capability lifetime (`1..=31536000`). | | `BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS` | Maximum encrypted-token installation lifetime (default 90 days, max one year). Clients must renew before expiry. | | `BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH` | Read-only mounted Apple App Attest root certificate PEM. | @@ -168,7 +167,8 @@ Alerting rules ship as an opt-in prometheus-operator `PrometheusRule` (`promethe Relay push is an explicit deployment opt-in through `BUZZ_PUSH_ENABLED=true`; the established strict boolean parser rejects unknown values and the default is false. When enabled, `BUZZ_PUSH_GATEWAY_DELIVERY_URL` is required and must be an -exact HTTPS `/v1/deliveries/apns` URL. An absent or explicitly empty URL while +exact HTTP(S) `/v1/deliveries/apns` URL. Credentials, query and fragment are +not accepted. The relay signs and sends the same configured URL. An absent or explicitly empty URL while enabled is a startup error. Only an enabled relay advertises its host-scoped NIP-PL descriptor, accepts leases, and starts the matcher and delivery worker. Relays retain lease matching, authorization, durable @@ -273,7 +273,9 @@ the environment's GitOps values; the chart then renders `ghcr.io/block/buzz-push-gateway@sha256:...` and ignores the mutable tag. `values-production.yaml` remains an intentionally invalid production-input contract: deployment CI must inject the verified image digest, the provisioned -dogfood Apple application identifier, `gatewayOrigin`, and the actual PostgreSQL network. In an +dogfood Apple application identifier and the actual PostgreSQL network. Set +`gatewayOrigin` only when enabling the chart’s optional HTTPRoute; it supplies +the routing hostname and is not passed to the gateway binary. In an environment with an existing ingress or service mesh route, keep `httpRoute.enabled=false`. If this chart owns a Gateway API route, enable it and inject an environment-owned `parentRef`; schema validation rejects an enabled @@ -320,3 +322,14 @@ before use: helm show chart oci://ghcr.io/block/buzz/charts/buzz-push-gateway --version X.Y.Z helm pull oci://ghcr.io/block/buzz/charts/buzz-push-gateway --version X.Y.Z ``` + + +### Delivery URL at the gateway + +The gateway verifies the signed method and delivery path using the NIP-98 event +format, rather than matching the full incoming HTTP(S) request URL. The signed +method must be `POST`; the signed URL path must be `/v1/deliveries/apns`, without +query, fragment or credentials. Incoming request queries are rejected. +Host and forwarding headers do not participate in delivery authentication. +Event signatures, body hashes, timestamps, grant checks and replay protection +remain enforced. No mobile App Attest audiences or relay activation settings change. diff --git a/migrations/0050_operator_listener_mentions.sql b/migrations/0050_operator_listener_mentions.sql new file mode 100644 index 0000000000..10f81f50aa --- /dev/null +++ b/migrations/0050_operator_listener_mentions.sql @@ -0,0 +1,57 @@ +-- Deployment-global operator-listener mention delivery. +-- Listener registrations span communities; community_id on the outbox is event +-- provenance, not a tenant boundary. + +-- Migration 0044 restored this function to the pre-0041 exclusion set. Extend +-- it here so migrated databases keep the outbox outside tenant fencing and +-- community-deletion catalog discovery, matching the desired-state schema. +CREATE OR REPLACE FUNCTION community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN +LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ + SELECT target::TEXT = ANY (ARRAY[ + 'community_deletion_requests', 'community_deletion_approvals', + 'community_deletion_checkpoints', 'community_serving_write_leases', + 'community_deletion_executor_heartbeats', 'product_feedback', + 'rate_limit_violations', 'operator_listener_outbox' + ]::TEXT[]) +$$; + +CREATE TABLE operator_listener_pubkeys ( + listener_pubkey BYTEA NOT NULL CHECK (length(listener_pubkey) = 32), + target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (listener_pubkey, target_pubkey) +); +CREATE INDEX operator_listener_pubkeys_target + ON operator_listener_pubkeys (target_pubkey, listener_pubkey); +CREATE INDEX operator_listener_pubkeys_created_at + ON operator_listener_pubkeys (created_at); + +CREATE TABLE operator_listener_outbox ( + id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), + listener_pubkey BYTEA NOT NULL CHECK (length(listener_pubkey) = 32), + target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32), + community_id UUID NOT NULL, + event_id BYTEA NOT NULL CHECK (length(event_id) = 32), + event_kind INTEGER NOT NULL, + event_created_at TIMESTAMPTZ NOT NULL, + state TEXT NOT NULL DEFAULT 'pending' + CHECK (state IN ('pending', 'sending')), + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + lease_until TIMESTAMPTZ, + claim_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (listener_pubkey, target_pubkey, community_id, event_id) +); +CREATE INDEX operator_listener_outbox_due + ON operator_listener_outbox (next_attempt_at, created_at, id) + WHERE state = 'pending'; +CREATE INDEX operator_listener_outbox_recovery + ON operator_listener_outbox (lease_until, created_at, id) + WHERE state = 'sending'; +CREATE INDEX operator_listener_outbox_created_at + ON operator_listener_outbox (created_at); + +INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('operator_listener_pubkeys', 'deployment-global target registrations for operator listeners'), + ('operator_listener_outbox', 'deployment-global mention delivery queue; community_id is event provenance'); diff --git a/schema/schema.sql b/schema/schema.sql index c91cd7375d..fdd382f463 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1522,7 +1522,7 @@ LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ 'community_deletion_requests', 'community_deletion_approvals', 'community_deletion_checkpoints', 'community_serving_write_leases', 'community_deletion_executor_heartbeats', 'product_feedback', - 'rate_limit_violations' + 'rate_limit_violations', 'operator_listener_outbox' ]::TEXT[]) $$; @@ -1906,6 +1906,51 @@ CREATE INDEX idx_relay_admin_outbox_pending INSERT INTO _operator_global_tables (table_name, reason) VALUES ('relay_admin_outbox', 'deployment-global enforcement artifact delivery queue'); +-- ── Operator-listener mention delivery ────────────────────────────────────── +-- Listener registrations are deployment-global. The outbox records community +-- provenance for the event, but is intentionally not tenant-owned. + +CREATE TABLE operator_listener_pubkeys ( + listener_pubkey BYTEA NOT NULL CHECK (length(listener_pubkey) = 32), + target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (listener_pubkey, target_pubkey) +); +CREATE INDEX operator_listener_pubkeys_target + ON operator_listener_pubkeys (target_pubkey, listener_pubkey); +CREATE INDEX operator_listener_pubkeys_created_at + ON operator_listener_pubkeys (created_at); + +CREATE TABLE operator_listener_outbox ( + id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), + listener_pubkey BYTEA NOT NULL CHECK (length(listener_pubkey) = 32), + target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32), + community_id UUID NOT NULL, + event_id BYTEA NOT NULL CHECK (length(event_id) = 32), + event_kind INTEGER NOT NULL, + event_created_at TIMESTAMPTZ NOT NULL, + state TEXT NOT NULL DEFAULT 'pending' + CHECK (state IN ('pending', 'sending')), + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + lease_until TIMESTAMPTZ, + claim_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (listener_pubkey, target_pubkey, community_id, event_id) +); +CREATE INDEX operator_listener_outbox_due + ON operator_listener_outbox (next_attempt_at, created_at, id) + WHERE state = 'pending'; +CREATE INDEX operator_listener_outbox_recovery + ON operator_listener_outbox (lease_until, created_at, id) + WHERE state = 'sending'; +CREATE INDEX operator_listener_outbox_created_at + ON operator_listener_outbox (created_at); + +INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('operator_listener_pubkeys', 'deployment-global target registrations for operator listeners'), + ('operator_listener_outbox', 'deployment-global mention delivery queue; community_id is event provenance'); + -- ── Relay operator audit (append-only roster mutation trail) ───────────────── -- One row per PUT/DELETE /operators/{pubkey} mutation. The roster is the -- deployment-wide root of trust and its mutations overwrite/remove in place; diff --git a/scripts/ci-required-gates.test.mjs b/scripts/ci-required-gates.test.mjs new file mode 100644 index 0000000000..e495a452ca --- /dev/null +++ b/scripts/ci-required-gates.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { runInNewContext } from "node:vm"; + +const workflow = readFileSync( + new URL("../.github/workflows/ci.yml", import.meta.url), + "utf8", +); +const gates = [ + "rust-lint", + "unit-tests", + "windows-rust", + "desktop", + "desktop-build-macos", + "desktop-e2e-relay", + "desktop-e2e-integration", + "backend-integration", + "postgres-tests", + "relay-e2e", + "web", + "mobile", + "security", +]; +for (const gate of gates) { + const body = workflow.match( + new RegExp(`^ ${gate}:\\n([\\s\\S]*?)(?=^ [\\w-]+:|$(?![\\s\\S]))`, "m"), + )[1]; + const condition = body.match(/^ {4}if: (.+)$/m)[1]; + const command = body.match(/^ {8}run: (.+)$/m)[1]; + function shouldRun( + selection, + selected = false, + event = "pull_request", + artifacts = "skipped", + ) { + // These workflow conditions use only booleans, string equality and grouping. + // Evaluate the actual expression after substituting its GitHub context values. + assert.match( + condition, + /\balways\(\)/, + "Required wrapper must override GitHub implicit success()", + ); + const expression = condition + .replace(/always\(\)/g, "true") + .replace( + /github\.event_name|needs\.[\w-]+\.(?:result|outputs\.[\w-]+)/g, + (key) => { + if (key === "github.event_name") return JSON.stringify(event); + if (key === "needs.changes.result") return JSON.stringify(selection); + if (key === "needs.relay-artifacts-domain.result") + return JSON.stringify(artifacts); + assert.match(key, /^needs\.changes\.outputs\./); + return JSON.stringify(selected ? "true" : "false"); + }, + ); + return runInNewContext(expression, {}, { timeout: 100 }); + } + function check(selection, result) { + assert.match(body, /SELECTION_RESULT: \$\{\{ needs.changes.result \}\}/); + assert.match(body, /RESULT: \$\{\{ needs\.[\w-]+\.outputs\.[\w_]+ \}\}/); + return spawnSync("bash", ["-c", command], { + env: { ...process.env, SELECTION_RESULT: selection, RESULT: result }, + timeout: 1000, + }).status; + } + test(`${gate}: selector failures run and fail the required check`, () => { + for (const selection of ["failure", "cancelled", "skipped"]) { + for (const artifacts of ["skipped", "success"]) { + assert.equal( + shouldRun(selection, false, "pull_request", artifacts), + true, + ); + } + for (const result of ["", "skipped", "success"]) { + assert.notEqual(check(selection, result), 0); + } + } + }); + test(`${gate}: successful selection preserves path gating and suite results`, () => { + assert.equal(shouldRun("success"), false); + assert.equal(shouldRun("success", true, "pull_request", "success"), true); + assert.equal(shouldRun("success", false, "push", "success"), true); + assert.equal(check("success", "success"), 0); + for (const result of ["", "failure", "cancelled", "skipped"]) { + assert.notEqual(check("success", result), 0); + } + }); +} diff --git a/scripts/ci-runtime-selection.mjs b/scripts/ci-runtime-selection.mjs new file mode 100644 index 0000000000..bef2a2b1a4 --- /dev/null +++ b/scripts/ci-runtime-selection.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { appendFileSync, readFileSync } from "node:fs"; + +const filters = JSON.parse(process.env.FILTER_OUTPUTS); +let runAll = false; +if (process.env.GITHUB_EVENT_NAME === "pull_request") { + const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); + const count = event.pull_request.changed_files; + assert.ok( + Number.isSafeInteger(count) && count >= 0, + "Invalid PR changed_files", + ); + // GitHub's PR-files endpoint returns at most 3,000 files. Never use a + // potentially incomplete list to skip a runtime suite. + runAll = count >= 3000; +} +for (const key of ["rust", "desktop", "desktop-rust", "web", "mobile"]) { + assert.ok( + ["true", "false"].includes(filters[key]), + `Invalid ${key} selection`, + ); + appendFileSync( + process.env.GITHUB_OUTPUT, + `${key}=${runAll ? "true" : filters[key]}\n`, + ); +} diff --git a/scripts/ci-selection.test.mjs b/scripts/ci-selection.test.mjs index 2099bf1899..6639063384 100644 --- a/scripts/ci-selection.test.mjs +++ b/scripts/ci-selection.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { execFileSync, spawnSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { existsSync, mkdtempSync, @@ -8,9 +8,11 @@ import { rmSync, writeFileSync, } from "node:fs"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { after, test } from "node:test"; +import { promisify } from "node:util"; const workflow = readFileSync( new URL("../.github/workflows/ci.yml", import.meta.url), @@ -44,7 +46,7 @@ assert.ok( actionPath && existsSync(actionPath), `Set PATHS_FILTER_ACTION to the local dist/index.js from dorny/paths-filter@${actionSha}`, ); -function select(paths) { +async function select(paths, pullRequest = false, apiStatus = 200) { const repo = mkdtempSync(join(scratch, "repo-")); const git = (...args) => execFileSync("git", args, { cwd: repo, stdio: "pipe", timeout: 10000 }); @@ -71,31 +73,139 @@ function select(paths) { writeFileSync(join(repo, path), "fixture\n"); } git("add", "."); + let server; + let apiUrl; + const eventPath = join(scratch, `event-${repo.split("/").pop()}.json`); + if (pullRequest) { + const commit = (message) => + git( + "-c", + "user.name=CI fixture", + "-c", + "user.email=ci@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + message, + "-s", + ); + const base = git("rev-parse", "HEAD").toString().trim(); + commit("PR documentation or code"); + const head = git("rev-parse", "HEAD").toString().trim(); + git("checkout", "-qb", "upstream", base); + const upstreamPath = "desktop/src/upstream-only.ts"; + mkdirSync(dirname(join(repo, upstreamPath)), { recursive: true }); + writeFileSync(join(repo, upstreamPath), "upstream change\n"); + git("add", upstreamPath); + commit("Unrelated upstream desktop change"); + git( + "-c", + "user.name=CI fixture", + "-c", + "user.email=ci@example.com", + "-c", + "commit.gpgsign=false", + "merge", + "--no-ff", + "-m", + "Synthetic merge", + head, + ); + git("checkout", "--detach"); + writeFileSync( + eventPath, + JSON.stringify({ + pull_request: { + number: 7809, + changed_files: paths.length, + base: { sha: base }, + head: { sha: head }, + }, + repository: { default_branch: "main" }, + }), + ); + // Serve the PR file list, which intentionally excludes the newer base's code. + server = createServer((request, response) => { + assert.equal( + request.url, + "/repos/block/buzz/pulls/7809/files?per_page=100", + ); + response.setHeader("Content-Type", "application/json"); + if (apiStatus !== 200) { + response.writeHead(apiStatus); + response.end(JSON.stringify({ message: "Fixture access denied" })); + return; + } + response.end( + JSON.stringify( + paths + .slice(0, 3000) + .map((filename) => ({ filename, status: "added" })), + ), + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + apiUrl = `http://127.0.0.1:${server.address().port}`; + } const output = join(repo, "action-output"); writeFileSync(output, ""); - const child = spawnSync(process.execPath, [actionPath], { - cwd: repo, - encoding: "utf8", - timeout: 30000, - env: { - ...process.env, - INPUT_BASE: "HEAD", - INPUT_FILTERS: filters, - INPUT_TOKEN: "", - INPUT_REF: "", - GITHUB_OUTPUT: output, - "INPUT_PREDICATE-QUANTIFIER": - workflow.match(/predicate-quantifier: ['"]?([\w-]+)/)?.[1] ?? "some", - }, - }); - assert.equal(child.status, 0, child.stdout + child.stderr); - return Object.fromEntries( + try { + await promisify(execFile)(process.execPath, [actionPath], { + cwd: repo, + encoding: "utf8", + timeout: 30000, + env: { + ...process.env, + INPUT_BASE: pullRequest ? "" : "HEAD", + INPUT_FILTERS: filters, + // Resolve the workflow's token expression to a fixture token. Removing + // it must reproduce the contaminated git comparison in the PR fixture. + INPUT_TOKEN: + pullRequest && /token: \$\{\{ github\.token \}\}/.test(workflow) + ? "fixture-token" + : "", + INPUT_REF: "", + GITHUB_EVENT_NAME: pullRequest ? "pull_request" : "", + GITHUB_EVENT_PATH: pullRequest ? eventPath : "", + GITHUB_REPOSITORY: "block/buzz", + GITHUB_API_URL: apiUrl || "https://api.github.com", + GITHUB_OUTPUT: output, + "INPUT_PREDICATE-QUANTIFIER": + workflow.match(/predicate-quantifier: ['"]?([\w-]+)/)?.[1] ?? "some", + }, + }); + } finally { + if (server) await new Promise((resolve) => server.close(resolve)); + } + const rawOutputs = Object.fromEntries( [ ...readFileSync(output, "utf8").matchAll( /^(rust|desktop|desktop-rust|web|mobile)<<([^\n]+)\n(true|false)\n\2/gm, ), ].map(([, key, , value]) => [key, value]), ); + const selectedOutput = join(repo, "selected-output"); + await promisify(execFile)( + process.execPath, + [new URL("./ci-runtime-selection.mjs", import.meta.url).pathname], + { + env: { + ...process.env, + FILTER_OUTPUTS: JSON.stringify(rawOutputs), + GITHUB_EVENT_NAME: pullRequest ? "pull_request" : "push", + GITHUB_EVENT_PATH: eventPath, + GITHUB_OUTPUT: selectedOutput, + }, + timeout: 10000, + }, + ); + return Object.fromEntries( + readFileSync(selectedOutput, "utf8") + .trim() + .split("\n") + .map((line) => line.split("=")), + ); } const scenarios = [ [ @@ -127,10 +237,47 @@ const scenarios = [ ], ["web", ["web/src/main.tsx"], ["web"]], ["documentation", ["README.md"], []], + [ + "incident documentation", + ["CONTEXT.md", "docs/mobile-push-suppression.md", "VISION_MOBILE.md"], + [], + ], + [ + "nested docs directory", + ["docs/mobile/design.md", "docs/nips/NIP-FI.md"], + [], + ], + ["crate Markdown", ["crates/buzz-cli/README.md"], ["rust"]], + ["desktop Markdown", ["desktop/README.md"], ["desktop"]], + [ + "Tauri Markdown", + ["desktop/src-tauri/README.md"], + ["desktop", "desktop-rust"], + ], + ["web Markdown", ["web/docs/design.md"], ["web"]], + ["mobile Markdown", ["mobile/test/README.md"], ["mobile"]], + ["schema Markdown", ["schema/README.md"], ["rust"]], + ["migration Markdown", ["migrations/README.md"], ["rust"]], + [ + "mixed documentation and desktop", + ["VISION_MOBILE.md", "docs/mobile/design.md", "desktop/src/main.tsx"], + ["desktop"], + ], + [ + "mixed documentation and relay", + ["docs/desktop/design.md", "crates/buzz-relay/src/lib.rs"], + ["rust"], + ], + ["embedded ACP prompt", ["crates/buzz-acp/src/base_prompt.md"], ["rust"]], + [ + "embedded Tauri skill", + ["desktop/src-tauri/src/managed_agents/nest_skill.md"], + ["desktop", "desktop-rust"], + ], ]; for (const [name, paths, expected] of scenarios) { - test(`real paths-filter: ${name}`, () => { - const outputs = select(paths); + test(`real paths-filter: ${name}`, async () => { + const outputs = await select(paths); assert.equal(Object.keys(outputs).length, 5); assert.deepEqual( Object.keys(outputs) @@ -140,3 +287,64 @@ for (const [name, paths, expected] of scenarios) { ); }); } + +for (const [name, paths, expected] of [ + [ + "docs-only", + ["CONTEXT.md", "docs/mobile-push-suppression.md", "VISION_MOBILE.md"], + [], + ], + [ + "mixed mobile and Markdown", + ["VISION_MOBILE.md", "mobile/lib/main.dart"], + ["mobile"], + ], + [ + "mixed desktop and Markdown", + ["CONTEXT.md", "desktop/src/main.tsx"], + ["desktop"], + ], +]) { + test(`PR file list ignores newer base changes: ${name}`, async () => { + const outputs = await select(paths, true); + assert.equal(Object.keys(outputs).length, 5); + assert.deepEqual( + Object.keys(outputs) + .filter((key) => outputs[key] === "true") + .sort(), + expected.sort(), + ); + }); +} + +test("workflow consumes guarded selection outputs", () => { + const outputs = workflow.match(/ {4}outputs:\n([\s\S]*?) {4}steps:/)[1]; + for (const key of ["rust", "desktop", "desktop-rust", "web", "mobile"]) { + assert.ok(outputs.includes(`steps.selection.outputs.${key}`)); + } + assert.match( + workflow, + /id: selection\n {8}env:\n {10}FILTER_OUTPUTS: \$\{\{ toJSON\(steps.filter.outputs\) \}\}\n {8}run: node scripts\/ci-runtime-selection.mjs/, + ); +}); +for (const count of [2999, 3000, 3001]) { + test(`PR file-list ceiling: ${count} changed files`, async () => { + const paths = Array.from( + { length: Math.min(count, 3000) }, + (_, i) => `docs/file-${i}.md`, + ); + if (count > 3000) paths.push("desktop/src/omitted-by-api.ts"); + const outputs = await select(paths, true); + for (const value of Object.values(outputs)) { + assert.equal(value, count >= 3000 ? "true" : "false"); + } + }); +} + +test("PR API denial fails path selection instead of reporting no changes", async () => { + await assert.rejects(select(["README.md"], true, 403), (error) => { + assert.equal(error.code, 1); + assert.match(error.stdout + error.stderr, /Fixture access denied/); + return true; + }); +});