diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 1ccb2981..ef1668c8 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -594,11 +594,10 @@ impl ChatRegistry { let id = room.read(cx).id; if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) { - let entity = room.to_owned(); - entity.update(cx, |room, cx| room.ensure_directory(cx)); - e.insert(entity.clone()); + room.update(cx, |room, cx| room.ensure_directory(cx)); + e.insert(room.clone()); - self.rooms.insert(0, entity); + self.rooms.insert(0, room.clone()); cx.notify(); } } diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index a1946fd6..bf12d6e8 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -1752,7 +1752,7 @@ impl ChatPanel { auto, ) .menu_with_check_and_disabled( - "Decoupled Encryption Key", + "Encryption Key", encryption, Box::new(Command::ChangeSigner(SignerKind::Encryption)), encryption, diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index bf289cbd..5419c44c 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -1196,6 +1196,10 @@ impl Panel for GroupPanel { window.dispatch_action(Box::new(HideGroupMeta { relay, group: id }), cx); } } + + fn on_removed(&mut self, window: &mut Window, cx: &mut Context) { + self.set_active(false, window, cx); + } } impl EventEmitter for GroupPanel {} diff --git a/crates/state/src/constants.rs b/crates/state/src/constants.rs index 0cd257a9..79bb193f 100644 --- a/crates/state/src/constants.rs +++ b/crates/state/src/constants.rs @@ -5,12 +5,14 @@ pub const CLIENT_NAME: &str = "Coop"; pub const APP_ID: &str = "su.reya.coop"; /// Keyring name -pub(crate) const MASTER_KEYRING: &str = "Coop Master Key"; -pub const USER_KEYRING: &str = "Coop User Credential"; +pub const USER_KEYRING: &str = "Coop Safe Storage"; /// Default timeout for subscription pub const TIMEOUT: u64 = 2; +/// Default timeout for Nostr Connect +pub const NOSTR_CONNECT_TIMEOUT: u64 = 60; + /// Default delay for searching pub const FIND_DELAY: u64 = 600; @@ -31,9 +33,8 @@ pub(crate) const INDEXER_RELAYS: [&str; 2] = pub(crate) const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"]; /// Default bootstrap relays -pub const BOOTSTRAP_RELAYS: [&str; 4] = [ +pub const BOOTSTRAP_RELAYS: [&str; 3] = [ "wss://relay.ditto.pub", "wss://relay.primal.net", - "wss://relay.nostr.net", "wss://profiles.nostr1.com", ]; diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 34e25d04..673e65b1 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -4,7 +4,7 @@ use anyhow::{Error, anyhow}; #[cfg(not(target_arch = "wasm32"))] use browser_signer_proxy::prelude::*; use common::config_dir; -use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; +use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, TaskExt}; use gpui_tokio::Tokio; use instant::Duration; use nostr_connect::prelude::*; @@ -53,6 +53,8 @@ pub enum StateEvent { NoSigner, /// The signer has changed SignerChanged, + /// A stored credential needs a passphrase to unlock + PassphraseRequired, /// An error occurred Error(String), } @@ -75,16 +77,12 @@ impl StateEvent { pub struct NostrRegistry { /// Nostr client client: Client, - /// Universal signer signer: UniversalSigner, - /// Current user's public key current_user: Option, - - /// Whether the initial credential check has concluded - ready: bool, - + /// Whether the stored credential needs a passphrase to unlock + passphrase_required: bool, /// Tasks for asynchronous operations tasks: Vec>>, } @@ -126,6 +124,8 @@ impl NostrRegistry { .gossip(NostrGossipMemory::unbounded()) .gossip_config(GossipConfig::default().no_background_refresh()) .connect_timeout(Duration::from_secs(10)) + .verify_subscriptions(true) + .ban_relay_on_mismatch(true) .sleep_when_idle(SleepWhenIdle::Enabled { timeout: Duration::from_secs(600), }) @@ -137,14 +137,13 @@ impl NostrRegistry { this.connect_bootstrap_relays(cx); if cfg!(target_arch = "wasm32") { - this.mark_ready(cx); cx.emit(StateEvent::NoSigner); } else if let Some(secret) = cli_key { - // Use CLI-provided key -- same path as get_user_credential + // Use CLI-provided key -- same path as restore_session let keys = Keys::new(secret); this.set_signer(keys, cx); } else { - this.get_user_credential(cx); + this.restore_session(cx); } }); }); @@ -153,7 +152,7 @@ impl NostrRegistry { client, signer, current_user: None, - ready: false, + passphrase_required: false, tasks: vec![], } } @@ -173,17 +172,8 @@ impl NostrRegistry { self.current_user } - fn mark_ready(&mut self, cx: &mut Context) { - if self.ready { - return; - } - - self.ready = true; - cx.notify(); - } - /// Update the signer - pub fn set_signer(&mut self, new_signer: T, cx: &mut Context) + fn set_signer(&mut self, new_signer: T, cx: &mut Context) where T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static, ::Error: std::error::Error + Send + Sync + 'static, @@ -196,7 +186,8 @@ impl NostrRegistry { this.update(cx, |this, cx| { this.signer.swap_inner(new_signer); this.current_user = Some(public_key); - this.mark_ready(cx); + this.passphrase_required = false; + cx.emit(StateEvent::SignerChanged); cx.notify(); })?; @@ -247,93 +238,249 @@ impl NostrRegistry { })); } - /// Check the user's credential and set the signer if valid - fn get_user_credential(&mut self, cx: &mut Context) { + /// Restore the signer from the stored credential + fn restore_session(&mut self, cx: &mut Context) { let user_keyring = cx.read_credentials(USER_KEYRING); - let master_keyring = self.get_master_key(cx); self.tasks.push(cx.spawn(async move |this, cx| { - match user_keyring.await { - Ok(Some((_username, secret))) => { - let content = String::from_utf8(secret)?; + let result = async { + match user_keyring.await { + Ok(Some((_username, secret))) => { + let content = String::from_utf8(secret)?; - if content.starts_with("nsec1") { - let secret_key = SecretKey::parse(&content)?; - let keys = Keys::new(secret_key); + if content.starts_with("nsec1") { + let keys = Keys::new(SecretKey::parse(&content)?); + this.update(cx, |this, cx| { + this.set_signer(keys, cx); + })?; + } else if content.starts_with("bunker://") { + // The master key used for Nostr Connect is embedded in + // the stored URI so reconnects reuse the same local key + let (base, keys) = extract_master_key(&content); + let uri = NostrConnectUri::parse(base)?; - this.update(cx, |this, cx| { - this.set_signer(keys, cx); - cx.notify(); + // Construct the nostr connect signer + let mut signer = NostrConnect::new( + uri, + keys, + Duration::from_secs(NOSTR_CONNECT_TIMEOUT), + None, + )?; + + // Handle auth url with the default browser + signer.auth_url_handler(CoopAuthUrlHandler); + + this.update(cx, |this, cx| { + this.set_signer(signer, cx); + })?; + } else if content.starts_with("ncryptsec1") { + this.update(cx, |this, cx| { + this.passphrase_required = true; + cx.emit(StateEvent::PassphraseRequired); + })?; + } else if content == "proxy" { + #[cfg(not(target_arch = "wasm32"))] + this.update(cx, |this, cx| { + this.connect_proxy(cx); + })?; + } else { + this.update(cx, |_, cx| { + cx.emit(StateEvent::NoSigner); + })?; + } + } + _ => { + this.update(cx, |_, cx| { + cx.emit(StateEvent::NoSigner); })?; - } else if content.starts_with("bunker://") { - let keys = master_keyring.await; - let timeout = Duration::from_secs(30); - let uri = NostrConnectUri::parse(content)?; - - // Construct the nostr connect signer - let mut signer = NostrConnect::new(uri, keys, timeout, None)?; - - // Handle auth url with the default browser - signer.auth_url_handler(CoopAuthUrlHandler); - - this.update(cx, |this, cx| { - this.set_signer(signer, cx); - cx.notify(); - })?; - } else if content == "proxy" { - #[cfg(not(target_arch = "wasm32"))] - this.update(cx, |this, cx| { - this.mark_ready(cx); - this.connect_proxy(cx); - })?; - } else { - this.update(cx, |this, cx| this.mark_ready(cx))?; } } - _ => { - this.update(cx, |this, cx| { - this.mark_ready(cx); - cx.emit(StateEvent::NoSigner); - })?; - } + + Ok::<(), Error>(()) + } + .await; + + if let Err(e) = result { + this.update(cx, |_, cx| { + cx.emit(StateEvent::error(e.to_string())); + cx.emit(StateEvent::NoSigner); + })?; } Ok(()) })); } - /// Get the master key that used for Nostr Connect - pub fn get_master_key(&self, cx: &App) -> Task { - if cfg!(target_arch = "wasm32") { - return cx.background_spawn(async move { Keys::generate() }); - } + /// Unlock the stored encrypted key with its passphrase + pub fn restore_with_passphrase( + &mut self, + password: &str, + cx: &mut Context, + ) -> Task> { + let password = password.to_owned(); + let user_keyring = cx.read_credentials(USER_KEYRING); - let task = cx.read_credentials(MASTER_KEYRING); + cx.spawn(async move |this, cx| { + let content = user_keyring + .await? + .map(|(_username, secret)| String::from_utf8(secret)) + .transpose()? + .ok_or_else(|| anyhow!("no stored credential; nothing to unlock"))?; - cx.spawn(async move |cx| { - let (keys, new_key) = match task.await { - Ok(Some((_user, secret))) => match SecretKey::from_slice(&secret) { - Ok(secret_key) => (Keys::new(secret_key), false), - _ => (Keys::generate(), true), - }, - _ => (Keys::generate(), true), - }; - - if new_key { - let keys_clone = keys.clone(); - let username = keys_clone.public_key().to_hex(); - let password = keys_clone.secret_key().to_secret_bytes(); - - cx.update(|cx| { - let task = cx.write_credentials(MASTER_KEYRING, &username, &password); - cx.background_spawn(async move { task.await.ok() }).detach(); - }); + if !content.starts_with("ncryptsec1") { + return Err(anyhow!("stored credential is not passphrase-encrypted")); } - keys + let decrypt_task = cx.background_spawn(async move { + let encrypted = EncryptedSecretKey::from_bech32(&content)?; + let secret = encrypted.decrypt(&password)?; + Ok::<_, Error>(Keys::new(secret)) + }); + + let keys = decrypt_task.await?; + let public_key = keys.public_key(); + + this.update(cx, |this, cx| { + this.set_signer(keys, cx); + })?; + + Ok(public_key) }) } + /// Import an `nsec1...` secret key, storing it NIP-49 encrypted with `password` + pub fn import_nsec( + &mut self, + nsec: &str, + password: &str, + cx: &mut Context, + ) -> Task> { + let keys = match SecretKey::parse(nsec.trim()) { + Ok(secret) => Keys::new(secret), + Err(e) => return Task::ready(Err(anyhow!(e))), + }; + + if password.is_empty() { + return Task::ready(Err(anyhow!("Passphrase must not be empty"))); + } + + let password = password.to_owned(); + + cx.spawn(async move |this, cx| { + let job = cx.background_spawn(async move { + let encrypted = + EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium)?; + let ncryptsec = encrypted.to_bech32()?; + Ok::<_, Error>((keys, ncryptsec)) + }); + + let (keys, ncryptsec) = job.await?; + let public_key = keys.public_key(); + + let write = cx.update(|cx| { + cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes()) + }); + write.await?; + + this.update(cx, |this, cx| { + this.set_signer(keys, cx); + })?; + + Ok(public_key) + }) + } + + /// Import an NIP-49 encrypted secret key (`ncryptsec1...`), decrypting it with `password` + pub fn import_ncryptsec( + &mut self, + ncryptsec: &str, + password: &str, + cx: &mut Context, + ) -> Task> { + let ncryptsec = ncryptsec.trim().to_owned(); + + if password.is_empty() { + return Task::ready(Err(anyhow!("Passphrase must not be empty"))); + } + + let password = password.to_owned(); + + cx.spawn(async move |this, cx| { + let stored = ncryptsec.clone(); + let decrypt_task = cx.background_spawn(async move { + let encrypted = EncryptedSecretKey::from_bech32(&ncryptsec)?; + let secret = encrypted.decrypt(&password)?; + Ok::<_, Error>(Keys::new(secret)) + }); + + let keys = decrypt_task.await?; + let public_key = keys.public_key(); + + let write = cx.update(|cx| { + cx.write_credentials(USER_KEYRING, &public_key.to_hex(), stored.as_bytes()) + }); + write.await?; + + this.update(cx, |this, cx| { + this.set_signer(keys, cx); + })?; + + Ok(public_key) + }) + } + + /// Import a `bunker://...` URI (NIP-46), connecting to the remote signer + pub fn import_bunker( + &mut self, + uri: &str, + cx: &mut Context, + ) -> Task> { + let uri_string = uri.trim().to_owned(); + + let connect_uri = match NostrConnectUri::parse(&uri_string) { + Ok(uri) => uri, + Err(e) => return Task::ready(Err(anyhow!(e))), + }; + + let keys = Keys::generate(); + let credential = with_master_key(&uri_string, &keys); + let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes()); + + cx.spawn(async move |this, cx| { + // Construct the nostr connect signer + let mut signer = NostrConnect::new( + connect_uri, + keys, + Duration::from_secs(NOSTR_CONNECT_TIMEOUT), + None, + )?; + + // Handle auth url with the default browser + signer.auth_url_handler(CoopAuthUrlHandler); + + // Verify the bunker responds before persisting the credential + let public_key = signer.get_public_key_async().await?; + write.await?; + + this.update(cx, |this, cx| { + this.set_signer(signer, cx); + })?; + + Ok(public_key) + }) + } + + /// Sign out and remove the stored credential + pub fn sign_out(&mut self, cx: &mut Context) { + self.current_user = None; + self.passphrase_required = false; + + cx.delete_credentials(USER_KEYRING).detach_and_log_err(cx); + cx.emit(StateEvent::SignerChanged); + cx.emit(StateEvent::NoSigner); + cx.notify(); + } + /// Start the browser proxy #[cfg(not(target_arch = "wasm32"))] pub fn connect_proxy(&mut self, cx: &mut Context) { @@ -514,3 +661,23 @@ impl NostrRegistry { }) } } + +// Extract the base URI and the master key from a bunker credential with `master=` +fn extract_master_key(credential: &str) -> (&str, Keys) { + match credential.split_once("master=") { + Some((base, nsec)) => { + let keys = SecretKey::parse(nsec) + .map(Keys::new) + .unwrap_or_else(|_| Keys::generate()); + (base.trim_end_matches(['?', '&']), keys) + } + None => (credential, Keys::generate()), + } +} + +// Embed the master key in a bunker URI so reconnects reuse the same local key +fn with_master_key(uri: &str, keys: &Keys) -> String { + let separator = if uri.contains('?') { '&' } else { '?' }; + let nsec = keys.secret_key().to_bech32().expect("infallible"); + format!("{uri}{separator}master={nsec}") +} diff --git a/crates/state/src/signer.rs b/crates/state/src/signer.rs index e92d022a..c710f0ca 100644 --- a/crates/state/src/signer.rs +++ b/crates/state/src/signer.rs @@ -5,32 +5,9 @@ use std::pin::Pin; use std::sync::{Arc, RwLock}; use nostr_connect::client::AuthUrlHandler; +use nostr_sdk::error::Error as SignerError; use nostr_sdk::prelude::*; -#[derive(Debug)] -pub struct UniversalSignerError(Box); - -impl fmt::Display for UniversalSignerError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl Error for UniversalSignerError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - Some(&*self.0) - } -} - -impl UniversalSignerError { - pub fn new(err: E) -> Self - where - E: Error + Send + Sync + 'static, - { - UniversalSignerError(Box::new(err)) - } -} - #[derive(Clone, Debug)] pub struct UniversalSigner { inner: Arc>>, @@ -50,7 +27,7 @@ impl UniversalSigner { } /// Swap the inner signer in-place. All clones see the new signer. - pub(crate) fn swap_inner(&self, new_signer: T) + pub fn swap_inner(&self, new_signer: T) where T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static, ::Error: Error + Send + Sync + 'static, @@ -65,7 +42,7 @@ impl UniversalSigner { &self, public_key: &PublicKey, content: &str, - ) -> Result { + ) -> Result { let inner = self.inner.read().expect("RwLock poisoned").clone(); inner.nip44_encrypt_async(public_key, content).await } @@ -75,7 +52,7 @@ impl UniversalSigner { &self, public_key: &PublicKey, payload: &str, - ) -> Result { + ) -> Result { let inner = self.inner.read().expect("RwLock poisoned").clone(); inner.nip44_decrypt_async(public_key, payload).await } @@ -84,21 +61,21 @@ impl UniversalSigner { trait InnerSigner: fmt::Debug + Send + Sync + 'static { fn get_public_key_async( &self, - ) -> Pin> + Send + '_>>; + ) -> Pin> + Send + '_>>; fn sign_event_async( &self, unsigned: UnsignedEvent, - ) -> Pin> + Send + '_>>; + ) -> Pin> + Send + '_>>; fn nip44_encrypt_async<'a>( &'a self, public_key: &'a PublicKey, content: &'a str, - ) -> Pin> + Send + 'a>>; + ) -> Pin> + Send + 'a>>; fn nip44_decrypt_async<'a>( &'a self, public_key: &'a PublicKey, payload: &'a str, - ) -> Pin> + Send + 'a>>; + ) -> Pin> + Send + 'a>>; } #[derive(Debug)] @@ -113,22 +90,22 @@ where { fn get_public_key_async( &self, - ) -> Pin> + Send + '_>> { + ) -> Pin> + Send + '_>> { Box::pin(async move { AsyncGetPublicKey::get_public_key_async(&self.0) .await - .map_err(UniversalSignerError::new) + .map_err(SignerError::other) }) } fn sign_event_async( &self, unsigned: UnsignedEvent, - ) -> Pin> + Send + '_>> { + ) -> Pin> + Send + '_>> { Box::pin(async move { AsyncSignEvent::sign_event_async(&self.0, unsigned) .await - .map_err(UniversalSignerError::new) + .map_err(SignerError::other) }) } @@ -136,11 +113,11 @@ where &'a self, public_key: &'a PublicKey, content: &'a str, - ) -> Pin> + Send + 'a>> { + ) -> Pin> + Send + 'a>> { Box::pin(async move { AsyncNip44::nip44_encrypt_async(&self.0, public_key, content) .await - .map_err(UniversalSignerError::new) + .map_err(SignerError::other) }) } @@ -148,17 +125,17 @@ where &'a self, public_key: &'a PublicKey, payload: &'a str, - ) -> Pin> + Send + 'a>> { + ) -> Pin> + Send + 'a>> { Box::pin(async move { AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload) .await - .map_err(UniversalSignerError::new) + .map_err(SignerError::other) }) } } impl AsyncGetPublicKey for UniversalSigner { - type Error = UniversalSignerError; + type Error = SignerError; fn get_public_key_async( &self, @@ -169,7 +146,7 @@ impl AsyncGetPublicKey for UniversalSigner { } impl AsyncSignEvent for UniversalSigner { - type Error = UniversalSignerError; + type Error = SignerError; fn sign_event_async( &self, @@ -181,7 +158,7 @@ impl AsyncSignEvent for UniversalSigner { } impl AsyncNip44 for UniversalSigner { - type Error = UniversalSignerError; + type Error = SignerError; fn nip44_encrypt_async<'a>( &'a self, @@ -211,7 +188,7 @@ impl AuthUrlHandler for CoopAuthUrlHandler { auth_url: Url, ) -> Pin> + Send + '_>> { Box::pin(async move { - webbrowser::open(auth_url.as_str()).unwrap(); + webbrowser::open(auth_url.as_str()).map_err(nostr_connect::error::Error::other)?; Ok(()) }) } diff --git a/crates/ui/src/dock/panel.rs b/crates/ui/src/dock/panel.rs index b9fb0636..4900f8c2 100644 --- a/crates/ui/src/dock/panel.rs +++ b/crates/ui/src/dock/panel.rs @@ -2,8 +2,8 @@ use std::any::Any; use std::sync::Arc; use gpui::{ - AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, - Render, SharedString, Window, + AnyElement, AnyView, App, Context, Element, Entity, EventEmitter, FocusHandle, Focusable, + ImageSource, Render, SharedString, Window, }; use gpui_base::dock::{PanelId, PanelState}; @@ -39,6 +39,9 @@ pub trait Panel: EventEmitter + Render + Focusable { /// Set active state of the panel. fn set_active(&self, _active: bool, _window: &mut Window, _cx: &mut App) {} + /// Called when the panel leaves the dock. + fn on_removed(&mut self, _window: &mut Window, _cx: &mut Context) {} + /// Set zoomed state of the panel. fn set_zoomed(&self, _zoomed: bool, _cx: &mut App) {} @@ -62,6 +65,7 @@ pub(crate) trait PanelView: 'static + Send + Sync { fn background(&self, cx: &App) -> Option; fn set_active(&self, active: bool, window: &mut Window, cx: &mut App); fn set_zoomed(&self, zoomed: bool, cx: &mut App); + fn on_removed(&self, window: &mut Window, cx: &mut App); fn view(&self) -> AnyView; fn focus_handle(&self, cx: &App) -> FocusHandle; } @@ -103,6 +107,12 @@ impl PanelView for Entity { }) } + fn on_removed(&self, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| { + this.on_removed(window, cx); + }) + } + fn view(&self) -> AnyView { self.clone().into() } @@ -173,7 +183,9 @@ impl gpui_base::dock::PanelView for PanelHandle { ) { } - fn on_removed(&self, _: &mut Window, _: &mut App) {} + fn on_removed(&self, window: &mut Window, cx: &mut App) { + self.panel.on_removed(window, cx); + } fn view(&self) -> AnyView { self.panel.view() diff --git a/crates/workspace/src/dialogs/import.rs b/crates/workspace/src/dialogs/import.rs index 75d58820..69567e39 100644 --- a/crates/workspace/src/dialogs/import.rs +++ b/crates/workspace/src/dialogs/import.rs @@ -1,4 +1,4 @@ -use anyhow::{Error, anyhow}; +use anyhow::Error; use gpui::prelude::FluentBuilder; use gpui::{ App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, @@ -6,11 +6,11 @@ use gpui::{ }; use instant::Duration; use nostr_connect::prelude::*; -use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING}; +use state::NostrRegistry; use theme::{ActiveTheme, TextStyle, Typeset as _, surface_radius}; use ui::button::{Button, ButtonVariants}; use ui::input::{Input, InputEvent, InputState}; -use ui::{Disableable, StyledExt, WindowExtension, v_flex}; +use ui::{Disableable, WindowExtension, v_flex}; pub fn open(window: &mut Window, cx: &mut App) { let import = cx.new(|cx| ImportIdentity::new(window, cx)); @@ -25,146 +25,157 @@ pub fn open(window: &mut Window, cx: &mut App) { }); } +/// Open a passphrase-only dialog to unlock the stored encrypted key +pub fn open_unlock(window: &mut Window, cx: &mut App) { + let import = cx.new(|cx| ImportIdentity::new_unlock(window, cx)); + + window.open_dialog(cx, move |this, _window, _cx| { + this.width(px(450.)) + .show_close(false) + .overlay_closable(false) + .keyboard(false) + .title("Unlock your identity") + .child(import.clone()) + }); +} + +#[derive(Debug, PartialEq, Eq)] +enum Mode { + /// Import a credential (nsec, ncryptsec or bunker) + Import, + /// Unlock the stored encrypted key with its passphrase + Unlock, +} + #[derive(Debug)] pub struct ImportIdentity { + mode: Mode, /// Secret key input key_input: Entity, - /// Password input (if required) + /// Passphrase input pass_input: Entity, + /// Repeated passphrase input when encrypting a plaintext key + repass_input: Entity, /// Error message error: Entity>, /// Whether the user is currently loading loading: bool, tasks: Vec>>, - _subscription: Option, + _subscriptions: Vec, } impl ImportIdentity { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("nsec or bunker://")); + Self::build(Mode::Import, window, cx) + } + + pub fn new_unlock(window: &mut Window, cx: &mut Context) -> Self { + Self::build(Mode::Unlock, window, cx) + } + + fn build(mode: Mode, window: &mut Window, cx: &mut Context) -> Self { + let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://")); let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true)); + let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true)); let error = cx.new(|_| None); - let input_subscription = - cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| { + let subscriptions = vec![ + cx.subscribe_in(&key_input, window, |this, _input, event, _window, cx| { if let InputEvent::PressEnter { .. } = event { - this.login(window, cx); + this.login(cx); }; - }); + }), + cx.subscribe_in(&pass_input, window, |this, _input, event, _window, cx| { + if let InputEvent::PressEnter { .. } = event { + this.login(cx); + }; + }), + cx.subscribe_in(&repass_input, window, |this, _input, event, _window, cx| { + if let InputEvent::PressEnter { .. } = event { + this.login(cx); + }; + }), + ]; Self { + mode, key_input, pass_input, + repass_input, error, loading: false, tasks: vec![], - _subscription: Some(input_subscription), + _subscriptions: subscriptions, } } - fn login(&mut self, window: &mut Window, cx: &mut Context) { - let value = self.key_input.read(cx).value(); - let password = self.pass_input.read(cx).value(); + fn login(&mut self, cx: &mut Context) { + let password = self.pass_input.read(cx).value().to_string(); + let nostr = NostrRegistry::global(cx); - // Set loading state + if self.mode == Mode::Unlock { + if password.is_empty() { + self.set_error("Passphrase is required", cx); + return; + } + + self.set_loading(true, cx); + let task = nostr.update(cx, |this, cx| this.restore_with_passphrase(&password, cx)); + self.finish(task, cx); + return; + } + + let value = self.key_input.read(cx).value().trim().to_string(); self.set_loading(true, cx); - if value.starts_with("ncryptsec1") { - self.ncryptsec(value, password, window, cx); - return; - } - if value.starts_with("bunker://") { - match NostrConnectUri::parse(value) { - Ok(uri) => { - self.bunker(uri, window, cx); - } - Err(e) => { - self.set_error(e.to_string(), cx); - } - } + let task = nostr.update(cx, |this, cx| this.import_bunker(&value, cx)); + self.finish(task, cx); return; } - if let Ok(secret) = SecretKey::parse(&value) { - let keys = Keys::new(secret); - let nostr = NostrRegistry::global(cx); + if value.starts_with("ncryptsec1") { + if password.is_empty() { + self.set_error("Passphrase is required", cx); + return; + } - // Update the signer - nostr.update(cx, |this, cx| { - this.set_signer(keys, cx); - }); - } else { - self.set_error("Invalid key", cx); + let task = nostr.update(cx, |this, cx| this.import_ncryptsec(&value, &password, cx)); + self.finish(task, cx); + return; } + + // A plaintext key is encrypted with the passphrase before it is stored + if value.starts_with("nsec1") || SecretKey::parse(&value).is_ok() { + if password.is_empty() { + self.set_error("Passphrase is required", cx); + return; + } + + let repass = self.repass_input.read(cx).value().to_string(); + + if password != repass { + self.set_error("Passphrases do not match", cx); + return; + } + + let task = nostr.update(cx, |this, cx| this.import_nsec(&value, &password, cx)); + self.finish(task, cx); + return; + } + + self.set_error("Enter an nsec, ncryptsec or bunker://", cx); } - fn ncryptsec(&mut self, content: S, pwd: S, window: &mut Window, cx: &mut Context) - where - S: Into, - { - let nostr = NostrRegistry::global(cx); - let content: String = content.into(); - let password: String = pwd.into(); - - if password.is_empty() { - self.set_error("Password is required", cx); - return; - } - - let Ok(enc) = EncryptedSecretKey::from_bech32(&content) else { - self.set_error("Secret Key is invalid", cx); - return; - }; - - // Decrypt in the background to ensure it doesn't block the UI - let task = cx.background_spawn(async move { - if let Ok(content) = enc.decrypt(&password) { - Ok(Keys::new(content)) - } else { - Err(anyhow!("Invalid password")) + /// Run the import task, surfacing errors while success closes the dialog + /// through the `SignerChanged` event + fn finish(&mut self, task: Task>, cx: &mut Context) { + self.tasks.push(cx.spawn(async move |this, cx| { + if let Err(e) = task.await { + this.update(cx, |this, cx| { + this.set_error(e.to_string(), cx); + })?; } - }); - - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - match task.await { - Ok(keys) => { - nostr.update_in(cx, |this, window, cx| { - this.set_signer(keys, cx); - window.close_dialog(cx); - })?; - } - Err(e) => { - this.update(cx, |this, cx| { - this.set_error(e.to_string(), cx); - })?; - } - } - Ok(()) - })); - } - - fn bunker(&mut self, uri: NostrConnectUri, window: &mut Window, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let master_keys = nostr.read(cx).get_master_key(cx); - let password = uri.to_string(); - let save = cx.write_credentials(USER_KEYRING, "bunker", password.as_bytes()); - - self.tasks.push(cx.spawn_in(window, async move |_this, cx| { - let keys = master_keys.await; - let timeout = Duration::from_secs(30); - - // Construct the nostr connect signer - let mut signer = NostrConnect::new(uri, keys, timeout, None)?; - - // Handle auth url with the default browser - signer.auth_url_handler(CoopAuthUrlHandler); - - nostr.update(cx, |this, cx| { - this.set_signer(signer, cx); - cx.background_spawn(async move { save.await.ok() }).detach(); - cx.notify(); - }); Ok(()) })); @@ -214,36 +225,62 @@ impl ImportIdentity { impl Render for ImportIdentity { fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages."; - const KEY_WARN: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login."; + const KEY_WARN: &str = "Coop encrypts your key with your passphrase and stores it on this device. You only need the passphrase to sign in next time."; let is_wasm = cfg!(target_arch = "wasm32"); - let require_password = self.key_input.read(cx).value().starts_with("ncryptsec1"); - let key_warning = self.key_input.read(cx).value().starts_with("nsec1") || require_password; - let bunker_warning = self.key_input.read(cx).value().starts_with("bunker://"); + let is_unlock = self.mode == Mode::Unlock; + + let value = self.key_input.read(cx).value().trim().to_string(); + let require_password = value.starts_with("ncryptsec1"); + let require_encrypt = value.starts_with("nsec1"); + let key_warning = require_password || require_encrypt; + let bunker_warning = value.starts_with("bunker://"); v_flex() .size_full() - .gap(px(theme::SPACE)) + .gap_4() .text_style(TextStyle::Callout) .child( v_flex() - .gap_1() - .child( - v_flex() - .gap_1() - .text_color(cx.theme().text_muted) - .child("Use secret key or bunker connection") - .child(Input::new(&self.key_input)), - ) - .when(require_password, |this| { + .gap(px(theme::SPACE)) + .when(!is_unlock, |this| { this.child( v_flex() .gap_1() .text_color(cx.theme().text_muted) - .child("Decrypt Password:") + .child("Use secret key or bunker connection") + .child(Input::new(&self.key_input)), + ) + }) + .when(is_unlock || require_password, |this| { + this.child( + v_flex() + .gap_1() + .text_color(cx.theme().text_muted) + .child(if is_unlock { + "Passphrase to unlock your identity:" + } else { + "Decrypt Password:" + }) .child(Input::new(&self.pass_input)), ) }) + .when(require_encrypt, |this| { + this.child( + v_flex() + .gap_1() + .text_color(cx.theme().text_muted) + .child("Passphrase to encrypt your key on this device:") + .child(Input::new(&self.pass_input)), + ) + .child( + v_flex() + .gap_1() + .text_color(cx.theme().text_muted) + .child("Repeat Passphrase:") + .child(Input::new(&self.repass_input)), + ) + }) .when(bunker_warning, |this| { this.child( div() @@ -271,21 +308,19 @@ impl Render for ImportIdentity { ) .child( Button::new("login") - .label("Continue") + .label(if is_unlock { "Unlock" } else { "Continue" }) .primary() - .font_semibold() .loading(self.loading) .disabled(self.loading) - .on_click(cx.listener(move |this, _ev, window, cx| { - this.login(window, cx); + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.login(cx); })), ) - .when(!is_wasm, |this| { + .when(!is_wasm && !is_unlock, |this| { this.child( Button::new("proxy") .label("Connect via Web Extension (Experimental)") .ghost() - .loading(self.loading) .disabled(self.loading) .on_click(cx.listener(move |this, _ev, _window, cx| { this.proxy(cx); diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 4f7f258b..d605bc06 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -21,7 +21,7 @@ use ui::notification::{Notification, NotificationKind}; use ui::{Root, WindowExtension, v_flex}; use crate::dialogs::restore::RestoreEncryption; -use crate::dialogs::{join_group, new_chat, new_group, profile, relays, settings}; +use crate::dialogs::{import, join_group, new_chat, new_group, profile, relays, settings}; use crate::panels::{backup, browse, contact_list, greeter, inbox}; use crate::sidebar::Sidebar; @@ -76,6 +76,7 @@ impl Workspace { let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx)); let left_sidebar = sidebar.clone(); + let workspace = cx.entity().downgrade(); let mut subscriptions = smallvec![]; subscriptions.push( @@ -87,10 +88,10 @@ impl Workspace { subscriptions.push( // Subscribe to the nostr events - cx.subscribe_in(&nostr, window, move |_this, _state, event, window, cx| { - if let StateEvent::SignerChanged = event { - window.close_all_dialogs(cx); - } + cx.subscribe_in(&nostr, window, move |_, _, event, window, cx| match event { + StateEvent::SignerChanged => window.close_all_dialogs(cx), + StateEvent::PassphraseRequired => import::open_unlock(window, cx), + _ => {} }), ); @@ -183,6 +184,27 @@ impl Workspace { }), ); + App::on_action(cx, { + let workspace = workspace.clone(); + move |action, cx| { + let Some(workspace) = workspace.upgrade() else { + return; + }; + workspace.update(cx, |this, cx| { + this.show_group_meta(action, cx); + }); + } + }); + + App::on_action(cx, move |action, cx| { + let Some(workspace) = workspace.upgrade() else { + return; + }; + workspace.update(cx, |this, cx| { + this.hide_group_meta(action, cx); + }); + }); + cx.defer_in(window, move |this, window, cx| { let sidebar = PanelHandle::new(left_sidebar); let greeter = PanelHandle::new(greeter::init(window, cx)); @@ -198,29 +220,6 @@ impl Workspace { }); }); - // Listen app-wide, so a group panel can show or hide the sidebar without holding it. - let workspace = cx.entity().downgrade(); - - App::on_action(cx, { - let workspace = workspace.clone(); - - move |action: &nip29_ui::ShowGroupMeta, cx| { - let Some(workspace) = workspace.upgrade() else { - return; - }; - - workspace.update(cx, |this, cx| this.show_group_meta(action, cx)); - } - }); - - App::on_action(cx, move |action: &nip29_ui::HideGroupMeta, cx| { - let Some(workspace) = workspace.upgrade() else { - return; - }; - - workspace.update(cx, |this, cx| this.hide_group_meta(action, cx)); - }); - Self { dock, sidebar,