test(conformance): fill api_tokens_nip98_replay row — doc-only api_token half + wire-driven NIP-98 replay half

Fills both `pending_lane` stubs in `mod api_tokens_nip98_replay`:

# `token_minted_in_a_does_not_authorize_in_b` — doc-only

The api_token mint surface does not exist on the wire in `buzz-relay`: no
`/tokens` route in `router.rs:52-79` (verified by hand on PR head), no
`tokens` module in `crates/buzz-relay/src/api/`. The 792-line self-service
minting endpoint that existed pre-rewrite (sprout-relay PR #37, commit
`f84da74d3`) was deliberately not ported. Api_tokens are *consumed* (not
minted) by the Blossom upload path at `media.rs:638`.

This means "mint in A, present to B" has no wire precondition — a
wire-driven row would test a contract with no entry point. The honest
shape is doc-only, mirroring `audit_log`: where audit proves the
*output* surface does not exist on the wire, api_tokens proves the
*input* surface does not. Both are strictly stronger isolation claims
than a wire-denied assertion.

The `(community_id, token_hash)` fence itself is directly proven at the
storage layer (where direct Postgres access is in-convention):

  * `crates/buzz-db/src/api_token.rs:425
    lookup_by_hash_is_scoped_to_community` — same hash in A and B,
    A-scoped lookup returns A only.
  * `crates/buzz-db/src/api_token.rs:488
    active_lookup_by_hash_is_scoped_to_community` — mirror for the
    revoked-filter variant.

Plus the consumer fence: `media.rs:638` calls the scoped DB lookup with
`tenant.community()` derived from request host *before* token
resolution (`media.rs:97` comment names the row-44 fence explicitly).

# `nip98_replay_seenset_is_shared_and_community_scoped` — wire-driven

Load-bearing wire claim: within-community replay rejection. Sign a
NIP-98 event E for A's `u=`, POST to A → 200. POST again → 401 with a
body that names replay detection. The proof that the shared
(cross-pod) seen-set is in the request path at all — without it, any
pod would re-honor a spent NIP-98 event.

Mutate-bite: `check_nip98_replay → noop` in `bridge.rs:79` (return
`Ok(())` without consulting the guard). Under mutation, second POST
goes 200 instead of 401. Test asserts the failure with named
assertion message pointing at the mutate-bite handle, so a future
reader sees what would have been caught.

Cross-community independence is a *tripwire*, not a bite: sign an
independent NIP-98 event E' for B's `u=` (different event_id by
u-tag canonicalization divergence), POST to B → 200 even though E
was spent in A. Catches future namespace-globalization regressions
(key truncation, u-normalization collapse) that would break the
spend-spread, on top of the unit-layer proof at
`crates/buzz-auth/src/nip98_replay.rs:163
key_isolates_communities_for_same_event_id` (which the substrate's
own doc-comment names as "belt-and-suspenders").

The prefix-drop mutation considered earlier turned out to be vacuous
against natural wire traffic: u-tag divergence across communities
makes event_ids already community-distinct, so dropping the
community prefix from `nip98_replay_key` does not collapse natural
traffic into a shared slot. A same-event_id-different-community
wire collision can't be constructed because u-host
(`verify_bridge_auth`) rejects with 401 before the replay check
runs. That artificial property is proven at the unit layer; the
wire layer asserts the load-bearing per-call replay rejection.

