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, }, );