From 604f5f3b2ea3284bc5af0a942c08936cc6301533 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 3 Jun 2026 15:00:06 +1000 Subject: [PATCH] fix(serverless): encrypt threaded replies in private channels (close plaintext leak) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replies in encrypted serverless channels (DM/private) were falling through to the plaintext path — a privacy leak: the reply content went to public relays as a cleartext kind-9. The encrypt branch was gated on parent_event_id.is_none(), so only top-level messages were gift-wrapped. Fix: encrypt replies too. The NIP-10 thread tags now live INSIDE the rumor (the encrypted inner event), so threading is preserved without leaking the reply. The thread root is resolved locally from decrypted messages (the parent rumor isn't queryable in plaintext on the relay) and passed via a new root_event_id command arg. Proven by encrypted_threaded_reply_is_wrapped_and_preserves_thread: asserts the wire event is kind-1059 (not plaintext kind-9), content never leaks, and the decrypted rumor carries the NIP-10 root + reply e-tags. --- desktop/scripts/check-file-sizes.mjs | 2 +- desktop/src-tauri/src/commands/messages.rs | 52 +++++++++++-- desktop/src-tauri/src/encrypted.rs | 89 ++++++++++++++++++++++ desktop/src/features/messages/hooks.ts | 8 ++ desktop/src/shared/api/tauri.ts | 4 + 5 files changed, 147 insertions(+), 8 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 5dedf4d57..0595adfc7 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -50,7 +50,7 @@ const overrides = new Map([ ["src-tauri/src/migration.rs", 1010], // worktree shared-agent-data symlink sync (SHARED_AGENT_FILES + SHARED_AGENT_DIRS symlink-to-canonical + sibling pack migration) + mcp_command provider reconciliation + persona_pack_path reconciliation + tests ["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests ["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload - ["src-tauri/src/commands/messages.rs", 680], // + NIP-17 encrypted send routing (encrypted_recipients + send_encrypted_message gift-wraps to all members for serverless DMs/private channels) // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ + edit_message media_tags param (Slack-style attachment-editable edits) + ["src-tauri/src/commands/messages.rs", 700], // + NIP-17 encrypted send routing (encrypted_recipients + send_encrypted_message gift-wraps to all members for serverless DMs/private channels) // + encrypted threaded replies (in-rumor NIP-10 thread tags, no plaintext leak) // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ + edit_message media_tags param (Slack-style attachment-editable edits) ["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests ["src-tauri/src/managed_agents/runtime.rs", 1320], // + SPROUT_SERVERLESS env passthrough to ACP subprocess (serverless mode) // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) + system-wide orphan sweep (proc_listallpids/proc on macOS, /proc on Linux) + SPROUT_MANAGED_AGENT env marker check (KERN_PROCARGS2 on macOS, /proc/environ on Linux) ["src-tauri/src/managed_agents/discovery.rs", 680], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index ee05a752b..aa8a5ab61 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -89,6 +89,7 @@ async fn send_encrypted_message( state: &AppState, channel_id: Uuid, content: &str, + thread_ref: Option<&events::ThreadRef>, mention_refs: &[&str], media: &[Vec], members_hex: &[String], @@ -98,9 +99,10 @@ async fn send_encrypted_message( guard.clone() }; - // The rumor is a normal kind:9 channel message (with the `h` tag), so once - // unwrapped it renders through the standard message pipeline. - let builder = events::build_message(channel_id, content, None, mention_refs, media)?; + // The rumor is a normal kind:9 channel message (with the `h` tag and any + // NIP-10 thread tags), so once unwrapped it renders through the standard + // message pipeline — including threaded replies. + let builder = events::build_message(channel_id, content, thread_ref, mention_refs, media)?; let rumor = builder.build(keys.public_key()); let rumor_id = rumor.id.map(|id| id.to_hex()).unwrap_or_default(); @@ -379,6 +381,9 @@ async fn resolve_thread_ref( }) } +// Tauri commands take flat args (each maps to a JS object field), so a high +// arg count is idiomatic here rather than a struct. +#[allow(clippy::too_many_arguments)] #[tauri::command] pub async fn send_channel_message( channel_id: String, @@ -387,6 +392,11 @@ pub async fn send_channel_message( media_tags: Option>>, mention_pubkeys: Option>, kind: Option, + // Thread root for encrypted replies. In an encrypted channel the parent is + // a gift-wrapped rumor not queryable in plaintext, so the caller resolves + // the root locally (from decrypted messages) and passes it here. Ignored on + // the plaintext path (which resolves the root via `resolve_thread_ref`). + root_event_id: Option, state: State<'_, AppState>, ) -> Result { let channel_uuid = uuid::Uuid::parse_str(&channel_id) @@ -399,12 +409,40 @@ pub async fn send_channel_message( // Encrypted serverless channels (DM / private): gift-wrap to all members. // Only plain messages (kind 9) are encrypted; forum posts/comments fall // through to the plaintext path (private forums aren't a serverless model). - if kind_num == sprout_core::kind::KIND_STREAM_MESSAGE && parent_event_id.is_none() { + // Replies ARE encrypted too: the NIP-10 thread tags live INSIDE the rumor + // (the encrypted inner event), so threading is preserved without leaking + // the reply as plaintext to the relays. + if kind_num == sprout_core::kind::KIND_STREAM_MESSAGE { if let Some(members) = encrypted_recipients(&state, &channel_id).await? { + // Build the in-rumor thread ref from caller-supplied ids (no relay + // lookup — the parent rumor isn't stored plaintext on the relay). + let thread_ref = match &parent_event_id { + Some(parent) => { + let parent_eid = EventId::from_hex(parent) + .map_err(|e| format!("invalid parent event ID: {e}"))?; + let root_eid = match &root_event_id { + Some(root) if root != parent => EventId::from_hex(root) + .map_err(|e| format!("invalid root event ID: {e}"))?, + _ => parent_eid, + }; + Some(events::ThreadRef { + root_event_id: root_eid, + parent_event_id: parent_eid, + }) + } + None => None, + }; + let depth = match (&parent_event_id, &root_event_id) { + (None, _) => 0, + (Some(p), Some(r)) if p == r => 1, + (Some(_), Some(_)) => 2, + (Some(_), None) => 1, + }; let rumor_id = send_encrypted_message( &state, channel_uuid, content.trim(), + thread_ref.as_ref(), &mention_refs, &media, &members, @@ -412,9 +450,9 @@ pub async fn send_channel_message( .await?; return Ok(SendChannelMessageResponse { event_id: rumor_id, - root_event_id: None, - parent_event_id: None, - depth: 0, + root_event_id: thread_ref.as_ref().map(|t| t.root_event_id.to_hex()), + parent_event_id: parent_event_id.clone(), + depth, created_at: chrono::Utc::now().timestamp(), }); } diff --git a/desktop/src-tauri/src/encrypted.rs b/desktop/src-tauri/src/encrypted.rs index b919a2303..9d256ba9c 100644 --- a/desktop/src-tauri/src/encrypted.rs +++ b/desktop/src-tauri/src/encrypted.rs @@ -295,4 +295,93 @@ mod tests { let got = unwrap_gift(&a, wrap_for_a).await.unwrap(); assert_eq!(got.rumor.content, "echo"); } + + /// Proves the encrypted-reply privacy fix: a THREADED reply in an encrypted + /// channel is gift-wrapped (kind 1059) — NOT leaked as a plaintext kind-9 — + /// and the NIP-10 thread tags travel INSIDE the rumor, so threading is + /// preserved after decryption. This mirrors what `send_channel_message` + /// does for an encrypted reply: build via `events::build_message` with a + /// `ThreadRef`, then gift-wrap. + #[tokio::test] + async fn encrypted_threaded_reply_is_wrapped_and_preserves_thread() { + use crate::events::{self, ThreadRef}; + use nostr::EventId; + + let a = Keys::generate(); // replier + let b = Keys::generate(); // other member + let channel = uuid::Uuid::new_v4(); + + // A reply two levels deep: root != parent. + let root = + EventId::from_hex("1111111111111111111111111111111111111111111111111111111111111111") + .unwrap(); + let parent = + EventId::from_hex("2222222222222222222222222222222222222222222222222222222222222222") + .unwrap(); + let thread_ref = ThreadRef { + root_event_id: root, + parent_event_id: parent, + }; + + // Build the rumor exactly as the command does for an encrypted reply. + let builder = + events::build_message(channel, "threaded reply", Some(&thread_ref), &[], &[]).unwrap(); + let rumor = builder.build(a.public_key()); + + let recipients = [a.public_key(), b.public_key()]; + let wraps = build_gift_wraps(&a, rumor, &recipients).await.unwrap(); + + // 1. Privacy: every wire event is a kind-1059 gift wrap, NOT a + // plaintext kind-9. The reply content never appears unencrypted. + for w in &wraps { + assert_eq!( + w.kind, + Kind::Custom(KIND_GIFT_WRAP), + "reply must be gift-wrapped, not plaintext" + ); + assert!( + !w.content.contains("threaded reply"), + "plaintext reply content leaked into the wrapper!" + ); + } + + // 2. Threading preserved: B decrypts and the rumor carries the NIP-10 + // root + reply `e` tags. + let b_pk = b.public_key().to_hex(); + let wrap_for_b = wraps + .iter() + .find(|w| { + w.tags.iter().any(|t| { + t.as_slice().len() >= 2 && t.as_slice()[0] == "p" && t.as_slice()[1] == b_pk + }) + }) + .expect("a wrap addressed to B"); + let got = unwrap_gift(&b, wrap_for_b).await.unwrap(); + assert_eq!(got.rumor.content, "threaded reply"); + assert_eq!( + got.channel_id().as_deref(), + Some(channel.to_string().as_str()) + ); + + let mut found_root = false; + let mut found_reply = false; + for t in got.rumor.tags.iter() { + let s = t.as_slice(); + if s.len() >= 4 && s[0] == "e" { + match s[3].as_str() { + "root" => { + assert_eq!(s[1], root.to_hex()); + found_root = true; + } + "reply" => { + assert_eq!(s[1], parent.to_hex()); + found_reply = true; + } + _ => {} + } + } + } + assert!(found_root, "rumor missing NIP-10 root e-tag"); + assert!(found_reply, "rumor missing NIP-10 reply e-tag"); + } } diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 0d5430ac0..b3470306c 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -421,12 +421,20 @@ export function useSendMessageMutation( queryClient.getQueryData( channelMessagesKey(channel.id), ) ?? []; + // Resolve the thread root locally (works for both plaintext and + // encrypted channels — in encrypted channels the parent rumor isn't + // queryable in plaintext, so the backend relies on this). + const resolvedRoot = parentEventId + ? resolveReplyRootId(parentEventId, cachedMessages) + : null; const result = await sendChannelMessage( channel.id, content, parentEventId ?? null, mediaTags, mentionPubkeys, + undefined, + resolvedRoot, ); // Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta. diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 06ef94310..6e343fbe1 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -765,6 +765,9 @@ export async function sendChannelMessage( mediaTags?: string[][], mentionPubkeys?: string[], kind?: number, + // Thread root for encrypted replies (resolved locally from decrypted + // messages, since the parent rumor isn't queryable in plaintext). + rootEventId?: string | null, ): Promise { const response = await invokeTauri( "send_channel_message", @@ -775,6 +778,7 @@ export async function sendChannelMessage( mediaTags: mediaTags ?? null, mentionPubkeys: mentionPubkeys ?? null, kind: kind ?? null, + rootEventId: rootEventId ?? null, }, );