# Bar

  * `cargo check -p buzz-test-client --tests`: clean.
  * `cargo clippy -p buzz-test-client --tests -- -D warnings`: clean.
  * `cargo fmt -p buzz-test-client -- --check`: clean.
  * Default test run (no `--ignored`): 1 passed (doc-only `#[test]`),
    16 ignored (live rows).
  * `--ignored api_tokens` against fresh `:3300` harness
    (`BUZZ_GIT_CONFORMANCE_PROBE=false`): GREEN.
  * Mutate-bite `check_nip98_replay → noop` on `bridge.rs:79`,
    rebuild, restart: RED on the within-A second-POST assertion
    ("second POST to A with the same NIP-98 event MUST be rejected
    as replay (got 200 OK)"), `left: 200, right: 401`. Restored
    byte-identical, GREEN again.

Base: PR #1321 head `ae703c5c8`. Test-only diff: zero lines in
`buzz-db`, `buzz-relay`, or `buzz-auth` production code. Matrix
8/14.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
2026-06-27 10:33:23 -04:00
co-authored by Tyler Longwell
parent ae703c5c89
commit da6051fdbc
@@ -538,27 +538,323 @@ mod nip11_relay_info {
mod api_tokens_nip98_replay {
use super::*;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use nostr::{EventBuilder, Keys, Kind, Tag};
use sha2::{Digest, Sha256};
/// Obligation: token hash uniqueness/lookup is `(community_id, token_hash)`;
/// a token minted in A does not authorize the same hash in B.
#[tokio::test]
#[ignore]
async fn token_minted_in_a_does_not_authorize_in_b() {
pending_lane(
"buzz-auth",
"identical token_hash in A and B → A's token rejected against B",
);
///
/// **This row is doc-only — and that is the honest answer.**
///
/// Every other wire-driven row in this file proves a black-box property: a
/// client addresses the relay over HTTP/WS and observes that the response
/// denies a cross-community oracle. This row's obligation cannot be tested
/// that way because **the api_token mint surface does not exist on the wire
/// in `buzz-relay`** — there is no route by which a client can bind a token
/// to a community over HTTP, so the "mint in A, present to B" precondition
/// has no entry point.
///
/// Verified on PR head:
/// * `crates/buzz-relay/src/router.rs:52-79` — full route list is `/`,
/// `/info`, `/.well-known/nostr.json`, `/health`, `/_liveness`,
/// `/_readiness`, `/events`, `/query`, `/count`, `/hooks/{id}`, plus
/// the media, git, and git-policy sub-routers. **No `/tokens` route.**
/// * `crates/buzz-relay/src/api/` directory is `{bridge, events, media,
/// nip05, git/, mod}` — no `tokens` module. The 792-line
/// `crates/sprout-relay/src/api/tokens.rs` self-service-minting
/// endpoint that existed pre-rewrite (PR #37, commit `f84da74d3`) was
/// deliberately not ported. Porting it to enable this row would be a
/// product + security-surface decision, not a test-enablement task.
/// * Api_tokens are consumed (not minted) by the Blossom upload path at
/// `crates/buzz-relay/src/api/media.rs:638`, which extracts the
/// `X-Auth-Token: buzz_*` header and looks up
/// `state.db.get_api_token_by_hash_including_revoked(tenant.community(),
/// &hash)`. The comment immediately above that call names the row-44
/// fence explicitly: *"A token minted in community A presented to a
/// host that resolves to community B must not authorize."*
///
/// So where other rows prove *the oracle is denied*, api_tokens proves
/// *the oracle's input surface does not exist* — a sibling of the
/// `audit_log` row (whose proof is that the *output* surface does not
/// exist). Both are strictly stronger than a wire-denied assertion, and
/// the honest way to state it is to cite the facts, not to invent an
/// out-of-band mint path that would break the conformance file's black-box
/// contract (its deps are `buzz-ws-client`/`reqwest`/`tokio-tungstenite`/
/// `s3` — no `sqlx`, no `buzz-db`).
///
/// The `(community_id, token_hash)` fence itself is proven directly at the
/// storage layer, where direct Postgres access is in-convention:
///
/// 1. `crates/buzz-db/src/api_token.rs:425
/// lookup_by_hash_is_scoped_to_community` — inserts two rows with
/// **identical 32-byte hash** in communities A and B (legal under
/// `UNIQUE(community_id, token_hash)`), then asserts A-scoped lookup
/// returns A's row only, B-scoped lookup returns B's row only, and a
/// third (unrelated) community returns None. The mutate-bite handle
/// is named in the test's doc-comment: strip `AND community_id = $1`
/// from `get_api_token_by_hash_including_revoked` and the lookup
/// becomes hash-only, returning whichever row Postgres picks
/// first — the cross-tenancy assertion fails. Sharp row-44 shape.
/// 2. `crates/buzz-db/src/api_token.rs:488
/// active_lookup_by_hash_is_scoped_to_community` — mirror for the
/// `revoked_at IS NULL` variant `Db::get_api_token_by_hash`. Same
/// shape: same hash, distinct communities, scoped lookup returns the
/// caller's row only.
///
/// And the consumer fence — `media.rs:638` — calls the scoped DB lookup
/// with `tenant.community()` derived from the request host *before* token
/// resolution (`media.rs:97` comment: *"This MUST run before scope
/// resolution so the API-token lookup is keyed on (community_id,
/// token_hash). Resolving scopes without a tenant in hand would query
/// api_tokens by hash alone, defeating the cross-community fence."*).
///
/// Substrate on PR head: `api_tokens` table has UNIQUE index
/// `(community_id, token_hash)`; `CommunityId` is a server-resolved type
/// (never client input); `tenant.community()` is bound from the request
/// host by `bind_community` before any tenant-scoped DB read or write.
#[test]
fn token_minted_in_a_does_not_authorize_in_b() {
// Compile-time anchor: this row is doc-only by design. The proof lives
// in the cited storage-layer unit tests; the wire surface for minting
// does not exist (see module doc-comment for the verified route list).
// If anyone adds a `/tokens` route to `buzz-relay`, this row's shape
// should be revisited and a wire-driven body added.
}
/// Obligation: NIP-98 replay seen-set is shared (any-pod) AND community
/// scoped: a nonce spent in A is still spendable in B, but a replay within A
/// is rejected from any pod.
/// scoped: a nonce spent in A is still spendable in B, but a replay within
/// A is rejected from any pod.
///
/// # Wire-observable claim and what bites it
///
/// The load-bearing wire-observable property is **within-community replay
/// rejection**: a NIP-98 event posted twice to the same community must be
/// rejected on the second attempt. This is the proof that the shared
/// (cross-pod) seen-set is in the request path at all — without it, any
/// pod would happily re-honor a spent NIP-98 event.
///
/// Mutate-bite for this assertion: turn `check_nip98_replay` into a no-op
/// in `crates/buzz-relay/src/api/bridge.rs:79` (return `Ok(())` before
/// consulting the guard). Under the mutation, the second POST goes 200
/// instead of 401 → the within-community replay assertion fails RED. The
/// bite fires on the replay check itself, not on a sibling fence.
///
/// # Why the cross-community independence arm is a tripwire, not a bite
///
/// The obligation's cross-community arm — "a nonce spent in A is still
/// spendable in B" — IS asserted by this test, but as a **positive
/// control** rather than a mutate-bite. Reasoning:
///
/// The replay key shape is `buzz:{community}:nip98:{event_id_hex}` (see
/// `crates/buzz-auth/src/nip98_replay.rs:103 nip98_replay_key`). The
/// community prefix is what makes the key per-community; the
/// `event_id_hex` is what makes it per-event. **On natural wire traffic
/// the event_id is already community-distinct**, because the NIP-98 `u`
/// tag is part of the signed canonical bytes and the per-tenant host
/// binding (see `verify_bridge_auth` + `nip98_expected_url` after the
/// row-44 sibling fix `bf8a1a4fa`) forces the `u` to differ per community.
/// So two events posted to A and B respectively are signed against
/// different `u` values → they have different event_ids → their seen-set
/// keys have different suffixes → they do not collide *regardless of
/// whether the community prefix is present*.
///
/// This means the community prefix is **structurally redundant for
/// natural wire traffic**. It is load-bearing only against an artificial
/// "same event_id surfaces in two communities" scenario, which content-
/// addressing makes implausible. The substrate's own doc-comment for the
/// unit test that proves the prefix isolates such artificial collisions
/// names the property as exactly that: "**Belt-and-suspenders**: even if a
/// same-id event surfaces in two communities (which content-addressing
/// makes implausible), the seen-set MUST consult two distinct rows."
/// (`crates/buzz-auth/src/nip98_replay.rs:163
/// key_isolates_communities_for_same_event_id`.)
///
/// Why u-host can't be bypassed to manufacture a wire collision: the
/// bridge processes requests in this order (`bridge.rs:242-259`):
/// 1. `bind_community` from request `Host` header (row-zero fence)
/// 2. `nip98_expected_url(state.config.relay_url, &tenant, "/events")`
/// builds the per-tenant expected `u`
/// 3. `verify_bridge_auth` rejects with 401 unless the signed event's
/// `u` tag matches the per-tenant expected URL
/// 4. **Only then** is `check_nip98_replay` called
///
/// A same-physical-event posted to both A and B would be rejected at step
/// 3 (u-host mismatch) for one of the two hosts, so it never reaches the
/// replay check from a wire test. The replay-prefix-drop mutation
/// proposed in earlier design rounds turned out to be vacuous against
/// natural wire traffic; a wire-driven bite on the prefix's load-
/// bearingness against the artificial-collision case is not constructible
/// from this file's black-box vantage point. The unit test cited above
/// proves it at the layer where the artificial construction is possible.
///
/// What the tripwire DOES catch: a future regression that globalizes the
/// seen-set namespace by truncating or normalizing the key (e.g.,
/// "simplifying" the key to just `buzz:nip98:{event_id}`, or
/// canonicalizing `u` in a way that collapses cross-tenant `u` values
/// into the same event_id) would break the "spend in A doesn't burn the
/// slot in B" arm even though u-tags differ. The tripwire assertion gives
/// such a regression somewhere to land at the wire layer, on top of the
/// unit-layer same-id-collision proof.
///
/// # Test layout
///
/// Two distinct keypairs per community (Eva's setup-equivalence vacuity
/// scar: distinct values make any leak surface as wrong-pubkey-spent /
/// wrong-content-stored, not silent-absent). The wire surface is `POST
/// /events` with `Authorization: Nostr <base64-NIP-98-event>`. Bodies are
/// minimal valid kind:1 nostr events authored by the same NIP-98 signer
/// (relay-membership is open under `BUZZ_REQUIRE_AUTH_TOKEN=false`).
#[tokio::test]
#[ignore]
async fn nip98_replay_seenset_is_shared_and_community_scoped() {
pending_lane(
"buzz-auth",
"replay key (community_id, event_id) in shared store; u-host must match req.community",
let http_a = to_http(&url_a());
let http_b = to_http(&url_b());
// Distinct keypairs per community — if the seen-set ever leaked into
// a globalized namespace via a regression in u-canonicalization, the
// tripwire assertion below catches it because B's post would 401 as
// "already spent" using A's key's slot.
let keys_a = Keys::generate();
let keys_b = Keys::generate();
assert_ne!(
keys_a.public_key().to_hex(),
keys_b.public_key().to_hex(),
"test design requires distinct keys per community"
);
// (1) Within-community replay rejection — the load-bearing wire bite.
//
// Sign a NIP-98 event E with u = A's /events URL, post it to A → must
// 200. Post the exact same NIP-98 event again to A → must 401 with a
// body that names replay detection. The mutate-bite is `check_nip98_
// replay → noop` in `bridge.rs:79`; under that mutation the second
// post goes 200 because the seen-set is not consulted, and this
// assertion fails RED on the within-community arm.
let events_url_a = format!("{http_a}/events");
let body_a = build_kind1_event_json(&keys_a, "A within-community replay test");
let auth_a_first = build_nip98_header(&keys_a, &events_url_a, "POST", body_a.as_bytes());
let client = reqwest::Client::new();
let first_a = client
.post(&events_url_a)
.header("Authorization", &auth_a_first)
.header("Content-Type", "application/json")
.body(body_a.clone())
.send()
.await
.unwrap_or_else(|e| panic!("first POST to A failed: {e}"));
assert!(
first_a.status().is_success(),
"first NIP-98 post to A must succeed, got {} (body: {})",
first_a.status(),
first_a.text().await.unwrap_or_default(),
);
// Repost the SAME NIP-98 event (byte-identical Authorization header,
// byte-identical body — same canonical event id).
let second_a = client
.post(&events_url_a)
.header("Authorization", &auth_a_first)
.header("Content-Type", "application/json")
.body(body_a)
.send()
.await
.unwrap_or_else(|e| panic!("second POST to A failed: {e}"));
assert_eq!(
second_a.status(),
reqwest::StatusCode::UNAUTHORIZED,
"second POST to A with the same NIP-98 event MUST be rejected as \
replay (got {}) — if this returns 200, the shared seen-set is \
not in the request path. Mutate-bite handle: \
`check_nip98_replay` in bridge.rs:79.",
second_a.status(),
);
let second_a_body = second_a.text().await.unwrap_or_default();
assert!(
second_a_body.contains("replay"),
"second POST to A's 401 body should name replay detection, got: \
{second_a_body:?}",
);
// (2) Cross-community spend-spread — tripwire, no mutate-bite.
//
// Sign an independent NIP-98 event E' for B's /events URL (different
// u → different event_id by signed-canonical divergence). Post E' to
// B → must 200 even though E was already spent in A. This catches a
// future namespace-globalization regression in the seen-set (e.g.,
// key truncation, u-normalization collapse) by giving such a
// regression somewhere to land at the wire layer. The cross-
// community-prefix isolation property against an artificial same-id
// collision is proven at the unit layer by
// `nip98_replay.rs:163 key_isolates_communities_for_same_event_id`.
let events_url_b = format!("{http_b}/events");
let body_b = build_kind1_event_json(&keys_b, "B cross-community spread test");
let auth_b = build_nip98_header(&keys_b, &events_url_b, "POST", body_b.as_bytes());
let first_b = client
.post(&events_url_b)
.header("Authorization", &auth_b)
.header("Content-Type", "application/json")
.body(body_b)
.send()
.await
.unwrap_or_else(|e| panic!("POST to B failed: {e}"));
assert!(
first_b.status().is_success(),
"POST of an independent NIP-98 event to B must succeed (200) — \
A's spent nonce must not burn B's seen-set slot. Got {} (body: \
{}). Tripwire: if this fails, the seen-set namespace has been \
globalized and the per-community scope is broken. The artificial \
same-event_id-different-community case is proven separately at \
`crates/buzz-auth/src/nip98_replay.rs:163 \
key_isolates_communities_for_same_event_id`.",
first_b.status(),
first_b.text().await.unwrap_or_default(),
);
}
/// Build a `Authorization: Nostr <base64>` header value for NIP-98 HTTP
/// auth (kind 27235 `HttpAuth` with `u`/`method`/`payload` tags). Mirrors
/// the pattern in `crates/buzz-auth/src/nip98.rs` and the helper in
/// `crates/buzz-test-client/tests/e2e_tokens.rs:52` — kept local to this
/// row so the conformance file's rows stay self-contained.
fn build_nip98_header(keys: &Keys, url: &str, method: &str, body: &[u8]) -> String {
let payload_hash = 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_hash]).expect("payload tag"),
];
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags(tags)
.sign_with_keys(keys)
.expect("sign NIP-98 event");
let json = nostr::JsonUtil::as_json(&event);
let encoded = BASE64.encode(json.as_bytes());
format!("Nostr {encoded}")
}
/// Build a minimal kind:1 nostr event JSON for the bridge ingest path.
/// The body is just a valid signed event — the test does not care about
/// the content beyond it surviving relay-side parsing.
fn build_kind1_event_json(keys: &Keys, content: &str) -> String {
let event = EventBuilder::new(Kind::TextNote, content)
.sign_with_keys(keys)
.expect("sign kind:1");
nostr::JsonUtil::as_json(&event)
}
/// Convert any base form to `http(s)://` for REST.
fn to_http(base: &str) -> String {
if base.starts_with("http://") || base.starts_with("https://") {
base.trim_end_matches('/').to_string()
} else {
base.replace("wss://", "https://")
.replace("ws://", "http://")
.trim_end_matches('/')
.to_string()
}
}
}