From 74deb9cf7733ce382707987ab6abcac493f9a103 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 2 Jun 2026 10:10:10 +1000 Subject: [PATCH] feat(serverless): NIP-17 encrypted DMs + private channels (desktop) Serverless private channels and DMs are made private by encryption (no server to enforce access). Messages are NIP-17 gift-wrapped (kind 1059) to every member; the relay only sees opaque blobs addressed by #p. - crate::encrypted: build_gift_wraps (one per member) + unwrap_gift - send_channel_message: routes serverless DM/private channels through encryption (rumor=kind9 with h tag -> seal -> wrap per member) - decrypt_gift_wrap command + relayClient encrypted fetch/subscribe (query kind1059 #p=me, decrypt, route by inner h tag) - proven end-to-end over wss://relay.damus.io (group A->{A,B,C}, B reads) Agent-side gift-wrap support (sprout-acp) follows separately. --- desktop/scripts/check-file-sizes.mjs | 8 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/src/commands/encrypted.rs | 58 ++++ desktop/src-tauri/src/commands/messages.rs | 139 ++++++++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/encrypted.rs | 299 ++++++++++++++++++ desktop/src-tauri/src/lib.rs | 2 + desktop/src/features/messages/hooks.ts | 34 +- .../features/workspaces/workspaceStorage.ts | 7 + desktop/src/shared/api/relayClientSession.ts | 66 +++- desktop/src/shared/api/tauri.ts | 34 ++ 11 files changed, 639 insertions(+), 12 deletions(-) create mode 100644 desktop/src-tauri/src/commands/encrypted.rs create mode 100644 desktop/src-tauri/src/encrypted.rs diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 209843a32..b3655326c 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -42,15 +42,15 @@ const overrides = new Map([ ["src/features/channels/useUnreadChannels.ts", 715], // NIP-RS read marker tracking + participated/authored/followed thread ID sets + localStorage persistence + catch-up REQ with thread activity collection + thread reply activity feed items + mutedRootIds denylist with localStorage persistence + muteThread/unmuteThread callbacks ["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state ["src/features/home/ui/HomeView.tsx", 505], // inbox/feed orchestration + thread context + reply/delete flow + NIP-RS read-state projection wiring (useHomeInboxReadState) - ["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates + ["src/features/messages/hooks.ts", 540], // message query/mutation hooks + optimistic updates + isEncryptedChannel routing (NIP-17 serverless DMs/private channels) ["src/features/messages/ui/MessageComposer.tsx", 800], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) + Slack-style attachment-editable edits: seed pendingImeta from edit target, stash/restore user's draft pendingImeta across edit-mode entry/exit, re-append imeta markdown lines on edit-submit so renderer draws them ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav - ["src/shared/api/relayClientSession.ts", 1080], // + serverless mode (setServerless, connect() skips AUTH-wait, late-challenge answering) // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts + ["src/shared/api/relayClientSession.ts", 1140], // + NIP-17 encrypted channels (fetchEncryptedHistory/subscribeToChannel decrypt gift wraps via decryptGiftWrap) + serverless mode (setServerless, connect() skips AUTH-wait, late-challenge answering) // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts ["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", 515], // 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", 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/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 @@ -81,7 +81,7 @@ const overrides = new Map([ ["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test ["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers ["src-tauri/src/lib.rs", 770], // +4 lines for PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands/reconcile_persona_pack_paths calls on launch + SIGINT/SIGTERM/SIGHUP signal handlers for agent process cleanup - ["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep + AcpProviderCatalogEntry raw types + fromRawAcpProviderCatalogEntry converter + installAcpRuntime + ["src/shared/api/tauri.ts", 1250], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep + AcpProviderCatalogEntry raw types + fromRawAcpProviderCatalogEntry converter + installAcpRuntime + decryptGiftWrap (NIP-17 serverless encrypted channels) ]); async function walkFiles(directory) { diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index d9fc21ad7..8310e51df 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -48,7 +48,7 @@ opus = "0.3" neteq = { version = "0.8", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" -nostr = { version = "0.44", features = ["nip44"] } +nostr = { version = "0.44", features = ["nip44", "nip59"] } zeroize = "1" reqwest = { version = "0.13", features = ["json", "query", "stream"] } url = "2" diff --git a/desktop/src-tauri/src/commands/encrypted.rs b/desktop/src-tauri/src/commands/encrypted.rs new file mode 100644 index 000000000..8adf6a2fa --- /dev/null +++ b/desktop/src-tauri/src/commands/encrypted.rs @@ -0,0 +1,58 @@ +//! Tauri commands for NIP-17 encrypted messaging (serverless private channels +//! and DMs). See `crate::encrypted` for the gift-wrap scheme. + +use nostr::{Event, JsonUtil}; +use tauri::State; + +use crate::app_state::AppState; + +/// A decrypted gift wrap, shaped like a `RelayEvent` so the frontend can feed +/// it straight into the existing message pipeline. +#[derive(serde::Serialize)] +pub struct DecryptedEvent { + /// Inner rumor id (stable logical message id). + pub id: String, + /// Real author (recovered + verified from the seal). + pub pubkey: String, + pub created_at: i64, + pub kind: u16, + pub tags: Vec>, + pub content: String, + /// Rumors carry no signature; always empty. + pub sig: String, + /// The channel id from the rumor's `h` tag, if present (convenience for + /// routing on the frontend without re-scanning tags). + pub channel_id: Option, +} + +/// Decrypt a `kind:1059` gift wrap addressed to us and return the inner message +/// rumor as a `RelayEvent`-shaped object. Errors if the wrap isn't addressed to +/// our identity or can't be decrypted. +#[tauri::command] +pub async fn decrypt_gift_wrap( + event_json: String, + state: State<'_, AppState>, +) -> Result { + let keys = { + let guard = state.keys.lock().map_err(|e| e.to_string())?; + guard.clone() + }; + + let wrap = Event::from_json(&event_json).map_err(|e| format!("invalid event JSON: {e}"))?; + let unwrapped = crate::encrypted::unwrap_gift(&keys, &wrap).await?; + + let channel_id = unwrapped.channel_id(); + let rumor = &unwrapped.rumor; + let tags: Vec> = rumor.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + + Ok(DecryptedEvent { + id: rumor.id.map(|id| id.to_hex()).unwrap_or_default(), + pubkey: unwrapped.sender.to_hex(), + created_at: rumor.created_at.as_u64() as i64, + kind: rumor.kind.as_u16(), + tags, + content: rumor.content.clone(), + sig: String::new(), + channel_id, + }) +} diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 2bbd75319..745d3d6cc 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -1,5 +1,6 @@ use nostr::EventId; use tauri::State; +use uuid::Uuid; use crate::{ app_state::AppState, @@ -13,6 +14,120 @@ use crate::{ relay::{query_relay, submit_event}, }; +// ── Encrypted channel routing (serverless private channels + DMs) ──────────── +// +// In serverless mode a "private" channel or DM is made private by encryption, +// not server access control. When a channel is encrypted, outbound messages +// are NIP-17 gift-wrapped to every member (see crate::encrypted) instead of +// published as plaintext kind:9 events. + +/// Whether the given channel must be encrypted: serverless mode AND the channel +/// is a DM or has `private` visibility. Returns the member pubkeys (hex) to +/// encrypt to (always including the sender) when encrypted, else `None`. +async fn encrypted_recipients( + state: &AppState, + channel_id: &str, +) -> Result>, String> { + if !state.is_serverless() { + return Ok(None); + } + + let meta = query_relay( + state, + &[serde_json::json!({"kinds":[39000],"#d":[channel_id],"limit":1})], + ) + .await?; + let Some(meta) = meta.first() else { + return Ok(None); + }; + let info = nostr_convert::channel_info_from_event(meta, None, None)?; + let encrypted = info.channel_type == "dm" || info.visibility == "private"; + if !encrypted { + return Ok(None); + } + + // Members come from the kind:39002 list; always include ourselves so we can + // read back our own sent messages. + let member_events = query_relay( + state, + &[serde_json::json!({"kinds":[39002],"#d":[channel_id],"limit":1})], + ) + .await?; + let mut members: Vec = member_events + .first() + .map(|ev| { + ev.tags + .iter() + .filter_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "p" { + Some(s[1].to_ascii_lowercase()) + } else { + None + } + }) + .collect() + }) + .unwrap_or_default(); + + let me = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.public_key().to_hex() + }; + if !members.contains(&me) { + members.push(me); + } + members.sort(); + members.dedup(); + Ok(Some(members)) +} + +/// Build a kind:9 message rumor, gift-wrap it to every member, and publish all +/// wraps. Returns the inner rumor's event id (stable across recipients) so the +/// UI can reference the logical message. +async fn send_encrypted_message( + state: &AppState, + channel_id: Uuid, + content: &str, + mention_refs: &[&str], + media: &[Vec], + members_hex: &[String], +) -> Result { + let keys = { + let guard = state.keys.lock().map_err(|e| e.to_string())?; + 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)?; + let rumor = builder.build(keys.public_key()); + let rumor_id = rumor.id.map(|id| id.to_hex()).unwrap_or_default(); + + let mut recipients = Vec::with_capacity(members_hex.len()); + for hex in members_hex { + let pk = nostr::PublicKey::from_hex(hex) + .map_err(|e| format!("invalid member pubkey {hex}: {e}"))?; + recipients.push(pk); + } + + let wraps = crate::encrypted::build_gift_wraps(&keys, rumor, &recipients).await?; + + let relay_urls = crate::relay::relay_ws_urls_with_override(state); + let mut published = 0; + let mut last_err = None; + for wrap in &wraps { + match crate::ws_relay::publish_signed_event_ws(wrap, &keys, &relay_urls).await { + Ok(()) => published += 1, + Err(e) => last_err = Some(e), + } + } + if published == 0 { + return Err(last_err.unwrap_or_else(|| "failed to publish any gift wrap".to_string())); + } + Ok(rumor_id) +} + // ── Reads (pure-nostr) ────────────────────────────────────────────────────── #[tauri::command] @@ -281,6 +396,30 @@ pub async fn send_channel_message( let media = media_tags.unwrap_or_default(); let kind_num = kind.unwrap_or(sprout_core::kind::KIND_STREAM_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() { + if let Some(members) = encrypted_recipients(&state, &channel_id).await? { + let rumor_id = send_encrypted_message( + &state, + channel_uuid, + content.trim(), + &mention_refs, + &media, + &members, + ) + .await?; + return Ok(SendChannelMessageResponse { + event_id: rumor_id, + root_event_id: None, + parent_event_id: None, + depth: 0, + created_at: chrono::Utc::now().timestamp(), + }); + } + } + let mut resolved_root: Option = None; let builder = match kind_num { diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 6cec86f5d..b1e3c2d3a 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -6,6 +6,7 @@ mod canvas; mod channel_templates; mod channels; mod dms; +mod encrypted; mod export_util; mod identity; mod identity_archive; @@ -30,6 +31,7 @@ pub use canvas::*; pub use channel_templates::*; pub use channels::*; pub use dms::*; +pub use encrypted::*; pub use identity::*; pub use identity_archive::*; pub use media::*; diff --git a/desktop/src-tauri/src/encrypted.rs b/desktop/src-tauri/src/encrypted.rs new file mode 100644 index 000000000..e1cb42907 --- /dev/null +++ b/desktop/src-tauri/src/encrypted.rs @@ -0,0 +1,299 @@ +//! NIP-17 encrypted messaging for serverless private channels and DMs. +//! +//! On a generic public relay there is no server to enforce channel access, so +//! "private" channels and DMs are made private by **encryption** rather than +//! access control. We use the NIP-17 / NIP-59 gift-wrap scheme: +//! +//! ```text +//! plaintext message (kind 9 rumor, with the channel `h` tag) +//! → seal (kind 13, nip44-encrypted to one recipient) +//! → gift wrap (kind 1059, nip44-encrypted with an ephemeral key, +//! tagged `#p` = recipient, randomized timestamp) +//! ``` +//! +//! One gift wrap is produced **per recipient** (every member, including the +//! sender, so the sender's own client can read its sent messages). The relay +//! only ever stores opaque `kind 1059` blobs addressed by `#p`; it never sees +//! the channel id, the content, or the real author. The inner rumor is a +//! normal `kind 9` event carrying the `h` tag, so once unwrapped it flows +//! through the exact same message-rendering path as a plaintext channel +//! message. +//! +//! This is the "small group" model: O(N) writes per message, no shared group +//! key, no forward secrecy. Suitable for small private groups; it naturally +//! gets heavy for large ones (the cost is the cap). + +use nostr::nips::nip59::UnwrappedGift; +use nostr::{Event, EventBuilder, Keys, PublicKey, UnsignedEvent}; + +/// Kind 1059 — NIP-59 gift wrap. +pub const KIND_GIFT_WRAP: u16 = 1059; + +/// Build the gift-wrapped events for `rumor`, one per recipient pubkey. +/// +/// `rumor` is the unsigned inner event (a normal kind-9 message with the `h` +/// tag). `sender_keys` signs the seal. `recipients` should include every +/// member of the channel **plus the sender** so the sender can read back their +/// own messages. Returns one signed `kind 1059` event per recipient, ready to +/// publish. +pub async fn build_gift_wraps( + sender_keys: &Keys, + rumor: UnsignedEvent, + recipients: &[PublicKey], +) -> Result, String> { + let mut wraps = Vec::with_capacity(recipients.len()); + for recipient in recipients { + let wrap = EventBuilder::gift_wrap(sender_keys, recipient, rumor.clone(), []) + .await + .map_err(|e| format!("gift wrap failed: {e}"))?; + wraps.push(wrap); + } + Ok(wraps) +} + +/// Unwrap a `kind 1059` gift wrap addressed to us, returning the inner rumor +/// as a signed-shaped event we can hand to the normal message pipeline. +/// +/// The recovered rumor is unsigned (NIP-17 rumors carry no signature), so we +/// surface the verified sender from the seal and rebuild a concrete event with +/// the rumor's id/pubkey/kind/tags/content. The `recipient_keys` must be the +/// identity the gift wrap was `#p`-addressed to. +pub async fn unwrap_gift( + recipient_keys: &Keys, + gift_wrap: &Event, +) -> Result { + let unwrapped = UnwrappedGift::from_gift_wrap(recipient_keys, gift_wrap) + .await + .map_err(|e| format!("gift unwrap failed: {e}"))?; + + let rumor = unwrapped.rumor; + Ok(UnwrappedRumor { + sender: unwrapped.sender, + rumor, + wrap_id: gift_wrap.id.to_hex(), + }) +} + +/// A decrypted gift wrap: the verified sender plus the inner rumor. +pub struct UnwrappedRumor { + /// The real author of the message (recovered + verified from the seal). + pub sender: PublicKey, + /// The inner message event (kind 9, carries the channel `h` tag). + pub rumor: UnsignedEvent, + /// The outer gift-wrap event id (used for relay-side dedup of the wrapper). + pub wrap_id: String, +} + +impl UnwrappedRumor { + /// Extract the channel id from the rumor's `h` tag, if present. + pub fn channel_id(&self) -> Option { + self.rumor.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "h" { + Some(s[1].clone()) + } else { + None + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::Kind; + + fn rumor(sender: &Keys, channel_id: &str, content: &str) -> UnsignedEvent { + EventBuilder::new(Kind::Custom(9), content) + .tags([nostr::Tag::parse(vec!["h", channel_id]).unwrap()]) + .build(sender.public_key()) + } + + #[tokio::test] + async fn group_member_can_decrypt_others_messages() { + // A sends to a 3-person group {A, B, C}. B must be able to read it. + let a = Keys::generate(); + let b = Keys::generate(); + let c = Keys::generate(); + let channel = uuid::Uuid::new_v4().to_string(); + + let recipients = [a.public_key(), b.public_key(), c.public_key()]; + let wraps = build_gift_wraps(&a, rumor(&a, &channel, "secret hello"), &recipients) + .await + .unwrap(); + assert_eq!(wraps.len(), 3, "one wrap per recipient"); + + // Each wrap is a kind 1059 addressed to exactly one recipient via #p. + for w in &wraps { + assert_eq!(w.kind, Kind::Custom(KIND_GIFT_WRAP)); + } + + // B finds the wrap addressed to B and decrypts it. + let b_pk = b.public_key().to_hex(); + let wrap_for_b = wraps + .iter() + .find(|w| { + w.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0] == "p" && s[1] == b_pk + }) + }) + .expect("a wrap addressed to B"); + + let got = unwrap_gift(&b, wrap_for_b).await.unwrap(); + assert_eq!(got.sender, a.public_key(), "sender recovered + verified"); + assert_eq!(got.rumor.content, "secret hello"); + assert_eq!(got.channel_id().as_deref(), Some(channel.as_str())); + } + + #[tokio::test] + async fn non_member_cannot_decrypt() { + // A sends to {A, B}. An outsider D (not p-tagged) cannot decrypt B's wrap. + let a = Keys::generate(); + let b = Keys::generate(); + let d = Keys::generate(); + let channel = uuid::Uuid::new_v4().to_string(); + + let wraps = build_gift_wraps( + &a, + rumor(&a, &channel, "members only"), + &[a.public_key(), b.public_key()], + ) + .await + .unwrap(); + + 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 + }) + }) + .unwrap(); + + // D tries to unwrap B's gift wrap — must fail (wrong recipient key). + assert!(unwrap_gift(&d, wrap_for_b).await.is_err()); + } + + #[tokio::test] + #[ignore = "network: hits wss://relay.damus.io"] + async fn encrypted_group_roundtrip_over_relay() { + // A sends an encrypted message to {A,B,C} on a real public relay. + // B fetches its gift-wrap inbox and must decrypt A's message. + use futures_util::{SinkExt, StreamExt}; + use tokio_tungstenite::{connect_async, tungstenite::Message}; + + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + let a = Keys::generate(); + let b = Keys::generate(); + let c = Keys::generate(); + let channel = uuid::Uuid::new_v4().to_string(); + let secret = format!("encrypted-{}", &channel[..8]); + let relay = "wss://relay.damus.io"; + + // A builds + publishes one gift wrap per member. + let wraps = build_gift_wraps( + &a, + rumor(&a, &channel, &secret), + &[a.public_key(), b.public_key(), c.public_key()], + ) + .await + .unwrap(); + + let (ws, _) = connect_async(relay).await.expect("connect"); + let (mut write, mut read) = ws.split(); + for w in &wraps { + let ev = serde_json::json!(["EVENT", w]).to_string(); + write + .send(Message::Text(ev.into())) + .await + .expect("send wrap"); + } + // Drain a few OKs. + for _ in 0..wraps.len() { + let _ = tokio::time::timeout(std::time::Duration::from_secs(3), read.next()).await; + } + let _ = write.close().await; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + // B connects fresh and queries its gift-wrap inbox (#p = B). + let (ws2, _) = connect_async(relay).await.expect("connect b"); + let (mut w2, mut r2) = ws2.split(); + let b_pk = b.public_key().to_hex(); + let req = serde_json::json!([ + "REQ", "inbox", + {"kinds":[KIND_GIFT_WRAP], "#p":[b_pk], "limit": 50} + ]) + .to_string(); + w2.send(Message::Text(req.into())).await.expect("req"); + + let mut found = false; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + while tokio::time::Instant::now() < deadline { + let Ok(Some(Ok(msg))) = + tokio::time::timeout(std::time::Duration::from_secs(5), r2.next()).await + else { + break; + }; + let Message::Text(text) = msg else { continue }; + let Ok(arr) = serde_json::from_str::(&text) else { + continue; + }; + match arr.get(0).and_then(|v| v.as_str()) { + Some("EVENT") => { + if let Some(ev) = arr + .get(2) + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + { + if let Ok(got) = unwrap_gift(&b, &ev).await { + if got.rumor.content == secret { + assert_eq!(got.sender, a.public_key()); + assert_eq!(got.channel_id().as_deref(), Some(channel.as_str())); + found = true; + break; + } + } + } + } + Some("EOSE") => break, + _ => {} + } + } + let _ = w2.close().await; + assert!( + found, + "B did not receive/decrypt A's encrypted group message" + ); + eprintln!("✅ encrypted group roundtrip OK over {relay}"); + } + + #[tokio::test] + async fn sender_can_read_own_message() { + // The sender includes itself as a recipient so it can read its sent msg. + let a = Keys::generate(); + let b = Keys::generate(); + let channel = uuid::Uuid::new_v4().to_string(); + + let wraps = build_gift_wraps( + &a, + rumor(&a, &channel, "echo"), + &[a.public_key(), b.public_key()], + ) + .await + .unwrap(); + + let a_pk = a.public_key().to_hex(); + let wrap_for_a = wraps + .iter() + .find(|w| { + w.tags.iter().any(|t| { + t.as_slice().len() >= 2 && t.as_slice()[0] == "p" && t.as_slice()[1] == a_pk + }) + }) + .unwrap(); + let got = unwrap_gift(&a, wrap_for_a).await.unwrap(); + assert_eq!(got.rumor.content, "echo"); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 4e6212d0e..c6b6e17c7 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod app_state; mod commands; +mod encrypted; mod events; mod huddle; mod managed_agents; @@ -550,6 +551,7 @@ pub fn run() { get_feed, search_messages, send_channel_message, + decrypt_gift_wrap, get_forum_posts, get_forum_thread, edit_message, diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 788c2cf8d..cb86a01ab 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -21,6 +21,21 @@ import { sendChannelMessage, } from "@/shared/api/tauri"; import type { Channel, Identity, RelayEvent } from "@/shared/api/types"; + +import { isActiveWorkspaceServerless } from "@/features/workspaces/workspaceStorage"; + +/** + * Encrypted channels in serverless mode: DMs and private channels are made + * private by NIP-17 gift-wrap encryption (no server to enforce access). On a + * Sprout-server workspace, privacy is server-enforced and messages stay + * plaintext, so this is always false. + */ +function isEncryptedChannel(channel: Channel | null): boolean { + if (!channel || !isActiveWorkspaceServerless()) { + return false; + } + return channel.channelType === "dm" || channel.visibility === "private"; +} // Same .mjs the renderer uses, so the cache-update projection can't drift // from the on-render overlay. import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs"; @@ -134,6 +149,7 @@ export function useChannelMessagesQuery(channel: Channel | null) { const history = await relayClient.fetchChannelHistory( channel.id, CHANNEL_HISTORY_LIMIT, + isEncryptedChannel(channel), ); const currentMessages = queryClient.getQueryData(queryKey) ?? []; @@ -153,6 +169,7 @@ export function useChannelSubscription(channel: Channel | null) { const queryClient = useQueryClient(); const channelId = channel?.id ?? null; const channelType = channel?.channelType ?? null; + const encrypted = isEncryptedChannel(channel); const syncLatestHistory = useEffectEvent(async () => { if (!channelId) { return; @@ -161,6 +178,7 @@ export function useChannelSubscription(channel: Channel | null) { const history = await relayClient.fetchChannelHistory( channelId, CHANNEL_HISTORY_LIMIT, + encrypted, ); queryClient.setQueryData( @@ -228,11 +246,15 @@ export function useChannelSubscription(channel: Channel | null) { }); relayClient - .subscribeToChannel(channelId, (event) => { - if (!isDisposed) { - appendMessage(event); - } - }) + .subscribeToChannel( + channelId, + (event) => { + if (!isDisposed) { + appendMessage(event); + } + }, + encrypted, + ) .then((dispose) => { if (isDisposed) { void dispose(); @@ -262,7 +284,7 @@ export function useChannelSubscription(channel: Channel | null) { void cleanup(); } }; - }, [channelId, channelType]); + }, [channelId, channelType, encrypted]); } export function useSendMessageMutation( diff --git a/desktop/src/features/workspaces/workspaceStorage.ts b/desktop/src/features/workspaces/workspaceStorage.ts index 2eaf3da6c..8f55e7f1e 100644 --- a/desktop/src/features/workspaces/workspaceStorage.ts +++ b/desktop/src/features/workspaces/workspaceStorage.ts @@ -67,6 +67,13 @@ export function saveActiveWorkspaceId(id: string): void { localStorage.setItem(ACTIVE_WORKSPACE_KEY, id); } +/** Whether the currently-active workspace is in serverless mode. */ +export function isActiveWorkspaceServerless(): boolean { + const id = loadActiveWorkspaceId(); + const active = loadWorkspaces().find((w) => w.id === id); + return isServerlessWorkspace(active); +} + export function normalizeRelayUrl(url: string): string { if (!url.startsWith("ws://") && !url.startsWith("wss://")) { return `wss://${url}`; diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index bb9bbcf3d..bcc82f11e 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -2,6 +2,7 @@ import { Channel, invoke } from "@tauri-apps/api/core"; import { createAuthEvent, + decryptGiftWrap, getRelayWsUrl, signRelayEvent, } from "@/shared/api/tauri"; @@ -41,9 +42,14 @@ const RECONNECT_BASE_DELAY_MS = 1_000, const STALL_CHECK_INTERVAL_MS = 10_000; const STALL_IDLE_TIMEOUT_MS = 60_000; +/** NIP-59 gift wrap kind — encrypted message envelope on the relay. */ +const GIFT_WRAP_KIND = 1059; + export class RelayClient { private wsId: number | null = null; private relayUrl: string | null = null; + /** Our identity pubkey (hex), cached for encrypted gift-wrap filters. */ + private cachedPubkey: string | null = null; private connectPromise: Promise | null = null; private reconnectTimeout: number | null = null; private reconnectDelayMs = RECONNECT_BASE_DELAY_MS; @@ -111,6 +117,7 @@ export class RelayClient { this.connectionGeneration++; this.keepAliveRequested = false; this.relayUrl = null; + this.cachedPubkey = null; this.hasConnectedOnce = false; this.notifyReconnectListeners = false; this.terminal = false; @@ -156,10 +163,47 @@ export class RelayClient { this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; } - async fetchChannelHistory(channelId: string, limit = 50) { + async fetchChannelHistory(channelId: string, limit = 50, encrypted = false) { + if (encrypted) { + return this.fetchEncryptedHistory(channelId, limit); + } return this.fetchHistory(this.buildChannelFilter(channelId, limit)); } + /** + * Encrypted (serverless private/DM) channel history. The relay stores only + * NIP-17 gift wraps (kind 1059) addressed to us by `#p`; it has no `#h` index + * for them. So we fetch all our gift wraps, decrypt each in the Rust backend, + * and keep the inner kind-9 rumors whose `h` tag matches this channel. + */ + private async fetchEncryptedHistory(channelId: string, limit: number) { + const wraps = await this.fetchHistory({ + kinds: [GIFT_WRAP_KIND], + "#p": [await this.myPubkey()], + limit: Math.max(limit * 4, 200), + }); + const out: RelayEvent[] = []; + for (const wrap of wraps) { + try { + const inner = await decryptGiftWrap(JSON.stringify(wrap)); + if (inner.channelId === channelId) { + out.push(inner); + } + } catch { + // Not addressed to us / undecryptable — skip. + } + } + return out; + } + + private async myPubkey(): Promise { + if (!this.cachedPubkey) { + const { getIdentity } = await import("@/shared/api/tauri"); + this.cachedPubkey = (await getIdentity()).pubkey; + } + return this.cachedPubkey; + } + async fetchChannelHistoryBefore( channelId: string, before: number, @@ -274,7 +318,27 @@ export class RelayClient { async subscribeToChannel( channelId: string, onEvent: (event: RelayEvent) => void, + encrypted = false, ) { + if (encrypted) { + // Subscribe to our gift wraps; decrypt each and dispatch only those whose + // inner rumor belongs to this channel. + return this.subscribe( + { kinds: [GIFT_WRAP_KIND], "#p": [await this.myPubkey()], limit: 50 }, + (wrap) => { + void (async () => { + try { + const inner = await decryptGiftWrap(JSON.stringify(wrap)); + if (inner.channelId === channelId) { + onEvent(inner); + } + } catch { + // Not ours / undecryptable — skip. + } + })(); + }, + ); + } return this.subscribe(this.buildChannelFilter(channelId, 50), onEvent); } diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index aae23a6d4..1b741cb1a 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -748,6 +748,40 @@ export async function sendChannelMessage( }; } +type RawDecryptedEvent = { + id: string; + pubkey: string; + created_at: number; + kind: number; + tags: string[][]; + content: string; + sig: string; + channel_id: string | null; +}; + +/** + * Decrypt a NIP-17 gift wrap (kind 1059) addressed to us, returning the inner + * message rumor shaped as a RelayEvent. Used for serverless encrypted channels + * and DMs. Throws if the wrap isn't addressed to us or can't be decrypted. + */ +export async function decryptGiftWrap( + eventJson: string, +): Promise { + const r = await invokeTauri("decrypt_gift_wrap", { + eventJson, + }); + return { + id: r.id, + pubkey: r.pubkey, + created_at: r.created_at, + kind: r.kind, + tags: r.tags, + content: r.content, + sig: r.sig, + channelId: r.channel_id, + }; +} + export type BlobDescriptor = { url: string; sha256: string;