diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 1d3a3cf2..a2e47ade 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -12,6 +12,7 @@ use gpui::{ WeakEntity, }; use instant::Duration; +use nip4e::{DeviceEvent, DeviceRegistry}; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP}; @@ -138,17 +139,24 @@ impl ChatRegistry { let (tx, rx) = flume::unbounded::(); let mut subscriptions = smallvec![]; - subscriptions.push( - // Subscribe to the signer event - cx.subscribe(&nostr, |this, _nostr, event, cx| { - if event.signer_changed() { - this.reset(cx); - this.handle_notifications(cx); - this.get_metadata(cx); - this.get_rooms(cx); - }; - }), - ); + let device = DeviceRegistry::global(cx); + + subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| { + if event.signer_changed() { + this.reset(cx); + this.handle_notifications(cx); + this.get_metadata(cx); + this.get_rooms(cx); + }; + })); + + subscriptions.push(cx.subscribe(&device, |this, _device, event, cx| { + if matches!(event, DeviceEvent::Set) { + this.handle_notifications(cx); + this.get_messages(cx); + this.reprocess_trash(cx); + } + })); cx.defer(move |cx| { let _ = entity.update(cx, |this, cx| { @@ -183,6 +191,9 @@ impl ChatRegistry { let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); + let device = DeviceRegistry::global(cx); + let device_signer = device.read(cx).signer(cx); + let seen = self.seen.clone(); let event_map = self.event_map.clone(); let trash = self.trash.downgrade(); @@ -249,7 +260,14 @@ impl ChatRegistry { } // Extract the rumor from the gift wrap event - match extract_rumor(&client, &signer, event.as_ref()).await { + match extract_rumor( + &client, + &signer, + device_signer.as_ref(), + event.as_ref(), + ) + .await + { Ok(rumor) => { let Some(rumor_id) = rumor.id else { log::error!("Rumor missing id after ensure_id"); @@ -385,6 +403,9 @@ impl ChatRegistry { let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); + let device = DeviceRegistry::global(cx); + let device_signer = device.read(cx).signer(cx); + self.tasks.push(cx.spawn(async move |this, cx| { let task: Task> = cx.background_spawn(async move { let public_key = signer.get_public_key_async().await?; @@ -417,6 +438,20 @@ impl ChatRegistry { client.subscribe(target).with_id(id).await?; + // Also pick up gift wraps addressed to this account's decoupled encryption key + if let Some(device_signer) = device_signer.as_ref() { + let device_public_key = device_signer.get_public_key_async().await?; + + let filter = Filter::new().kind(Kind::GiftWrap).pubkey(device_public_key); + let id = SubscriptionId::new(DEVICE_GIFTWRAP); + + let target: HashMap = nip17::extract_relay_list(&event) + .map(|relay| (relay, filter.clone())) + .collect(); + + client.subscribe(target).with_id(id).await?; + } + Ok(()) }); @@ -430,6 +465,49 @@ impl ChatRegistry { })); } + /// Retry unwrapping trashed gift wraps with the newly available encryption key. + fn reprocess_trash(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + let device_signer = DeviceRegistry::global(cx).read(cx).signer(cx); + + let failed: Vec = self.trash.read(cx).iter().cloned().collect(); + + if failed.is_empty() { + return; + } + + self.tasks.push(cx.spawn(async move |this, cx| { + for message in failed { + let Ok(event) = Event::from_json(message.raw_event.as_str()) else { + continue; + }; + + if extract_rumor(&client, &signer, device_signer.as_ref(), &event) + .await + .is_err() + { + continue; + } + + let Ok(rumor) = get_rumor(&client, event.id).await else { + continue; + }; + + this.update(cx, |this, cx| { + this.trash.update(cx, |trash, cx| { + trash.remove(&message); + cx.notify(); + }); + this.new_message(NewMessage::new(event.id, rumor), cx); + })?; + } + + Ok(()) + })); + } + /// Reload the chat registry, fetching messages and contact list from relays. pub fn reload(&mut self, cx: &mut Context) { self.reset(cx); @@ -740,21 +818,30 @@ impl ChatRegistry { } /// Unwraps a gift-wrapped event and processes its contents. -async fn extract_rumor( +async fn extract_rumor( client: &Client, signer: &S, + device_signer: Option<&D>, gift_wrap: &Event, ) -> Result where S: AsyncNip44 + ?Sized, + D: AsyncNip44 + ?Sized, { // Try to get cached rumor first if let Ok(rumor) = get_rumor(client, gift_wrap.id).await { return Ok(rumor); } - // Try to unwrap with the available signer - let unwrapped = try_unwrap_with(signer, gift_wrap).await?; + let unwrapped = match try_unwrap_with(signer, gift_wrap).await { + Ok(unwrapped) => unwrapped, + Err(error) => { + let Some(device_signer) = device_signer else { + return Err(error); + }; + try_unwrap_with(device_signer, gift_wrap).await? + } + }; let mut rumor = unwrapped.rumor; // Verify rumor author matches the seal sender (as per mobile implementation) diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 59c3545d..d1885300 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -3,7 +3,6 @@ use std::hash::{Hash, Hasher}; use anyhow::{Error, anyhow}; use common::EventExt; -use nip4e::DeviceRegistry; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task}; use instant::Duration; use itertools::Itertools; @@ -15,7 +14,6 @@ use state::{NostrRegistry, TIMEOUT}; use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage}; const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet."; -const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available."; /// The outcome of delivering an encrypted rumor to a single receiver. #[derive(Debug, Clone)] @@ -472,35 +470,10 @@ impl Room { tags } - /// Select the appropriate signer based on signer kind and available keys. - fn select_signer<'a, S>( - signer_kind: &SignerKind, - has_announcement: bool, - encryption_signer: &'a Option, - user_signer: &'a S, - ) -> &'a S { - match signer_kind { - SignerKind::Auto => { - if has_announcement { - encryption_signer.as_ref().unwrap_or(user_signer) - } else { - user_signer - } - } - SignerKind::Encryption => encryption_signer - .as_ref() - .expect("encryption signer must be set"), - SignerKind::User => user_signer, - } - } - /// Send the rumor to every member's messaging relays. pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option>> { let config = self.config.clone(); - let device = DeviceRegistry::global(cx); - let encryption_signer = device.read(cx).signer(cx); - let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let user_signer = nostr.read(cx).signer(); @@ -522,31 +495,14 @@ impl Room { let signer_kind = config.signer_kind(); // Deliver to every receiver concurrently - let sends = receivers.into_iter().map(|receiver| { - deliver( - &client, - signer_kind, - receiver, - &rumor, - &encryption_signer, - &user_signer, - ) - }); + let sends = receivers + .into_iter() + .map(|receiver| deliver(&client, signer_kind, receiver, &rumor, &user_signer)); let mut reports: Vec = futures::future::join_all(sends).await; // Back up the message to ourselves once at least one receiver got it if config.backup() && reports.iter().any(SendReport::success) { - reports.push( - deliver( - &client, - signer_kind, - sender, - &rumor, - &encryption_signer, - &user_signer, - ) - .await, - ); + reports.push(deliver(&client, signer_kind, sender, &rumor, &user_signer).await); } reports @@ -560,7 +516,6 @@ async fn deliver( signer_kind: &SignerKind, receiver: Person, rumor: &UnsignedEvent, - encryption_signer: &Option, user_signer: &S, ) -> SendReport where @@ -577,25 +532,9 @@ where result: Err(NO_DEKEY.into()), }; } - - // Sender didn't set up a decoupled encryption key - if encryption_signer.is_none() { - return SendReport { - receiver: public_key, - result: Err(USER_NO_DEKEY.into()), - }; - } } - // Determine the signer to use - let signer = Room::select_signer( - signer_kind, - announcement.is_some(), - encryption_signer, - user_signer, - ); - - match send_gift_wrap(client, signer, &receiver, rumor, signer_kind).await { + match send_gift_wrap(client, user_signer, &receiver, rumor, signer_kind).await { Ok(output) => SendReport { receiver: public_key, result: Ok(output), diff --git a/docs/nip4e-audit.md b/docs/nip4e-audit.md index 5f32a8d4..82f407d2 100644 --- a/docs/nip4e-audit.md +++ b/docs/nip4e-audit.md @@ -1,5 +1,10 @@ # NIP-4e Audit Report +> **Update**: The P0 findings (C1, C2, C3) are now fixed — seals are always signed by the +> identity key, `extract_rumor` falls back to the device key, the `DEVICE_GIFTWRAP` +> subscription is created, and trashed wraps are retried on `DeviceEvent::Set`. +> The remaining findings (F1–F8, P2 items, UX bugs) are still open. + **Scope**: `crates/nip4e/src/lib.rs` (key announcement/sync), the send path (`crates/chat/src/room.rs`), the receive path (`crates/chat/src/lib.rs`), UI integration (`crates/workspace`), compared against the [NIP-4e draft](https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md) and [PsstPsst's implementation](https://github.com/CodyTseng/psstpsst) (`docs/protocols/nip4e.md` + `docs/protocols/nip17-extensions.md`, plus rust-nostr's NIP-59 source to verify crypto mechanics). ## Executive summary