diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 4e43e34..f5172dd 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -34,6 +34,9 @@ pub const INDEXER_RELAYS: [&str; 3] = [ pub enum BackendEvent { /// User has no signer configured. SignerRequired, + /// The stored identity is NIP-49 encrypted (`ncryptsec1...`); a + /// passphrase is required to decrypt it before the session can resume. + PassphraseRequired, /// The signer has changed (login/logout/account switch). SignerChanged, /// Relay bootstrap finished. @@ -76,6 +79,9 @@ pub struct Backend { current_user: Option, connected: bool, sync_progress: Option<(u64, u64)>, + /// Whether the stored credential is NIP-49 encrypted and a passphrase + /// is still needed to resume the session. + passphrase_required: bool, tasks: Vec>>, } @@ -125,6 +131,7 @@ impl Backend { current_user: None, connected: false, sync_progress: None, + passphrase_required: false, tasks: vec![pump], }; @@ -171,7 +178,9 @@ impl Backend { } /// Restore the saved session from the keyring. Emits - /// [`BackendEvent::SignerRequired`] if no credential is stored. + /// [`BackendEvent::SignerRequired`] if no credential is stored, or + /// [`BackendEvent::PassphraseRequired`] if the stored identity is + /// NIP-49 encrypted. pub fn restore_session(&mut self, cx: &mut Context) { if cfg!(target_arch = "wasm32") { cx.emit(BackendEvent::SignerRequired); @@ -206,9 +215,12 @@ impl Backend { this.update(cx, |this, cx| this.set_signer(signer, cx))?; } else if content.starts_with("ncryptsec1") { // Encrypted identity: a passphrase is required to - // decrypt it, which is not implemented yet. - log::warn!("stored identity is ncryptsec-encrypted; passphrase restore is not implemented"); - this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; + // decrypt it before the session can resume. + log::warn!("stored identity is ncryptsec-encrypted; waiting for passphrase"); + this.update(cx, |this, cx| { + this.passphrase_required = true; + cx.emit(BackendEvent::PassphraseRequired); + })?; } else { this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; } @@ -228,6 +240,56 @@ impl Backend { })); } + /// Decrypt the NIP-49 encrypted credential stored in the keyring with + /// the given passphrase and resume the session. + /// + /// The scrypt decryption runs off the UI thread. The returned receiver + /// yields the public key on success, or the failure reason (e.g. wrong + /// passphrase), so callers can render inline errors. + pub fn restore_with_passphrase( + &mut self, + password: &str, + cx: &mut Context, + ) -> flume::Receiver> { + let (tx, rx) = flume::bounded(1); + + let password = password.to_owned(); + let user = cx.read_credentials(USER_KEYRING); + + self.tasks.push(cx.spawn(async move |this, cx| { + let result = async { + let content = user + .await? + .map(|(_username, secret)| String::from_utf8(secret)) + .transpose()? + .ok_or_else(|| anyhow!("no stored credential; nothing to unlock"))?; + + if !content.starts_with("ncryptsec1") { + Err(anyhow!("stored credential is not passphrase-encrypted"))?; + } + + 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::<_, Error>(public_key) + } + .await; + + tx.send_async(result).await.ok(); + Ok(()) + })); + + rx + } + /// Create a new identity: generate keys, encrypt the secret key with the /// passphrase (NIP-49) and persist it in the keyring, then publish the /// user's NIP-65 relay list, metadata and grasp list. @@ -448,6 +510,7 @@ impl Backend { this.update(cx, |this, cx| { this.signer.swap_inner(Keys::generate()); this.current_user = None; + this.passphrase_required = false; cx.emit(BackendEvent::SignerChanged); cx.emit(BackendEvent::SignerRequired); cx.notify(); @@ -510,6 +573,12 @@ impl Backend { self.current_user } + /// Whether the stored credential is NIP-49 encrypted and a passphrase + /// is still needed to resume the session. + pub fn passphrase_required(&self) -> bool { + self.passphrase_required + } + /// Surface an error message through [`BackendEvent::Error`]. pub fn emit_error(&mut self, message: impl Into, cx: &mut Context) { cx.emit(BackendEvent::error(message)); @@ -540,6 +609,7 @@ impl Backend { this.update(cx, |this, cx| { this.signer.swap_inner(new_signer); this.current_user = Some(public_key); + this.passphrase_required = false; this.bootstrap_user(public_key, cx); cx.emit(BackendEvent::SignerChanged); cx.notify(); diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index 3a96c03..4ae1b91 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -1,5 +1,5 @@ mod repo_list; -mod sidebar; +pub(crate) mod sidebar; pub use repo_list::RepoListView; pub use sidebar::SidebarPanel; diff --git a/crates/workspace/src/views/sidebar/import_identity_dialog.rs b/crates/workspace/src/views/sidebar/import_dialog.rs similarity index 100% rename from crates/workspace/src/views/sidebar/import_identity_dialog.rs rename to crates/workspace/src/views/sidebar/import_dialog.rs diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index b0b276d..081c1ea 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -13,8 +13,9 @@ use signed_state::{Backend, BackendEvent}; use super::RepoListView; -mod import_identity_dialog; +mod import_dialog; mod onboarding_dialog; +pub(crate) mod passphrase_dialog; use self::onboarding_dialog::OnboardingState; @@ -95,7 +96,7 @@ impl SidebarPanel { /// Show the Import Identity dialog. fn open_import(&mut self, window: &mut Window, cx: &mut Context) { - import_identity_dialog::open(window, cx); + import_dialog::open(window, cx); } } diff --git a/crates/workspace/src/views/sidebar/passphrase_dialog.rs b/crates/workspace/src/views/sidebar/passphrase_dialog.rs new file mode 100644 index 0000000..8738b96 --- /dev/null +++ b/crates/workspace/src/views/sidebar/passphrase_dialog.rs @@ -0,0 +1,143 @@ +use gpui::prelude::*; +use gpui::{AnyWindowHandle, App, Entity, SharedString, Subscription, Window, div, px}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; +use gpui_component::form::{field, v_form}; +use gpui_component::input::{Input, InputEvent, InputState}; +use gpui_component::{ActiveTheme, Disableable, WindowExt}; +use signed_state::Backend; + +/// Shared state for the passphrase dialog, so async results can be rendered. +#[derive(Default)] +pub struct PassphraseState { + pub busy: bool, + pub error: Option, + /// Keeps the Enter-to-submit subscription alive while the dialog is open. + _enter_subscription: Option, +} + +/// Open the dialog asking for the passphrase that protects the stored +/// NIP-49 encrypted identity (`ncryptsec1...`). +/// +/// Called when the backend emits [`signed_state::BackendEvent::PassphraseRequired`]. +pub fn open(window: &mut Window, cx: &mut App) { + let pass_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Passphrase to unlock your identity") + .masked(true) + }); + + let handle = window.window_handle(); + let state = cx.new(|_| PassphraseState::default()); + + // Enter in the passphrase field submits, same as the Unlock button. + let enter_pass_input = pass_input.clone(); + let enter_state = state.clone(); + let enter_subscription = cx.subscribe(&pass_input, move |_input, event, cx| { + if matches!(event, InputEvent::PressEnter { .. }) { + unlock(&enter_pass_input, &enter_state, &handle, cx); + } + }); + + state.update(cx, |state, _| { + state._enter_subscription = Some(enter_subscription) + }); + + window.open_dialog(cx, move |dialog, _window, _cx| { + let pass_input = pass_input.clone(); + let state = state.clone(); + + dialog + .width(px(420.)) + .margin_top(px(50.)) + .content(move |content, _window, cx| { + let busy = state.read(cx).busy; + let error = state.read(cx).error.clone(); + + content + .child( + DialogHeader::new() + .child(DialogTitle::new().child("Unlock your identity")) + .child( + DialogDescription::new() + .child("Enter the passphrase used to encrypt this identity."), + ), + ) + .child( + v_form().child( + field() + .label("Passphrase") + .required(true) + .child(Input::new(&pass_input)), + ), + ) + .children(error.map(|message| { + div().text_sm().text_color(cx.theme().danger).child(message) + })) + .child( + DialogFooter::new().justify_end().child( + Button::new("unlock") + .primary() + .label("Unlock") + .loading(busy) + .disabled(busy) + .on_click({ + let pass_input = pass_input.clone(); + let state = state.clone(); + + move |_ev, _window, cx| { + unlock(&pass_input, &state, &handle, cx); + } + }), + ), + ) + }) + }); +} + +/// Submit the passphrase to the backend. On success the dialog is closed; +/// on failure the error is rendered inline and the dialog stays open. +fn unlock( + pass_input: &Entity, + state: &Entity, + handle: &AnyWindowHandle, + cx: &mut App, +) { + let backend = Backend::global(cx); + let pass = pass_input.read(cx).value().to_string(); + + if pass.is_empty() { + state.update(cx, |state, _| { + state.error = Some("Passphrase must not be empty".into()); + }); + return; + } + + state.update(cx, |state, _| { + state.busy = true; + state.error = None; + }); + + let rx = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx)); + + let handle = *handle; + let state = state.clone(); + + cx.spawn(async move |cx| match rx.recv_async().await { + Ok(Ok(_)) => { + cx.update_window(handle, |_, window, cx| window.close_dialog(cx)) + .ok(); + } + Ok(Err(e)) => { + cx.update_window(handle, |_, _window, cx| { + state.update(cx, |state, _| { + state.busy = false; + state.error = Some(e.to_string().into()); + }); + }) + .ok(); + } + Err(_) => {} + }) + .detach(); +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 0dae153..42d54d4 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -7,12 +7,14 @@ use gpui_component::{ActiveTheme, Root, StyledExt, TitleBar, h_flex, v_flex}; use signed_state::{Backend, BackendEvent}; use crate::views::SidebarPanel; +use crate::views::sidebar::passphrase_dialog; /// Root view of the app: title bar, dock area, status bar. pub struct Workspace { dock: Entity, status: SharedString, _subscription: Subscription, + _passphrase_subscription: Subscription, } impl Workspace { @@ -59,10 +61,28 @@ impl Workspace { cx.notify(); }); + // Ask for the passphrase when the stored identity is NIP-49 + // encrypted. Subscribed via the window, since opening a dialog + // needs one. + let passphrase_subscription = + window.subscribe(&backend, cx, |_backend, event, window, cx| { + if matches!(event, BackendEvent::PassphraseRequired) { + passphrase_dialog::open(window, cx); + } + }); + + // The event may have fired before this window existed (the backend + // is initialized before the first window opens); fall back to the + // backend state in that case. + if backend.read(cx).passphrase_required() { + passphrase_dialog::open(window, cx); + } + Self { dock, status, _subscription: subscription, + _passphrase_subscription: passphrase_subscription, } } }