From 79a9a89a4de77371cedbc992fd49f83e01cd204c Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sat, 11 Jul 2026 12:27:11 -0400 Subject: [PATCH] feat(relay): add NIP-37 draft wrap support (kind:31234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add kind:31234 as an author-only, channel-less, parameterized-replaceable event kind for encrypted draft wraps per NIP-37. Privacy enforcement spans every relay read path: - WS REQ: AUTHOR_ONLY_KINDS gate closes the subscription with restricted: for any requester who isn't the author - WS COUNT: same gate applied before the count query executes - HTTP bridge /query + /count: post-filter and guard use AUTHOR_ONLY_KINDS - Live fan-out: AUTHOR_ONLY_KINDS check in dispatch_persistent_event_inner prevents draft events from being pushed to non-author subscribers - FTS (NIP-50): migration 0007 sets search_tsv = NULL for kind:31234, making drafts storage-level unsearchable Ingest validation (validate_draft_wrap_envelope): - Exactly one non-empty d tag (any bounded value; relay is grammar-agnostic) - Exactly one k tag with canonical u16 decimal (no leading zeros, fits u16) - No h or p outer tags (compose context belongs in encrypted payload only) - Content: empty string (tombstone) or NIP-44 v2 ciphertext shape check - Optional expiration: at most one, decimal, strictly future, ≤ safe integer NIP-11 now advertises NIP-37. NIP-40 is intentionally not advertised because Buzz does not yet suppress expired rows on read. Schema migration 0007 extends the search_tsv generated column exclusion list with kind 31234. New tests: - 23 unit tests for validate_draft_wrap_envelope in ingest.rs covering every acceptance and rejection path - Comprehensive E2E test suite in e2e_nip37_draft.rs covering write validation, NIP-01 replacement ordering, tombstone persistence, author-only REQ/COUNT/HTTP, kindless/mixed filter privacy, known-d privacy tripwires, live fan-out isolation, and NIP-11 advertisement Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/ci.yml | 9 + crates/buzz-core/src/kind.rs | 25 +- crates/buzz-db/src/migration.rs | 20 +- crates/buzz-relay/src/handlers/event.rs | 12 +- crates/buzz-relay/src/handlers/ingest.rs | 434 +++++- crates/buzz-relay/src/nip11.rs | 21 +- crates/buzz-search/tests/fts_integration.rs | 8 +- .../buzz-test-client/tests/e2e_nip37_draft.rs | 1213 +++++++++++++++++ migrations/0012_draft_wrap_fts.sql | 37 + schema/schema.sql | 4 +- 10 files changed, 1757 insertions(+), 26 deletions(-) create mode 100644 crates/buzz-test-client/tests/e2e_nip37_draft.rs create mode 100644 migrations/0012_draft_wrap_fts.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d8b5d378..d54c3cfd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -610,6 +610,15 @@ jobs: run: cargo test --profile ci -p buzz-test-client --test e2e_event_reminder -- --ignored 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. + run: cargo test --profile ci -p buzz-test-client --test e2e_nip37_draft -- --ignored + env: + RELAY_URL: ws://localhost:3000 - name: Upload relay log if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 495ced0b6..01763b5b0 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -101,16 +101,31 @@ pub const KIND_AGENT_ENGRAM: u32 = 30174; /// author-only (see [`AUTHOR_ONLY_KINDS`]). See `docs/nips/NIP-ER.md`. pub const KIND_EVENT_REMINDER: u32 = 30300; +/// NIP-37: Draft wrap (parameterized replaceable, author-only). +/// +/// Encrypted draft of an unsent message, addressed by `(pubkey, kind=31234, d_tag)`. +/// Content is NIP-44 v2 ciphertext (to self) containing an unsigned inner event, +/// or the empty string as a NIP-37 deletion tombstone. The outer envelope carries +/// no channel scope (`channel_id = NULL`); compose context (channel, reply target, +/// etc.) lives only inside the encrypted payload. +/// +/// Reads are strictly author-only — see [`AUTHOR_ONLY_KINDS`] and the author-only +/// gate in the REQ/COUNT/fan-out handlers. Draft wraps are excluded from FTS +/// (`search_tsv = NULL`) and must not trigger workflow dispatch. +pub const KIND_DRAFT: u32 = 31234; + /// Kinds whose stored events are readable only by their author. /// /// The relay must never reveal the existence, count, tags, content, schedule, /// or search matches of these events to anyone but the authenticated author. -/// Shared across the ingest write path (NIP-ER `not_before` validation) and the -/// read path (REQ/COUNT/subscription author-only filtering). +/// Shared across the ingest write path and the read path (REQ/COUNT/subscription +/// author-only filtering). Also drives the FTS null-tsvector tripwire in +/// `buzz-search/tests/fts_integration.rs` — every kind added here MUST also +/// appear in the `search_tsv` generated column exclusion list in +/// `schema/schema.sql` and the additive migration that introduced it. /// -/// Currently O(1) with a single entry. If this grows past ~4 kinds, convert to -/// a compile-time bitset or sorted array with binary search for hot-path use. -pub const AUTHOR_ONLY_KINDS: &[u32] = &[KIND_EVENT_REMINDER]; +/// Sorted for binary-search readiness if the list grows. +pub const AUTHOR_ONLY_KINDS: &[u32] = &[KIND_EVENT_REMINDER, KIND_DRAFT]; /// Kinds that require a result-level read gate beyond the filter-layer /// `#p` check: even a reader who knows an event id MUST match the event's diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index d62f7e206..75c851c81 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -542,7 +542,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 11); + assert_eq!(migrations.len(), 12); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -710,6 +710,24 @@ mod tests { .contains("CREATE OR REPLACE FUNCTION purge_soft_deleted_nip_rs")); assert!(migrations[10].sql.as_str().contains("tag->>0 = 'd'")); assert!(migrations[10].sql.as_str().contains(") = 1")); + + // NIP-37 (kind 31234) FTS exclusion: additive conditional migration. + // On legacy-blocklist DBs: drops and re-adds search_tsv with 31234. + // On fresh-install allowlist DBs: no-op (31234 already unsearchable). + // 0001 must NOT carry 31234; migration 12 must carry it separately. + assert_eq!(migrations[11].version, 12); + assert!( + migrations[11].sql.as_str().contains("search_tsv"), + "migration 0012 must reference the search_tsv generated column" + ); + assert!( + migrations[11].sql.as_str().contains("31234"), + "migration 0012 must add kind 31234 to the FTS exclusion list" + ); + assert!( + !migrations[0].sql.as_str().contains("31234"), + "kind 31234 must not be folded into the initial migration" + ); } #[test] diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 9e3bb05d8..8182d429b 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -38,12 +38,13 @@ fn bounded_kind_label(kind: u32) -> String { 8000..=8003 | 9000..=9022 | 9030..=9036 => kind.to_string(), 13534..=13535 => kind.to_string(), 20000..=29999 => kind.to_string(), - 30023 | 30315 | 39000..=39003 => kind.to_string(), + 30023 | 30078 | 30174 | 30175..=30177 | 30300 | 30315 | 31234 | 39000..=39003 => { + kind.to_string() + } 40002..=40100 => kind.to_string(), 41001 | 41010..=41012 => kind.to_string(), 43001..=43006 => kind.to_string(), - 44100..=44101 => kind.to_string(), - 44200 => kind.to_string(), + 44100..=44101 | 44200 => kind.to_string(), 45001..=45003 => kind.to_string(), 46001..=46012 | 46020 | 46030..=46031 => kind.to_string(), 48001 | 48100..=48103 | 48106 => kind.to_string(), @@ -506,6 +507,11 @@ async fn dispatch_persistent_event_inner( && !buzz_core::kind::is_command_kind(kind_u32) && !is_relay_workflow_msg && kind_u32 != KIND_GIFT_WRAP + // Author-only kinds (NIP-ER reminders, NIP-37 draft wraps) are private per-user + // state that must not trigger workspace-level workflows. Excluded here explicitly + // because today's channel-less engine no-ops on them, but this guard is the + // invariant that must hold for any future workflow expansion. + && !AUTHOR_ONLY_KINDS.contains(&kind_u32) { let workflow_engine = Arc::clone(&state.workflow_engine); let workflow_event = stored_event.clone(); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 8ddcd56e4..18ea4b2ea 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -15,18 +15,18 @@ use buzz_core::kind::{ is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, - KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET, KIND_FORUM_COMMENT, - KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, - KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, - KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, - KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, - KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, - KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_RELAY_STATUS, KIND_MODERATION_BAN, - KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, - KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, - KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, - KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, + KIND_DRAFT, KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET, + KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, + KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, + KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, + KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, + KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_RELAY_STATUS, + KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, + KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, + KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, + KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, @@ -157,6 +157,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result { Ok(Scope::UsersWrite) } + // NIP-37: draft wraps are author-private global state (UsersWrite scope). + KIND_DRAFT => Ok(Scope::UsersWrite), // NIP-AM: agent turn metrics are agent-authored global events (encrypted to owner). KIND_AGENT_TURN_METRIC => Ok(Scope::MessagesWrite), // NIP-56 reports are ordinary member writes into the mod-only queue. @@ -402,6 +404,11 @@ 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 ) } @@ -1192,6 +1199,147 @@ fn validate_not_before(tag_value: &str) -> Result { Ok(value) } +/// Validate the public envelope of a NIP-37 `kind:31234` draft wrap before it +/// reaches NIP-33 parameterized replacement. +/// +/// The relay cannot decrypt or verify the payload; it enforces the mandatory outer +/// tag shape so a malformed event cannot win replacement against a valid head: +/// +/// 1. Exactly one non-empty `d` tag within `D_TAG_MAX_LEN`. Grammar is unrestricted +/// (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. +/// 4. No outer `p` tag — prevents this event from entering the mention/feed index. +/// 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. +/// 6. At most one `expiration` tag, whose value must be canonical ASCII decimal, +/// strictly greater than both `event.created_at` and the relay's current time. +/// If present, it must be in the safe-integer range for JSON interoperability. +fn validate_draft_wrap_envelope(event: &Event) -> Result<(), String> { + let now_secs = chrono::Utc::now().timestamp() as u64; + let event_created_at = event.created_at.as_secs(); + + let mut d_count = 0usize; + let mut d_value: Option<&str> = None; + let mut k_count = 0usize; + let mut k_value: Option<&str> = None; + let mut expiration_count = 0usize; + let mut expiration_value: Option<&str> = None; + + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.len() < 2 { + continue; + } + match parts[0].as_str() { + "d" => { + d_count += 1; + d_value = Some(&parts[1]); + } + "k" => { + k_count += 1; + 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(), + ); + } + "p" => { + return Err( + "draft-wrap event must not have a `p` tag (prevents mention/feed indexing)" + .to_string(), + ); + } + "expiration" => { + expiration_count += 1; + expiration_value = Some(&parts[1]); + } + _ => {} + } + } + + // Validate `d` tag. + if d_count != 1 { + return Err(format!( + "draft-wrap event must have exactly one `d` tag (got {d_count})" + )); + } + let d = d_value.unwrap(); + if d.is_empty() { + return Err("draft-wrap `d` tag must not be empty".to_string()); + } + if d.len() > buzz_db::event::D_TAG_MAX_LEN { + return Err(format!( + "draft-wrap `d` tag too long ({} bytes, max {})", + d.len(), + buzz_db::event::D_TAG_MAX_LEN, + )); + } + + // Validate `k` tag — canonical decimal, fits u16, no leading zeros. + if k_count != 1 { + return Err(format!( + "draft-wrap event must have exactly one `k` tag (got {k_count})" + )); + } + let k = k_value.unwrap(); + if k.is_empty() || !k.bytes().all(|b| b.is_ascii_digit()) { + return Err("draft-wrap `k` tag must be a canonical decimal integer".to_string()); + } + if k.len() > 1 && k.starts_with('0') { + return Err("draft-wrap `k` tag must not have leading zeros".to_string()); + } + let _inner_kind: u16 = k.parse().map_err(|_| { + "draft-wrap `k` tag value out of range (must fit unsigned 16-bit kind)".to_string() + })?; + + // Validate `expiration` tag (optional, at most one). + if expiration_count > 1 { + return Err(format!( + "draft-wrap event must have at most one `expiration` tag (got {expiration_count})" + )); + } + if let Some(exp_str) = expiration_value { + if exp_str.is_empty() || !exp_str.bytes().all(|b| b.is_ascii_digit()) { + return Err( + "draft-wrap `expiration` tag must be a canonical decimal Unix timestamp" + .to_string(), + ); + } + if exp_str.len() > 1 && exp_str.starts_with('0') { + return Err("draft-wrap `expiration` tag must not have leading zeros".to_string()); + } + const MAX_SAFE_INT: u64 = 9_007_199_254_740_991; + let exp: u64 = exp_str.parse().map_err(|_| { + "draft-wrap `expiration` tag value out of safe-integer range".to_string() + })?; + if exp > MAX_SAFE_INT { + return Err( + "draft-wrap `expiration` tag value exceeds JSON safe-integer limit".to_string(), + ); + } + if exp <= event_created_at { + return Err( + "draft-wrap `expiration` must be strictly after event `created_at`".to_string(), + ); + } + if exp <= now_secs { + return Err("draft-wrap `expiration` must be in the future".to_string()); + } + } + + // Validate content: either empty (tombstone) or NIP-44 v2 ciphertext. + if !event.content.is_empty() { + validate_engram_nip44_content(&event.content) + .map_err(|e| e.replace("agent-engram", "draft-wrap"))?; + } + + Ok(()) +} + /// Validate the public tag envelope of a NIP-ER `kind:30300` event before it /// reaches NIP-33 parameterized replacement. /// @@ -1900,6 +2048,11 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + if kind_u32 == KIND_DRAFT { + validate_draft_wrap_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + if kind_u32 == KIND_PERSONA { validate_persona_envelope(&event) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; @@ -3386,4 +3539,261 @@ mod tests { // error comes from validate_engram_nip44_content with label replaced assert!(err.contains("agent-turn-metric"), "got: {err}"); } + + // ────────────────────────────────────────────────────────────────────────── + // validate_draft_wrap_envelope tests (kind:31234 / NIP-37) + // ────────────────────────────────────────────────────────────────────────── + + fn make_draft(tags: &[&[&str]], content: &str) -> Event { + make_event_with_tags(KIND_DRAFT, content, tags) + } + + // ── acceptance ──────────────────────────────────────────────────────────── + + #[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()); + assert!( + validate_draft_wrap_envelope(&ev).is_ok(), + "canonical draft with ciphertext content must be accepted" + ); + } + + #[test] + fn draft_wrap_accepts_blank_tombstone() { + let d = uuid::Uuid::new_v4().to_string(); + let ev = make_draft(&[&["d", &d], &["k", "9"]], ""); + assert!( + validate_draft_wrap_envelope(&ev).is_ok(), + "tombstone (empty content) must be accepted" + ); + } + + #[test] + 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]], ""); + assert!( + validate_draft_wrap_envelope(&ev).is_ok(), + "k={k} must be accepted" + ); + } + } + + // ── d-tag validation ────────────────────────────────────────────────────── + + #[test] + fn draft_wrap_rejects_missing_d_tag() { + let ev = make_draft(&[&["k", "9"]], &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 err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("`d` tag"), "got: {err}"); + } + + #[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 err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("`d` tag"), "got: {err}"); + } + + // ── k-tag validation ────────────────────────────────────────────────────── + + #[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 err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("`k` tag"), "got: {err}"); + } + + #[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 err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("`k` tag"), "got: {err}"); + } + + #[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 err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("canonical decimal"), "got: {err}"); + } + + #[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 err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("leading zero"), "got: {err}"); + } + + #[test] + fn draft_wrap_rejects_k_tag_out_of_u16_range() { + let d = uuid::Uuid::new_v4().to_string(); + // 65536 = u16::MAX + 1 + let ev = make_draft(&[&["d", &d], &["k", "65536"]], &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}"); + } + + #[test] + fn draft_wrap_rejects_p_tag() { + let d = 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()]], + &fake_nip44_v2(), + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("`p` tag"), "got: {err}"); + } + + // ── content validation ──────────────────────────────────────────────────── + + #[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 err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!( + err.contains("base64") || err.contains("NIP-44"), + "got: {err}" + ); + } + + #[test] + fn draft_wrap_rejects_wrong_nip44_version_byte() { + let d = 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 err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!( + err.contains("NIP-44 v2") || err.contains("0x02"), + "got: {err}" + ); + } + + #[test] + 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 err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("too short"), "got: {err}"); + } + + // ── expiration tag validation ───────────────────────────────────────────── + + #[test] + fn draft_wrap_accepts_valid_future_expiration() { + let d = uuid::Uuid::new_v4().to_string(); + // A timestamp far in the future (year 2100). + let ev = make_draft( + &[&["d", &d], &["k", "9"], &["expiration", "4102444800"]], + "", + ); + assert!( + validate_draft_wrap_envelope(&ev).is_ok(), + "valid future expiration must be accepted" + ); + } + + #[test] + fn draft_wrap_rejects_duplicate_expiration_tag() { + let d = uuid::Uuid::new_v4().to_string(); + let ev = make_draft( + &[ + &["d", &d], + &["k", "9"], + &["expiration", "4102444800"], + &["expiration", "4102444800"], + ], + "", + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("expiration"), "got: {err}"); + } + + #[test] + fn draft_wrap_rejects_expiration_in_past() { + let d = uuid::Uuid::new_v4().to_string(); + let ev = make_draft( + &[&["d", &d], &["k", "9"], &["expiration", "1000000000"]], + "", + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("expiration"), "got: {err}"); + } + + #[test] + fn draft_wrap_rejects_non_decimal_expiration() { + let d = uuid::Uuid::new_v4().to_string(); + let ev = make_draft( + &[&["d", &d], &["k", "9"], &["expiration", "not-a-number"]], + "", + ); + let err = validate_draft_wrap_envelope(&ev).unwrap_err(); + assert!(err.contains("expiration"), "got: {err}"); + } + + // ── routing invariants ──────────────────────────────────────────────────── + + #[test] + fn draft_wrap_is_global_only() { + // Draft wraps must never be channel-scoped; compose context lives in + // the encrypted payload only. + assert!( + is_global_only_kind(KIND_DRAFT), + "KIND_DRAFT must be global-only (no h-tag channel scope)" + ); + } + + #[test] + fn draft_wrap_requires_users_write_scope() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_DRAFT, &dummy).unwrap(), + Scope::UsersWrite, + "KIND_DRAFT must require UsersWrite scope" + ); + } + + #[test] + fn draft_wrap_does_not_require_h_tag() { + assert!( + !requires_h_channel_scope(KIND_DRAFT), + "KIND_DRAFT must not require an h tag" + ); + } } diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 6241bfb2b..2b3db9bc2 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -12,7 +12,8 @@ use crate::config::DEFAULT_MAX_FRAME_BYTES; /// /// NIP-43 (relay membership) is advertised separately by [`RelayInfo::build`] /// only when membership enforcement is actually enabled — see that function. -pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 38, 42, 50, 56]; +pub(crate) const SUPPORTED_NIPS: &[u32] = + &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 37, 38, 42, 50, 56]; /// NIP-43 (relay membership). Advertised only when the relay actually /// enforces membership (`BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`) AND has a @@ -297,6 +298,24 @@ mod tests { ); } + #[test] + fn supported_nips_includes_nip37() { + assert!( + SUPPORTED_NIPS.contains(&37), + "NIP-37 (draft wraps) must be advertised — kind:31234 ingest and author-only reads are live" + ); + } + + #[test] + fn supported_nips_does_not_include_nip40() { + // NIP-40 requires expired stored rows to be suppressed on read; Buzz does not + // do this generically yet. Desktop must not rely on relay-side expiry suppression. + assert!( + !SUPPORTED_NIPS.contains(&40), + "NIP-40 must NOT be advertised until expired stored rows are generically suppressed on read" + ); + } + #[test] fn build_advertises_buzz_repository_url() { let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); diff --git a/crates/buzz-search/tests/fts_integration.rs b/crates/buzz-search/tests/fts_integration.rs index 65c405260..0e472b99a 100644 --- a/crates/buzz-search/tests/fts_integration.rs +++ b/crates/buzz-search/tests/fts_integration.rs @@ -3,12 +3,12 @@ //! Run with a local PG: `BUZZ_TEST_DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz cargo test -p buzz-search --tests -- --include-ignored` //! //! Each test creates a uniquely-named schema, applies the full migration chain -//! (0001 through 0008) into it, exercises a scenario, and drops it. Tests are +//! (0001 through 0012) into it, exercises a scenario, and drops it. Tests are //! parallel-safe. use buzz_core::{ kind::{ - AUTHOR_ONLY_KINDS, KIND_AGENT_TURN_METRIC, KIND_MEMBER_ADDED_NOTIFICATION, + AUTHOR_ONLY_KINDS, KIND_AGENT_TURN_METRIC, KIND_DRAFT, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, P_GATED_KINDS, }, CommunityId, @@ -27,6 +27,7 @@ const MIGRATION_0006_SQL: &str = include_str!("../../../migrations/0006_moderati const MIGRATION_0007_SQL: &str = include_str!("../../../migrations/0007_nip_rs_retention.sql"); const MIGRATION_0008_SQL: &str = include_str!("../../../migrations/0008_fresh_install_search_allowlist.sql"); +const MIGRATION_0012_SQL: &str = include_str!("../../../migrations/0012_draft_wrap_fts.sql"); async fn setup() -> (PgPool, String) { let url = std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string()); @@ -77,6 +78,9 @@ async fn setup() -> (PgPool, String) { pool.execute(MIGRATION_0008_SQL) .await .expect("apply 0008 migration"); + pool.execute(MIGRATION_0012_SQL) + .await + .expect("apply 0012 migration"); (pool, schema) } diff --git a/crates/buzz-test-client/tests/e2e_nip37_draft.rs b/crates/buzz-test-client/tests/e2e_nip37_draft.rs new file mode 100644 index 000000000..61fbfccda --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_nip37_draft.rs @@ -0,0 +1,1213 @@ +//! End-to-end integration tests for NIP-37 draft wraps (kind:31234). +//! +//! 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 +//! - 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 +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! RELAY_URL=ws://localhost:3000 cargo test -p buzz-test-client --test e2e_nip37_draft -- --ignored +//! ``` + +use std::time::Duration; + +use buzz_test_client::{BuzzTestClient, RelayMessage}; +use nostr::{EventBuilder, Filter, Keys, Kind, Tag, Timestamp}; +use reqwest::Client; +use serde_json::{json, Value}; + +const KIND_DRAFT: u16 = 31234; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn relay_http_url() -> String { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() +} + +fn sub_id(name: &str) -> String { + format!("e2e-nip37-{name}-{}", uuid::Uuid::new_v4()) +} + +fn http_client() -> Client { + Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("failed to build HTTP client") +} + +/// Minimal syntactically-plausible NIP-44 v2 payload. +/// base64(b"\x02" + b"\x00" * 98) — 132 chars, decoded 99 bytes, first byte 0x02. +fn fake_nip44_v2() -> String { + let mut s = String::from("Ag"); + s.push_str(&"A".repeat(130)); + 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) + .tags([ + Tag::parse(["d", d_tag]).unwrap(), + Tag::parse(["k", k_val]).unwrap(), + ]) + .custom_created_at(ts) + .sign_with_keys(keys) + .unwrap() +} + +/// 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()) +} + +/// 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) +} + +/// Submit an event via the HTTP bridge and return (accepted, message). +async fn submit_event_http(client: &Client, keys: &Keys, event: &nostr::Event) -> (bool, String) { + let pubkey_hex = keys.public_key().to_hex(); + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", &pubkey_hex) + .header("Content-Type", "application/json") + .body(serde_json::to_string(event).unwrap()) + .send() + .await + .expect("submit event"); + let status = resp.status().as_u16(); + let body: Value = resp.json().await.expect("parse response"); + if status == 200 { + let accepted = body["accepted"].as_bool().unwrap_or(false); + let message = body["message"].as_str().unwrap_or("").to_string(); + (accepted, message) + } else { + let message = body["error"].as_str().unwrap_or("").to_string(); + (false, message) + } +} + +/// Query events via HTTP bridge as `as_pubkey_hex`. Returns events array. +async fn query_events_http( + client: &Client, + as_pubkey_hex: &str, + filters: Vec, +) -> Vec { + let resp = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", as_pubkey_hex) + .header("Content-Type", "application/json") + .json(&filters) + .send() + .await + .expect("query events"); + assert!( + resp.status().is_success(), + "query failed: {}", + resp.status() + ); + resp.json::>() + .await + .expect("parse query response") +} + +// ─── Ingest validation ──────────────────────────────────────────────────────── + +#[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; + assert!(accepted, "valid draft rejected: {msg}"); +} + +#[tokio::test] +#[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; + assert!(accepted, "blank tombstone rejected: {msg}"); +} + +#[tokio::test] +#[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 event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d_tag]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + Tag::parse(["expiration", "4102444800"]).unwrap(), // year 2100 + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(accepted, "future expiration draft rejected: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_missing_d_tag() { + let client = http_client(); + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) + .tags([Tag::parse(["k", "9"]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "missing d tag should be rejected"); + assert!(msg.contains("d` tag"), "unexpected message: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_empty_d_tag() { + let client = http_client(); + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) + .tags([ + Tag::parse(["d", ""]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "empty d tag should be rejected"); + assert!(msg.contains("d` tag"), "unexpected message: {msg}"); +} + +#[tokio::test] +#[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 event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2()) + .tags([ + Tag::parse(["d", &d_tag]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "oversized d tag should be rejected"); + assert!( + msg.contains("d` tag") || msg.contains("too long"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_duplicate_d_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(["d", &d]).unwrap(), + Tag::parse(["k", "9"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "duplicate d tag should be rejected"); + assert!(msg.contains("d` tag"), "unexpected message: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_missing_k_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()]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "missing k tag should be rejected"); + assert!(msg.contains("k` tag"), "unexpected message: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_duplicate_k_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(["k", "9"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "duplicate k tag should be rejected"); + assert!(msg.contains("k` tag"), "unexpected message: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_malformed_k_tag_non_decimal() { + 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", "0x9"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "non-decimal k tag should be rejected"); + assert!( + msg.contains("canonical decimal"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_k_tag_leading_zero() { + 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", "09"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "k tag with leading zero should be rejected"); + assert!(msg.contains("leading zero"), "unexpected message: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_k_tag_out_of_range() { + 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", "65536"]).unwrap(), // u16::MAX + 1 + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &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 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(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "p tag on draft should be rejected"); + assert!(msg.contains("p` tag"), "unexpected message: {msg}"); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_malformed_ciphertext() { + let client = http_client(); + let keys = Keys::generate(); + 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(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "malformed ciphertext should be rejected"); + assert!( + msg.contains("base64") || msg.contains("NIP-44"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_rejected_expiration_in_past() { + 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(["expiration", "1000000000"]).unwrap(), // long past + ]) + .sign_with_keys(&keys) + .unwrap(); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "past expiration should be rejected"); + assert!(msg.contains("expiration"), "unexpected message: {msg}"); +} + +// ─── NIP-01 replacement / tombstone ordering ───────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_replaced_by_newer_event() { + let client = http_client(); + let keys = Keys::generate(); + 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 v2_id = v2.id; + + let (ok1, msg1) = submit_event_http(&client, &keys, &v1).await; + assert!(ok1, "v1 must be accepted: {msg1}"); + let (ok2, msg2) = submit_event_http(&client, &keys, &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()) + .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; + assert_eq!(results.len(), 1, "should return exactly the latest draft"); + assert_eq!( + results[0]["id"].as_str().unwrap(), + v2_id.to_hex(), + "latest event must be the returned head" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_stale_write_cannot_supersede_current_head() { + let client = http_client(); + let keys = Keys::generate(); + 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); + + // Submit new first, then try to replace with stale. + let (ok_n, msg_n) = submit_event_http(&client, &keys, &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); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(keys.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; + assert_eq!(results.len(), 1, "should have exactly one head"); + assert_eq!( + results[0]["id"].as_str().unwrap(), + v_new.id.to_hex(), + "stale write must not replace current head" + ); +} + +#[tokio::test] +#[ignore] +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 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); + candidates.push(e); + } + 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; + assert!(ok_h, "highest-id draft must be accepted: {msg_h}"); + let (ok_l, msg_l) = submit_event_http(&client, &keys, &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()) + .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; + assert_eq!(results.len(), 1, "tie-break must leave exactly one head"); + assert_eq!( + results[0]["id"].as_str().unwrap(), + lowest.id.to_hex(), + "lower event ID must win same-second tie" + ); +} + +#[tokio::test] +#[ignore] +async fn test_draft_tombstone_head_queryable_by_author() { + let client = http_client(); + let keys = Keys::generate(); + 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 draft = build_draft_at(&keys, &d, "9", &fake_nip44_v2(), t_draft); + let tombstone = build_tombstone(&keys, &d, "9", t_tomb); + let tomb_id = tombstone.id; + + let (ok_d, msg_d) = submit_event_http(&client, &keys, &draft).await; + assert!(ok_d, "draft must be accepted: {msg_d}"); + let (ok_t, msg_t) = submit_event_http(&client, &keys, &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()) + .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; + assert_eq!(results.len(), 1, "tombstone must be the queryable head"); + assert_eq!( + results[0]["id"].as_str().unwrap(), + tomb_id.to_hex(), + "tombstone is the current head" + ); + assert_eq!( + results[0]["content"].as_str().unwrap(), + "", + "tombstone content must be empty" + ); +} + +// ─── Author-only read gates ─────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_draft_author_can_req_own_drafts_ws() { + let url = relay_url(); + let client = http_client(); + let keys = Keys::generate(); + let d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&keys, &d, "9", &fake_nip44_v2()); + let draft_id = draft.id; + let (ok, msg) = submit_event_http(&client, &keys, &draft).await; + assert!(ok, "draft must be accepted: {msg}"); + + let mut c = BuzzTestClient::connect(&url, &keys) + .await + .expect("connect author"); + let sid = sub_id("author-req"); + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(keys.public_key()); + c.subscribe(&sid, vec![filter]).await.expect("subscribe"); + let results = c + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + assert!( + results.iter().any(|e| e.id == draft_id), + "author must receive own draft" + ); + c.disconnect().await.expect("disconnect"); +} + +#[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 d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + let (ok, msg) = submit_event_http(&client, &victim, &draft).await; + assert!(ok, "victim draft must be accepted: {msg}"); + + let mut ac = BuzzTestClient::connect(&url, &attacker) + .await + .expect("connect attacker"); + let sid = sub_id("attacker-excl"); + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(victim.public_key()); + ac.subscribe(&sid, vec![filter]).await.expect("subscribe"); + + let msg = ac + .recv_event(Duration::from_secs(5)) + .await + .expect("recv response"); + match msg { + RelayMessage::Closed { + subscription_id, + message, + } => { + assert_eq!(subscription_id, sid); + assert!( + message.contains("restricted:") || message.contains("author-only"), + "expected restricted message, got: {message}" + ); + } + RelayMessage::Event { event, .. } => { + panic!( + "attacker received victim's draft via exclusive filter: event {}", + event.id + ); + } + other => panic!("expected CLOSED for exclusive draft filter, got: {other:?}"), + } + ac.disconnect().await.expect("disconnect"); +} + +#[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 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(); + let profile_id = profile.id; + 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_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}"); + + let mut ac = BuzzTestClient::connect(&url, &attacker) + .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 + .collect_until_eose(&sid, Duration::from_secs(5)) + .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" + ); + ac.disconnect().await.expect("disconnect"); +} + +#[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 d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &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}"); + + let mut ac = BuzzTestClient::connect(&url, &attacker) + .await + .expect("connect attacker"); + let sid = sub_id("attacker-ids"); + let filter = Filter::new().id(draft_id); + ac.subscribe(&sid, vec![filter]).await.expect("subscribe"); + let results = ac + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + assert!( + !results.iter().any(|e| e.id == draft_id), + "knowing a draft's event id must not expose it to another user" + ); + ac.disconnect().await.expect("disconnect"); +} + +// ─── known-#d privacy tripwires ─────────────────────────────────────────────── + +#[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 d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + let (ok, msg) = submit_event_http(&client, &victim, &draft).await; + assert!(ok, "victim draft must be accepted: {msg}"); + + let mut ac = BuzzTestClient::connect(&url, &attacker) + .await + .expect("connect attacker"); + let sid = sub_id("d-excl"); + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(victim.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + ac.subscribe(&sid, vec![filter]).await.expect("subscribe"); + + let relay_msg = ac + .recv_event(Duration::from_secs(5)) + .await + .expect("recv response"); + match relay_msg { + RelayMessage::Closed { + subscription_id, + message, + } => { + assert_eq!(subscription_id, sid); + assert!( + message.contains("restricted:") || message.contains("author-only"), + "expected restricted message for #d exclusive filter, got: {message}" + ); + } + RelayMessage::Event { event, .. } => { + panic!( + "attacker retrieved victim's draft via exclusive #d filter: event {}", + event.id + ); + } + other => panic!("expected CLOSED for #d exclusive filter, got: {other:?}"), + } + ac.disconnect().await.expect("disconnect"); +} + +#[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 d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &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}"); + + let mut ac = BuzzTestClient::connect(&url, &attacker) + .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(), + ); + ac.subscribe(&sid, vec![filter]).await.expect("subscribe"); + let results = ac + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + assert!( + !results.iter().any(|e| e.id == draft_id), + "kindless #d filter must not expose victim's draft to attacker" + ); + ac.disconnect().await.expect("disconnect"); +} + +// ─── COUNT privacy gates ────────────────────────────────────────────────────── + +#[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 d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + let (ok, msg) = submit_event_http(&client, &victim, &draft).await; + assert!(ok, "victim draft must be accepted: {msg}"); + + let mut ac = BuzzTestClient::connect(&url, &attacker) + .await + .expect("connect attacker"); + let sid = sub_id("count-ws"); + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(victim.public_key()); + ac.send_raw(&json!(["COUNT", sid, filter])) + .await + .expect("send COUNT"); + + let relay_msg = ac + .recv_event(Duration::from_secs(5)) + .await + .expect("recv response"); + match relay_msg { + RelayMessage::Closed { + subscription_id, + message, + } => { + assert_eq!(subscription_id, sid); + assert!( + message.contains("restricted:") || message.contains("author-only"), + "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:?}"), + } + ac.disconnect().await.expect("disconnect"); +} + +#[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 d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + let (ok, msg) = submit_event_http(&client, &victim, &draft).await; + assert!(ok, "victim draft must be accepted: {msg}"); + + let mut ac = BuzzTestClient::connect(&url, &attacker) + .await + .expect("connect attacker"); + let sid = sub_id("count-ws-d"); + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(victim.public_key()) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), + d.as_str(), + ); + ac.send_raw(&json!(["COUNT", sid, filter])) + .await + .expect("send COUNT"); + + let relay_msg = ac + .recv_event(Duration::from_secs(5)) + .await + .expect("recv response"); + match relay_msg { + RelayMessage::Closed { message, .. } => { + assert!( + message.contains("restricted:") || message.contains("author-only"), + "expected restricted for #d COUNT, got: {message}" + ); + } + other => panic!("expected CLOSED for #d COUNT, got: {other:?}"), + } + ac.disconnect().await.expect("disconnect"); +} + +#[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 d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + let (ok, msg) = submit_event_http(&client, &victim, &draft).await; + assert!(ok, "victim draft must be accepted: {msg}"); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(victim.public_key()); + let resp = client + .post(format!("{}/count", relay_http_url())) + .header("X-Pubkey", &attacker.public_key().to_hex()) + .header("Content-Type", "application/json") + .json(&vec![filter]) + .send() + .await + .expect("count request"); + assert_eq!( + resp.status().as_u16(), + 403, + "HTTP exclusive COUNT for another author's drafts must return 403" + ); +} + +#[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 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; + assert!(ok, "draft must be accepted: {msg}"); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(keys.public_key()); + let resp = client + .post(format!("{}/count", relay_http_url())) + .header("X-Pubkey", &keys.public_key().to_hex()) + .header("Content-Type", "application/json") + .json(&vec![filter]) + .send() + .await + .expect("count request"); + assert!( + resp.status().is_success(), + "author's own count must succeed, got: {}", + resp.status() + ); + let body: Value = resp.json().await.expect("parse count response"); + let count = body["count"].as_u64().unwrap_or(0); + assert!(count >= 1, "author must count at least 1 own draft"); +} + +// ─── HTTP /query exclusive-author privacy ──────────────────────────────────── + +#[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 d = uuid::Uuid::new_v4().to_string(); + + let draft = build_draft(&victim, &d, "9", &fake_nip44_v2()); + let (ok, msg) = submit_event_http(&client, &victim, &draft).await; + assert!(ok, "victim draft must be accepted: {msg}"); + + let filter = Filter::new() + .kind(nostr::Kind::Custom(KIND_DRAFT)) + .author(victim.public_key()); + let resp = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", &attacker.public_key().to_hex()) + .header("Content-Type", "application/json") + .json(&vec![filter]) + .send() + .await + .expect("query request"); + assert_eq!( + resp.status().as_u16(), + 403, + "exclusive other-author HTTP /query for kind:31234 must return 403" + ); +} + +// ─── Live fan-out privacy ───────────────────────────────────────────────────── + +#[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 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"); + let sid_fanout = sub_id("fanout-attacker"); + let filter = Filter::new() + .kinds(vec![Kind::Metadata, Kind::Custom(KIND_DRAFT)]) + .author(victim.public_key()) + .limit(0); // live only, no stored events + 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_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(); + let profile_id = profile.id; + 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); + loop { + let remaining = deadline + .checked_duration_since(tokio::time::Instant::now()) + .unwrap_or(Duration::ZERO); + if remaining.is_zero() { + break; + } + match ac.recv_event(remaining).await { + Ok(RelayMessage::Event { event, .. }) => { + if event.id == draft_id { + received_draft = true; + } + if event.id == profile_id { + received_profile = true; + } + } + _ => break, + } + } + + assert!( + !received_draft, + "attacker must NOT receive victim's draft via live fan-out" + ); + assert!( + received_profile, + "attacker MUST receive victim's public profile (positive control)" + ); + ac.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 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) + .unwrap(); + let note_id = note.id; + 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_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() + .any(|e| e["id"].as_str() == Some(&draft_id.to_hex())), + "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, + &attacker.public_key().to_hex(), + vec![search_filter2], + ) + .await; + assert!( + !attacker_results + .iter() + .any(|e| e["id"].as_str() == Some(&draft_id.to_hex())), + "draft must not appear in attacker's NIP-50 search either" + ); +} + +// ─── NIP-11 advertisement ───────────────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_nip11_advertises_nip37_not_nip40() { + let client = http_client(); + let resp = client + .get(relay_http_url()) + .header("Accept", "application/nostr+json") + .send() + .await + .expect("NIP-11 request"); + assert!(resp.status().is_success()); + let info: Value = resp.json().await.expect("parse NIP-11 response"); + let nips = info["supported_nips"] + .as_array() + .expect("supported_nips must be an array"); + let nip_numbers: Vec = nips.iter().filter_map(|v| v.as_u64()).collect(); + assert!( + nip_numbers.contains(&37), + "NIP-11 must advertise NIP-37 (draft wraps); got {nip_numbers:?}" + ); + assert!( + !nip_numbers.contains(&40), + "NIP-11 must NOT advertise NIP-40 (expiry suppression not implemented); got {nip_numbers:?}" + ); +} diff --git a/migrations/0012_draft_wrap_fts.sql b/migrations/0012_draft_wrap_fts.sql new file mode 100644 index 000000000..cb31cff62 --- /dev/null +++ b/migrations/0012_draft_wrap_fts.sql @@ -0,0 +1,37 @@ +-- Exclude kind 31234 (NIP-37 draft wraps) from full-text search. +-- +-- NIP-37 draft wraps carry NIP-44-v2 ciphertext in `content` (or empty string +-- for deletion tombstones). Indexing ciphertext would waste storage and violate +-- the "channel/DM context lives only inside the encrypted payload" invariant +-- that makes draft wraps author-private. The relay also must not index +-- plaintext compose context through any search surface. +-- +-- Additive migration: previously applied files must not change checksum. +-- We must DROP the generated column and re-ADD it with the extended exclusion +-- list; ALTER COLUMN cannot change a GENERATED expression in Postgres. +-- +-- Final kind exclusion list after this migration: +-- 1059 = KIND_GIFT_WRAP (NIP-17 ciphertext) +-- 30300 = KIND_EVENT_REMINDER (AUTHOR_ONLY_KINDS — defense in depth) +-- 30622 = KIND_DM_VISIBILITY (per-viewer private hide state) +-- 31234 = KIND_DRAFT (NIP-37: AUTHOR_ONLY_KINDS — ciphertext or tombstone) +-- 44100 = KIND_MEMBER_ADDED_NOTIFICATION (p-gated membership notice) +-- 44101 = KIND_MEMBER_REMOVED_NOTIFICATION (p-gated membership notice) +-- 44200 = KIND_AGENT_TURN_METRIC (NIP-AM: p-gated encrypted turn metrics) +-- Constants kept in `buzz_core::kind`; inlined here because a sqlx migration +-- is frozen SQL and cannot import the Rust constant. If a new privacy-sensitive +-- kind is added there, add a new additive migration following this pattern and +-- add a regression test in `buzz-search/tests/fts_integration.rs`. +-- +-- NULL tsvector never matches `@@`, so excluded rows are storage-level +-- unsearchable. + +ALTER TABLE events DROP COLUMN search_tsv; +ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS ( + CASE WHEN kind IN (1059, 30300, 30622, 31234, 44100, 44101, 44200) THEN NULL::tsvector + ELSE to_tsvector('simple', content) + END +) STORED; + +-- Recreate the GIN index dropped with the column. +CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); diff --git a/schema/schema.sql b/schema/schema.sql index e31347d72..880809d5b 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -206,9 +206,9 @@ CREATE TABLE events ( -- Privacy: encrypted/private routing wrappers and p-gated membership notices -- must never be discoverable through NIP-50 full-text search. NULL tsvector -- never matches `@@`. - -- Keep in sync with migrations (final state: 0001 + 0005_agent_turn_metric_fts). + -- Keep in sync with migrations (final state: 0001 + 0005_agent_turn_metric_fts + 0006_moderation + 0007_draft_wrap_fts). search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101, 44200) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30300, 30622, 31234, 44100, 44101, 44200) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED,