From d4ba71a36275b0d5125c068812208c26a7054ed8 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 4 Oct 2026 10:55:32 +0700 Subject: [PATCH] feat: add identity import dialog --- crates/signed_state/src/backend.rs | 146 ++++++-- .../src/views/sidebar/import_dialog.rs | 335 +++++++++++++++++- crates/workspace/src/views/sidebar/mod.rs | 2 +- 3 files changed, 459 insertions(+), 24 deletions(-) diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 938a268..31a4d09 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -250,6 +250,119 @@ impl Backend { }) } + /// 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| { + let mut signer = NostrConnect::new( + connect_uri, + keys, + Duration::from_secs(NOSTR_CONNECT_TIMEOUT), + None, + )?; + signer.auth_url_handler(SignedAuthUrlHandler); + + // 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) + }) + } + pub fn create_identity( &mut self, name: &str, @@ -568,8 +681,7 @@ impl Backend { }) } - // Errors when no grasp server accepted the push; the outcome reports - // which servers did when only some accepted it. + // Errors when no grasp server accepted the push. pub fn push_repository( &mut self, announcement: Announcement, @@ -614,8 +726,6 @@ impl Backend { let relays = announcement.relays.clone(); cx.spawn(async move |this, cx| { - // Runs on completion, on error and on cancellation alike; the - // guard would be dropped with the task if not held. let _guard = cx.on_drop(&this, { let addr = addr.clone(); move |backend, cx| { @@ -634,8 +744,7 @@ impl Backend { work.await? }; - // Keep the announced default branch in `HEAD` when it is among - // the pushed refs; otherwise `HEAD` stays the checkout's branch. + // Keep the announced default branch in `HEAD` when it is among the pushed refs. let heads: Vec<&str> = state .refs .iter() @@ -648,8 +757,7 @@ impl Backend { state.head = Some(head); } - // Grasp servers authorize a push by the state event they hold in - // purgatory: stage it on each server's relay before the git push. + // Grasp servers authorize a push by the state event they hold in purgatory. let refs = state.refs.clone(); let head = state.head.clone(); @@ -960,8 +1068,7 @@ impl Backend { .detach(); } - // Each target gets its own deletion event: a relay rejecting or dropping - // one does not affect the others. + // Each target gets its own deletion event. fn retract_events(&mut self, events: &[Event], cx: &mut Context) { let pusher = GraspPush::new(self.client.clone(), self.signer.clone()); @@ -1001,10 +1108,7 @@ fn repository_announcement( } } -// Announce the repository, then stage the state event and push: the state -// event is the push authorization, so it must be accepted before the push. -// Connect to all servers first — the nostr client queues events until each -// relay is connected. +// Announce the repository, then stage the state event and push. #[allow(clippy::too_many_arguments)] async fn announce_repository_and_push( backend: &WeakEntity, @@ -1031,10 +1135,7 @@ async fn announce_repository_and_push( GraspPush::require_relay_accepted(output, event)? }; - // The state event is the push authorization. Stage it on each grasp - // server's relay, then push the git data. The push fails only when no - // server accepted it. The announcement is then retracted so the - // repository is not left announced without content. + // The state event is the push authorization. let outcome = if refs.is_empty() { PushOutcome::default() } else { @@ -1063,8 +1164,7 @@ async fn announce_repository_and_push( }; if outcome.accepted() == 0 { - // Retract the announcement so the repository is not left announced - // without content. + // Retract the announcement so the repository is not left announced without content. backend .update(cx, |backend, cx| { backend.retract_events(std::slice::from_ref(&event), cx); @@ -1135,3 +1235,9 @@ fn extract_master_key(credential: &str) -> (&str, Keys) { None => (credential, Keys::generate()), } } + +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/workspace/src/views/sidebar/import_dialog.rs b/crates/workspace/src/views/sidebar/import_dialog.rs index 1e71ddc..d0f8682 100644 --- a/crates/workspace/src/views/sidebar/import_dialog.rs +++ b/crates/workspace/src/views/sidebar/import_dialog.rs @@ -1,8 +1,337 @@ -use gpui::{App, Window, px}; -use gpui_component::WindowExt; +use gpui::prelude::*; +use gpui::{AnyWindowHandle, App, Entity, Subscription, Task, Window, 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::{Disableable, WindowExt}; +use signed_state::Backend; + +use crate::views::dialog_state::{DialogProgress, error_row}; + +#[derive(Clone, Copy, Default)] +enum Stage { + #[default] + Credential, + /// An `nsec` was entered; ask for a passphrase to encrypt it with. + Encrypt, + /// An `ncryptsec` was entered; ask for its passphrase to decrypt it. + Decrypt, +} + +/// The credential entered on the first step. +enum Credential { + Nsec(String), + Ncryptsec(String), + Bunker(String), +} + +impl Credential { + fn parse(input: &str) -> Option { + let input = input.trim(); + + if input.starts_with("nsec1") { + Some(Self::Nsec(input.to_owned())) + } else if input.starts_with("ncryptsec1") { + Some(Self::Ncryptsec(input.to_owned())) + } else if input.starts_with("bunker://") { + Some(Self::Bunker(input.to_owned())) + } else { + None + } + } +} + +/// State of the import dialog, so the current step and async results can be rendered. +#[derive(Default)] +pub struct ImportState { + stage: Stage, + credential: String, + progress: DialogProgress, + /// Keeps the subscriptions alive while the dialog is open. + subscriptions: Vec, +} pub fn open(window: &mut Window, cx: &mut App) { + let secret_input = + cx.new(|cx| InputState::new(window, cx).placeholder("secret key or bunker://")); + + let pass_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Passphrase to protect your key") + .masked(true) + }); + + let repass_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Repeat passphrase") + .masked(true) + }); + + let handle = window.window_handle(); + let state = cx.new(|_| ImportState::default()); + + // Enter in any field submits the step the dialog is on. + let mut subscriptions = Vec::new(); + + for input in [&secret_input, &pass_input, &repass_input] { + let secret_input = secret_input.clone(); + let pass_input = pass_input.clone(); + let repass_input = repass_input.clone(); + let state = state.clone(); + + subscriptions.push(cx.subscribe(input, move |_input, event, cx| { + if matches!(event, InputEvent::PressEnter { .. }) { + submit( + &secret_input, + &pass_input, + &repass_input, + &state, + &handle, + cx, + ); + } + })); + } + + state.update(cx, |state, _cx| { + state.subscriptions = subscriptions; + }); + window.open_dialog(cx, move |dialog, _window, _cx| { - dialog.title("Import identity").width(px(400.)) + let secret_input = secret_input.clone(); + let pass_input = pass_input.clone(); + let repass_input = repass_input.clone(); + let state = state.clone(); + + dialog + .width(px(520.)) + .margin_top(px(50.)) + .content(move |content, _window, cx| { + let stage = state.read(cx).stage; + let busy = state.read(cx).progress.busy; + let error = state.read(cx).progress.error.clone(); + + content + .child( + DialogHeader::new() + .child(DialogTitle::new().child("Import identity")) + .child(DialogDescription::new().child(match stage { + Stage::Credential => { + "Paste the secret key or bunker URI of an existing identity." + } + Stage::Encrypt => { + "Choose a passphrase to encrypt your key on this device." + } + Stage::Decrypt => "Enter the passphrase used to encrypt this key.", + })), + ) + .child(match stage { + Stage::Credential => v_form().child( + field() + .label("Secret key or bunker URI") + .required(true) + .child(Input::new(&secret_input)), + ), + Stage::Encrypt => v_form() + .child( + field() + .label("Passphrase") + .required(true) + .child(Input::new(&pass_input)), + ) + .child(field().required(true).child(Input::new(&repass_input))), + Stage::Decrypt => v_form().child( + field() + .label("Passphrase") + .required(true) + .child(Input::new(&pass_input)), + ), + }) + .children(error_row(&error, cx)) + .child( + DialogFooter::new().justify_end().child( + Button::new("import") + .primary() + .label(match stage { + Stage::Credential => "Continue", + Stage::Encrypt => "Import identity", + Stage::Decrypt => "Unlock", + }) + .loading(busy) + .disabled(busy) + .on_click({ + let secret_input = secret_input.clone(); + let pass_input = pass_input.clone(); + let repass_input = repass_input.clone(); + let state = state.clone(); + + move |_ev, _window, cx| { + submit( + &secret_input, + &pass_input, + &repass_input, + &state, + &handle, + cx, + ); + } + }), + ), + ) + }) }); } + +fn submit( + secret_input: &Entity, + pass_input: &Entity, + repass_input: &Entity, + state: &Entity, + handle: &AnyWindowHandle, + cx: &mut App, +) { + if state.read(cx).progress.busy { + return; + } + + match state.read(cx).stage { + Stage::Credential => start_import(secret_input, state, handle, cx), + Stage::Encrypt => import_nsec(pass_input, repass_input, state, handle, cx), + Stage::Decrypt => import_ncryptsec(pass_input, state, handle, cx), + } +} + +/// Parses the entered credential and either moves to the passphrase step or connects to a bunker. +fn start_import( + secret_input: &Entity, + state: &Entity, + handle: &AnyWindowHandle, + cx: &mut App, +) { + let credential = match Credential::parse(&secret_input.read(cx).value()) { + Some(credential) => credential, + None => { + state.update(cx, |state, _| { + state + .progress + .fail("Enter an nsec, ncryptsec or bunker:// URI"); + }); + return; + } + }; + + match credential { + Credential::Nsec(secret) => { + state.update(cx, |state, _| { + state.credential = secret; + state.stage = Stage::Encrypt; + state.progress.error = None; + }); + } + Credential::Ncryptsec(secret) => { + state.update(cx, |state, _| { + state.credential = secret; + state.stage = Stage::Decrypt; + state.progress.error = None; + }); + } + Credential::Bunker(uri) => { + state.update(cx, |state, _| state.progress.begin()); + + let backend = Backend::global(cx); + let task = backend.update(cx, |backend, cx| backend.import_bunker(&uri, cx)); + finish_import(task, state, handle, cx); + } + } +} + +/// Encrypts the entered `nsec` with the passphrase and signs in with it. +fn import_nsec( + pass_input: &Entity, + repass_input: &Entity, + state: &Entity, + handle: &AnyWindowHandle, + cx: &mut App, +) { + let backend = Backend::global(cx); + let nsec = state.read(cx).credential.clone(); + let pass = pass_input.read(cx).value().to_string(); + let repass = repass_input.read(cx).value().to_string(); + + if pass.is_empty() { + state.update(cx, |state, _| { + state.progress.fail("Passphrase must not be empty"); + }); + return; + } + + if pass != repass { + state.update(cx, |state, _| { + state.progress.fail("Passphrases do not match"); + }); + return; + } + + state.update(cx, |state, _cx| { + state.progress.begin(); + }); + + let task = backend.update(cx, |backend, cx| backend.import_nsec(&nsec, &pass, cx)); + finish_import(task, state, handle, cx); +} + +/// Decrypts the entered `ncryptsec` with the passphrase and signs in with it. +fn import_ncryptsec( + pass_input: &Entity, + state: &Entity, + handle: &AnyWindowHandle, + cx: &mut App, +) { + let backend = Backend::global(cx); + let ncryptsec = state.read(cx).credential.clone(); + let pass = pass_input.read(cx).value().to_string(); + + if pass.is_empty() { + state.update(cx, |state, _| { + state.progress.fail("Passphrase must not be empty"); + }); + return; + } + + state.update(cx, |state, _cx| { + state.progress.begin(); + }); + + let task = backend.update(cx, |backend, cx| { + backend.import_ncryptsec(&ncryptsec, &pass, cx) + }); + finish_import(task, state, handle, cx); +} + +/// Runs the import task to completion, closing the dialog on success. +fn finish_import( + task: Task>, + state: &Entity, + handle: &AnyWindowHandle, + cx: &mut App, +) { + let handle = *handle; + let state = state.clone(); + + cx.spawn(async move |cx| match task.await { + Ok(_) => { + cx.update_window(handle, |_, window, cx| { + window.close_dialog(cx); + }) + .ok(); + } + Err(e) => { + cx.update_window(handle, |_, _window, cx| { + state.update(cx, |state, _| state.progress.fail(e.to_string())); + }) + .ok(); + } + }) + .detach(); +} diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 396f441..7cb55f0 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -610,7 +610,7 @@ impl SidebarPanel { })), ) .child( - BaseButton::new("onboarding") + BaseButton::new("import") .h_flex() .h_8() .px_2()