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.
This commit is contained in:
Michael Neale
2026-06-02 10:10:10 +10:00
parent 8934a07051
commit 74deb9cf77
11 changed files with 639 additions and 12 deletions
+4 -4
View File
@@ -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) {
+1 -1
View File
@@ -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"
@@ -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<Vec<String>>,
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<String>,
}
/// 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<DecryptedEvent, String> {
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<Vec<String>> = 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,
})
}
+139
View File
@@ -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<Option<Vec<String>>, 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<String> = 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<String>],
members_hex: &[String],
) -> Result<String, String> {
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<String> = None;
let builder = match kind_num {
+2
View File
@@ -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::*;
+299
View File
@@ -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<Vec<Event>, 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<UnwrappedRumor, String> {
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<String> {
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::<serde_json::Value>(&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::<Event>(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");
}
}
+2
View File
@@ -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,
+28 -6
View File
@@ -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<RelayEvent[]>(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<RelayEvent[]>(
@@ -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(
@@ -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}`;
+65 -1
View File
@@ -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<void> | 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<string> {
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);
}
+34
View File
@@ -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<RelayEvent & { channelId: string | null }> {
const r = await invokeTauri<RawDecryptedEvent>("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;