add passphrase dialog

This commit is contained in:
2026-08-07 07:24:33 +07:00
parent e2ec35a673
commit b137f54a66
6 changed files with 241 additions and 7 deletions
+74 -4
View File
@@ -34,6 +34,9 @@ pub const INDEXER_RELAYS: [&str; 3] = [
pub enum BackendEvent { pub enum BackendEvent {
/// User has no signer configured. /// User has no signer configured.
SignerRequired, 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). /// The signer has changed (login/logout/account switch).
SignerChanged, SignerChanged,
/// Relay bootstrap finished. /// Relay bootstrap finished.
@@ -76,6 +79,9 @@ pub struct Backend {
current_user: Option<PublicKey>, current_user: Option<PublicKey>,
connected: bool, connected: bool,
sync_progress: Option<(u64, u64)>, 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<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
} }
@@ -125,6 +131,7 @@ impl Backend {
current_user: None, current_user: None,
connected: false, connected: false,
sync_progress: None, sync_progress: None,
passphrase_required: false,
tasks: vec![pump], tasks: vec![pump],
}; };
@@ -171,7 +178,9 @@ impl Backend {
} }
/// Restore the saved session from the keyring. Emits /// 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<Self>) { pub fn restore_session(&mut self, cx: &mut Context<Self>) {
if cfg!(target_arch = "wasm32") { if cfg!(target_arch = "wasm32") {
cx.emit(BackendEvent::SignerRequired); cx.emit(BackendEvent::SignerRequired);
@@ -206,9 +215,12 @@ impl Backend {
this.update(cx, |this, cx| this.set_signer(signer, cx))?; this.update(cx, |this, cx| this.set_signer(signer, cx))?;
} else if content.starts_with("ncryptsec1") { } else if content.starts_with("ncryptsec1") {
// Encrypted identity: a passphrase is required to // Encrypted identity: a passphrase is required to
// decrypt it, which is not implemented yet. // decrypt it before the session can resume.
log::warn!("stored identity is ncryptsec-encrypted; passphrase restore is not implemented"); log::warn!("stored identity is ncryptsec-encrypted; waiting for passphrase");
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; this.update(cx, |this, cx| {
this.passphrase_required = true;
cx.emit(BackendEvent::PassphraseRequired);
})?;
} else { } else {
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; 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<Self>,
) -> flume::Receiver<Result<PublicKey, Error>> {
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 /// Create a new identity: generate keys, encrypt the secret key with the
/// passphrase (NIP-49) and persist it in the keyring, then publish the /// passphrase (NIP-49) and persist it in the keyring, then publish the
/// user's NIP-65 relay list, metadata and grasp list. /// user's NIP-65 relay list, metadata and grasp list.
@@ -448,6 +510,7 @@ impl Backend {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.signer.swap_inner(Keys::generate()); this.signer.swap_inner(Keys::generate());
this.current_user = None; this.current_user = None;
this.passphrase_required = false;
cx.emit(BackendEvent::SignerChanged); cx.emit(BackendEvent::SignerChanged);
cx.emit(BackendEvent::SignerRequired); cx.emit(BackendEvent::SignerRequired);
cx.notify(); cx.notify();
@@ -510,6 +573,12 @@ impl Backend {
self.current_user 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`]. /// Surface an error message through [`BackendEvent::Error`].
pub fn emit_error(&mut self, message: impl Into<String>, cx: &mut Context<Self>) { pub fn emit_error(&mut self, message: impl Into<String>, cx: &mut Context<Self>) {
cx.emit(BackendEvent::error(message)); cx.emit(BackendEvent::error(message));
@@ -540,6 +609,7 @@ impl Backend {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.signer.swap_inner(new_signer); this.signer.swap_inner(new_signer);
this.current_user = Some(public_key); this.current_user = Some(public_key);
this.passphrase_required = false;
this.bootstrap_user(public_key, cx); this.bootstrap_user(public_key, cx);
cx.emit(BackendEvent::SignerChanged); cx.emit(BackendEvent::SignerChanged);
cx.notify(); cx.notify();
+1 -1
View File
@@ -1,5 +1,5 @@
mod repo_list; mod repo_list;
mod sidebar; pub(crate) mod sidebar;
pub use repo_list::RepoListView; pub use repo_list::RepoListView;
pub use sidebar::SidebarPanel; pub use sidebar::SidebarPanel;
+3 -2
View File
@@ -13,8 +13,9 @@ use signed_state::{Backend, BackendEvent};
use super::RepoListView; use super::RepoListView;
mod import_identity_dialog; mod import_dialog;
mod onboarding_dialog; mod onboarding_dialog;
pub(crate) mod passphrase_dialog;
use self::onboarding_dialog::OnboardingState; use self::onboarding_dialog::OnboardingState;
@@ -95,7 +96,7 @@ impl SidebarPanel {
/// Show the Import Identity dialog. /// Show the Import Identity dialog.
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
import_identity_dialog::open(window, cx); import_dialog::open(window, cx);
} }
} }
@@ -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<SharedString>,
/// Keeps the Enter-to-submit subscription alive while the dialog is open.
_enter_subscription: Option<Subscription>,
}
/// 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<InputState>,
state: &Entity<PassphraseState>,
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();
}
+20
View File
@@ -7,12 +7,14 @@ use gpui_component::{ActiveTheme, Root, StyledExt, TitleBar, h_flex, v_flex};
use signed_state::{Backend, BackendEvent}; use signed_state::{Backend, BackendEvent};
use crate::views::SidebarPanel; use crate::views::SidebarPanel;
use crate::views::sidebar::passphrase_dialog;
/// Root view of the app: title bar, dock area, status bar. /// Root view of the app: title bar, dock area, status bar.
pub struct Workspace { pub struct Workspace {
dock: Entity<DockArea>, dock: Entity<DockArea>,
status: SharedString, status: SharedString,
_subscription: Subscription, _subscription: Subscription,
_passphrase_subscription: Subscription,
} }
impl Workspace { impl Workspace {
@@ -59,10 +61,28 @@ impl Workspace {
cx.notify(); 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 { Self {
dock, dock,
status, status,
_subscription: subscription, _subscription: subscription,
_passphrase_subscription: passphrase_subscription,
} }
} }
} }