From d01380f7b3efebc9c40a2e71c2ddd635fa936be4 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 24 Sep 2024 15:03:58 +0100 Subject: feat(login): login via nip46 QR code or nostrconnect url string which is a much better UX flow for nip46 --- src/bin/git_remote_nostr/fetch.rs | 7 +- src/bin/git_remote_nostr/push.rs | 8 +- src/bin/git_remote_nostr/utils.rs | 14 --- src/lib/cli_interactor.rs | 48 ++++++++ src/lib/client.rs | 16 +++ src/lib/login/mod.rs | 233 +++++++++++++++++++++++++++++++------- 6 files changed, 261 insertions(+), 65 deletions(-) (limited to 'src') diff --git a/src/bin/git_remote_nostr/fetch.rs b/src/bin/git_remote_nostr/fetch.rs index 46e7ad3..ff55d6f 100644 --- a/src/bin/git_remote_nostr/fetch.rs +++ b/src/bin/git_remote_nostr/fetch.rs @@ -10,6 +10,7 @@ use anyhow::{anyhow, bail, Context, Result}; use auth_git2::GitAuthenticator; use git2::{Progress, Repository}; use ngit::{ + cli_interactor::count_lines_per_msg_vec, git::{ nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol}, utils::check_ssh_keys, @@ -23,9 +24,9 @@ use nostr::nips::nip19; use nostr_sdk::{Event, ToBech32}; use crate::utils::{ - count_lines_per_msg_vec, fetch_or_list_error_is_not_authentication_failure, - find_proposal_and_patches_by_branch_name, get_oids_from_fetch_batch, get_open_proposals, - get_read_protocols_to_try, join_with_and, set_protocol_preference, Direction, + fetch_or_list_error_is_not_authentication_failure, find_proposal_and_patches_by_branch_name, + get_oids_from_fetch_batch, get_open_proposals, get_read_protocols_to_try, join_with_and, + set_protocol_preference, Direction, }; pub async fn run_fetch( diff --git a/src/bin/git_remote_nostr/push.rs b/src/bin/git_remote_nostr/push.rs index 3bda6ba..0f4e792 100644 --- a/src/bin/git_remote_nostr/push.rs +++ b/src/bin/git_remote_nostr/push.rs @@ -16,6 +16,7 @@ use git_events::{ generate_cover_letter_and_patch_events, generate_patch_event, get_commit_id_from_patch, }; use ngit::{ + cli_interactor::count_lines_per_msg_vec, client::{self, get_event_from_cache_by_id}, git::{ self, @@ -39,10 +40,9 @@ use crate::{ git::Repo, list::list_from_remotes, utils::{ - count_lines_per_msg_vec, find_proposal_and_patches_by_branch_name, get_all_proposals, - get_remote_name_by_url, get_short_git_server_name, get_write_protocols_to_try, - join_with_and, push_error_is_not_authentication_failure, read_line, - set_protocol_preference, Direction, + find_proposal_and_patches_by_branch_name, get_all_proposals, get_remote_name_by_url, + get_short_git_server_name, get_write_protocols_to_try, join_with_and, + push_error_is_not_authentication_failure, read_line, set_protocol_preference, Direction, }, }; diff --git a/src/bin/git_remote_nostr/utils.rs b/src/bin/git_remote_nostr/utils.rs index 7b5c2d2..3ae1bab 100644 --- a/src/bin/git_remote_nostr/utils.rs +++ b/src/bin/git_remote_nostr/utils.rs @@ -384,20 +384,6 @@ pub fn error_might_be_authentication_related(error: &anyhow::Error) -> bool { false } -fn count_lines_per_msg(width: u16, msg: &str, prefix_len: usize) -> usize { - if width == 0 { - return 1; - } - // ((msg_len+prefix) / width).ceil() implemented using Integer Arithmetic - ((msg.chars().count() + prefix_len) + (width - 1) as usize) / width as usize -} - -pub fn count_lines_per_msg_vec(width: u16, msgs: &[String], prefix_len: usize) -> usize { - msgs.iter() - .map(|msg| count_lines_per_msg(width, msg, prefix_len)) - .sum() -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/lib/cli_interactor.rs b/src/lib/cli_interactor.rs index 4cf6357..dcaccf1 100644 --- a/src/lib/cli_interactor.rs +++ b/src/lib/cli_interactor.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result}; use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password}; +use indicatif::TermLike; #[cfg(test)] use mockall::*; @@ -184,3 +185,50 @@ impl PromptMultiChoiceParms { self } } + +#[derive(Debug, Default)] +pub struct Printer { + printed_lines: Vec, +} +impl Printer { + pub fn println(&mut self, line: String) { + eprintln!("{line}"); + self.printed_lines.push(line); + } + pub fn println_with_custom_formatting( + &mut self, + line: String, + line_without_formatting: String, + ) { + eprintln!("{line}"); + self.printed_lines.push(line_without_formatting); + } + pub fn printlns(&mut self, lines: Vec) { + for line in lines { + self.println(line); + } + } + pub fn clear_all(&mut self) { + let term = console::Term::stderr(); + let _ = term.clear_last_lines(count_lines_per_msg_vec( + term.width(), + &self.printed_lines, + 0, + )); + self.printed_lines.drain(..); + } +} + +pub fn count_lines_per_msg(width: u16, msg: &str, prefix_len: usize) -> usize { + if width == 0 { + return 1; + } + // ((msg_len+prefix) / width).ceil() implemented using Integer Arithmetic + ((msg.chars().count() + prefix_len) + (width - 1) as usize) / width as usize +} + +pub fn count_lines_per_msg_vec(width: u16, msgs: &[String], prefix_len: usize) -> usize { + msgs.iter() + .map(|msg| count_lines_per_msg(width, msg, prefix_len)) + .sum() +} diff --git a/src/lib/client.rs b/src/lib/client.rs index 455725c..59e17f2 100644 --- a/src/lib/client.rs +++ b/src/lib/client.rs @@ -53,6 +53,7 @@ pub struct Client { fallback_relays: Vec, more_fallback_relays: Vec, blaster_relays: Vec, + fallback_signer_relays: Vec, } #[cfg_attr(test, automock)] @@ -66,6 +67,7 @@ pub trait Connect { fn get_fallback_relays(&self) -> &Vec; fn get_more_fallback_relays(&self) -> &Vec; fn get_blaster_relays(&self) -> &Vec; + fn get_fallback_signer_relays(&self) -> &Vec; async fn send_event_to( &self, git_repo_path: &Path, @@ -132,6 +134,13 @@ impl Connect for Client { } else { vec!["wss://nostr.mutinywallet.com".to_string()] }; + + let fallback_signer_relays: Vec = if std::env::var("NGITTEST").is_ok() { + vec!["ws://localhost:8051".to_string()] + } else { + vec!["wss://relay.nsec.app".to_string()] + }; + Client { client: nostr_sdk::ClientBuilder::new() .opts(Options::new().relay_limits(RelayLimits::disable())) @@ -139,6 +148,7 @@ impl Connect for Client { fallback_relays, more_fallback_relays, blaster_relays, + fallback_signer_relays, } } fn new(opts: Params) -> Self { @@ -153,6 +163,7 @@ impl Connect for Client { fallback_relays: opts.fallback_relays, more_fallback_relays: opts.more_fallback_relays, blaster_relays: opts.blaster_relays, + fallback_signer_relays: opts.fallback_signer_relays, } } @@ -198,6 +209,10 @@ impl Connect for Client { &self.blaster_relays } + fn get_fallback_signer_relays(&self) -> &Vec { + &self.fallback_signer_relays + } + async fn send_event_to( &self, git_repo_path: &Path, @@ -629,6 +644,7 @@ pub struct Params { pub fallback_relays: Vec, pub more_fallback_relays: Vec, pub blaster_relays: Vec, + pub fallback_signer_relays: Vec, } fn get_dedup_events(relay_results: Vec>>) -> Vec { diff --git a/src/lib/login/mod.rs b/src/lib/login/mod.rs index b6e7623..825ec30 100644 --- a/src/lib/login/mod.rs +++ b/src/lib/login/mod.rs @@ -1,14 +1,19 @@ -use std::{collections::HashSet, path::Path, str::FromStr, time::Duration}; +use std::{collections::HashSet, path::Path, str::FromStr, sync::Arc, time::Duration}; use anyhow::{bail, Context, Result}; +use console::Style; +use dialoguer::theme::{ColorfulTheme, Theme}; use nostr::{ nips::{nip05, nip46::NostrConnectURI}, PublicKey, }; use nostr_sdk::{ Alphabet, FromBech32, JsonUtil, Keys, Kind, NostrSigner, SingleLetterTag, Timestamp, ToBech32, + Url, }; use nostr_signer::Nip46Signer; +use qrcode::QrCode; +use tokio::sync::{oneshot, Mutex}; #[cfg(not(test))] use crate::client::Client; @@ -16,7 +21,8 @@ use crate::client::Client; use crate::client::MockConnect; use crate::{ cli_interactor::{ - Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptPasswordParms, + Interactor, InteractorPrompt, Printer, PromptConfirmParms, PromptInputParms, + PromptPasswordParms, }, client::{fetch_public_key, get_event_from_global_cache, Connect}, git::{Repo, RepoActions}, @@ -356,60 +362,198 @@ async fn fresh_login( #[cfg(not(test))] client: Option<&Client>, always_save: bool, ) -> Result<(NostrSigner, UserRef)> { - let mut public_key: Option = None; - // prompt for nsec - let mut prompt = "login with nostr address / nsec"; - let signer = loop { - let input = Interactor::default() - .input(PromptInputParms::default().with_prompt(prompt)) - .context("failed to get nsec input from interactor")?; - if let Ok(keys) = nostr::Keys::from_str(&input) { - if let Err(error) = save_keys(git_repo, &keys, always_save) { - eprintln!("{error}"); + let app_key = Keys::generate(); + let app_key_secret = app_key.secret_key()?.to_secret_hex(); + let relays = if let Some(client) = client { + client + .get_fallback_signer_relays() + .iter() + .flat_map(|s| Url::parse(s)) + .collect::>() + } else { + vec![] + }; + let offline = client.is_none(); + let nostr_connect_url = NostrConnectURI::client(app_key.public_key(), relays.clone(), "ngit"); + let qr = generate_qr(&nostr_connect_url.to_string())?; + + let printer = Arc::new(Mutex::new(Printer::default())); + if !offline { + let printer_clone = Arc::clone(&printer); + let mut printer_locked = printer_clone.lock().await; + printer_locked.printlns(qr); + printer_locked.println(format!( + "scan QR or paste into remote signer: {nostr_connect_url}" + )); + printer_locked.println_with_custom_formatting( + { + let mut s = String::new(); + let _ = ColorfulTheme::default().format_confirm_prompt( + &mut s, + "login with nsec / bunker url / nostr address instead", + Some(true), + ); + s + }, + "? login with nsec / bunker url / nostr address instead? (y/n) › yes".to_string(), + ); + } + + let (tx, rx) = oneshot::channel(); + let printer_clone = Arc::clone(&printer); + + let qr_listener = tokio::spawn(async move { + if offline { + return; + } + if let Ok(nip46_signer) = Nip46Signer::new( + nostr_connect_url.clone(), + app_key.clone(), + Duration::from_secs(10 * 60), + None, + ) + .await + { + let signer = NostrSigner::nip46(nip46_signer); + if let Ok(pub_key) = fetch_public_key(&signer).await { + let mut printer_locked = printer_clone.lock().await; + printer_locked.clear_all(); + + printer_locked.println_with_custom_formatting( + format!( + "{}", + Style::new().bold().apply_to("connected to remote signer"), + ), + "connected to remote signer".to_string(), + ); + printer_locked.println("press any key to continue...".to_string()); + let _ = tx.send(Some((signer, pub_key))); } - break NostrSigner::Keys(keys); } - let uri = if let Ok(uri) = NostrConnectURI::parse(&input) { - uri - } else if input.contains('@') { - if let Ok(uri) = fetch_nip46_uri_from_nip05(&input).await { - uri - } else { - prompt = "failed. try again with nostr address / bunker uri / nsec"; - continue; + }); + if !offline { + let _ = console::Term::stderr().read_char(); + } + qr_listener.abort(); + let printer_clone = Arc::clone(&printer); + let mut printer = printer_clone.lock().await; + printer.clear_all(); + + let (signer, public_key) = { + if let Ok(Some((signer, public_key))) = rx.await { + let bunker_url = NostrConnectURI::Bunker { + signer_public_key: public_key, + relays: relays.clone(), + secret: None, + }; + if let Err(error) = save_bunker( + git_repo, + &public_key, + &bunker_url.to_string(), + &app_key_secret, + always_save, + ) { + eprintln!("{error}"); } + (signer, public_key) } else { - prompt = "invalid. try again with nostr address / bunker uri / nsec"; - continue; - }; - let app_key = Keys::generate().secret_key()?.to_secret_hex(); - match get_nip46_signer_from_uri_and_key(&uri.to_string(), &app_key).await { - Ok(signer) => { - let pub_key = fetch_public_key(&signer).await?; - if let Err(error) = - save_bunker(git_repo, &pub_key, &uri.to_string(), &app_key, always_save) - { - eprintln!("{error}"); + let mut public_key: Option = None; + // prompt for nsec + let mut prompt = "login with nsec / bunker url / nostr address"; + let signer = loop { + let input = Interactor::default() + .input(PromptInputParms::default().with_prompt(prompt)) + .context("failed to get nsec input from interactor")?; + if let Ok(keys) = nostr::Keys::from_str(&input) { + if let Err(error) = save_keys(git_repo, &keys, always_save) { + eprintln!("{error}"); + } + break NostrSigner::Keys(keys); } - public_key = Some(pub_key); - break signer; - } - Err(_) => { - prompt = "failed. try again with nostr address / bunker uri / nsec"; - } + let uri = if let Ok(uri) = NostrConnectURI::parse(&input) { + uri + } else if input.contains('@') { + if let Ok(uri) = fetch_nip46_uri_from_nip05(&input).await { + uri + } else { + prompt = "failed. try again with nostr address / bunker uri / nsec"; + continue; + } + } else { + prompt = "invalid. try again with nostr address / bunker uri / nsec"; + continue; + }; + match get_nip46_signer_from_uri_and_key(&uri.to_string(), &app_key_secret).await { + Ok(signer) => { + let pub_key = fetch_public_key(&signer).await?; + if let Err(error) = save_bunker( + git_repo, + &pub_key, + &uri.to_string(), + &app_key_secret, + always_save, + ) { + eprintln!("{error}"); + } + public_key = Some(pub_key); + break signer; + } + Err(_) => { + prompt = "failed. try again with nostr address / bunker uri / nsec"; + } + } + }; + let public_key = if let Some(public_key) = public_key { + public_key + } else { + signer.public_key().await? + }; + (signer, public_key) } }; - let public_key = if let Some(public_key) = public_key { - public_key - } else { - signer.public_key().await? - }; // lookup profile let user_ref = get_user_details(&public_key, client, git_repo.get_path()?, false).await?; print_logged_in_as(&user_ref, client.is_none())?; Ok((signer, user_ref)) } +fn generate_qr(data: &str) -> Result> { + let mut lines = vec![]; + let qr = + QrCode::new(data.as_bytes()).context("failed to create QR of nostrconnect login url")?; + let colors = qr.to_colors(); + let rows: Vec<&[qrcode::Color]> = colors.chunks(qr.width()).collect(); + for (row, data) in rows.iter().enumerate() { + let odd = row % 2 != 0; + if odd { + continue; + } + let mut line = String::new(); + for (col, color) in data.iter().enumerate() { + let top = color; + let mut bottom = qrcode::Color::Light; + if let Some(next_row_data) = rows.get(row + 1) { + if let Some(color) = next_row_data.get(col) { + bottom = *color; + } + } + line.push(if *top == qrcode::Color::Dark { + if bottom == qrcode::Color::Dark { + '█' + } else { + '▀' + } + } else if bottom == qrcode::Color::Dark { + '▄' + } else { + ' ' + }); + } + lines.push(line); + } + Ok(lines) +} + pub async fn fetch_nip46_uri_from_nip05(nip05: &str) -> Result { let term = console::Term::stderr(); term.write_line("contacting login service provider...")?; @@ -447,7 +591,7 @@ fn save_bunker( { let global = !Interactor::default().confirm( PromptConfirmParms::default() - .with_prompt("just for this repository?") + .with_prompt("save login just for this repository?") .with_default(false), )?; let npub = public_key.to_bech32()?; @@ -523,6 +667,7 @@ fn save_keys(git_repo: &Repo, keys: &nostr::Keys, always_save: bool) -> Result<( save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, false)?; } } else { + eprintln!("{error}"); Err(error)?; } }; -- cgit v1.2.3