diff --git a/Cargo.lock b/Cargo.lock index 070d77cd..a5710ca7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1073,7 +1073,6 @@ version = "1.0.2" dependencies = [ "anyhow", "common", - "device", "flume 0.11.1", "futures", "fuzzy-matcher", @@ -1081,8 +1080,10 @@ dependencies = [ "instant", "itertools 0.13.0", "log", + "nip4e", "nostr-sdk", "person", + "rand 0.10.3", "settings", "smallvec", "smol", @@ -1413,7 +1414,6 @@ dependencies = [ "auto_update", "chat", "common", - "device", "gpui-pre", "gpui-pre-linux", "gpui-pre-macos", @@ -1422,6 +1422,7 @@ dependencies = [ "gpui-pre-windows", "log", "nip29", + "nip4e", "nostr-sdk", "person", "settings", @@ -1441,7 +1442,6 @@ dependencies = [ "common", "console_error_panic_hook", "console_log", - "device", "getrandom 0.2.17", "getrandom 0.3.4", "getrandom 0.4.3", @@ -1451,6 +1451,7 @@ dependencies = [ "instant", "log", "nip29", + "nip4e", "person", "settings", "state", @@ -1823,26 +1824,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "device" -version = "1.0.2" -dependencies = [ - "anyhow", - "common", - "flume 0.11.1", - "gpui-pre", - "instant", - "log", - "nostr-sdk", - "person", - "settings", - "smallvec", - "smol", - "state", - "theme", - "ui", -] - [[package]] name = "digest" version = "0.10.7" @@ -4577,6 +4558,26 @@ dependencies = [ "ui", ] +[[package]] +name = "nip4e" +version = "1.0.2" +dependencies = [ + "anyhow", + "common", + "flume 0.11.1", + "gpui-pre", + "instant", + "log", + "nostr-sdk", + "person", + "settings", + "smallvec", + "smol", + "state", + "theme", + "ui", +] + [[package]] name = "no_std_io2" version = "0.9.4" @@ -9438,12 +9439,12 @@ dependencies = [ "chat", "chat_ui", "common", - "device", "gpui-pre", "instant", "log", "nip29", "nip29_ui", + "nip4e", "nostr-connect", "nostr-sdk", "person", diff --git a/Cargo.toml b/Cargo.toml index 8ca3bc30..c17f1307 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ hkdf = "0.12" chacha20 = "0.9" hmac = "0.12" # Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it -rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng" ] } +rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng", "thread_rng" ] } # Others anyhow = "1.0.44" diff --git a/crates/chat/Cargo.toml b/crates/chat/Cargo.toml index ad7fd71b..efc4f0e4 100644 --- a/crates/chat/Cargo.toml +++ b/crates/chat/Cargo.toml @@ -7,7 +7,7 @@ publish.workspace = true [dependencies] common = { path = "../common" } state = { path = "../state" } -device = { path = "../device" } +nip4e = { path = "../nip4e" } person = { path = "../person" } settings = { path = "../settings" } @@ -17,6 +17,7 @@ instant.workspace = true anyhow.workspace = true itertools.workspace = true +rand.workspace = true smallvec.workspace = true log.workspace = true flume.workspace = true diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 1d3a3cf2..98c14cdd 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) @@ -787,9 +874,17 @@ where let seal: Event = Event::from_json(seal)?; seal.verify()?; + let sender_encryption_key = seal + .tags + .iter() + .find(|tag| tag.kind() == "n") + .and_then(|tag| tag.content()) + .and_then(|content| PublicKey::parse(content).ok()) + .unwrap_or(seal.pubkey); + // Get the rumor event let rumor = signer - .nip44_decrypt_async(&seal.pubkey, &seal.content) + .nip44_decrypt_async(&sender_encryption_key, &seal.content) .await?; let rumor = UnsignedEvent::from_json(rumor)?; diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 00bd2294..691d8c29 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -3,19 +3,19 @@ use std::hash::{Hash, Hasher}; use anyhow::{Error, anyhow}; use common::EventExt; -use device::DeviceRegistry; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task}; use instant::Duration; use itertools::Itertools; +use nip4e::DeviceRegistry; use nostr_sdk::prelude::*; use person::{Person, PersonRegistry}; use settings::{RoomConfig, SignerKind}; -use state::{NostrRegistry, TIMEOUT}; +use state::{NostrRegistry, TIMEOUT, UniversalSigner}; 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."; +const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key yet. Set one up in the settings to message them."; /// The outcome of delivering an encrypted rumor to a single receiver. #[derive(Debug, Clone)] @@ -472,28 +472,6 @@ 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(); @@ -528,21 +506,23 @@ impl Room { signer_kind, receiver, &rumor, - &encryption_signer, + encryption_signer.as_ref(), &user_signer, ) }); + let mut reports: Vec = futures::future::join_all(sends).await; + let minimum_success = reports.iter().any(SendReport::success); // Back up the message to ourselves once at least one receiver got it - if config.backup() && reports.iter().any(SendReport::success) { + if config.backup() && minimum_success { reports.push( deliver( &client, signer_kind, sender, &rumor, - &encryption_signer, + encryption_signer.as_ref(), &user_signer, ) .await, @@ -560,7 +540,7 @@ async fn deliver( signer_kind: &SignerKind, receiver: Person, rumor: &UnsignedEvent, - encryption_signer: &Option, + encryption_signer: Option<&UniversalSigner>, user_signer: &S, ) -> SendReport where @@ -587,15 +567,16 @@ where } } - // Determine the signer to use - let signer = Room::select_signer( + match send_gift_wrap( + client, + &receiver, + rumor, signer_kind, - announcement.is_some(), encryption_signer, user_signer, - ); - - match send_gift_wrap(client, signer, &receiver, rumor, signer_kind).await { + ) + .await + { Ok(output) => SendReport { receiver: public_key, result: Ok(output), @@ -610,10 +591,11 @@ where /// Build the gift-wrapped event for a rumor and send it. async fn send_gift_wrap( client: &Client, - signer: &S, receiver: &Person, rumor: &UnsignedEvent, config: &SignerKind, + encryption_signer: Option<&UniversalSigner>, + user_signer: &S, ) -> Result, Error> where S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44, @@ -639,11 +621,51 @@ where SignerKind::User => receiver.public_key(), }; - // Construct the gift wrap event - let event = nip59::GiftWrapBuilder::new(receiver_key, rumor.clone()) - .extra_tags(extra_tags) - .finalize_async(signer) - .await?; + // Make sure the rumor carries an event ID before it's serialized + let mut rumor = rumor.clone(); + rumor.ensure_id(); + + let rumor_json = rumor.as_json(); + let has_encryption_announcement = receiver.announcement().is_some(); + + let seal: Event = match (encryption_signer, has_encryption_announcement) { + (Some(encryption_signer), true) => { + let encryption_pubkey = encryption_signer.get_public_key_async().await?; + let content = encryption_signer + .nip44_encrypt_async(&receiver_key, &rumor_json) + .await?; + + EventBuilder::new(Kind::Seal, content) + .tags(vec![Tag::custom("n", [encryption_pubkey.to_hex()])]) + .custom_created_at(tweaked_timestamp()) + .finalize_async(user_signer) + .await? + } + _ => { + let content = user_signer + .nip44_encrypt_async(&receiver_key, &rumor_json) + .await?; + + EventBuilder::new(Kind::Seal, content) + .custom_created_at(tweaked_timestamp()) + .finalize_async(user_signer) + .await? + } + }; + + let ephemeral_keys = Keys::generate(); + let content = nip44::encrypt( + ephemeral_keys.secret_key(), + &receiver_key, + seal.as_json(), + nip44::Version::default(), + )?; + extra_tags.push(Tag::public_key(receiver_key)); + + let event = EventBuilder::new(Kind::GiftWrap, content) + .tags(extra_tags) + .custom_created_at(tweaked_timestamp()) + .finalize(&ephemeral_keys)?; // Send to the receiver's NIP-17 relays. client @@ -652,3 +674,11 @@ where .await .map_err(Into::into) } + +/// A tweaked timestamp to thwart time-analysis attacks, per NIP-59 +fn tweaked_timestamp() -> Timestamp { + const MAX_TWEAK_SECONDS: u64 = 2 * 24 * 60 * 60; + + let seconds = rand::random_range(0..=MAX_TWEAK_SECONDS); + Timestamp::from_secs(Timestamp::now().as_secs().saturating_sub(seconds)) +} diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index 18384202..cb6df300 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -126,6 +126,7 @@ impl GroupsRegistry { if event.signer_changed() { this.reset(cx); this.handle_notifications(cx); + this.load_cached(cx); this.load(cx); } })); @@ -133,6 +134,7 @@ impl GroupsRegistry { cx.defer(move |cx| { if let Err(error) = this.update(cx, |this, cx| { this.handle_notifications(cx); + this.load_cached(cx); if nostr.read(cx).current_user().is_some() { this.load(cx); @@ -349,20 +351,21 @@ impl GroupsRegistry { cx.notify(); } - fn load(&mut self, cx: &mut Context) { + fn load_cached(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); + + if nostr.read(cx).current_user().is_none() { + return; + } + let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); + // Show the cached groups before the relays answer. let task: Task> = cx.background_spawn(async move { let me = signer.get_public_key_async().await?; let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1); - client - .subscribe(ReqTarget::auto([filter.clone()])) - .with_id(list_subscription_id()) - .await?; - match client.database().query(filter).await?.into_iter().next() { Some(event) => GroupList::parse(&event), None => Ok(GroupList::default()), @@ -373,11 +376,37 @@ impl GroupsRegistry { .push(cx.spawn(async move |this, cx| match task.await { Ok(list) => { if let Err(error) = this.update(cx, |this, cx| this.merge(list, cx)) { - log::warn!("nip29: applying the group list failed: {error}"); + log::warn!("nip29: applying the cached group list failed: {error}"); } } - Err(error) => log::warn!("nip29: loading the group list failed: {error}"), + Err(error) => log::warn!("nip29: loading the cached group list failed: {error}"), })); + } + + fn load(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + + let updates: Task> = cx.background_spawn(async move { + let me = signer.get_public_key_async().await?; + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1); + + client + .subscribe(ReqTarget::auto([filter])) + .with_id(list_subscription_id()) + .close_on(opts) + .await?; + + Ok(()) + }); + + self.tasks.push(cx.spawn(async move |_this, _cx| { + if let Err(error) = updates.await { + log::warn!("nip29: subscribing to the group list failed: {error}"); + } + })); self.watch_forks(cx); } diff --git a/crates/device/Cargo.toml b/crates/nip4e/Cargo.toml similarity index 97% rename from crates/device/Cargo.toml rename to crates/nip4e/Cargo.toml index fa2b0c37..9ec26d9b 100644 --- a/crates/device/Cargo.toml +++ b/crates/nip4e/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "device" +name = "nip4e" version.workspace = true edition.workspace = true publish.workspace = true diff --git a/crates/device/src/lib.rs b/crates/nip4e/src/lib.rs similarity index 73% rename from crates/device/src/lib.rs rename to crates/nip4e/src/lib.rs index d74bc577..b08ffe52 100644 --- a/crates/device/src/lib.rs +++ b/crates/nip4e/src/lib.rs @@ -10,7 +10,6 @@ use gpui::{ App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement, SharedString, Styled, Subscription, Task, Window, div, relative, }; -use instant::Duration; use nostr_sdk::prelude::*; use person::PersonRegistry; use settings::AppSettings; @@ -40,7 +39,7 @@ pub enum DeviceEvent { /// User have not setup encryption key NotSet, /// The device is requesting an encryption key - Requesting, + Requesting(SharedString), /// An error occurred Error(SharedString), } @@ -59,19 +58,14 @@ impl DeviceEvent { /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md #[derive(Debug)] pub struct DeviceRegistry { + signer: Entity>, /// Whether there is a pending request for encryption key approval pub pending_request: bool, - /// Whether an announcement has been made for this device pub announcement_existed: Arc, - - /// Signer - signer: Entity>, - - /// Async tasks + /// Relay notification listener + notification_listener: Option>>, tasks: Vec>>, - - /// Event subscription _subscriptions: SmallVec<[Subscription; 2]>, } @@ -110,6 +104,9 @@ impl DeviceRegistry { // Observe the user signer cx.subscribe(&nostr, move |this, _nostr, event, cx| { if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) { + // The old account's state must not leak into the new one + this.announcement_existed.store(false, Ordering::Relaxed); + this.handle_notifications(cx); this.get_announcement(cx); } }), @@ -125,12 +122,15 @@ impl DeviceRegistry { signer, pending_request: false, announcement_existed: Arc::new(AtomicBool::new(false)), + notification_listener: None, tasks: vec![], _subscriptions: subscriptions, } } fn handle_notifications(&mut self, cx: &mut Context) { + self.notification_listener = None; + let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); @@ -138,7 +138,7 @@ impl DeviceRegistry { let announcement_existed = self.announcement_existed.clone(); let (tx, rx) = flume::bounded::(100); - self.tasks.push(cx.background_spawn(async move { + self.notification_listener = Some(cx.background_spawn(async move { let mut notifications = client.notifications(); let mut processed_events = HashSet::new(); let current_user = signer.get_public_key_async().await.ok(); @@ -245,39 +245,41 @@ impl DeviceRegistry { let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); - self.tasks.push(cx.background_spawn(async move { - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let current_user = signer.get_public_key_async().await?; - - // Construct the filter for the device announcement event - let filter = Filter::new() - .kind(Kind::Custom(10044)) - .author(current_user) - .limit(1); - - client - .subscribe(filter) - .close_on(opts) - .with_id(SubscriptionId::new("nip4e")) - .await?; - - Ok(()) - })); - let announcement_existed = self.announcement_existed.clone(); self.tasks.push(cx.spawn(async move |this, cx| { - // Wait for 5 seconds - cx.background_executor().timer(Duration::from_secs(5)).await; + let task: Task> = cx.background_spawn(async move { + let opts = + SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + let current_user = signer.get_public_key_async().await?; - // Then check if the msg relays have been found - if announcement_existed.load(Ordering::Acquire) { + // Construct the filter for the device announcement event + let filter = Filter::new() + .kind(Kind::Custom(10044)) + .author(current_user) + .limit(1); + + client + .subscribe(filter) + .close_on(opts) + .with_id(SubscriptionId::new("nip4e")) + .await?; + + Ok(()) + }); + + if let Err(e) = task.await { + this.update(cx, |_this, cx| { + cx.emit(DeviceEvent::error(e.to_string())); + })?; return Ok(()); } - this.update(cx, |_this, cx| { - cx.emit(DeviceEvent::NotSet); - })?; + if !announcement_existed.load(Ordering::Acquire) { + this.update(cx, |_this, cx| { + cx.emit(DeviceEvent::NotSet); + })?; + } Ok(()) })); @@ -317,13 +319,13 @@ impl DeviceRegistry { cx.background_spawn(async move { // Construct an announcement event let event = EventBuilder::new(Kind::Custom(10044), "") - .tags(vec![ - Tag::custom("n", vec![n]), - Tag::custom("client", vec![CLIENT_NAME]), - ]) + .tag(Tag::custom("n", vec![n])) .finalize_async(&signer) .await?; + // Persist the key locally before announcing it + set_keys(&client, &signer, &secret).await?; + // Publish announcement client .send_event(&event) @@ -331,20 +333,21 @@ impl DeviceRegistry { .ack_policy(AckPolicy::none()) .await?; - // Save device keys to the database - set_keys(&client, &signer, &secret).await?; - Ok(keys) }) } /// Set encryption key from the announcement event fn set_encryption(&mut self, event: &Event, cx: &mut Context) { + let Some(announcement) = Announcement::from_event(event) else { + log::warn!("Ignoring malformed encryption announcement"); + return; + }; + let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); - let announcement = Announcement::from(event); let device_pubkey = announcement.public_key(); // Get encryption key from the database and compare with the announcement @@ -384,11 +387,8 @@ impl DeviceRegistry { let public_key = signer.get_public_key_async().await?; let id = SubscriptionId::new("dekey-requests"); - // Construct a filter for encryption key requests - let filter = Filter::new() - .kind(Kind::Custom(4454)) - .author(public_key) - .since(Timestamp::now()); + // Construct a filter for encryption key requests. + let filter = Filter::new().kind(Kind::Custom(4454)).author(public_key); // Subscribe to the device key requests on user's write relays client.subscribe(vec![filter]).with_id(id).await?; @@ -417,26 +417,27 @@ impl DeviceRegistry { .pubkey(app_pubkey) .limit(1); - match client.database().query(filter).await?.into_iter().next() { - // Found an approval event - Some(event) => Ok(Some(event)), - // No approval event found, construct a request event - None => { - // Construct an event for device key request - let event = EventBuilder::new(Kind::Custom(4454), "") - .tags(vec![ - Tag::custom("P", vec![app_pubkey]), - Tag::custom("client", vec![CLIENT_NAME]), - ]) - .finalize_async(&signer) - .await?; - - // Send the event to write relays - client.send_event(&event).to_nip65().await?; - - Ok(None) + if let Some(event) = client.database().query(filter).await?.into_iter().next() { + // Only reuse the stored approval if its key is still valid, + // otherwise it's stale and a fresh request is needed + if decrypt_approval(&client, &app_keys, public_key, &event) + .await + .is_ok() + { + return Ok(Some(event)); } } + + // No usable approval event found, construct a request event + let event = EventBuilder::new(Kind::Custom(4454), "") + .tags(vec![Tag::custom("P", vec![app_pubkey])]) + .finalize_async(&signer) + .await?; + + // Send the event to write relays + client.send_event(&event).to_nip65().await?; + + Ok(None) }); self.tasks.push(cx.spawn(async move |this, cx| { @@ -466,20 +467,45 @@ impl DeviceRegistry { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); + let app_keys_task = get_or_init_app_keys(cx); - cx.emit(DeviceEvent::Requesting); + self.tasks.push(cx.spawn(async move |this, cx| { + let app_keys = match app_keys_task.await { + Ok(app_keys) => app_keys, + Err(e) => { + this.update(cx, |_this, cx| { + cx.emit(DeviceEvent::error(e.to_string())); + })?; + return Ok(()); + } + }; - self.tasks.push(cx.background_spawn(async move { - let public_key = signer.get_public_key_async().await?; + this.update(cx, |_this, cx| { + cx.emit(DeviceEvent::Requesting(authorization_code( + &app_keys.public_key(), + ))); + })?; - // Construct a filter for device key requests - let filter = Filter::new() - .kind(Kind::Custom(4455)) - .author(public_key) - .since(Timestamp::now()); + let task: Task> = cx.background_spawn(async move { + let public_key = signer.get_public_key_async().await?; - // Subscribe to the device key requests on user's write relays - client.subscribe(filter).await?; + // Only approvals addressed to this device's app key matter + let filter = Filter::new() + .kind(Kind::Custom(4455)) + .author(public_key) + .pubkey(app_keys.public_key()); + + // Subscribe to the device key approvals on user's write relays + client.subscribe(filter).await?; + + Ok(()) + }); + + if let Err(e) = task.await { + this.update(cx, |_this, cx| { + cx.emit(DeviceEvent::error(e.to_string())); + })?; + } Ok(()) })); @@ -488,22 +514,16 @@ impl DeviceRegistry { /// Parse the approval event to get encryption key then set it fn extract_encryption(&mut self, event: Event, cx: &mut Context) { let app_keys_task = get_or_init_app_keys(cx); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + + let approval_id = event.id; let task: Task> = cx.background_spawn(async move { let app_keys = app_keys_task.await?; - let master = event - .tags - .iter() - .find(|tag| tag.kind() == "P") - .and_then(|tag| tag.content()) - .and_then(|content| PublicKey::parse(content).ok()) - .context("Invalid event's tags")?; - - let payload = event.content.as_str(); - let decrypted = app_keys.nip44_decrypt_async(&master, payload).await?; - - let secret = SecretKey::from_hex(&decrypted)?; - let keys = Keys::new(secret); + let identity = signer.get_public_key_async().await?; + let keys = decrypt_approval(&client, &app_keys, identity, &event).await?; Ok(keys) }); @@ -513,6 +533,8 @@ impl DeviceRegistry { Ok(keys) => { this.update(cx, |this, cx| { this.set_signer(keys, cx); + // Clean up our request and the approval event from relays + this.cleanup_sync_events(approval_id, cx); })?; } Err(e) => { @@ -525,6 +547,47 @@ impl DeviceRegistry { })); } + /// Delete our key request and the consumed approval event from relays + fn cleanup_sync_events(&mut self, approval_id: EventId, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + + self.tasks.push(cx.background_spawn(async move { + let result: Result<(), Error> = async { + let public_key = signer.get_public_key_async().await?; + + // Collect our key request events + let filter = Filter::new().kind(Kind::Custom(4454)).author(public_key); + + let mut ids: Vec = client + .database() + .query(filter) + .await? + .into_iter() + .map(|event| event.id) + .collect(); + ids.push(approval_id); + + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tags(Tags::from_list(ids.into_iter().map(Tag::event).collect())) + .finalize_async(&signer) + .await?; + + client.send_event(&deletion).to_nip65().await?; + + Ok(()) + } + .await; + + if let Err(e) = result { + log::error!("Failed to delete sync events: {e}"); + } + + Ok(()) + })); + } + /// Approve requests for device keys from other devices fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context) { let nostr = NostrRegistry::global(cx); @@ -570,9 +633,14 @@ impl DeviceRegistry { Ok(()) }); - self.tasks.push(cx.spawn_in(window, async move |_this, cx| { + self.tasks.push(cx.spawn_in(window, async move |this, cx| { match task.await { Ok(_) => { + this.update(cx, |this, cx| { + // Allow future requests to surface again + this.set_pending_request(false, cx); + })?; + cx.update(|window, cx| { window.clear_notification_by_id::(id, cx); }) @@ -601,11 +669,15 @@ impl DeviceRegistry { } self.set_pending_request(true, cx); - // Show notification - let notification = self.notification(event, cx); + let notification = match self.notification(event, cx) { + Ok(notification) => notification, + Err(e) => { + log::warn!("Ignoring invalid encryption key request: {e}"); + self.set_pending_request(false, cx); + return; + } + }; - // The registry is global and not bound to a window, so surface the - // request in an open window. if let Some(window) = cx.windows().first().copied() { if let Err(error) = window.update(cx, |_view, window, cx| { window.push_notification(notification, cx); @@ -618,19 +690,30 @@ impl DeviceRegistry { } /// Build a notification for the encryption request. - fn notification(&self, event: Event, cx: &Context) -> Notification { + fn notification(&self, event: Event, cx: &Context) -> Result { const MSG: &str = "You've requested an encryption key from another device. \ - Approve to allow Coop to share with it."; + Approve to allow Coop to share with it. Before approving, \ + compare the code with the one shown on the requesting \ + device."; - let request = Announcement::from(&event); + let requester = event + .tags + .iter() + .find(|tag| tag.kind() == "P") + .and_then(|tag| tag.content()) + .and_then(|content| PublicKey::parse(content).ok()) + .context("Request has no valid requester key")?; + + let code = authorization_code(&requester); let persons = PersonRegistry::global(cx); - let profile = persons.read(cx).get(&request.public_key(), cx); + let profile = persons.read(cx).get(&requester, cx); + let code_label = code.clone(); let entity = cx.entity().downgrade(); let loading = Rc::new(Cell::new(false)); let key = SharedString::from(event.id.to_hex()); - Notification::new() + Ok(Notification::new() .type_id::(key) .autohide(false) .with_kind(NotificationKind::Info) @@ -687,7 +770,7 @@ impl DeviceRegistry { .font_semibold() .text_xs() .text_color(cx.theme().text_muted) - .child(SharedString::from("Client:")), + .child(SharedString::from("Code:")), ) .child( div() @@ -696,7 +779,7 @@ impl DeviceRegistry { .px_2() .rounded(cx.theme().radius) .bg(cx.theme().elevated_surface_background) - .child(request.client_name()), + .child(code_label.clone()), ), ), ) @@ -722,12 +805,19 @@ impl DeviceRegistry { .ok(); } }) - }) + })) } } struct DeviceNotification; +/// The first eight hexadecimal characters of the client public key. +fn authorization_code(public_key: &PublicKey) -> SharedString { + let hex = public_key.to_hex(); + let code = hex[..8].to_uppercase(); + SharedString::from(format!("{} {}", &code[..4], &code[4..])) +} + /// Get or create new app keys (async, returns a task) fn get_or_init_app_keys(cx: &App) -> Task> { let read = cx.read_credentials(CLIENT_NAME); @@ -758,6 +848,51 @@ fn get_or_init_app_keys(cx: &App) -> Task> { }) } +/// Decrypt and validate an approval event against the current announcement. +async fn decrypt_approval( + client: &Client, + app_keys: &Keys, + identity: PublicKey, + event: &Event, +) -> Result { + let master = event + .tags + .iter() + .find(|tag| tag.kind() == "P") + .and_then(|tag| tag.content()) + .and_then(|content| PublicKey::parse(content).ok()) + .context("Invalid event's tags")?; + + let decrypted = app_keys + .nip44_decrypt_async(&master, event.content.as_str()) + .await?; + + let secret = SecretKey::from_hex(&decrypted)?; + let keys = Keys::new(secret); + + // Reject approvals carrying a key that no longer matches the published announcement. + let filter = Filter::new() + .kind(Kind::Custom(10044)) + .author(identity) + .limit(1); + + if let Some(announcement) = client.database().query(filter).await?.into_iter().next() { + let announced = announcement + .tags + .iter() + .find(|tag| tag.kind() == "n") + .and_then(|tag| tag.content()) + .and_then(|content| PublicKey::parse(content).ok()) + .context("Announcement has no valid encryption key")?; + + if keys.public_key() != announced { + return Err(anyhow!("Approval key doesn't match the announcement")); + } + } + + Ok(keys) +} + /// Encrypt and store device keys in the local database. async fn set_keys(client: &Client, signer: &S, secret: &str) -> Result<(), Error> where diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index c1aadd1b..d4eb870b 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -216,7 +216,10 @@ impl PersonRegistry { /// Set profile encryption keys announcement fn set_announcement(&mut self, event: &Event, cx: &mut App) { - let announcement = Announcement::from(event); + let Some(announcement) = Announcement::from_event(event) else { + log::warn!("Ignoring announcement with missing or invalid key tag"); + return; + }; if let Some(person) = self.persons.get(&event.pubkey) { person.update(cx, |person, cx| { diff --git a/crates/state/src/nip4e.rs b/crates/state/src/nip4e.rs index 475795c3..179e863f 100644 --- a/crates/state/src/nip4e.rs +++ b/crates/state/src/nip4e.rs @@ -1,4 +1,3 @@ -use gpui::SharedString; use nostr_sdk::prelude::*; /// Announcement @@ -6,50 +5,29 @@ use nostr_sdk::prelude::*; pub struct Announcement { /// The public key of the device that created this announcement. public_key: PublicKey, - - /// The name of the device that created this announcement. - client_name: Option, } -impl From<&Event> for Announcement { - fn from(val: &Event) -> Self { - let public_key = val +impl Announcement { + /// Parse an announcement from an event, returning `None` when the `n` + /// tag is missing or malformed so callers never mistake the author's + /// identity key for an encryption key. + pub fn from_event(event: &Event) -> Option { + let public_key = event .tags .iter() .find(|tag| tag.kind() == "n") .and_then(|tag| tag.content()) - .and_then(|c| PublicKey::parse(c).ok()) - .unwrap_or(val.pubkey); + .and_then(|c| PublicKey::parse(c).ok())?; - let client_name = val - .tags - .iter() - .find(|tag| tag.kind() == "client") - .and_then(|tag| tag.content()) - .map(|c| c.to_string()); - - Self::new(public_key, client_name) + Some(Self { public_key }) } -} -impl Announcement { - pub fn new(public_key: PublicKey, client_name: Option) -> Self { - Self { - public_key, - client_name, - } + pub fn new(public_key: PublicKey) -> Self { + Self { public_key } } /// Returns the public key of the device that created this announcement. pub fn public_key(&self) -> PublicKey { self.public_key } - - /// Returns the client name of the device that created this announcement. - pub fn client_name(&self) -> SharedString { - self.client_name - .as_ref() - .map(SharedString::from) - .unwrap_or(SharedString::from("Unknown")) - } } diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 90e002e4..018905b9 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -9,7 +9,7 @@ ui = { path = "../ui" } theme = { path = "../theme" } common = { path = "../common" } state = { path = "../state" } -device = { path = "../device" } +nip4e = { path = "../nip4e" } chat = { path = "../chat" } chat_ui = { path = "../chat_ui" } nip29 = { path = "../nip29" } diff --git a/crates/workspace/src/dialogs/restore.rs b/crates/workspace/src/dialogs/restore.rs index 7196bc00..5511c909 100644 --- a/crates/workspace/src/dialogs/restore.rs +++ b/crates/workspace/src/dialogs/restore.rs @@ -1,12 +1,11 @@ -use instant::Duration; - use anyhow::Error; -use device::DeviceRegistry; use gpui::prelude::FluentBuilder; use gpui::{ AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, }; +use instant::Duration; +use nip4e::DeviceRegistry; use nostr_sdk::prelude::*; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; @@ -52,8 +51,9 @@ impl RestoreEncryption { let device = DeviceRegistry::global(cx); let content = self.key_input.read(cx).value(); - if !content.is_empty() { + if content.is_empty() { self.set_error("Secret Key cannot be empty.", cx); + return; } let Ok(secret) = SecretKey::parse(&content) else { diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 72cd17a0..8135dd4e 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -5,11 +5,11 @@ use anyhow::Error; use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry}; use common::download_dir; -use device::{DeviceEvent, DeviceRegistry}; use gpui::{ Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, px, }; +use nip4e::{DeviceEvent, DeviceRegistry}; use nip29::{GroupId, GroupKey}; use nostr_sdk::prelude::*; use serde::Deserialize; @@ -106,22 +106,24 @@ impl Workspace { // Observe all events emitted by the device registry cx.subscribe_in(&device, window, |_this, _device, event, window, cx| { match event { - DeviceEvent::Requesting => { - const MSG: &str = - "Please open other client and approve the request for encryption key."; + DeviceEvent::Requesting(code) => { + const MSG: &str = "Please open another client and approve the request \ + for the encryption key. Compare the code with the \ + one shown there before approving."; let note = Notification::new() .id::() .autohide(false) - .title("Wait for approval") + .title(format!("Wait for approval ยท {code}")) .message(MSG) .with_kind(NotificationKind::Info); window.push_notification(note, cx); } DeviceEvent::NotSet => { - const MSG: &str = - "User're not setup encryption key yet. Do you want to create one?"; + const MSG: &str = "No encryption key announcement was found. It may \ + still be propagating, or you can restore one \ + from the settings."; let note = Notification::new() .id::() @@ -132,7 +134,7 @@ impl Workspace { move |_this, window, cx| { let device = DeviceRegistry::global(cx); device.update(cx, |this, cx| { - this.set_announcement(Keys::generate(), cx); + this.get_announcement(cx); }); window.clear_notification::(cx); }, diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index c587cccb..a9f13c51 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -33,7 +33,7 @@ ui = { path = "../crates/ui" } theme = { path = "../crates/theme" } common = { path = "../crates/common" } state = { path = "../crates/state" } -device = { path = "../crates/device" } +nip4e = { path = "../crates/nip4e" } chat = { path = "../crates/chat" } nip29 = { path = "../crates/nip29" } settings = { path = "../crates/settings" } diff --git a/desktop/src/main.rs b/desktop/src/main.rs index c19a3b30..a1efb187 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -73,7 +73,7 @@ fn main() { // Initialize device signer // // NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - device::init(cx); + nip4e::init(cx); // Initialize app registry chat::init(cx); diff --git a/web/Cargo.toml b/web/Cargo.toml index 2e701222..51482e8b 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -14,7 +14,7 @@ ui = { path = "../crates/ui" } theme = { path = "../crates/theme" } common = { path = "../crates/common" } state = { path = "../crates/state" } -device = { path = "../crates/device" } +nip4e = { path = "../crates/nip4e" } chat = { path = "../crates/chat" } nip29 = { path = "../crates/nip29" } settings = { path = "../crates/settings" } diff --git a/web/src/lib.rs b/web/src/lib.rs index ce22e58a..91398653 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -65,7 +65,7 @@ pub fn run() -> Result<(), JsValue> { // Initialize device signer // // NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - device::init(cx); + nip4e::init(cx); // Initialize app registry chat::init(cx);