fix(serverless): encrypt threaded replies in private channels (close plaintext leak)

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.
This commit is contained in:
Michael Neale
2026-06-03 15:00:06 +10:00
parent 2d76bec1a0
commit 604f5f3b2e
5 changed files with 147 additions and 8 deletions
+1 -1
View File
@@ -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
+45 -7
View File
@@ -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<String>],
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<Vec<Vec<String>>>,
mention_pubkeys: Option<Vec<String>>,
kind: Option<u32>,
// 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<String>,
state: State<'_, AppState>,
) -> Result<SendChannelMessageResponse, String> {
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(),
});
}
+89
View File
@@ -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");
}
}
+8
View File
@@ -421,12 +421,20 @@ export function useSendMessageMutation(
queryClient.getQueryData<RelayEvent[]>(
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.
+4
View File
@@ -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<SendChannelMessageResult> {
const response = await invokeTauri<RawSendChannelMessageResult>(
"send_channel_message",
@@ -775,6 +778,7 @@ export async function sendChannelMessage(
mediaTags: mediaTags ?? null,
mentionPubkeys: mentionPubkeys ?? null,
kind: kind ?? null,
rootEventId: rootEventId ?? null,
},
);