diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d54c3cfd3..c4e5f9dbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -611,11 +611,12 @@ jobs: env: RELAY_URL: ws://localhost:3000 - name: NIP-37 draft wrap e2e - # Feature e2e for NIP-37 draft wraps (kind:31234): write-path - # validation, NIP-01 replacement/tombstone ordering, author-only privacy - # across all read paths (WS REQ, WS COUNT, HTTP /query, /count, live - # fan-out), known-d privacy tripwires, FTS/NIP-50 exclusion, and NIP-11 - # advertisement. + # Feature e2e for NIP-37 draft wraps (kind:31234), channel-bound + # contract: h-tag validation, channel existence + membership gates, + # immutable channel binding, author-only privacy (WS REQ, WS COUNT, + # HTTP /query, /count, live fan-out), known-d privacy tripwires, + # FTS/NIP-50 exclusion, workflow exclusion, tenant confinement, and + # NIP-11 advertisement. run: cargo test --profile ci -p buzz-test-client --test e2e_nip37_draft -- --ignored env: RELAY_URL: ws://localhost:3000 diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 999d4e1a4..8f5f44c16 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -915,6 +915,38 @@ pub async fn get_latest_global_replaceable( } } +/// Fetch the `channel_id` of the current NIP-33 head for a kind:31234 draft address. +/// +/// Used by the relay ingest handler to enforce immutable channel binding: if a +/// head already exists for `(community, author, 31234, d_tag)`, its `channel_id` +/// must match the incoming event's `channel_id`. A draft cannot be re-bound to a +/// different channel. +/// +/// Returns `Ok(Some(Some(uuid)))` if a live head exists with a channel_id, +/// `Ok(Some(None))` if a head exists but has NULL channel_id (should not happen +/// in practice but handled defensively), `Ok(None)` if no live head exists, +/// `Err` on database failure. +pub async fn get_draft_head_channel_id( + pool: &PgPool, + community_id: CommunityId, + pubkey_bytes: &[u8], + d_tag: &str, +) -> Result>> { + let row: Option<(Option,)> = sqlx::query_as( + "SELECT channel_id FROM events \ + WHERE community_id = $1 AND kind = 31234 AND pubkey = $2 AND d_tag = $3 \ + AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(pubkey_bytes) + .bind(d_tag) + .fetch_optional(pool) + .await?; + + Ok(row.map(|(ch,)| ch)) +} + /// Fetches a single event by its raw 32-byte ID, **including soft-deleted rows**. /// /// Most callers should use [`get_event_by_id`] instead. This variant is needed diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9dd7b501a..4ec1dbb50 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -721,6 +721,20 @@ impl Db { event::get_latest_global_replaceable(&self.pool, community_id, kind, pubkey_bytes).await } + /// Fetch the `channel_id` of the current NIP-33 head for a kind:31234 draft address. + /// + /// Returns `Ok(Some(Some(uuid)))` if a live head exists with a channel_id, + /// `Ok(Some(None))` if a live head exists without a channel_id (defensive), + /// `Ok(None)` if no live head exists, `Err` on database failure. + pub async fn get_draft_head_channel_id( + &self, + community_id: CommunityId, + pubkey_bytes: &[u8], + d_tag: &str, + ) -> Result>> { + event::get_draft_head_channel_id(&self.pool, community_id, pubkey_bytes, d_tag).await + } + /// Fetches a single non-deleted event by its raw ID bytes. /// /// Returns `None` if the event does not exist or has been soft-deleted. diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 18ea4b2ea..48165e51a 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -404,11 +404,6 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { // NIP-AM: agent turn metrics are owner-scoped global events. // Channel identity is encrypted inside the payload — no `h` tag. | KIND_AGENT_TURN_METRIC - // NIP-37: draft wraps are author-private global events. - // Compose context (channel, DM, reply target) is encrypted inside - // the payload — no outer `h` tag. A stray `h` tag is rejected at - // validation time (see `validate_draft_wrap_envelope`). - | KIND_DRAFT ) } @@ -441,6 +436,12 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { | KIND_HUDDLE_PARTICIPANT_LEFT | KIND_HUDDLE_ENDED | KIND_HUDDLE_GUIDELINES + // NIP-37: draft wraps are channel-bound author-private events. + // Each draft is scoped to a specific channel or DM; the `h` tag + // carries the channel UUID. The relay resolves and validates the + // channel, enforces membership, and persists the draft with + // channel_id set. + | KIND_DRAFT ) } @@ -1209,8 +1210,11 @@ fn validate_not_before(tag_value: &str) -> Result { /// (NIP-37 does not prescribe it); the relay accepts any non-empty opaque identifier. /// 2. Exactly one `k` tag with a canonical ASCII-decimal inner kind value in the /// unsigned 16-bit range (0..=65535) with no leading zeros (except bare "0"). -/// 3. No outer `h` tag — compose context is encrypted inside the payload. +/// 3. Exactly one `h` tag with a canonical UUID value — the channel or DM this +/// draft is bound to. The relay resolves and validates the channel separately; +/// this check only validates the tag's syntactic shape. /// 4. No outer `p` tag — prevents this event from entering the mention/feed index. +/// Recipient/reply/edit details remain encrypted inside the payload. /// 5. Non-empty content must be a syntactically plausible NIP-44 v2 ciphertext /// (reuses `validate_engram_nip44_content`). Empty content is the NIP-37 /// deletion tombstone and is explicitly valid. @@ -1225,6 +1229,8 @@ fn validate_draft_wrap_envelope(event: &Event) -> Result<(), String> { let mut d_value: Option<&str> = None; let mut k_count = 0usize; let mut k_value: Option<&str> = None; + let mut h_count = 0usize; + let mut h_value: Option<&str> = None; let mut expiration_count = 0usize; let mut expiration_value: Option<&str> = None; @@ -1243,9 +1249,8 @@ fn validate_draft_wrap_envelope(event: &Event) -> Result<(), String> { k_value = Some(&parts[1]); } "h" => { - return Err( - "draft-wrap event must not have an `h` tag (compose context belongs inside the encrypted payload)".to_string(), - ); + h_count += 1; + h_value = Some(&parts[1]); } "p" => { return Err( @@ -1296,6 +1301,20 @@ fn validate_draft_wrap_envelope(event: &Event) -> Result<(), String> { "draft-wrap `k` tag value out of range (must fit unsigned 16-bit kind)".to_string() })?; + // Validate `h` tag — required, exactly one, must be a well-formed UUID. + if h_count != 1 { + return Err(format!( + "draft-wrap event must have exactly one `h` tag (got {h_count}); \ + draft wraps are channel-bound" + )); + } + let h = h_value.unwrap(); + if uuid::Uuid::parse_str(h).is_err() { + return Err(format!( + "draft-wrap `h` tag must be a canonical UUID (channel or DM id), got: {h:?}" + )); + } + // Validate `expiration` tag (optional, at most one). if expiration_count > 1 { return Err(format!( @@ -2362,6 +2381,46 @@ async fn ingest_event_inner( buzz_db::event::D_TAG_MAX_LEN, ))); } + + // Immutable channel binding for kind:31234 (NIP-37 draft wraps). + // + // The NIP-33 address (community, author, 31234, d_tag) is unique, but + // channel_id is NOT part of the NIP-33 key. This means a replacement + // with a different h-tag would silently re-bind the draft to a new + // channel while still winning the NIP-01 replacement. To prevent that, + // we reject any incoming kind:31234 update whose channel_id differs + // from the stored head's channel_id. + // + // A tombstone (empty content) carries the same h-tag as the draft it + // closes — this check enforces that invariant too. + if kind_u32 == KIND_DRAFT { + let pubkey_bytes = auth.pubkey().to_bytes().to_vec(); + match state + .db + .get_draft_head_channel_id(tenant.community(), &pubkey_bytes, &d_tag) + .await + { + Ok(Some(head_ch)) => { + // A live head exists. Its channel_id must match the incoming event's. + if head_ch != channel_id { + return Err(IngestError::Rejected( + "invalid: draft-wrap channel binding is immutable — \ + `h` tag must match the existing head's channel" + .into(), + )); + } + } + Ok(None) => { + // No live head — this is the first write for this address. + } + Err(e) => { + return Err(IngestError::Internal(format!( + "error: checking draft channel binding: {e}" + ))); + } + } + } + state .db .replace_parameterized_event(tenant.community(), &event, &d_tag, channel_id) @@ -3553,7 +3612,8 @@ mod tests { #[test] fn draft_wrap_accepts_ciphertext_content() { let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft(&[&["d", &d], &["k", "9"]], &fake_nip44_v2()); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", "9"], &["h", &ch]], &fake_nip44_v2()); assert!( validate_draft_wrap_envelope(&ev).is_ok(), "canonical draft with ciphertext content must be accepted" @@ -3563,7 +3623,8 @@ mod tests { #[test] fn draft_wrap_accepts_blank_tombstone() { let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft(&[&["d", &d], &["k", "9"]], ""); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", "9"], &["h", &ch]], ""); assert!( validate_draft_wrap_envelope(&ev).is_ok(), "tombstone (empty content) must be accepted" @@ -3574,7 +3635,8 @@ mod tests { fn draft_wrap_accepts_various_valid_k_values() { for k in ["0", "1", "9", "1000", "30023", "65535"] { let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft(&[&["d", &d], &["k", k]], ""); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", k], &["h", &ch]], ""); assert!( validate_draft_wrap_envelope(&ev).is_ok(), "k={k} must be accepted" @@ -3586,14 +3648,16 @@ mod tests { #[test] fn draft_wrap_rejects_missing_d_tag() { - let ev = make_draft(&[&["k", "9"]], &fake_nip44_v2()); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["k", "9"], &["h", &ch]], &fake_nip44_v2()); let err = validate_draft_wrap_envelope(&ev).unwrap_err(); assert!(err.contains("`d` tag"), "got: {err}"); } #[test] fn draft_wrap_rejects_empty_d_tag() { - let ev = make_draft(&[&["d", ""], &["k", "9"]], &fake_nip44_v2()); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", ""], &["k", "9"], &["h", &ch]], &fake_nip44_v2()); let err = validate_draft_wrap_envelope(&ev).unwrap_err(); assert!(err.contains("`d` tag"), "got: {err}"); } @@ -3601,7 +3665,11 @@ mod tests { #[test] fn draft_wrap_rejects_duplicate_d_tag() { let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft(&[&["d", &d], &["d", &d], &["k", "9"]], &fake_nip44_v2()); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft( + &[&["d", &d], &["d", &d], &["k", "9"], &["h", &ch]], + &fake_nip44_v2(), + ); let err = validate_draft_wrap_envelope(&ev).unwrap_err(); assert!(err.contains("`d` tag"), "got: {err}"); } @@ -3611,7 +3679,8 @@ mod tests { #[test] fn draft_wrap_rejects_missing_k_tag() { let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft(&[&["d", &d]], &fake_nip44_v2()); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["h", &ch]], &fake_nip44_v2()); let err = validate_draft_wrap_envelope(&ev).unwrap_err(); assert!(err.contains("`k` tag"), "got: {err}"); } @@ -3619,7 +3688,11 @@ mod tests { #[test] fn draft_wrap_rejects_duplicate_k_tag() { let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft(&[&["d", &d], &["k", "9"], &["k", "9"]], &fake_nip44_v2()); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft( + &[&["d", &d], &["k", "9"], &["k", "9"], &["h", &ch]], + &fake_nip44_v2(), + ); let err = validate_draft_wrap_envelope(&ev).unwrap_err(); assert!(err.contains("`k` tag"), "got: {err}"); } @@ -3627,7 +3700,8 @@ mod tests { #[test] fn draft_wrap_rejects_k_tag_non_decimal() { let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft(&[&["d", &d], &["k", "0x9"]], &fake_nip44_v2()); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", "0x9"], &["h", &ch]], &fake_nip44_v2()); let err = validate_draft_wrap_envelope(&ev).unwrap_err(); assert!(err.contains("canonical decimal"), "got: {err}"); } @@ -3635,7 +3709,8 @@ mod tests { #[test] fn draft_wrap_rejects_k_tag_leading_zero() { let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft(&[&["d", &d], &["k", "09"]], &fake_nip44_v2()); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", "09"], &["h", &ch]], &fake_nip44_v2()); let err = validate_draft_wrap_envelope(&ev).unwrap_err(); assert!(err.contains("leading zero"), "got: {err}"); } @@ -3643,35 +3718,31 @@ mod tests { #[test] fn draft_wrap_rejects_k_tag_out_of_u16_range() { let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); // 65536 = u16::MAX + 1 - let ev = make_draft(&[&["d", &d], &["k", "65536"]], &fake_nip44_v2()); + let ev = make_draft( + &[&["d", &d], &["k", "65536"], &["h", &ch]], + &fake_nip44_v2(), + ); let err = validate_draft_wrap_envelope(&ev).unwrap_err(); assert!(err.contains("range"), "got: {err}"); } - // ── h / p outer-tag exclusion ───────────────────────────────────────────── - - #[test] - fn draft_wrap_rejects_h_tag() { - let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft( - &[ - &["d", &d], - &["k", "9"], - &["h", &uuid::Uuid::new_v4().to_string()], - ], - &fake_nip44_v2(), - ); - let err = validate_draft_wrap_envelope(&ev).unwrap_err(); - assert!(err.contains("`h` tag"), "got: {err}"); - } + // ── p outer-tag exclusion ───────────────────────────────────────────────── + // Note: `h` is now *required* (not forbidden); see h-tag validation section. #[test] fn draft_wrap_rejects_p_tag() { let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); let keys = nostr::Keys::generate(); let ev = make_draft( - &[&["d", &d], &["k", "9"], &["p", &keys.public_key().to_hex()]], + &[ + &["d", &d], + &["k", "9"], + &["h", &ch], + &["p", &keys.public_key().to_hex()], + ], &fake_nip44_v2(), ); let err = validate_draft_wrap_envelope(&ev).unwrap_err(); @@ -3683,7 +3754,8 @@ mod tests { #[test] fn draft_wrap_rejects_non_base64_content() { let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft(&[&["d", &d], &["k", "9"]], "not-a-ciphertext"); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", "9"], &["h", &ch]], "not-a-ciphertext"); let err = validate_draft_wrap_envelope(&ev).unwrap_err(); assert!( err.contains("base64") || err.contains("NIP-44"), @@ -3694,9 +3766,10 @@ mod tests { #[test] fn draft_wrap_rejects_wrong_nip44_version_byte() { let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); // 132 chars of valid base64 but version byte decodes to 0x00, not 0x02. let bad = "A".repeat(132); - let ev = make_draft(&[&["d", &d], &["k", "9"]], &bad); + let ev = make_draft(&[&["d", &d], &["k", "9"], &["h", &ch]], &bad); let err = validate_draft_wrap_envelope(&ev).unwrap_err(); assert!( err.contains("NIP-44 v2") || err.contains("0x02"), @@ -3708,7 +3781,8 @@ mod tests { fn draft_wrap_rejects_short_ciphertext() { // 1-byte decoded (version prefix only, no payload) — too short. let d = uuid::Uuid::new_v4().to_string(); - let ev = make_draft(&[&["d", &d], &["k", "9"]], "Ag=="); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", "9"], &["h", &ch]], "Ag=="); let err = validate_draft_wrap_envelope(&ev).unwrap_err(); assert!(err.contains("too short"), "got: {err}"); } @@ -3718,9 +3792,15 @@ mod tests { #[test] fn draft_wrap_accepts_valid_future_expiration() { let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); // A timestamp far in the future (year 2100). let ev = make_draft( - &[&["d", &d], &["k", "9"], &["expiration", "4102444800"]], + &[ + &["d", &d], + &["k", "9"], + &["h", &ch], + &["expiration", "4102444800"], + ], "", ); assert!( @@ -3732,10 +3812,12 @@ mod tests { #[test] fn draft_wrap_rejects_duplicate_expiration_tag() { let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); let ev = make_draft( &[ &["d", &d], &["k", "9"], + &["h", &ch], &["expiration", "4102444800"], &["expiration", "4102444800"], ], @@ -3748,8 +3830,14 @@ mod tests { #[test] fn draft_wrap_rejects_expiration_in_past() { let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); let ev = make_draft( - &[&["d", &d], &["k", "9"], &["expiration", "1000000000"]], + &[ + &["d", &d], + &["k", "9"], + &["h", &ch], + &["expiration", "1000000000"], + ], "", ); let err = validate_draft_wrap_envelope(&ev).unwrap_err(); @@ -3759,8 +3847,14 @@ mod tests { #[test] fn draft_wrap_rejects_non_decimal_expiration() { let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); let ev = make_draft( - &[&["d", &d], &["k", "9"], &["expiration", "not-a-number"]], + &[ + &["d", &d], + &["k", "9"], + &["h", &ch], + &["expiration", "not-a-number"], + ], "", ); let err = validate_draft_wrap_envelope(&ev).unwrap_err(); @@ -3770,12 +3864,20 @@ mod tests { // ── routing invariants ──────────────────────────────────────────────────── #[test] - fn draft_wrap_is_global_only() { - // Draft wraps must never be channel-scoped; compose context lives in - // the encrypted payload only. + fn draft_wrap_requires_h_channel_scope() { + // Draft wraps are channel-bound; compose context exposed via `h` tag. assert!( - is_global_only_kind(KIND_DRAFT), - "KIND_DRAFT must be global-only (no h-tag channel scope)" + requires_h_channel_scope(KIND_DRAFT), + "KIND_DRAFT must require an `h` tag (channel-bound)" + ); + } + + #[test] + fn draft_wrap_is_not_global_only() { + // Draft wraps have a required `h` tag — they are channel-scoped, not global. + assert!( + !is_global_only_kind(KIND_DRAFT), + "KIND_DRAFT must not be global-only (it requires an h tag)" ); } @@ -3789,11 +3891,56 @@ mod tests { ); } + // ── h-tag validation ────────────────────────────────────────────────────── + #[test] - fn draft_wrap_does_not_require_h_tag() { + fn draft_wrap_accepts_valid_h_tag_uuid() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", "9"], &["h", &ch]], &fake_nip44_v2()); assert!( - !requires_h_channel_scope(KIND_DRAFT), - "KIND_DRAFT must not require an h tag" + validate_draft_wrap_envelope(&ev).is_ok(), + "draft with valid UUID h tag must be accepted" + ); + } + + #[test] + fn draft_wrap_rejects_missing_h_tag() { + let d = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", "9"]], &fake_nip44_v2()); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!( + err.contains("`h` tag") || err.contains("channel-bound"), + "got: {err}" + ); + } + + #[test] + fn draft_wrap_rejects_duplicate_h_tag() { + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let ev = make_draft( + &[&["d", &d], &["k", "9"], &["h", &ch], &["h", &ch]], + &fake_nip44_v2(), + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!( + err.contains("`h` tag") || err.contains("channel-bound"), + "got: {err}" + ); + } + + #[test] + fn draft_wrap_rejects_non_uuid_h_tag() { + let d = uuid::Uuid::new_v4().to_string(); + let ev = make_draft( + &[&["d", &d], &["k", "9"], &["h", "not-a-uuid"]], + &fake_nip44_v2(), + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!( + err.contains("`h` tag") || err.contains("UUID"), + "got: {err}" ); } } diff --git a/crates/buzz-test-client/tests/e2e_nip37_draft.rs b/crates/buzz-test-client/tests/e2e_nip37_draft.rs index 61fbfccda..5755684f4 100644 --- a/crates/buzz-test-client/tests/e2e_nip37_draft.rs +++ b/crates/buzz-test-client/tests/e2e_nip37_draft.rs @@ -1,23 +1,20 @@ -//! End-to-end integration tests for NIP-37 draft wraps (kind:31234). +//! End-to-end integration tests for NIP-37 draft wraps (kind:31234), +//! channel-bound contract. //! -//! These tests verify: -//! - Write-path validation: d/k tag rules, h/p rejection, expiration, -//! ciphertext validation, blank tombstone acceptance, oversized d -//! - Replacement ordering: NIP-01 last-write-wins, same-second tie-break -//! (lower lexicographic event ID wins), stale write cannot supersede -//! current head, tombstone replaces live draft as addressable head +//! Every kind:31234 must carry exactly one `h` UUID binding it to a Buzz +//! channel (or DM). The relay enforces: +//! +//! - Structural: valid `d`/`k`/`h` tags, `p` forbidden +//! - Channel existence: `h` UUID must resolve to a live channel +//! - Membership: author must be a member of that channel +//! - Immutable binding: once written, the `h` tag is frozen per (author, d_tag) //! - Author-only reads: REQ, WS COUNT, WS subscription, HTTP /query, /count -//! all confine drafts to their author — exclusive, mixed/kindless, ids, -//! known-#d filters, search/FTS, fan-out -//! - known-#d privacy tripwires: attacker knowing the `d` value does NOT -//! retrieve or count the draft via exclusive or kindless #d filters -//! - FTS / NIP-50: draft content is never surfaced in search results -//! - NIP-11: relay advertises NIP-37, does not advertise NIP-40 +//! - FTS exclusion: search_tsv = NULL, never surfaces in NIP-50 results +//! - Workflow exclusion: draft events must not appear in workflow triggers +//! - NIP-11 advertisement: relay claims NIP-37 //! //! # Running //! -//! Start the relay, then run: -//! //! ```text //! RELAY_URL=ws://localhost:3000 cargo test -p buzz-test-client --test e2e_nip37_draft -- --ignored //! ``` @@ -30,6 +27,9 @@ use reqwest::Client; use serde_json::{json, Value}; const KIND_DRAFT: u16 = 31234; +const KIND_CREATE_CHANNEL: u16 = 9007; +const KIND_PUT_USER: u16 = 9000; +const KIND_REMOVE_USER: u16 = 9001; fn relay_url() -> String { std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) @@ -62,32 +62,110 @@ fn fake_nip44_v2() -> String { s } -/// Build a valid kind:31234 draft wrap event with given timestamps. -fn build_draft_at( - keys: &Keys, - d_tag: &str, - k_val: &str, - content: &str, - ts: Timestamp, -) -> nostr::Event { - EventBuilder::new(Kind::Custom(KIND_DRAFT), content) +/// Create an open channel as `owner`; returns the channel UUID string. +async fn create_open_channel(owner: &Keys) -> String { + let client = http_client(); + let ch_id = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_CREATE_CHANNEL), "") .tags([ - Tag::parse(["d", d_tag]).unwrap(), - Tag::parse(["k", k_val]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + Tag::parse(["name", &format!("nip37-test-{ch_id}")]).unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", "open"]).unwrap(), ]) - .custom_created_at(ts) - .sign_with_keys(keys) - .unwrap() + .sign_with_keys(owner) + .unwrap(); + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", &owner.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).unwrap()) + .send() + .await + .expect("create channel"); + let body: Value = resp.json().await.expect("parse channel response"); + assert!( + body["accepted"].as_bool().unwrap_or(false), + "channel creation not accepted: {body}" + ); + ch_id } -/// Build a valid kind:31234 draft wrap event at current time. -fn build_draft(keys: &Keys, d_tag: &str, k_val: &str, content: &str) -> nostr::Event { - build_draft_at(keys, d_tag, k_val, content, Timestamp::now()) +/// Create a private channel as `owner`; returns the channel UUID string. +async fn create_private_channel(owner: &Keys) -> String { + let client = http_client(); + let ch_id = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_CREATE_CHANNEL), "") + .tags([ + Tag::parse(["h", &ch_id]).unwrap(), + Tag::parse(["name", &format!("nip37-priv-{ch_id}")]).unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", "private"]).unwrap(), + ]) + .sign_with_keys(owner) + .unwrap(); + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", &owner.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).unwrap()) + .send() + .await + .expect("create private channel"); + let body: Value = resp.json().await.expect("parse channel response"); + assert!( + body["accepted"].as_bool().unwrap_or(false), + "private channel creation not accepted: {body}" + ); + ch_id } -/// Build a blank-content tombstone (NIP-37 deletion) for a draft address. -fn build_tombstone(keys: &Keys, d_tag: &str, k_val: &str, ts: Timestamp) -> nostr::Event { - build_draft_at(keys, d_tag, k_val, "", ts) +/// Add `member` to a channel via kind:9000 submitted by `owner` over HTTP. +async fn add_member_http(client: &Client, owner: &Keys, channel_id: &str, member: &Keys) { + let event = EventBuilder::new(Kind::Custom(KIND_PUT_USER), "") + .tags([ + Tag::parse(["h", channel_id]).unwrap(), + Tag::parse(["p", &member.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(owner) + .unwrap(); + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", &owner.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).unwrap()) + .send() + .await + .expect("add member"); + let body: Value = resp.json().await.expect("parse add-member response"); + assert!( + body["accepted"].as_bool().unwrap_or(false), + "add member not accepted: {body}" + ); +} + +/// Remove `member` from a channel via kind:9001 submitted by `owner` over HTTP. +async fn remove_member_http(client: &Client, owner: &Keys, channel_id: &str, member: &Keys) { + let event = EventBuilder::new(Kind::Custom(KIND_REMOVE_USER), "") + .tags([ + Tag::parse(["h", channel_id]).unwrap(), + Tag::parse(["p", &member.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(owner) + .unwrap(); + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", &owner.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).unwrap()) + .send() + .await + .expect("remove member"); + let body: Value = resp.json().await.expect("parse remove-member response"); + assert!( + body["accepted"].as_bool().unwrap_or(false), + "remove member not accepted: {body}" + ); } /// Submit an event via the HTTP bridge and return (accepted, message). @@ -137,16 +215,299 @@ async fn query_events_http( .expect("parse query response") } -// ─── Ingest validation ──────────────────────────────────────────────────────── +/// Build a valid kind:31234 draft wrap event bound to `channel_id`. +fn build_draft( + keys: &Keys, + d_tag: &str, + k_val: &str, + channel_id: &str, + content: &str, +) -> nostr::Event { + build_draft_at(keys, d_tag, k_val, channel_id, content, Timestamp::now()) +} + +/// Build a valid kind:31234 draft wrap event bound to `channel_id` at `ts`. +fn build_draft_at( + keys: &Keys, + d_tag: &str, + k_val: &str, + channel_id: &str, + content: &str, + ts: Timestamp, +) -> nostr::Event { + EventBuilder::new(Kind::Custom(KIND_DRAFT), content) + .tags([ + Tag::parse(["d", d_tag]).unwrap(), + Tag::parse(["k", k_val]).unwrap(), + Tag::parse(["h", channel_id]).unwrap(), + ]) + .custom_created_at(ts) + .sign_with_keys(keys) + .unwrap() +} + +/// Build a blank-content tombstone (NIP-37 deletion) bound to `channel_id`. +fn build_tombstone( + keys: &Keys, + d_tag: &str, + k_val: &str, + channel_id: &str, + ts: Timestamp, +) -> nostr::Event { + build_draft_at(keys, d_tag, k_val, channel_id, "", ts) +} + +// ─── h-tag validation ───────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_missing_h_tag() { + let client = http_client(); + let keys = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + // no h tag + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "missing h tag should be rejected"); + assert!( + msg.contains("h` tag") || msg.contains("channel-bound"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_duplicate_h_tag() { + let client = http_client(); + let keys = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + let ch = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch]).unwrap(), + Tag::parse(["h", &ch]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "duplicate h tag should be rejected"); + assert!( + msg.contains("h` tag") || msg.contains("channel-bound"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_non_uuid_h_tag() { + let client = http_client(); + let keys = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", "not-a-uuid"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "non-UUID h tag should be rejected"); + assert!( + msg.contains("UUID") || msg.contains("h` tag"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_nonexistent_channel_h_tag() { + // h tag is a syntactically valid UUID, but no channel exists for it. + let client = http_client(); + let keys = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + let nonexistent_ch = uuid::Uuid::new_v4().to_string(); + let event = build_draft(&keys, &d, "9", &nonexistent_ch, &fake_nip44_v2()); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "draft to nonexistent channel should be rejected"); + assert!( + msg.contains("channel") || msg.contains("not found") || msg.contains("member"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_non_member_author() { + // Channel exists but author is not a member. + let client = http_client(); + let owner = Keys::generate(); + let non_member = Keys::generate(); + + let ch_id = create_private_channel(&owner).await; + + let d = uuid::Uuid::new_v4().to_string(); + let event = build_draft(&non_member, &d, "9", &ch_id, &fake_nip44_v2()); + let (accepted, msg) = submit_event_http(&client, &non_member, &event).await; + assert!( + !accepted, + "non-member should be unable to post draft: {msg}" + ); + assert!( + msg.contains("member") || msg.contains("restricted"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_accepted_by_channel_member() { + // Channel owner is always a member — their draft must be accepted. + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + + let d = uuid::Uuid::new_v4().to_string(); + let event = build_draft(&owner, &d, "9", &ch_id, &fake_nip44_v2()); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; + assert!(accepted, "owner draft must be accepted: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_after_member_removed() { + // Member writes a draft; gets removed; attempts a replacement — must be rejected. + let client = http_client(); + let owner = Keys::generate(); + let member = Keys::generate(); + let ch_id = create_private_channel(&owner).await; + add_member_http(&client, &owner, &ch_id, &member).await; + + let d = uuid::Uuid::new_v4().to_string(); + let now = Timestamp::now().as_secs(); + let v1 = build_draft_at( + &member, + &d, + "9", + &ch_id, + &fake_nip44_v2(), + Timestamp::from(now - 1), + ); + let (ok1, msg1) = submit_event_http(&client, &member, &v1).await; + assert!(ok1, "member draft v1 must be accepted: {msg1}"); + + remove_member_http(&client, &owner, &ch_id, &member).await; + + let v2 = build_draft(&member, &d, "9", &ch_id, &fake_nip44_v2()); + let (accepted, msg) = submit_event_http(&client, &member, &v2).await; + assert!( + !accepted, + "removed member should not be able to update draft: {msg}" + ); + assert!( + msg.contains("member") || msg.contains("restricted"), + "unexpected message: {msg}" + ); +} + +// ─── Immutable channel binding ──────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_channel_binding_is_immutable() { + // Once a draft is bound to channel A, updating it with h=B must be rejected. + let client = http_client(); + let owner = Keys::generate(); + let ch_a = create_open_channel(&owner).await; + let ch_b = create_open_channel(&owner).await; + + let d = uuid::Uuid::new_v4().to_string(); + let now = Timestamp::now().as_secs(); + let v1 = build_draft_at( + &owner, + &d, + "9", + &ch_a, + &fake_nip44_v2(), + Timestamp::from(now - 1), + ); + let (ok1, msg1) = submit_event_http(&client, &owner, &v1).await; + assert!(ok1, "initial draft to ch_a must be accepted: {msg1}"); + + // Attempt to update the same d to a different channel. + let v2 = build_draft(&owner, &d, "9", &ch_b, &fake_nip44_v2()); + let (accepted, msg) = submit_event_http(&client, &owner, &v2).await; + assert!( + !accepted, + "rebinding draft to a different channel must be rejected" + ); + assert!( + msg.contains("immutable") || msg.contains("channel"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_same_channel_replacement_accepted() { + // Updating a draft on the same channel must succeed (normal NIP-33 replacement). + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + + let d = uuid::Uuid::new_v4().to_string(); + let now = Timestamp::now().as_secs(); + let v1 = build_draft_at( + &owner, + &d, + "9", + &ch_id, + &fake_nip44_v2(), + Timestamp::from(now - 1), + ); + let (ok1, msg1) = submit_event_http(&client, &owner, &v1).await; + assert!(ok1, "v1 must be accepted: {msg1}"); + + let v2 = build_draft(&owner, &d, "9", &ch_id, &fake_nip44_v2()); + let v2_id = v2.id; + let (ok2, msg2) = submit_event_http(&client, &owner, &v2).await; + assert!(ok2, "v2 same-channel replacement must be accepted: {msg2}"); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(owner.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + let results = query_events_http(&client, &owner.public_key().to_hex(), vec![filter]).await; + assert_eq!(results.len(), 1, "replacement must leave exactly one head"); + assert_eq!( + results[0]["id"].as_str().unwrap(), + v2_id.to_hex(), + "v2 must be the current head" + ); +} + +// ─── Ingest validation (structural) ────────────────────────────────────────── #[tokio::test] #[ignore] async fn test_draft_accepted_with_ciphertext_content() { let client = http_client(); - let keys = Keys::generate(); - let d_tag = uuid::Uuid::new_v4().to_string(); - let event = build_draft(&keys, &d_tag, "9", &fake_nip44_v2()); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + let event = build_draft(&owner, &d, "9", &ch_id, &fake_nip44_v2()); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; assert!(accepted, "valid draft rejected: {msg}"); } @@ -154,10 +515,11 @@ async fn test_draft_accepted_with_ciphertext_content() { #[ignore] async fn test_draft_accepted_blank_tombstone() { let client = http_client(); - let keys = Keys::generate(); - let d_tag = uuid::Uuid::new_v4().to_string(); - let event = build_tombstone(&keys, &d_tag, "9", Timestamp::now()); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + let event = build_tombstone(&owner, &d, "9", &ch_id, Timestamp::now()); + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; assert!(accepted, "blank tombstone rejected: {msg}"); } @@ -165,17 +527,19 @@ async fn test_draft_accepted_blank_tombstone() { #[ignore] async fn test_draft_accepted_future_expiration() { let client = http_client(); - let keys = Keys::generate(); - let d_tag = uuid::Uuid::new_v4().to_string(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) .tags([ - Tag::parse(["d", &d_tag]).unwrap(), + Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), Tag::parse(["expiration", "4102444800"]).unwrap(), // year 2100 ]) - .sign_with_keys(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; assert!(accepted, "future expiration draft rejected: {msg}"); } @@ -183,12 +547,16 @@ async fn test_draft_accepted_future_expiration() { #[ignore] async fn test_draft_rejected_missing_d_tag() { let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) - .tags([Tag::parse(["k", "9"]).unwrap()]) - .sign_with_keys(&keys) + .tags([ + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + ]) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; assert!(!accepted, "missing d tag should be rejected"); assert!(msg.contains("d` tag"), "unexpected message: {msg}"); } @@ -197,15 +565,17 @@ async fn test_draft_rejected_missing_d_tag() { #[ignore] async fn test_draft_rejected_empty_d_tag() { let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) .tags([ Tag::parse(["d", ""]).unwrap(), Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), ]) - .sign_with_keys(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; assert!(!accepted, "empty d tag should be rejected"); assert!(msg.contains("d` tag"), "unexpected message: {msg}"); } @@ -214,17 +584,19 @@ async fn test_draft_rejected_empty_d_tag() { #[ignore] async fn test_draft_rejected_oversized_d_tag() { let client = http_client(); - let keys = Keys::generate(); - // D_TAG_MAX_LEN is 255 bytes in buzz-db. Use 256 'a' chars. - let d_tag = "a".repeat(256); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + // D_TAG_MAX_LEN is 1024 bytes in buzz-db. Use 1025 'a' chars. + let d_tag = "a".repeat(1025); let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) .tags([ Tag::parse(["d", &d_tag]).unwrap(), Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), ]) - .sign_with_keys(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; assert!(!accepted, "oversized d tag should be rejected"); assert!( msg.contains("d` tag") || msg.contains("too long"), @@ -236,17 +608,19 @@ async fn test_draft_rejected_oversized_d_tag() { #[ignore] async fn test_draft_rejected_duplicate_d_tag() { let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), ]) - .sign_with_keys(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; assert!(!accepted, "duplicate d tag should be rejected"); assert!(msg.contains("d` tag"), "unexpected message: {msg}"); } @@ -255,13 +629,17 @@ async fn test_draft_rejected_duplicate_d_tag() { #[ignore] async fn test_draft_rejected_missing_k_tag() { let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) - .tags([Tag::parse(["d", &d]).unwrap()]) - .sign_with_keys(&keys) + .tags([ + Tag::parse(["d", &d]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + ]) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; assert!(!accepted, "missing k tag should be rejected"); assert!(msg.contains("k` tag"), "unexpected message: {msg}"); } @@ -270,17 +648,19 @@ async fn test_draft_rejected_missing_k_tag() { #[ignore] async fn test_draft_rejected_duplicate_k_tag() { let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "9"]).unwrap(), Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), ]) - .sign_with_keys(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; assert!(!accepted, "duplicate k tag should be rejected"); assert!(msg.contains("k` tag"), "unexpected message: {msg}"); } @@ -289,16 +669,18 @@ async fn test_draft_rejected_duplicate_k_tag() { #[ignore] async fn test_draft_rejected_malformed_k_tag_non_decimal() { let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "0x9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), ]) - .sign_with_keys(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; assert!(!accepted, "non-decimal k tag should be rejected"); assert!( msg.contains("canonical decimal"), @@ -310,16 +692,18 @@ async fn test_draft_rejected_malformed_k_tag_non_decimal() { #[ignore] async fn test_draft_rejected_k_tag_leading_zero() { let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "09"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), ]) - .sign_with_keys(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; assert!(!accepted, "k tag with leading zero should be rejected"); assert!(msg.contains("leading zero"), "unexpected message: {msg}"); } @@ -328,54 +712,39 @@ async fn test_draft_rejected_k_tag_leading_zero() { #[ignore] async fn test_draft_rejected_k_tag_out_of_range() { let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "65536"]).unwrap(), // u16::MAX + 1 + Tag::parse(["h", &ch_id]).unwrap(), ]) - .sign_with_keys(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; assert!(!accepted, "k=65536 should be rejected (out of u16 range)"); assert!(msg.contains("range"), "unexpected message: {msg}"); } -#[tokio::test] -#[ignore] -async fn test_draft_rejected_h_tag() { - let client = http_client(); - let keys = Keys::generate(); - let d = uuid::Uuid::new_v4().to_string(); - let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) - .tags([ - Tag::parse(["d", &d]).unwrap(), - Tag::parse(["k", "9"]).unwrap(), - Tag::parse(["h", &uuid::Uuid::new_v4().to_string()]).unwrap(), - ]) - .sign_with_keys(&keys) - .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; - assert!(!accepted, "h tag on draft should be rejected"); - assert!(msg.contains("h` tag"), "unexpected message: {msg}"); -} - #[tokio::test] #[ignore] async fn test_draft_rejected_p_tag() { let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "9"]).unwrap(), - Tag::parse(["p", &keys.public_key().to_hex()]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), + Tag::parse(["p", &owner.public_key().to_hex()]).unwrap(), ]) - .sign_with_keys(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; assert!(!accepted, "p tag on draft should be rejected"); assert!(msg.contains("p` tag"), "unexpected message: {msg}"); } @@ -384,16 +753,18 @@ async fn test_draft_rejected_p_tag() { #[ignore] async fn test_draft_rejected_malformed_ciphertext() { let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), "not-a-ciphertext") .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), ]) - .sign_with_keys(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; assert!(!accepted, "malformed ciphertext should be rejected"); assert!( msg.contains("base64") || msg.contains("NIP-44"), @@ -405,17 +776,19 @@ async fn test_draft_rejected_malformed_ciphertext() { #[ignore] async fn test_draft_rejected_expiration_in_past() { let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) .tags([ Tag::parse(["d", &d]).unwrap(), Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["h", &ch_id]).unwrap(), Tag::parse(["expiration", "1000000000"]).unwrap(), // long past ]) - .sign_with_keys(&keys) + .sign_with_keys(&owner) .unwrap(); - let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + let (accepted, msg) = submit_event_http(&client, &owner, &event).await; assert!(!accepted, "past expiration should be rejected"); assert!(msg.contains("expiration"), "unexpected message: {msg}"); } @@ -426,32 +799,31 @@ async fn test_draft_rejected_expiration_in_past() { #[ignore] async fn test_draft_replaced_by_newer_event() { let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); - // Use timestamps offset from now so they pass ±15-min ingest gate. let now = Timestamp::now().as_secs(); let t0 = Timestamp::from(now - 2); let t1 = Timestamp::from(now - 1); - let v1 = build_draft_at(&keys, &d, "9", &fake_nip44_v2(), t0); - let v2 = build_draft_at(&keys, &d, "9", &fake_nip44_v2(), t1); + let v1 = build_draft_at(&owner, &d, "9", &ch_id, &fake_nip44_v2(), t0); + let v2 = build_draft_at(&owner, &d, "9", &ch_id, &fake_nip44_v2(), t1); let v2_id = v2.id; - let (ok1, msg1) = submit_event_http(&client, &keys, &v1).await; + let (ok1, msg1) = submit_event_http(&client, &owner, &v1).await; assert!(ok1, "v1 must be accepted: {msg1}"); - let (ok2, msg2) = submit_event_http(&client, &keys, &v2).await; + let (ok2, msg2) = submit_event_http(&client, &owner, &v2).await; assert!(ok2, "v2 must be accepted: {msg2}"); - // Author queries by #d — only the latest should be returned. let filter = Filter::new() .kind(nostr::Kind::Custom(KIND_DRAFT)) - .author(keys.public_key()) + .author(owner.public_key()) .custom_tag( nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), d.as_str(), ); - let results = query_events_http(&client, &keys.public_key().to_hex(), vec![filter]).await; + let results = query_events_http(&client, &owner.public_key().to_hex(), vec![filter]).await; assert_eq!(results.len(), 1, "should return exactly the latest draft"); assert_eq!( results[0]["id"].as_str().unwrap(), @@ -464,32 +836,31 @@ async fn test_draft_replaced_by_newer_event() { #[ignore] async fn test_draft_stale_write_cannot_supersede_current_head() { let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); let now = Timestamp::now().as_secs(); let t_old = Timestamp::from(now - 2); let t_new = Timestamp::from(now - 1); - let v_new = build_draft_at(&keys, &d, "9", &fake_nip44_v2(), t_new); - let v_old = build_draft_at(&keys, &d, "9", &fake_nip44_v2(), t_old); + let v_new = build_draft_at(&owner, &d, "9", &ch_id, &fake_nip44_v2(), t_new); + let v_old = build_draft_at(&owner, &d, "9", &ch_id, &fake_nip44_v2(), t_old); - // Submit new first, then try to replace with stale. - let (ok_n, msg_n) = submit_event_http(&client, &keys, &v_new).await; + let (ok_n, msg_n) = submit_event_http(&client, &owner, &v_new).await; assert!(ok_n, "newer draft must be accepted: {msg_n}"); - let (ok_o, msg_o) = submit_event_http(&client, &keys, &v_old).await; - // Relay may accept (duplicate) or reject the old event — either is correct; - // what matters is that the returned head is still the newer one. - let _ = (ok_o, msg_o); + // Relay may accept (no-op duplicate) or reject the stale event — either is + // correct; what matters is that the head is still the newer one. + let _ = submit_event_http(&client, &owner, &v_old).await; let filter = Filter::new() .kind(nostr::Kind::Custom(KIND_DRAFT)) - .author(keys.public_key()) + .author(owner.public_key()) .custom_tag( nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), d.as_str(), ); - let results = query_events_http(&client, &keys.public_key().to_hex(), vec![filter]).await; + let results = query_events_http(&client, &owner.public_key().to_hex(), vec![filter]).await; assert_eq!(results.len(), 1, "should have exactly one head"); assert_eq!( results[0]["id"].as_str().unwrap(), @@ -504,40 +875,45 @@ async fn test_draft_same_second_tie_break_lower_id_wins() { // Two events at identical timestamps: NIP-01 tie-break retains the one // with the lexically lower event ID, regardless of submission order. let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); - let now = Timestamp::now(); - // Generate candidates until we have two with different IDs at the same ts. - // Sign 10 candidates and pick the lexically lowest and highest pair. - let mut candidates = Vec::new(); - for _ in 0..10 { - let e = build_draft_at(&keys, &d, "9", &fake_nip44_v2(), now); + let ts = Timestamp::now(); + + // Sign candidates until we have at least two with distinct IDs. Because + // kind:31234 is parameterised-replaceable, the same (author, d, ts) may + // produce distinct IDs via different signing randomness. Generate up to 20 + // candidates; if every pair has the same ID (near-impossible), skip. + let mut candidates: Vec = Vec::new(); + for _ in 0..20 { + let e = build_draft_at(&owner, &d, "9", &ch_id, &fake_nip44_v2(), ts); candidates.push(e); } + // Deduplicate by ID. + candidates.dedup_by_key(|e| e.id.to_hex()); + if candidates.len() < 2 { + // Extremely unlikely — skip rather than fail. + return; + } candidates.sort_by(|a, b| a.id.to_hex().cmp(&b.id.to_hex())); let lowest = candidates.first().unwrap().clone(); let highest = candidates.last().unwrap().clone(); - if lowest.id == highest.id { - // Extremely unlikely — skip rather than fail. - return; - } - // Submit highest first, then lowest. - let (ok_h, msg_h) = submit_event_http(&client, &keys, &highest).await; + let (ok_h, msg_h) = submit_event_http(&client, &owner, &highest).await; assert!(ok_h, "highest-id draft must be accepted: {msg_h}"); - let (ok_l, msg_l) = submit_event_http(&client, &keys, &lowest).await; + let (ok_l, msg_l) = submit_event_http(&client, &owner, &lowest).await; assert!(ok_l, "lowest-id draft must be accepted: {msg_l}"); let filter = Filter::new() .kind(nostr::Kind::Custom(KIND_DRAFT)) - .author(keys.public_key()) + .author(owner.public_key()) .custom_tag( nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), d.as_str(), ); - let results = query_events_http(&client, &keys.public_key().to_hex(), vec![filter]).await; + let results = query_events_http(&client, &owner.public_key().to_hex(), vec![filter]).await; assert_eq!(results.len(), 1, "tie-break must leave exactly one head"); assert_eq!( results[0]["id"].as_str().unwrap(), @@ -550,30 +926,31 @@ async fn test_draft_same_second_tie_break_lower_id_wins() { #[ignore] async fn test_draft_tombstone_head_queryable_by_author() { let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); let now = Timestamp::now().as_secs(); let t_draft = Timestamp::from(now - 1); - let t_tomb = Timestamp::now(); // strictly newer + let t_tomb = Timestamp::now(); - let draft = build_draft_at(&keys, &d, "9", &fake_nip44_v2(), t_draft); - let tombstone = build_tombstone(&keys, &d, "9", t_tomb); + let draft = build_draft_at(&owner, &d, "9", &ch_id, &fake_nip44_v2(), t_draft); + let tombstone = build_tombstone(&owner, &d, "9", &ch_id, t_tomb); let tomb_id = tombstone.id; - let (ok_d, msg_d) = submit_event_http(&client, &keys, &draft).await; + let (ok_d, msg_d) = submit_event_http(&client, &owner, &draft).await; assert!(ok_d, "draft must be accepted: {msg_d}"); - let (ok_t, msg_t) = submit_event_http(&client, &keys, &tombstone).await; + let (ok_t, msg_t) = submit_event_http(&client, &owner, &tombstone).await; assert!(ok_t, "tombstone must be accepted: {msg_t}"); let filter = Filter::new() .kind(nostr::Kind::Custom(KIND_DRAFT)) - .author(keys.public_key()) + .author(owner.public_key()) .custom_tag( nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), d.as_str(), ); - let results = query_events_http(&client, &keys.public_key().to_hex(), vec![filter]).await; + let results = query_events_http(&client, &owner.public_key().to_hex(), vec![filter]).await; assert_eq!(results.len(), 1, "tombstone must be the queryable head"); assert_eq!( results[0]["id"].as_str().unwrap(), @@ -594,21 +971,22 @@ async fn test_draft_tombstone_head_queryable_by_author() { async fn test_draft_author_can_req_own_drafts_ws() { let url = relay_url(); let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); - let draft = build_draft(&keys, &d, "9", &fake_nip44_v2()); + let draft = build_draft(&owner, &d, "9", &ch_id, &fake_nip44_v2()); let draft_id = draft.id; - let (ok, msg) = submit_event_http(&client, &keys, &draft).await; + let (ok, msg) = submit_event_http(&client, &owner, &draft).await; assert!(ok, "draft must be accepted: {msg}"); - let mut c = BuzzTestClient::connect(&url, &keys) + let mut c = BuzzTestClient::connect(&url, &owner) .await .expect("connect author"); let sid = sub_id("author-req"); let filter = Filter::new() .kind(nostr::Kind::Custom(KIND_DRAFT)) - .author(keys.public_key()); + .author(owner.public_key()); c.subscribe(&sid, vec![filter]).await.expect("subscribe"); let results = c .collect_until_eose(&sid, Duration::from_secs(5)) @@ -624,15 +1002,14 @@ async fn test_draft_author_can_req_own_drafts_ws() { #[tokio::test] #[ignore] async fn test_draft_attacker_cannot_req_victims_drafts_exclusive_ws() { - // Victim stores a draft; attacker queries {kinds:[31234], authors:[victim]}. - // The relay must CLOSE the subscription with "restricted:". let url = relay_url(); let client = http_client(); let victim = Keys::generate(); let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; let d = uuid::Uuid::new_v4().to_string(); - let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); let (ok, msg) = submit_event_http(&client, &victim, &draft).await; assert!(ok, "victim draft must be accepted: {msg}"); @@ -645,11 +1022,11 @@ async fn test_draft_attacker_cannot_req_victims_drafts_exclusive_ws() { .author(victim.public_key()); ac.subscribe(&sid, vec![filter]).await.expect("subscribe"); - let msg = ac + let relay_msg = ac .recv_event(Duration::from_secs(5)) .await .expect("recv response"); - match msg { + match relay_msg { RelayMessage::Closed { subscription_id, message, @@ -674,16 +1051,13 @@ async fn test_draft_attacker_cannot_req_victims_drafts_exclusive_ws() { #[tokio::test] #[ignore] async fn test_draft_attacker_cannot_see_draft_in_kindless_filter_ws() { - // Victim stores a draft; attacker issues a kindless filter. - // Draft must be silently omitted. A public kind:0 event provides a - // positive control — the attacker MUST receive that but NOT the draft. let url = relay_url(); let client = http_client(); let victim = Keys::generate(); let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; let d = uuid::Uuid::new_v4().to_string(); - // Victim publishes a kind:0 profile event (public) and a draft (private). let profile = EventBuilder::new(Kind::Metadata, "{}") .sign_with_keys(&victim) .unwrap(); @@ -691,7 +1065,7 @@ async fn test_draft_attacker_cannot_see_draft_in_kindless_filter_ws() { let (ok_p, msg_p) = submit_event_http(&client, &victim, &profile).await; assert!(ok_p, "victim profile must be accepted: {msg_p}"); - let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); let draft_id = draft.id; let (ok_d, msg_d) = submit_event_http(&client, &victim, &draft).await; assert!(ok_d, "victim draft must be accepted: {msg_d}"); @@ -700,7 +1074,6 @@ async fn test_draft_attacker_cannot_see_draft_in_kindless_filter_ws() { .await .expect("connect attacker"); let sid = sub_id("attacker-kindless"); - // Kindless filter targeting victim's pubkey. let filter = Filter::new().author(victim.public_key()).limit(50); ac.subscribe(&sid, vec![filter]).await.expect("subscribe"); let results = ac @@ -708,12 +1081,10 @@ async fn test_draft_attacker_cannot_see_draft_in_kindless_filter_ws() { .await .expect("collect"); - // Positive control: profile must be present. assert!( results.iter().any(|e| e.id == profile_id), "attacker must receive victim's public profile event" ); - // Privacy gate: draft must be absent. assert!( !results.iter().any(|e| e.id == draft_id), "kindless filter must not expose victim's draft to attacker" @@ -724,14 +1095,14 @@ async fn test_draft_attacker_cannot_see_draft_in_kindless_filter_ws() { #[tokio::test] #[ignore] async fn test_draft_attacker_cannot_retrieve_by_known_event_id_ws() { - // Knowing the exact event ID of a draft must not grant access. let url = relay_url(); let client = http_client(); let victim = Keys::generate(); let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; let d = uuid::Uuid::new_v4().to_string(); - let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); let draft_id = draft.id; let (ok, msg) = submit_event_http(&client, &victim, &draft).await; assert!(ok, "victim draft must be accepted: {msg}"); @@ -758,15 +1129,14 @@ async fn test_draft_attacker_cannot_retrieve_by_known_event_id_ws() { #[tokio::test] #[ignore] async fn test_draft_attacker_cannot_retrieve_by_known_d_tag_exclusive_ws() { - // Attacker queries {kinds:[31234], authors:[victim], #d:[known_d]}. - // The relay must CLOSE the subscription with "restricted:". let url = relay_url(); let client = http_client(); let victim = Keys::generate(); let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; let d = uuid::Uuid::new_v4().to_string(); - let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); let (ok, msg) = submit_event_http(&client, &victim, &draft).await; assert!(ok, "victim draft must be accepted: {msg}"); @@ -812,17 +1182,14 @@ async fn test_draft_attacker_cannot_retrieve_by_known_d_tag_exclusive_ws() { #[tokio::test] #[ignore] async fn test_draft_attacker_cannot_retrieve_by_known_d_tag_kindless_ws() { - // Attacker queries {#d:[known_d]} — kindless, no authors filter. - // Draft must be silently omitted; a public kind:9 message on the same - // d-value (different kind, different event) provides a positive control - // that the attacker can receive from a public channel. let url = relay_url(); let client = http_client(); let victim = Keys::generate(); let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; let d = uuid::Uuid::new_v4().to_string(); - let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); let draft_id = draft.id; let (ok, msg) = submit_event_http(&client, &victim, &draft).await; assert!(ok, "victim draft must be accepted: {msg}"); @@ -831,7 +1198,6 @@ async fn test_draft_attacker_cannot_retrieve_by_known_d_tag_kindless_ws() { .await .expect("connect attacker"); let sid = sub_id("d-kindless"); - // Kindless #d filter — this is the dictionary-attack vector for draft addresses. let filter = Filter::new().custom_tag( nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), d.as_str(), @@ -853,14 +1219,14 @@ async fn test_draft_attacker_cannot_retrieve_by_known_d_tag_kindless_ws() { #[tokio::test] #[ignore] async fn test_draft_attacker_cannot_count_exclusive_ws() { - // WS COUNT: {kinds:[31234], authors:[victim]} must be CLOSED with restricted:. let url = relay_url(); let client = http_client(); let victim = Keys::generate(); let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; let d = uuid::Uuid::new_v4().to_string(); - let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); let (ok, msg) = submit_event_http(&client, &victim, &draft).await; assert!(ok, "victim draft must be accepted: {msg}"); @@ -890,7 +1256,9 @@ async fn test_draft_attacker_cannot_count_exclusive_ws() { "expected restricted message for COUNT on another author's drafts, got: {message}" ); } - other => panic!("expected CLOSED for WS COUNT on another author's drafts, got: {other:?}"), + other => { + panic!("expected CLOSED for WS COUNT on another author's drafts, got: {other:?}") + } } ac.disconnect().await.expect("disconnect"); } @@ -898,14 +1266,14 @@ async fn test_draft_attacker_cannot_count_exclusive_ws() { #[tokio::test] #[ignore] async fn test_draft_attacker_cannot_count_via_known_d_ws() { - // WS COUNT: {kinds:[31234], authors:[victim], #d:[known]} must be CLOSED. let url = relay_url(); let client = http_client(); let victim = Keys::generate(); let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; let d = uuid::Uuid::new_v4().to_string(); - let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); let (ok, msg) = submit_event_http(&client, &victim, &draft).await; assert!(ok, "victim draft must be accepted: {msg}"); @@ -943,13 +1311,13 @@ async fn test_draft_attacker_cannot_count_via_known_d_ws() { #[tokio::test] #[ignore] async fn test_draft_attacker_cannot_count_exclusive_http() { - // HTTP /count: {kinds:[31234], authors:[victim]} must return 403. let client = http_client(); let victim = Keys::generate(); let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; let d = uuid::Uuid::new_v4().to_string(); - let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); let (ok, msg) = submit_event_http(&client, &victim, &draft).await; assert!(ok, "victim draft must be accepted: {msg}"); @@ -974,21 +1342,21 @@ async fn test_draft_attacker_cannot_count_exclusive_http() { #[tokio::test] #[ignore] async fn test_draft_author_can_count_own_drafts_http() { - // Author's own HTTP /count must succeed and return ≥1. let client = http_client(); - let keys = Keys::generate(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; let d = uuid::Uuid::new_v4().to_string(); - let draft = build_draft(&keys, &d, "9", &fake_nip44_v2()); - let (ok, msg) = submit_event_http(&client, &keys, &draft).await; + let draft = build_draft(&owner, &d, "9", &ch_id, &fake_nip44_v2()); + let (ok, msg) = submit_event_http(&client, &owner, &draft).await; assert!(ok, "draft must be accepted: {msg}"); let filter = Filter::new() .kind(nostr::Kind::Custom(KIND_DRAFT)) - .author(keys.public_key()); + .author(owner.public_key()); let resp = client .post(format!("{}/count", relay_http_url())) - .header("X-Pubkey", &keys.public_key().to_hex()) + .header("X-Pubkey", &owner.public_key().to_hex()) .header("Content-Type", "application/json") .json(&vec![filter]) .send() @@ -1009,13 +1377,13 @@ async fn test_draft_author_can_count_own_drafts_http() { #[tokio::test] #[ignore] async fn test_draft_attacker_cannot_query_exclusive_http() { - // HTTP /query: exclusive other-author draft query must return 403. let client = http_client(); let victim = Keys::generate(); let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; let d = uuid::Uuid::new_v4().to_string(); - let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); let (ok, msg) = submit_event_http(&client, &victim, &draft).await; assert!(ok, "victim draft must be accepted: {msg}"); @@ -1042,18 +1410,13 @@ async fn test_draft_attacker_cannot_query_exclusive_http() { #[tokio::test] #[ignore] async fn test_draft_live_fanout_only_reaches_author() { - // Attacker subscribes to a mixed filter BEFORE the draft is published. - // They must NOT receive the draft in live fan-out. They MUST receive a - // public control event (kind:0 profile) from the same author — this - // proves fan-out is working and the draft was specifically excluded. let url = relay_url(); let client = http_client(); let victim = Keys::generate(); let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; let d = uuid::Uuid::new_v4().to_string(); - // Attacker subscribes to victim's events using a MIXED filter - // (not exclusively kind:31234, so it won't be immediately CLOSED). let mut ac = BuzzTestClient::connect(&url, &attacker) .await .expect("connect attacker"); @@ -1061,22 +1424,19 @@ async fn test_draft_live_fanout_only_reaches_author() { let filter = Filter::new() .kinds(vec![Kind::Metadata, Kind::Custom(KIND_DRAFT)]) .author(victim.public_key()) - .limit(0); // live only, no stored events + .limit(0); ac.subscribe(&sid_fanout, vec![filter]) .await .expect("subscribe to mixed filter"); - // Drain EOSE. let _ = ac .collect_until_eose(&sid_fanout, Duration::from_secs(3)) .await; - // Victim publishes a draft — must NOT reach attacker via fan-out. - let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); let draft_id = draft.id; let (ok_d, msg_d) = submit_event_http(&client, &victim, &draft).await; assert!(ok_d, "draft must be accepted: {msg_d}"); - // Victim also publishes a public profile event — MUST reach attacker. let profile = EventBuilder::new(Kind::Metadata, "{}") .sign_with_keys(&victim) .unwrap(); @@ -1084,7 +1444,6 @@ async fn test_draft_live_fanout_only_reaches_author() { let (ok_p, msg_p) = submit_event_http(&client, &victim, &profile).await; assert!(ok_p, "profile must be accepted: {msg_p}"); - // Drain messages briefly and check what arrived. let mut received_draft = false; let mut received_profile = false; let deadline = tokio::time::Instant::now() + Duration::from_secs(3); @@ -1119,20 +1478,103 @@ async fn test_draft_live_fanout_only_reaches_author() { ac.disconnect().await.expect("disconnect"); } +// ─── Tenant confinement ─────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_tenant_confinement_channel_from_different_community() { + // Channel UUID that exists in one community must not be valid in another. + // This test requires a second community/tenant to be reachable — if the + // relay runs as a single tenant the test is a no-op (channel simply won't + // exist from the adversarial requester's perspective). + // + // We simulate by using a randomly generated UUID that is almost certain + // not to exist in any community: submitting a draft to that UUID must + // be rejected by the nonexistent-channel check. + let client = http_client(); + let adversary = Keys::generate(); + let alien_channel_id = uuid::Uuid::new_v4().to_string(); + + let d = uuid::Uuid::new_v4().to_string(); + let event = build_draft(&adversary, &d, "9", &alien_channel_id, &fake_nip44_v2()); + let (accepted, msg) = submit_event_http(&client, &adversary, &event).await; + assert!( + !accepted, + "draft to alien/nonexistent channel must be rejected" + ); + assert!( + msg.contains("channel") || msg.contains("member") || msg.contains("not found"), + "unexpected message: {msg}" + ); +} + +// ─── Workflow exclusion ─────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_not_returned_in_kindless_channel_query() { + // A kindless channel filter must not return draft events even when the + // requester is the author. Draft content is private — never leaked via + // channel-scoped queries. + let url = relay_url(); + let client = http_client(); + let owner = Keys::generate(); + let ch_id = create_open_channel(&owner).await; + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&owner, &d, "9", &ch_id, &fake_nip44_v2()); + let draft_id = draft.id; + let (ok, msg) = submit_event_http(&client, &owner, &draft).await; + assert!(ok, "draft must be accepted: {msg}"); + + // Also publish a public channel message as a positive control. + let msg_event = EventBuilder::new(Kind::Custom(9), "hello channel") + .tags([Tag::parse(["h", &ch_id]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + let msg_id = msg_event.id; + let (ok_m, msg_m) = submit_event_http(&client, &owner, &msg_event).await; + assert!(ok_m, "channel message must be accepted: {msg_m}"); + + // Query by channel h-tag, no kind filter. + let mut c = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect"); + let sid = sub_id("ch-kindless"); + let filter = Filter::new().custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::H), + ch_id.as_str(), + ); + c.subscribe(&sid, vec![filter]).await.expect("subscribe"); + let results = c + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + // Channel message must appear. + assert!( + results.iter().any(|e| e.id == msg_id), + "channel message must appear in h-tag query (positive control)" + ); + // Draft must be absent — drafts are author-private, not channel-public. + assert!( + !results.iter().any(|e| e.id == draft_id), + "draft must not be returned by a kindless channel h-tag filter" + ); + c.disconnect().await.expect("disconnect"); +} + // ─── FTS / NIP-50 exclusion ─────────────────────────────────────────────────── #[tokio::test] #[ignore] async fn test_draft_not_indexed_in_fts_search() { - // The relay stores search_tsv = NULL for kind:31234. Even if we could - // search by the author, the draft must never surface in NIP-50 results. - // We use the FTS HTTP query path as an attacker to verify. let client = http_client(); let victim = Keys::generate(); let attacker = Keys::generate(); + let ch_id = create_open_channel(&victim).await; let d = uuid::Uuid::new_v4().to_string(); - // Publish a kind:1 text note with a unique marker as a positive control. let marker = format!("nip37fts_probe_{}", uuid::Uuid::new_v4().simple()); let note = EventBuilder::new(Kind::TextNote, &marker) .sign_with_keys(&victim) @@ -1141,26 +1583,21 @@ async fn test_draft_not_indexed_in_fts_search() { let (ok_note, msg_note) = submit_event_http(&client, &victim, ¬e).await; assert!(ok_note, "control note must be accepted: {msg_note}"); - // Publish a draft from the same author. - let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2()); let draft_id = draft.id; let (ok_d, msg_d) = submit_event_http(&client, &victim, &draft).await; assert!(ok_d, "draft must be accepted: {msg_d}"); - // NIP-50 search as the victim — the kind:1 note must appear; the draft must not. let search_filter = Filter::new().search(&marker).limit(50); let results = query_events_http(&client, &victim.public_key().to_hex(), vec![search_filter]).await; - // Positive control: the text note must be found. assert!( results .iter() .any(|e| e["id"].as_str() == Some(¬e_id.to_hex())), "FTS must index the control kind:1 note (positive control)" ); - - // Privacy gate: draft must never appear in search results. assert!( !results .iter() @@ -1168,7 +1605,6 @@ async fn test_draft_not_indexed_in_fts_search() { "kind:31234 must have NULL search_tsv — draft must not appear in NIP-50 search" ); - // Searching as the attacker must also not expose the draft. let search_filter2 = Filter::new().search(&marker).limit(50); let attacker_results = query_events_http( &client,