mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
f7708d0694dbbd92bb6f4cd2b6872f4bbdc3cebb
10 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6286a54321 |
feat(relay): store per-community NIP-FI issuer bindings (#8372)
Stack: this PR → #8377 → #8399 → #8401 bb-public creates communities all the time, so a hand-edited `BUZZ_NIP_FI_COMMUNITIES` map can't list them. This PR stores an explicit NIP-FI issuer allowlist for each community in the database and writes it when the community is created. Login checks don't read the table yet; that comes in #8377 together with the database-backed resolver. Operator steps are in [`docs/operator-nip-fi-bindings.md`](docs/operator-nip-fi-bindings.md). **Storage.** Migration `0066_community_nip_fi_issuers` adds `community_nip_fi_issuers (community_id, issuer, created_at)`, with primary key `(community_id, issuer)` and the community write fence attached. The assertion audience is `https://<communities.host>`, so the table has no `aud` column. Zero rows means no binding. The table is included in whole-community deletion and purge. **One bindable rule.** `BUZZ_NIP_FI_PROVISION_BINDING` (`{"host_suffix": ..., "authorized_issuers": [...]}`) is parsed in `buzz-auth` with `BUZZ_NIP_FI_ISSUERS`, whatever `BUZZ_NIP_FI_MODE` is set to, and both the relay and `buzz-admin` use the same parser. Each authorized issuer must be a configured issuer (1 to 2048 bytes), and the suffix must not cover any host declared in `BUZZ_NIP_FI_COMMUNITIES`; an unreadable declaration refuses startup even in mode `off`. `ProvisionBindingPolicy::is_bindable(host)` is true for a host exactly one label under the suffix that is not reserved. It decides creation-time binding, explicit `bind`, and what the lookup route reports. **Binding on creation.** Rows are written in the same transaction as the community insert on all three creation paths (`POST /operator/communities` `create_only`, its legacy convergence branch, and startup bootstrap), and only when the call actually created the community, so existing rows are never rewritten. **Reserved host.** A host whose `https://<host>` equals a configured `command_audiences` value never holds a binding. Operator provisioning of it is rejected with a 400 and the `reserved_host` error code, bootstrap creates it unbound, and the availability route reports it unavailable with an error code. `main.rs` calls `verify_reserved_hosts_unbound` at startup; if a reserved host has rows the relay refuses to start and names the fix (`buzz-admin nip-fi unbind --host <h> --issuer <i>`, or revert the command audience). A database failure during that check is a separate `Check` error. **Operator commands.** - `buzz-admin nip-fi backfill` pages unbound communities by host (1000 per page) and binds each page with one `INSERT ... SELECT` limited to active communities. It skips communities being deleted, always prints `{rows_bound, unbound, unbound_hosts, truncated}`, and exits non-zero while any bindable community is still unbound. Archived communities are bound on purpose. Both bind paths take the shared deletion lock and then lock the community row (`FOR NO KEY UPDATE`, in id order for a batch), the same order deletion uses, and the backfill insert also requires the community to still have no binding, so an operator `bind` that lands after the backfill listed a community wins and the default issuer is not added beside it. - `buzz-admin nip-fi bind --host --issuer` refuses a reserved host, a host outside the suffix, or an unconfigured issuer. - `buzz-admin nip-fi unbind --host --issuer` refuses to remove a community's last issuer (archive or delete the community instead), with one exception: when a valid provisioning policy shows that backfill can't bind the host (it's reserved, or outside the suffix), the last issuer can be removed. That is the reserved-host recovery path in the runbook. A missing or malformed policy doesn't block `unbind`; it just means the exception never applies. **Lookup route.** `GET /operator/communities/nip-fi-binding?host=` (NIP-98, `RELAY_OPERATOR_PUBKEYS`) reads the database and returns `{host, active, issuers[]}`. `host` is the stored host. It returns 404 for an unknown host, 200 with `active:false` for an archived or quiescing community, and `issuers:[]` when no provisioning policy is set. It lists only issuers that are configured and on a bindable host. kgoose depends on this contract; it is also documented in the runbook. **NIP-FA.** The `relay_url` bullet now also says: "An adapter MAY obtain its exact relay list from a configured, authenticated source; it MUST still compare `relay_url` exactly." **Deployment dependency.** The kgoose config side is already handled by [cash-server#137671](https://github.com/squareup/cash-server/pull/137671). In public-community mode it requires the command audience not to be a community audience. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> |
||
|
|
2ebfde9147 |
feat(nip-fi): bind assertions to the request Host's community (#8028)
🤖 Follows #7990 (merged). Binds every enforce-mode NIP-FI assertion to the community served at the request's `Host`. Before this, an assertion was accepted if its `aud` matched any value in a deployment-wide set. That meant an issuer trusted by one community could mint an assertion another community accepted. **Rollout consequence: an enforce relay will not start without `BUZZ_NIP_FI_COMMUNITIES`.** `off` and `deny_protected` do not read it, so repair mode still boots. ## What changes - **Host resolution runs first.** In enforce mode, right after the `off` and `deny_protected` checks, the `Host` must map to a configured community. An unmapped or missing `Host` returns 503 `authorization unavailable`. This happens at all three call sites: the router guard, the upgrade (WS root h1/h2 and audio), and HTTP admission. Off mode behaves exactly as before. - **The allowlist is checked before JWKS.** `VerifyAssertion::verify_assertion(token, &CommunityBinding)` rejects an issuer the community does not authorize before any key-source lookup. Clients can't tell this rejection apart from an unknown `iss`. `aud` must exactly equal the community's canonical URI. - **New config: `BUZZ_NIP_FI_COMMUNITIES`.** It is a JSON array of `{canonical_uri, authorized_issuers}`. Startup fails if: - a `canonical_uri` is anything other than a canonical `https://<host>[:port]`: the URL parser must reserialize the configured authority byte-for-byte, so whitespace, control characters, backslashes, uppercase, a redundant `:443`, and non-canonical IPv6 are rejected rather than repaired. The authority must also already be Host-normalized, so a trailing dot or `:80` is rejected and the Host map key always equals the `aud` authority exactly (never empty); - two communities share a `Host`; - an allowlisted issuer is missing from `BUZZ_NIP_FI_ISSUERS`; - an allowlist is empty; - a configured issuer belongs to no community. Errors name entries by index only. - **`IssuerPolicy.audiences` is removed.** Command JWTs use the new per-issuer `command_audiences`, so #7977's admin-disconnect command behavior is unchanged. A config that still sets `audiences`, even to `null`, fails with a migration message instead of being silently ignored. - **`AssertionPolicyId` now covers the community allowlist.** It hashes the `aud` of each community that authorizes the issuer, so changing the allowlist changes the ID. This is code only; no spec file changes here. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <wpfleger@block.xyz> Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> |
||
|
|
16839a0773 |
feat(nip-fi): admin disconnect/deny API with in-memory deny-until-TTL map (S4) (#7977)
This adds the NIP-FI S4 admin command surface. An authorized issuer principal can deny an `(issuer, pubkey)` pair until a TTL expires. When that happens, the relay records the entry in a bounded in-memory deny map, closes matching live sockets on every pod, and refuses new admissions while the entry is live. Supersedes #7265 ## Breaking In `BUZZ_NIP_FI_MODE=enforce`, every entry in `BUZZ_NIP_FI_ISSUERS` now has to carry `maximum_command_age_seconds` (1–60) and a non-empty `authorized_principals` list, and startup fails if any issuer is missing either one. That means an S3-only enforce config that boots on `main` won't boot with this change. No deployment sets `BUZZ_NIP_FI_*` yet. The per-issuer field table and a working example are in the `nip_fi_config.rs` module docs. ## What changes - **`buzz-auth`**: - `nip_fi/command.rs` verifies signed admin commands. It checks issuer policy, command age, the authorized principal (exact, case-sensitive `sub` match), and a `jti` of at most 512 bytes. - `nip_fi/deny_map.rs` is a per-issuer, capacity-bounded deny-until-TTL map on top of `DashMap`. Expired entries and `jti` reservations are only swept when a write would otherwise hit capacity. The sweep is O(n) and holds the lock every admission check takes, so this keeps it off the admission path. Reads compare against `now`, so an expired entry that hasn't been swept yet never counts as live. - **Cross-pod command replay fence**: `nip_fi/command_replay.rs` adds a `CommandReplayGuard` trait, and `buzz-pubsub` implements it in Redis (`RedisCommandReplayGuard`), following the NIP-98 replay guard. Once a command is authenticated, the verifier takes an atomic `SET buzz:nip-fi:command:{sha256(len(iss)‖iss‖len(jti)‖jti)} 1 NX EX <ttl>` claim, with the TTL set to the command's remaining validity rounded up to whole seconds plus skew, clamped like NIP-98, so the claim outlives acceptance by a verifier whose clock trails by the full skew. Before the claim, the verifier reserves a pending `jti` and deny-entry slot in this pod's deny map under the issuer lock, so a full deny set returns `503 deny set full` without touching Redis and the same signed command can be retried. The claim is taken only after the signature and claims verify, so a forged token cannot burn a real `jti`. A `jti` already claimed on any pod returns `403`. A Redis error fails closed with `503 authorization unavailable`: no deny entry, no session close, no publish. A replayed claim, a Redis error, or a cancelled request drops the reservation. After a successful claim the reservation commits to the `jti` record and deny entry, which cannot fail on capacity. A claim can still remain with no deny entry if Redis applied `SET` but the reply was lost, or the pod stops between claim and commit; that fails closed, and a retry gets `403` until the TTL expires. - **`api/nip_fi.rs`**: the admin disconnect/deny endpoint. It verifies the command, inserts the deny entry, disconnects locally, and publishes over `buzz-pubsub` `conn_control` so every other pod runs the same disconnect. A receiving pod clamps `until` to its own ceiling instead of rejecting it. Otherwise a pod whose clock trails the origin would drop a command the origin already answered with 200, and the target's sockets on that pod would stay open. Malformed pubkeys, unknown issuers, and unrepresentable timestamps are still rejected. - **Admission and live enforcement**: - WS upgrade (`router.rs`) and HTTP (`nip_fi_http.rs`) refuse keys that have a live entry with `403 authorization_denied`. - Root NIP-42 auth and audio auth register the proven identity (pubkey + admitting issuer) first, and only then check the deny map. With that order, a concurrent disconnect either finds the socket in its close scan or the check finds the entry. `OK(true)` isn't sent if a concurrent disconnect already cancelled the session. - `ConnectionManager::disconnect_nip_fi` and `CommunityConnectionRegistry::disconnect_nip_fi` are scoped to the issuer. - **One wire form for `authorization_denied`** (FI-TRACE-DENIAL-ORACLE): every root and audio WebSocket `authorization_denied` denial now goes through one first-writer-wins transition on `CommunityConnectionControl`. That covers the S4 deny-set hit and admin disconnect, and also the existing S3 key-mismatch, lease-expiry, and membership refusals. The transition sets the `AuthorizationDenied` reason only if nothing else has set a reason, and only the winner enqueues the denial frame. So every one of these denials is the class's denial frame followed by `Close(1008, "authorization denied")`, and a concurrent registry scan can't queue a second one. Before this, S3 key mismatch and expiry closed with `Close(None)`, which made them distinguishable from a deny-set hit on the wire. `evidence_rejected` and `authorization_unavailable` keep the bare close. - **Config**: see Breaking. `deny_set_capacity` is optional and defaults to 50000. Setting any command field without a command age fails closed. - **Boot** (`main.rs`): installs the command verifier and deny map. It subscribes to cross-pod disconnects before the Redis subscriber starts, so disconnects relayed during boot are buffered instead of dropped. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> |
||
|
|
8519db1532 |
feat(buzz-relay): NIP-FI stateless enforcement (S3) — upgrade gate, NIP-42 pairing, session lifetime, JWKS warm (#7224)
## What this PR does Implements NIP-FI stateless enforcement at the relay WebSocket upgrade point. Every connection attempt on a NIP-FI–enabled relay must carry a valid federated identity assertion signed for the connecting Nostr pubkey; the relay validates it before the first frame is read, pairs it with NIP-42 AUTH, and enforces a bounded session lifetime without persistent state. ### Core enforcement seam - **Assertion at upgrade** (`nip_fi_upgrade.rs`): identity header is parsed with the same `extract_bearer_token` and denied with the same `http_denial` as HTTP ingress (`nip_fi_http.rs`), JWT verified (RS256/ES256/EdDSA), expiry checked, and the Nostr pubkey embedded in the sub claim must match the NIP-42 AUTH pubkey that arrives in the socket within the AUTH timeout. Denial returns exact byte contracts — `401 + WWW-Authenticate: Nostr + "authentication required\n"` for missing evidence, `403 + "evidence rejected\n"` for malformed or invalid evidence. The active HTTP/1.1 gate runs before `bind_community`, so denied upgrades pay zero DB cost. - **NIP-42 pairing** (`nip_fi_gate.rs`, `audio/handler.rs`): after the assertion is validated at upgrade, a `SessionAdmissionGate` is created with the assertion's expiry deadline and a `CancellationToken`. The gate is passed to every effect-acquiring path so no new effect permit is granted after the session expires. AUTH is verified against the assertion pubkey; a mismatched AUTH key receives `NOTICE restricted: authorization denied` and a socket close. Every other post-upgrade AUTH denial on an FI connection uses the same terminal shape — the canonical spec NOTICE on the terminal queue, then close — so frame type and close behaviour reveal nothing about private policy: ban, allowlist, or relay-membership denial → `restricted: authorization denied`; ban/allowlist/membership lookup failure or an internal verifier failure → `restricted: authorization unavailable`; an invalid NIP-42 proof → `restricted: evidence rejected`. Connections without an assertion keep today's `OK false` replies. - **Session lifetime** (`nip_fi_session.rs`): three independent deadlines bound each session — token `exp`, max assertion age from config, and relay max connection lifetime. The first to fire cancels the gate and sends the terminal denied frame. - **Effect admission at expiry** (`audio/join.rs`, `handlers/req.rs`): no new effect permit is granted after expiry. Two paths carry calculated budgets. The audio **lease resolver** holds its permit for at most `worst_case::PERMIT_HOLD` (14.5 s), and cancellation **during that resolver** reaches socket close within `worst_case::EXIT` (25 s: resolver, release of the won lease, terminal flush). Redis checkout has one total deadline and directory scripts are single-round-trip `EVAL`, which is what makes those budgets hold. When post-write fence verification fails after a landed CAS, the acquire keeps the reply and attempts an exact owner+generation cleanup on the same connection without a serving-write lease; if that cleanup fails or its reply is uncertain, the lease expires by its 30 s TTL. Neither number bounds audio close in general: other admission and teardown waits (for example the channel lookup after the resolver) and the known limitations below have no client-side deadline. REQ liveness, search and history reads race gate cancellation: a lost race drops the read, releases the permit, retires the claim through `close_if_owner`, and sends no further EVENT or EOSE. A history statement timeout ends the read with a timed-out outcome, and its owner-checked retirement (`CLOSED`, map, index and topic release) runs after the race with the permit dropped, so cancellation cannot drop it halfway. Subscription registration stays one uncancellable unit, and the permit is still taken before the claim. - **JWKS recovery** (`main.rs`): builds on main's `warm_nip_fi_jwks_snapshots` / `nip_fi_jwks_refresh_loop`. Issuers warm concurrently and the first refresh is seeded from the warm result (one interval for a warm issuer, 5 s for a cold one). A refresh that finds no live snapshot retries at the fast cadence (5 s after a warm issuer dies, doubling to a 300 s cap) instead of waiting a full interval, and `run_jwks_refresh_supervisor` restarts the loop after a panic (1 s → 60 s backoff); On shutdown it is cancelled and awaited before the drain backstop is disarmed; cancel drops an in-flight fetch and starts no further issuer, so the join stays inside the drain bound. Logs carry `issuer_index` only. ### Known limitations - **Known limitations: bounded only by the dependency.** No new effect is admitted after expiry. Two awaits that are already admitted have no client-side bound, so if a dependency stalls at expiry, teardown waits for it: - **Audio join commit:** its effect permit spans `tx.commit()`, room publication and the `event_mentions` insert. During a DB or Redis stall, audio cleanup and socket close wait for the dependency. - **REQ subscription setup:** the first subscriber to a topic sends `Subscribe` to the pubsub task through a bounded channel (capacity 4096). The send blocks only if that queue is full, which happens when the pubsub task isn't keeping up (for example during reconnect backoff or a burst). Registration holds the connection's `conn.subscriptions` lifecycle lock across the send, so a blocked send also blocks other claims on that connection, including search, and any cleanup waiting on that lock. The send itself predates this PR; holding the REQ permit across it is new here. ### Archive/join serialization `commit_participant_join` now takes a row-level write lock (`SELECT ... FOR NO KEY UPDATE`) on the channel row at step 3. This serializes all join commits against concurrent `archive_channel` calls — the archive UPDATE (`archived_at` non-key column) blocks on the row lock, closing the READ COMMITTED race on both the `Existing` and `AutoAddRequired` paths. ### HTTP/2 extended-CONNECT gate (latent path) The root WebSocket handler uses a belt-and-suspenders approach. The HTTP/1.1 path (currently the only live WebSocket shape — workspace Axum does not enable `http2`) is gated pre-extractor via the `Upgrade: websocket` + `Connection: Upgrade` header predicate. A second gate point inside the `Ok(ws)` arm is structural future-proofing: if `http2` is ever enabled, any h2 extended-CONNECT shape the extractor accepts but the h1 predicate misses is caught there. The active HTTP/1.1 fire-point sits before `bind_community` (zero DB cost on denial); the latent h2 fire-point runs after it. ### Mixed-version huddle control (rolling deploy) Owners advertise the `huddle-commit-phase` mesh capability. The ingress selects the mode after the owner stream opens: it sends the appended `RegisterPeerCommitPhase` variant, and later `CommitConfirmed`, only when the owner's exact runtime gossip record carries that flag at that point, and latches the result for the session. A successful open implies the owner's record is present, so a record that lands during acquisition is not missed. Otherwise it sends plain `RegisterPeer`, which every owner decodes. A new owner publishes a plain `RegisterPeer` once at registration (pre-commit-phase behavior) and holds a `RegisterPeerCommitPhase` peer until its confirm; duplicate or legacy confirms publish nothing. Existing wire variant indices are unchanged. ### Off-mode compatibility When `BUZZ_NIP_FI_MODE=off` (or unset), the relay reads no identity header and installs no upgrade gate, NIP-42 pairing check, or session deadline. Existing NIP-42 AUTH flows are unchanged. Huddle commit-before-publish is not FI-gated and applies in every mode, so huddle join publication timing differs from main even in Off mode. External connection cancellation (socket close, archival, revoke) now stops an in-flight liveness, search or history read at its next await; an uncancelled connection's frames and payload bytes are unchanged. ## Test coverage Every test this PR adds, or whose assertions it changes, runs in exactly one CI lane: infra-free tests in `just test-unit`, Postgres-backed tests in `postgres_tests` modules under the PostgreSQL lane. The `buzz-relay` bin tests run whole in `just test-unit`. `scripts/run-tests.sh` mirrors the effective relay unit selection for machines without nextest, except main's pre-existing `api::admin::` module. Every `buzz-relay` test fixture reads `Config` through `Config::for_test()`, which holds `NIP_FI_ENV_LOCK`, so a single-process `cargo test` run cannot observe a half-written FI environment (`fixture_config_read_waits_for_fi_env_lock`). The stub pools in the router and AUTH fixtures use a 100ms acquire timeout, which shortens a database fallthrough rather than removing it. - **Upgrade/router gate** (`router::tests`): Enforce-mode `nip_fi_*` gate tests for h1 WS upgrade, NIP-11 content negotiation, and audio ingress. Off-mode witnesses `nip_fi_off_root_passes_without_header`, `nip_fi_off_root_ignores_malformed_header`, `nip_fi_off_audio_passes_without_header`, `nip_fi_off_audio_ignores_malformed_header` prove the gate is bypassed (status outside 401/403/503, and no-header status equals malformed-header status); they do not prove a successful upgrade. - **NIP-FI units**: `nip_fi_config`, `nip_fi_upgrade`, `nip_fi_gate`, `nip_fi_session`, covering assertion contracts, NIP-42 pairing, session lifetime, and gate behavior. - **Effect barrier at cancellation** (`postgres_tests`, PostgreSQL lane): `w3_b2_req_barrier_expiry_mid_flight_blocks_subscription_registration`, `w4_b2_count_barrier_expiry_mid_flight_blocks_count_query`. - **AUTH denial uniformity** (`handlers::auth::tests`): each production-handler witness asserts the exact terminal frame, no denial on the data or ctrl channel, and cancellation, plus the unchanged Off-mode `OK false`. Unit: `fi_invalid_nip42_proof_emits_terminal_evidence_rejected`, `fi_ban_check_error_emits_terminal_authorization_unavailable`, `handle_auth_pairing_mismatch_runs_full_root_denial_path`, and `nip42_denial_class_separates_internal_failure_from_bad_evidence` (internal verifier failure → `authorization unavailable`; each client-evidence NIP-42 failure → `evidence rejected`). PostgreSQL lane: `fi_ban_denial_emits_terminal_authorization_denied` (real `community_bans` row; Off mode keeps `OK false` on ctrl, then close), `fi_relay_membership_denial_emits_terminal_authorization_denied`, `fi_allowlist_check_error_emits_terminal_authorization_unavailable`, `fi_relay_membership_check_error_emits_terminal_authorization_unavailable`, `fix_4a_allowlist_denial_with_fi_assertion_emits_canonical_restricted_frame`. - **Bounded audio exits** (`audio::handler::tests`, `just test-unit`): `check_cancel_exit_with_never_ready_sink_returns_within_flush_budget` drives the production `check_cancel!` fence with a queued FI denial into a never-ready sink and requires it to return within `WS_TERMINAL_FLUSH_TIMEOUT`; `challenge_send_to_never_ready_sink_exits_within_flush_budget_on_cancel` requires the NIP-42 challenge send to yield to cancellation and exit within the same budget; every pre-`send_loop` audio exit write goes through the same `connection::send_exit_frames_bounded` helper. - **Audio NIP-42 classification** (`audio::handler::tests`, `just test-unit`): `audio_bad_nip42_proof_with_fi_emits_one_evidence_rejected_then_cancels` requires exactly one canonical `restricted: evidence rejected` frame and cancellation; `audio_bad_nip42_proof_off_mode_keeps_auth_failed_frame` pins the Off-mode frame. - **Effect permit before pre-auth writes** (PostgreSQL lane): `handlers::auth::tests::postgres_tests::expiry_before_nip_oa_materialize_writes_no_rows_and_commits_no_auth` expires the session just before NIP-OA materialize and asserts no `users` or agent-owner row and no committed auth. - **Community admission** (`just test-unit`): `state::tests::on_not_run_runs_once_on_each_deny_arm_and_never_on_admit`. - **Auth metrics contract**: `connection::tests::auth_lifecycle_reconciles_every_terminal_and_never_leaks_gauge` records every terminal including `PairingMismatch`; `metrics::contract_tests::*` derive the series count from `AuthOutcome::ALL`. - **JWKS** (`main.rs`): `hard_dead_warm_issuer_resets_to_fast_cadence` and `jwks_refresh_success_waits_interval_failure_uses_fast_backoff` drive the real loop with paused Tokio time; `cold_issuer_*` cover the backoff helper; `f1_supervisor_loop_drives_recovery_and_restores_admission` runs `run_jwks_refresh_supervisor` over a real `ProductionJwksSource` through injected panic → restart → hard-dead → 5 s retry → recovery, then checks `key_set` admission and that the first post-restart fetch follows the cold cadence (every issuer restarts cold); `jwks_refresh_cancel_drops_in_flight_fetch_and_skips_remaining_issuers` cancels during a pending fetch with a second issuer due. - **Bounded lease acquire** (`audio::handler::tests::postgres_tests`, PostgreSQL lane): `failed_post_cas_verification_is_fenced_and_discards_the_won_lease` requires a won-then-unverified lease to return fenced and be deleted by exact owner+generation; `stalled_lease_sql_and_stalled_exit_release_close_within_exit_bound` stalls lease SQL and the exit release and requires socket close within `worst_case::EXIT`. `audio::join::tests` pins `PERMIT_HOLD` at 14.5 s and `EXIT` at 25 s. - **REQ/search cancellation** (`handlers::req::tests`, `just test-unit`): `expiry_during_stalled_history_read_releases_permit_and_claim` and `expiry_during_stalled_search_read_releases_permit_and_claim` stall the read inside production `handle_req`, expire the gate, and require `expire()` to return within 2 s, an empty subscription map, and no EVENT or EOSE. - **History timeout retirement** (`handlers::req::tests::postgres_tests`, PostgreSQL lane): `history_timeout_retirement_survives_expiry` drives a two-channel history REQ into a real statement timeout, pauses retirement after the index removal and before the topic release, expires the gate, then resumes. It requires the map and index entries gone and both channel topic refcounts at zero. - **Archive/join serialization** (PostgreSQL lane): `f2a_archived_channel_rejects_existing_join`, `f2b_join_for_update_blocks_concurrent_archive_existing_path`, `f2c_join_for_update_blocks_concurrent_archive_auto_add_path`. - **Huddle room and handler**: `audio::room::tests` pending-peer tests, including `roster_revisions_are_ordered_and_snapshot_is_authoritative`; `fix_b_w1_w3_handler_pre_commit_cancel_emits_no_delta`, `fix_b_w2_success_emits_exactly_one_delta_with_monotone_revision`. PostgreSQL lane: `b1_cross_pod_bootstrap_precedes_buffered_owner_delta`, `commit_confirm_timeout_at_seam_triggers_teardown_on_stalled_owner_stream`, `confirm_failure_combined_outcome_no_orphan_join_exactly_one_48102`, `confirm_send_pending_expiry_terminates_client_with_denial_within_1s`, `c1_handler_ingress_sends_only_base_frames_to_legacy_owner`, `f7a_cross_pod_joined_payload_includes_owner_pod_peers`. - **Mixed-version control** (`audio::join::tests`): `shared_control_variants_are_byte_identical_to_base_wire` (against frozen `audio::join::base_wire`), `c1_new_ingress_speaks_base_wire_to_legacy_owner`, `c2_base_ingress_register_publishes_once_on_new_owner`, `c3_capable_owner_record_selects_commit_phase_hold_back`, `capability_record_installed_during_open_selects_commit_phase`, `duplicate_or_legacy_commit_confirmed_is_a_no_op`, `fix_b_commit_confirmed_arm_commits_peer_and_broadcasts_joined`, `pending_remote_stream_close_emits_no_joined_no_left`. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> |
||
|
|
6410e685a8 |
feat(relay): enforce NIP-FI assertion+NIP-98 pairing on HTTP ingress (#7264)
Implements NIP-FI HTTP ingress enforcement per the merged spec (PR #7254). Every protected HTTP surface in enforce mode requires a valid `Nostr-Federated-Identity: Bearer <JWS>` assertion alongside its NIP-98 authorization, bound to the same key. The NIP-98 pubkey (proven actor) must equal the assertion's `nostr_pubkey`; absent, mismatched, or unverifiable assertions are denied fail-closed with byte-exact text/plain responses per the spec's rejection table. This is S5 of the `buzz-enterprise-identity` program. WS enforcement (S3) is separate. ## What changed **New `nip_fi_http` module:** `admit_nip_fi_http()` covers the full admission sequence — `Nostr-Federated-Identity` extraction, offline JWKS verification, NIP-98 key pairing [FI-INV-05], issuer-qualified deny-map check via `(iss, pubkey, now)` [FI-INV-14], and byte-exact denial for all four `DenialClass` variants. `admit_nip_fi_http_on_state()` is the single call-site wrapper for all surfaces [FI-TRACE-AUTHORITY-UNIFORM]. **Protected surfaces** (all gated via the uniform authority): - HTTP bridge: `POST /events`, `/query`, `/count` - GIF proxy: `POST /gifs/search`, `/gifs/share` - Workflow reads: `GET /workflows/{id}/runs`, `GET /workflows/{id}/runs/{id}/approvals` - Moderation reads: `GET /moderation/reports`, `/moderation/audit`, `/moderation/restricted` - Media/Blossom: `PUT /upload`, `/media/upload` - Git smart-HTTP: `info/refs`, `upload-pack`, `receive-pack` - Git settings control plane: `GET`/`POST /git/:owner/:repo/default-branch` (now routed through `admit_nip_fi_http_on_state` for full assertion key-pairing) - Invites: `POST /api/invites` (mint only; claim exempt as pre-membership enrollment door) **NIP-98 strictness in NIP-FI mode:** - When NIP-FI is not Off, bridge POST handlers force `require_auth_token = true` — the `X-Pubkey` dev-mode fallback cannot satisfy the pairing requirement [NIP-FI.md:547-567] - POST bodies on `/events`, `/query`, `/count` require a matching `payload` tag in enforce mode [NIP-FI.md:579-597] - A payload tag present with no content (one-element `["payload"]` or empty string) is rejected as a structurally invalid claim — NIP-98 requires the content to be a valid 64-char lowercase hex SHA-256 digest. Malformed payload diagnostics use a static error message (no attacker-controlled content in logs). - Off mode preserves pre-NIP-FI behavior per [FI-INV-15] **Off-mode compatibility changes:** Admission now runs before request validation on every protected surface, so Off mode differs from pre-NIP-FI behavior in these ways: - **GIF routes:** NIP-98 authentication happens before the upstream provider lookup. An unauthenticated request on a tenant with no GIF provider now returns 401 instead of 404. The 401 goes through the legacy `api_error()` path, so the body is JSON `{"error":"..."}` with no `WWW-Authenticate` header. - **Workflow runs** (`GET /workflows/{id}/runs`): the query string is parsed after admission. A request with a missing credential and a malformed query (for example `?limit=abc`) now returns 401 instead of 400. - **Authorized malformed query:** once admitted, a malformed query returns a JSON 400 `{"error":"invalid query: ..."}` from `parse_query_or_400` instead of axum's `text/plain` `Query` rejection. - **Moderation reads** (`/moderation/reports`, `/moderation/audit`): same extraction change as workflow runs. Admission comes first, then a JSON 400 for a malformed query. - **Malformed `payload` tag:** shared NIP-98 verification now rejects a malformed `payload` tag in every mode, including on bodyless requests. That means a one-element `["payload"]`, an empty digest, or anything other than 64 lowercase hex characters. Before this change, a bodyless request ignored the tag. **Auth header cardinality:** Enforce mode rejects requests with more than one `Authorization` header with 403 `evidence rejected\n`. DenyProtected returns 503 `authorization unavailable\n` before this check (and before NIP-98 or assertion verification) runs. The cardinality gate is enforced at the NIP-FI admission boundary (`admit_nip_fi_http`), not via legacy auth boolean flags, so it applies uniformly to all Enforce-mode surfaces including Git. Off mode passes through the first header value unchanged, preserving pre-NIP-FI behavior [FI-INV-15]. **Denial-class remapping:** In Enforce mode, NIP-98 extraction/verification failures from the closure are mapped to the appropriate `DenialClass`: `MissingEvidence` when no `Authorization` header is present, `EvidenceRejected` when the header is present but invalid. Off mode propagates legacy responses unchanged [FI-INV-15]; for example, the Git missing-Authorization and wrong-scheme 401s keep their legacy bytes with no `content-type` header. **JWKS refresh scheduling:** The refresh timer's `*last` checkpoint is set after the fetch completes, not from the pre-fetch timestamp, preventing interval drift under network latency. The loop is extracted to `nip_fi_jwks_refresh_loop` for unit-testability. **Log hygiene [NIP-FI.md:777-779]:** Issuer identity is never written to logs or error messages. All startup/refresh diagnostics use an ordinal index (`issuer_index = N`). Config parse errors for malformed issuers use classify()/line/column codes rather than raw config values. **Admin SPA document routes:** `/reports`, `/reports/{id}`, and `/feedback` are exempt from the NIP-FI assertion guard only on the admin host (host-qualified via `is_admin_host`). These paths are not added to the broad `NIP_FI_EXEMPT_PREFIXES` list — tenant hosts remain fully protected. **Deny-map seam (`HttpDenyMap` trait):** signature is `(issuer, pubkey, now)` matching `NipFiDenyMap::is_denied` from S4 (PR #7265) so integration is a one-liner. Stub is `AlwaysAdmitStubDenyMap` — named to accurately describe stub-phase semantics (fail-open intentionally pending S4). **Route inventory:** every authenticated HTTP route is classified as Protected or Exempt with justification. Documented in the F4 seam-test comment in `bridge.rs`. **Outer-guard tests:** `#[ignore = "requires Postgres"]` tests in `bridge.rs` drive the full router (`build_router`) in Enforce mode with valid NIP-98 but no `Nostr-Federated-Identity` header. These exercise the **outer `nip_fi_assertion_guard` middleware** in `router.rs` — the layer that fires before any handler runs. They prove the guard's exact response bytes (401 `authentication required\n`, `text/plain; charset=utf-8`, `WWW-Authenticate: Nostr`) for each protected route. Per-handler `admit_nip_fi_http_on_state` wiring is proven separately by the cardinality test and the media/settings/git handler-level suites. ## Seams **S3 seam:** `nip_fi_config.rs` is duplicated (S3 not landed; session-deadline parts stripped). Removed when S5 rebases onto S3. **S4 seam:** `AlwaysAdmitStubDenyMap` until S4 provides the real map. Integration is one line at the `admit_nip_fi_http_on_state` wrapper. ## Evidence At head `ee338ea9c` (branch `hayt/nip-fi-http-enforcement`), hosted CI run [36167450853](https://github.com/block/buzz/actions/runs/36167450853): - **Rust / Unit Tests** ([job 108179338458](https://github.com/block/buzz/actions/runs/36167450853/job/108179338458)): passed. The `buzz-relay` `test-unit` step ran 171 tests with 171 passes. Its PASS lines include all 57 non-ignored tests this PR adds to the relay: `nip_fi_http::tests` (25), `nip_fi_config::tests` (8), `router::tests` (8), `api::parse_query_tests` (5), `api: |
||
|
|
70895b355f |
feat(buzz-auth): add production NIP-FI federated assertion runtime (#7109)
Depends on #6994 (merged). Stack: PR 2 #6994 (merged) → this PR (#7109) → PR 4 #7148 → PR 5 ## What Production assertion runtime for NIP-FI Phase A: JWKS caching layer, SSRF-hardened HTTP fetcher, startup validation gate, NIP-11 discovery serialization, federated assertion verifier with sealed key-source authority, and supporting invariant tests. ## Changes ### `crates/buzz-auth/src/nip_fi/jwks/` `ProductionJwksSource<F>` implements the sealed `IssuerKeySource` trait: - `HttpJwksFetcher`: reqwest-backed fetch with SSRF protection — URI validation (HTTPS, no credentials, no fragment, no host matched by the shared enumerated deny policy), per-fetch DNS resolution rejecting any resolved address matched by the shared enumerated deny policy, address pinning to prevent DNS rebinding TOCTOU, redirect denial, incremental body streaming capped at 512 KiB before any parse - IPv6 host extraction via typed `Url::host()` accessor (strips brackets before SSRF check and provides the correct bare input form for reqwest `resolve()` pinning; the bracketed form from `host_str()` fails `IpAddr::parse` and does not match the URL authority key) - Complete-operation deadline via `tokio::time::timeout` covering DNS resolution through body streaming - Bounded periodic refresh with configurable interval and hard snapshot deadline - Cancellation-safe RAII refresh permit: dropped on future cancellation so the next caller can re-fetch - Content-digest-gated generation counter: identical re-fetches preserve generation; key rotations advance it - Injectable clock (`now_fn: Arc<dyn Fn() -> DateTime<Utc> + Send + Sync>`): production uses `Arc::new(Utc::now)`; all four deadline creation and expiry checks use `(self.now_fn)()`, enabling controlled-time testing without wall-clock sleep `JwksSourceContract`: a closed value type that is the single source of truth for the three deployment fields whose change alters which keys the runtime trusts and how long it trusts them: - `jwks_uri` — selects the authenticated key source; validated at construction (HTTPS, no credentials/fragment, no bare private-IP host); stored as the `Url`-normalized form so that equivalent spellings (uppercase host, explicit default port `:443`, dot-segment paths like `/.well-known/./jwks.json`) converge to the same `AssertionPolicyId` - `refresh_interval_seconds` — defines bounded refresh behavior; positive, ≤ 1 year, strictly < `key_snapshot_hard_deadline_seconds` - `key_snapshot_hard_deadline_seconds` — defines the source's accepted time rule; every `VerifiedAssertion.revalidation_dependencies` deadline derives from this `JwksSourceContract` is a required `IssuerPolicy` input and is included in `derive_assertion_policy_id` after a domain separator. `IssuerJwksConfig` embeds the contract instead of independently restating these fields — startup validation rejects any contract mismatch (`NipFiStartupError::JwksContractMismatch`). ### `crates/buzz-auth/src/nip_fi/verifier.rs` - `FederatedAssertionVerifier<S>`: provider-neutral verifier over a closed multi-issuer registry and sealed `IssuerKeySource` - `Arc<S>: IssuerKeySource` forwarding impl (blanket seal for `Arc<S>` in the sealed module) — one `Arc<ProductionJwksSource>` can be shared across multiple verifiers; all observe JWKS refreshes through the shared cache without rebuilding the verifier - `AssertionKeySet`: crate-private constructor seals issuer binding — no external crate can relabel issuer B's JWKS as issuer A - Sealed `IssuerKeySource` trait closes the authority-construction seam at both ends ### `crates/buzz-core/src/network.rs` Renamed `is_private_ip` to `is_not_global_unicast` (compat alias retained) and restored the complete IANA deny/exception table from this branch's own history (`272dacadb`). The predicate is an enumerated deny/explicit exception policy: addresses covered by a named deny rule are rejected; addresses not covered by any explicit deny rule (e.g. `fe00::1`) pass through. Deny rules are derived from the IANA Special-Purpose Address Space registries (last updated 2025-10-09), with globally-reachable exceptions carved out explicitly (e.g. PCP/TURN anycast inside 2001::/23). Blocked IPv4 classes: loopback (127/8), private RFC 1918 (10/8, 172.16/12, 192.168/16), link-local (169.254/16), unspecified (0/8), broadcast, CGNAT/RFC 6598 (100.64/10), benchmarking/RFC 2544 (198.18/15), IETF Protocol Assignments (192.0.0.0/24, globally reachable exceptions: 192.0.0.9 PCP anycast RFC 7723 and 192.0.0.10 TURN anycast RFC 8155), documentation/RFC 5737 (192.0.2/24, 198.51.100/24, 203.0.113/24), deprecated 6to4 relay anycast (192.88.99.0/24, RFC 7526, global=None → conservative deny), multicast/RFC 5771 (224/4), reserved class-E (240/4). Blocked IPv6 classes: loopback (::1), unspecified (::), ULA (fc00::/7), link-local (fe80::/10), deprecated site-local (fec0::/10, RFC 3879), multicast (ff00::/8), IETF Protocol Assignments envelope (2001::/23, globally reachable exceptions: 2001:1::1–::3 PCP/TURN/DNS-SD anycast, 2001:3::/32 AMT RFC 7450, 2001:4:112::/48 AS112-v6 RFC 7535, 2001:20::/28 ORCHIDv2 RFC 7343, 2001:30::/28 DETs RFC 9374), documentation (2001:db8::/32 RFC 3849, 3fff::/20 RFC 9637), 6to4 (2002::/16, RFC 3056), Discard-Only (100::/64, RFC 6666), Dummy IPv6 Prefix (100:0:0:1::/64, RFC 9780), SRv6 SIDs (5f00::/16, RFC 9252), NAT64 local-use (64:ff9b:1::/48, RFC 8215). IPv4 embedded in mapped, compatible, NAT64 well-known (64:ff9b::/96), and SIIT IPv4-translated (::ffff:0:0:0/96) forms is checked recursively. All three callers (JWKS boundary, webhook SSRF, link-preview SSRF) inherit the complete predicate through the inline `is_private_ip` compatibility alias. ### Invariant coverage **Canonical URI convergence.** `jwks_contract_uri_canonicalization_convergence_and_divergence` asserts that uppercase host, explicit `:443`, and dot-segment path (`/.well-known/./jwks.json`) each produce the same `AssertionPolicyId` as the canonical form; a genuinely different host or path diverges. Mutation: storing raw input bytes instead of `parsed.to_string()` turns the three convergence assertions red. **Resolved-target and pin-input seam.** `resolved_target_and_pin_key_seam_public_ipv6_and_fec0_rejection` carries a public `2606:4700::1` URI through all three stages of `fetch_jwks_inner`: `extract_url_host_and_port` yields the bare host (no brackets), `resolve_and_check_ssrf` takes the IP-literal fast path and returns the accepted `IpAddr`, and the extracted host string equals the URL authority form (verifying the correct bare input to reqwest's `resolve()` pin call). `fec0::1` traverses the same extraction and SSRF stages and is rejected as `InvalidUri`. Network-free: both addresses are IP literals with no DNS lookup. Mutation: restoring `host_str()` brackets the address, `IpAddr::parse` fails, the SSRF fast path is unreachable, and all three assertions flip red. **Controlled original-deadline rotation.** `shared_arc_source_verifier_rejects_expired_a1_accepts_a2` uses an `AtomicI64`-backed injectable clock to advance past A1's original absolute deadline without wall-clock sleep. A1's deadline is computed at T0 and never mutated. The clock advances to T0 + HARD_DEADLINE_SECS + 1; `get_snapshot` fires a re-fetch and installs A2. One unchanged `FederatedAssertionVerifier` then rejects A1-signed tokens (deadline enforced by the `key_set` read path) and accepts A2-signed tokens, proves A2's generation is strictly greater, and confirms A2's deadline is later than A1's original. Mutation oracle: replace the shared `Arc` with an independently constructed source built from the same configs and sharing the same controlled clock, warmed with a separate A1 fetch before advancement. Post-advancement, `key_set()` on the verifier's independent source filters the expired A1 snapshot (`filter(|c| now < c.hard_deadline)`) and returns no keys — the verifier never re-fetches and never observes A2. A1-reject stays green (the independent cache is also expired, so no A1 keys are served), but A2-accept flips red, because the verifier never observes A2. A2 acceptance is the reliable shared-source oracle. **Complete SSRF classifier boundary.** JWKS-boundary tests cover every newly restored class through `validate_jwks_uri` (URI-validation path): `192.0.0.1` (IETF Protocol Assignments interior), `192.0.0.9`/`.10` (PCP/TURN anycast global exceptions), `192.88.99.1` (deprecated 6to4 anycast), `2001:2::1` (2001::/23 interior), `2001:1::1` (2001::/23 global exception), `100::1` (Discard-Only), `3fff::1` (documentation), and `5f00::1` (SRv6 SIDs). URI validation and resolved-target enforcement share the same `is_not_global_unicast` predicate, so these URI-path tests exercise the complete classifier table. All pass mutation: removing any deny branch makes the rejection assertion red; removing any exception branch makes the acceptance assertion red. The resolved-target enforcement path is covered separately by `resolved_target_and_pin_key_seam_public_ipv6_and_fec0_rejection` for `::1` (loopback), public `2606:4700::1`, and `fec0::1`. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com> |
||
|
|
953a1798b2 |
feat(auth): add NIP-FI canonical assertion verifier and contracts (#6776)
The closed, provider-neutral contract layer at the root of the NIP-FI federated-identity dependency graph — Phase A, PR 1 of the plan. It has no dependencies on any other PR and defines no database schema, migration, runtime JWKS fetching, binding resolution, enrollment, or request/proof binding; those belong to later PRs. ## What this adds - **Multi-issuer assertion-policy config** (`IssuerRegistry`, `IssuerPolicy`) keyed by exact `iss`. Identity is issuer-qualified `(iss, sub)` throughout — equal `sub` under different `iss` are distinct identities. The subject coordinate is fixed to the JWT `sub` claim (`SUBJECT_CLAIM`), never configurable, so no deployment can seal a mutable attribute like `email` as identity. Issuer URL and audience remain deployment configuration. - **The two deterministic semantic contract identities** — `AssertionPolicyId = H(canonical assertion-policy contract)` and `TransportContractId = H(canonical transport contract)` — derived by length-prefixed, domain-separated SHA-256 so a semantic change moves exactly its owning ID while benign JWKS rotation never changes policy lineage. Set-valued policy inputs (audiences, algorithms, subject-class values, scope capture) are canonicalized before derivation, so the ID is invariant under permutation and duplication. Config fields that a freshness class never reads are rejected at construction (an `offline-jwt` policy cannot carry `maximum_status_age`), so the canonical encoding stays total over valid configs and semantically identical policies always derive one ID. - **The single canonical verifier** (`FederatedAssertionVerifier`, `FI-INV-16`) producing the origin-sealed, provider-neutral `VerifiedAssertion` normalized result. Its constructor is crate-private, so unverified claims cannot be promoted into authority. The issuer→JWKS authority is entirely crate-owned: `AssertionKeySet` has no public constructor and `IssuerKeySource` is sealed, so no downstream crate can relabel one issuer's keys as another's. The authenticated key set is bounded (`MAX_JWKS_KEYS`) and the bound is folded into `AssertionPolicyId`, so an unbounded attacker-controlled `kid` scan cannot be driven; every snapshot requires a finite positive hard deadline. - **Sealed revalidation dependencies** — `RevalidationDependencies` carries the key-snapshot hard deadline and a `ConfidentialAssertion` handle to the exact compact JWS (no `Debug`/`Display`/`serde` leak, sole read path `compact_jws()`), so a changed snapshot can revalidate the same evidence: a retained key revalidates, a removed key denies. - **The privacy-preserving four-class denial contract** (`DenialClass`, `FI-INV-13`) with the byte-exact Nostr text, HTTP status, body, `Content-Type`, and `WWW-Authenticate` values fixed by the spec's rejection table. An unreadable required current dependency maps to `authorization_unavailable`/503, never to rejected evidence, and rejected evidence never masquerades as a 503 at either end of the pipeline: all bounded, dependency-independent checks (compact structure, header, signature shape, policy, algorithm, token class) precede key-source lookup, and all offline validation (token-class, key, signature, audience, claims, time) completes before any status-witness deferral. A wrong-`typ` or structurally malformed token, or malformed/invalidly-signed input naming a current-status issuer, therefore denies with `evidence_rejected`/403 rather than being reported as a 503 availability signal. ## Corrections applied to the mined source Mined from the `buzz-auth` verifier core in [#1476](https://github.com/block/buzz/pull/1476) and corrected to the settled spec (the merged `docs/nips/NIP-FI*.md`, [#5946](https://github.com/block/buzz/pull/5946)), which settled after #1476 was written: - Token-class selection with exact `typ` enforcement. Two classes are offered: `at+jwt` and `nip-fi+jwt`. There is deliberately no generic/absent-`typ` "named compatibility" class: it cannot be proven disjoint from an OIDC ID token by claim presence alone (an issuer can mint an ID token carrying `client_id`), and the only authenticated discriminator is `typ`, which such a mode declines to constrain. - OIDC ID-token denial — denies even when `iss`, `aud`, and `sub` match, via exact `typ` mismatch against every accepted class. - The fixed `nostr_pubkey` claim accepted only as lowercase hex of exactly one 32-byte key; bech32 and other aliases deny. - Resource-owner / client-subject ambiguity denial and required `client_id` for `at+jwt`. - JWK admissibility: a key's `use` and, when present, `key_ops` must authorize signature verification (a key restricted to other operations such as `encrypt` is rejected), and the selected JOSE algorithm is bound to the key's required family and curve (ES256↔EC/P-256, ES384↔EC/P-384, EdDSA↔OKP/Ed25519, RS/PS↔RSA). The JWK `alg` is advisory; the actual key material is what signs, so a JWK declaring a matching `alg` over mismatched material (a different family or curve) is rejected before signature verification. - Spec-exact time arithmetic (`now < exp`, `iat <= now + skew`, `now < iat + maximum_assertion_age`, equality at expiry is expired). `exp`/`iat`/`nbf` accept finite integer or fractional RFC 7519 `NumericDate` values with checked, overflow-safe conversion; non-finite and out-of-range values deny. ## Verification In-crate tests in `crates/buzz-auth/src/nip_fi/verifier/tests.rs` sign real ES256 assertions against a fixed test key and cover: the happy path, exact-wire-text for all four denial classes, deterministic and semantic contract IDs, canonicalization invariance, token-class enforcement including ID-token denial, JWK `key_ops` rejection, `nostr_pubkey` hex handling, time bounds, and multi-issuer selection (same subject across distinct issuers yields distinct identities and policy IDs). They also cover the round-3 contracts: the key-set bound (oversized rejected, at-bound accepted, empty rejected), fixed-`sub` identity (identity is `sub` not a configured `email`; a token without `sub` denies), offline-before-deferral (invalid-signature, wrong-audience, malformed-claim, and expired under a current-status issuer each deny 403/`evidence_rejected`), and the revalidation contracts (dependencies carry the deadline and exact JWS; a retained key revalidates, a removed key denies). And the round-4 contracts: algorithm↔key-material binding, covered exactly per accepted algorithm (a table-driven matrix asserts every policy-acceptable algorithm — ES256, ES384, EdDSA, RS256/384/512, PS256/384/512 — matches only its required family/curve and rejects every other, so each mapping mutation fails individually; and an ES256 token against P-384, RSA, and Ed25519 material each deny `InvalidKey`); malformed evidence classified before key lookup (wrong-`typ`, two-segment, four-segment, empty-signature, and non-base64url-signature tokens against an empty source deny 403 not 503); `maximum_status_age` inapplicable under offline-jwt rejected at construction while current-status still requires it; and fractional `NumericDate` (finite fractional `iat`/`exp`/`nbf` within bounds verify, non-finite and out-of-range deny). Compile-fail doctests guard the sealed authority seam (`AssertionKeySet::new`, `IssuerKeySource`) and the absence of the named-compatibility token class. ## Notes - `Co-authored-by` attribution is preserved for the mined author. The #1476 `buzz-auth` commits are authored by Cea Stapleton Cordasco (the salvage map records #1476 under Franco; the git history on that PR is Cea's — surfacing for the attribution/closure record). - Amends `docs/nips/NIP-FI.md` to remove the "named compatibility access token" class and its `FI-TRACE-TOKEN-CLASS` oracle reference, so the normative spec matches the two-class root implementation (a generic/absent `typ` cannot be proven disjoint from an OIDC ID token). Removal-only, no neighbor redesign; a `docs/nips/` grep confirms no dangling cross-reference to the class remains. - Adds `jsonwebtoken 10.4.0` (`aws_lc_rs`) as a workspace dependency. Dependencies: none. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> |
||
|
|
1de916bdd2 |
feat(relay): add atomic create-only community provisioning (#1689)
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub16ynjrn8tm9gxg4kpgfgj24eup8met48z58m9g0cvmpq9msjht0lq7r96r6 <d12721ccebd9506456c1425125573c09f795d4e2a1f6543f0cd8405dc2575bfe@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub16ynjrn8tm9gxg4kpgfgj24eup8met48z58m9g0cvmpq9msjht0lq7r96r6 <d12721ccebd9506456c1425125573c09f795d4e2a1f6543f0cd8405dc2575bfe@sprout-oss.stage.blox.sqprod.co> |
||
|
|
14fba21e57 |
Multi-tenant Buzz relay: community_id as a server-resolved key (comprehensive rewrite) (#1321)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> |
||
|
|
d99ad131f1 |
refactor: rename sprout backend to buzz (#958)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <wpfleger@block.xyz> Signed-off-by: Will Pfleger <wpfleger@squareup.com> Signed-off-by: Will Pfleger <wpfleger96@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Will Pfleger <wpfleger96@gmail.com> |
