mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user