diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2024-11-21 16:53:17 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2024-11-21 16:53:17 +0000 |
| commit | f79014235e85554e3661b3f2a02b8fa88bc192ff (patch) | |
| tree | fceec3ff2df212148a3420af7cef81a3f818463e /src/lib/login | |
| parent | 91b0eac4daf92b7b740267ef203a1a8ba591974b (diff) | |
feat(login): overhaul login experience
* simplify login menu, making it more accessable to newcomers and
easier to select remote signer options
* enable `ngit login` to work from anywhere (not just a git repo)
* assume fresh login details saved to global git config but fallback
to local repository
* maintain local repository login via `ngit login --local`
* maintain login via CLI arguments eg `ngit send --nsec nsec123`
* nudge users to remember nsec when pasting in ncryptsec for a
better UX, whilst maintaining the option to be prompted for
password everytime
* create placeholder menu items for help menu and create account
Diffstat (limited to 'src/lib/login')
| -rw-r--r-- | src/lib/login/existing.rs | 212 | ||||
| -rw-r--r-- | src/lib/login/fresh.rs | 595 | ||||
| -rw-r--r-- | src/lib/login/key_encryption.rs | 38 | ||||
| -rw-r--r-- | src/lib/login/mod.rs | 883 | ||||
| -rw-r--r-- | src/lib/login/user.rs | 155 |
5 files changed, 1036 insertions, 847 deletions
diff --git a/src/lib/login/existing.rs b/src/lib/login/existing.rs new file mode 100644 index 0000000..e388a34 --- /dev/null +++ b/src/lib/login/existing.rs | |||
| @@ -0,0 +1,212 @@ | |||
| 1 | use std::{str::FromStr, sync::Arc, time::Duration}; | ||
| 2 | |||
| 3 | use anyhow::{bail, Context, Result}; | ||
| 4 | use nostr::nips::nip46::NostrConnectURI; | ||
| 5 | use nostr_connect::client::NostrConnect; | ||
| 6 | use nostr_sdk::{NostrSigner, PublicKey}; | ||
| 7 | |||
| 8 | use super::{ | ||
| 9 | key_encryption::decrypt_key, | ||
| 10 | print_logged_in_as, | ||
| 11 | user::{get_user_details, UserRef}, | ||
| 12 | SignerInfo, SignerInfoSource, | ||
| 13 | }; | ||
| 14 | #[cfg(not(test))] | ||
| 15 | use crate::client::Client; | ||
| 16 | #[cfg(test)] | ||
| 17 | use crate::client::MockConnect; | ||
| 18 | use crate::{ | ||
| 19 | cli_interactor::{Interactor, InteractorPrompt, PromptPasswordParms}, | ||
| 20 | client::fetch_public_key, | ||
| 21 | git::{get_git_config_item, Repo, RepoActions}, | ||
| 22 | }; | ||
| 23 | |||
| 24 | /// load signer from git config and UserProfile from cache or relays | ||
| 25 | /// | ||
| 26 | /// # Parameters | ||
| 27 | /// - `client`: include client to fetch profiles from relays that are missing | ||
| 28 | /// from cache | ||
| 29 | /// - `silent`: do not print outcome in termianl | ||
| 30 | pub async fn load_existing_login( | ||
| 31 | git_repo: &Option<&Repo>, | ||
| 32 | signer_info: &Option<SignerInfo>, | ||
| 33 | password: &Option<String>, | ||
| 34 | source: &Option<SignerInfoSource>, | ||
| 35 | #[cfg(test)] client: Option<&MockConnect>, | ||
| 36 | #[cfg(not(test))] client: Option<&Client>, | ||
| 37 | silent: bool, | ||
| 38 | prompt_for_password: bool, | ||
| 39 | ) -> Result<(Arc<dyn NostrSigner>, UserRef, SignerInfoSource)> { | ||
| 40 | let (signer_info, source) = get_signer_info(git_repo, signer_info, password, source)?; | ||
| 41 | |||
| 42 | let (signer, public_key) = get_signer(&signer_info, prompt_for_password).await?; | ||
| 43 | |||
| 44 | let user_ref = get_user_details( | ||
| 45 | &public_key, | ||
| 46 | client, | ||
| 47 | if let Some(git_repo) = git_repo { | ||
| 48 | Some(git_repo.get_path()?) | ||
| 49 | } else { | ||
| 50 | None | ||
| 51 | }, | ||
| 52 | silent, | ||
| 53 | ) | ||
| 54 | .await?; | ||
| 55 | |||
| 56 | if !silent { | ||
| 57 | print_logged_in_as(&user_ref, client.is_none(), &source)?; | ||
| 58 | } | ||
| 59 | Ok((signer, user_ref, source)) | ||
| 60 | } | ||
| 61 | |||
| 62 | /// priority order: cli arguments, local git config, global git config | ||
| 63 | fn get_signer_info( | ||
| 64 | git_repo: &Option<&Repo>, | ||
| 65 | signer_info: &Option<SignerInfo>, | ||
| 66 | password: &Option<String>, | ||
| 67 | source: &Option<SignerInfoSource>, | ||
| 68 | ) -> Result<(SignerInfo, SignerInfoSource)> { | ||
| 69 | Ok(match source { | ||
| 70 | None => { | ||
| 71 | let mut result = None; | ||
| 72 | for source in &[ | ||
| 73 | SignerInfoSource::CommandLineArguments, | ||
| 74 | SignerInfoSource::GitLocal, | ||
| 75 | SignerInfoSource::GitGlobal, | ||
| 76 | ] { | ||
| 77 | if let Ok(res) = | ||
| 78 | get_signer_info(git_repo, signer_info, password, &Some(source.clone())) | ||
| 79 | { | ||
| 80 | result = Some(res); | ||
| 81 | break; | ||
| 82 | } | ||
| 83 | } | ||
| 84 | result.context("cannot get or find signer info in cli arguments, local git config or global git config")? | ||
| 85 | } | ||
| 86 | Some(SignerInfoSource::CommandLineArguments) => { | ||
| 87 | if let Some(signer_info) = signer_info { | ||
| 88 | (signer_info.clone(), SignerInfoSource::CommandLineArguments) | ||
| 89 | } else { | ||
| 90 | bail!("cannot get signer from cli signer arguments because none were specified") | ||
| 91 | } | ||
| 92 | } | ||
| 93 | Some(SignerInfoSource::GitLocal) => { | ||
| 94 | let git_repo = | ||
| 95 | git_repo.context("failed to get local git config as no git_repo supplied")?; | ||
| 96 | if let Ok(nsec) = get_git_config_item(&Some(git_repo), "nostr.nsec") | ||
| 97 | .context("failed get local git config")? | ||
| 98 | .context("git local config item nostr.nsec doesn't exist") | ||
| 99 | { | ||
| 100 | ( | ||
| 101 | SignerInfo::Nsec { | ||
| 102 | nsec: nsec.to_string(), | ||
| 103 | password: password.clone(), | ||
| 104 | npub: get_git_config_item(&Some(git_repo), "nostr.npub") | ||
| 105 | .context("failed get local git config")?, | ||
| 106 | }, | ||
| 107 | SignerInfoSource::GitLocal, | ||
| 108 | ) | ||
| 109 | } else if let Ok(bunker_uri) = get_git_config_item(&Some(git_repo), "nostr.bunker-uri") | ||
| 110 | .context("failed get local git config")? | ||
| 111 | .context("git local config item nostr.bunker-uri doesn't exist") | ||
| 112 | { | ||
| 113 | (SignerInfo::Bunker { | ||
| 114 | bunker_uri, bunker_app_key: get_git_config_item(&Some(git_repo), "nostr.bunker-app-key") | ||
| 115 | .context("failed get local git config")? | ||
| 116 | .context("git local config item nostr.bunker-uri exists but nostr.bunker-app-key doesn't")?, | ||
| 117 | npub: get_git_config_item(&Some(git_repo), "nostr.npub") | ||
| 118 | .context("failed get local git config")?, | ||
| 119 | }, SignerInfoSource::GitLocal) | ||
| 120 | } else { | ||
| 121 | bail!("no signer info in local git config") | ||
| 122 | } | ||
| 123 | } | ||
| 124 | Some(SignerInfoSource::GitGlobal) => { | ||
| 125 | if let Some(nsec) = get_git_config_item(&None, "nostr.nsec") | ||
| 126 | .context("failed to get global git config")? | ||
| 127 | { | ||
| 128 | ( | ||
| 129 | SignerInfo::Nsec { | ||
| 130 | nsec: nsec.to_string(), | ||
| 131 | password: password.clone(), | ||
| 132 | npub: get_git_config_item(&None, "nostr.npub") | ||
| 133 | .context("failed to get global git config")?, | ||
| 134 | }, | ||
| 135 | SignerInfoSource::GitGlobal, | ||
| 136 | ) | ||
| 137 | } else if let Some(bunker_uri) = get_git_config_item(&None, "nostr.bunker-uri") | ||
| 138 | .context("failed to get global git config")? | ||
| 139 | { | ||
| 140 | (SignerInfo::Bunker { | ||
| 141 | bunker_uri, bunker_app_key: get_git_config_item(&None, "nostr.bunker-app-key") | ||
| 142 | .context("failed get local git config")? | ||
| 143 | .context("git global config item nostr.bunker-uri exists but nostr.bunker-app-key doesn't")?, | ||
| 144 | npub: get_git_config_item(&None, "nostr.npub") | ||
| 145 | .context("failed get global git config")?, | ||
| 146 | }, SignerInfoSource::GitGlobal) | ||
| 147 | } else { | ||
| 148 | bail!("no signer info in global git config") | ||
| 149 | } | ||
| 150 | } | ||
| 151 | }) | ||
| 152 | } | ||
| 153 | |||
| 154 | async fn get_signer( | ||
| 155 | signer_info: &SignerInfo, | ||
| 156 | prompt_for_ncryptsec_password: bool, | ||
| 157 | ) -> Result<(Arc<dyn NostrSigner>, PublicKey)> { | ||
| 158 | match signer_info { | ||
| 159 | SignerInfo::Nsec { | ||
| 160 | nsec, | ||
| 161 | password, | ||
| 162 | npub: _, | ||
| 163 | } => { | ||
| 164 | let keys = if nsec.contains("ncryptsec") { | ||
| 165 | // TODO get user details from npub | ||
| 166 | // TODO add retry loop | ||
| 167 | // TODO in retry loop give option to login again | ||
| 168 | let password = if let Some(password) = password { | ||
| 169 | password.clone() | ||
| 170 | } else { | ||
| 171 | if !prompt_for_ncryptsec_password { | ||
| 172 | bail!("cannot login without prompts a nsec is encrypted with a password"); | ||
| 173 | } | ||
| 174 | Interactor::default() | ||
| 175 | .password(PromptPasswordParms::default().with_prompt("password")) | ||
| 176 | .context("failed to get password input from interactor.password")? | ||
| 177 | }; | ||
| 178 | decrypt_key(nsec, password.clone().as_str()) | ||
| 179 | .context("failed to decrypt key with provided password") | ||
| 180 | .context("failed to decrypt ncryptsec supplied as nsec with password")? | ||
| 181 | } else { | ||
| 182 | nostr::Keys::from_str(nsec).context("invalid nsec parameter")? | ||
| 183 | }; | ||
| 184 | let public_key = keys.public_key(); | ||
| 185 | Ok((Arc::new(keys), public_key)) | ||
| 186 | } | ||
| 187 | SignerInfo::Bunker { | ||
| 188 | bunker_uri, | ||
| 189 | bunker_app_key, | ||
| 190 | npub, | ||
| 191 | } => { | ||
| 192 | let term = console::Term::stderr(); | ||
| 193 | term.write_line("connecting to remote signer...")?; | ||
| 194 | let uri = NostrConnectURI::parse(bunker_uri)?; | ||
| 195 | let signer: Arc<dyn NostrSigner> = Arc::new(NostrConnect::new( | ||
| 196 | uri, | ||
| 197 | nostr::Keys::from_str(bunker_app_key).context("invalid app key")?, | ||
| 198 | Duration::from_secs(10 * 60), | ||
| 199 | None, | ||
| 200 | )?); | ||
| 201 | term.clear_last_lines(1)?; | ||
| 202 | let public_key = if let Some(pubic_key) = | ||
| 203 | npub.clone().and_then(|npub| PublicKey::parse(npub).ok()) | ||
| 204 | { | ||
| 205 | pubic_key | ||
| 206 | } else { | ||
| 207 | fetch_public_key(&signer).await? | ||
| 208 | }; | ||
| 209 | Ok((signer, public_key)) | ||
| 210 | } | ||
| 211 | } | ||
| 212 | } | ||
diff --git a/src/lib/login/fresh.rs b/src/lib/login/fresh.rs new file mode 100644 index 0000000..3e88f68 --- /dev/null +++ b/src/lib/login/fresh.rs | |||
| @@ -0,0 +1,595 @@ | |||
| 1 | use std::{str::FromStr, sync::Arc, time::Duration}; | ||
| 2 | |||
| 3 | use anyhow::{bail, Context, Result}; | ||
| 4 | use console::{Style, Term}; | ||
| 5 | use dialoguer::theme::{ColorfulTheme, Theme}; | ||
| 6 | use nostr::nips::{nip05, nip46::NostrConnectURI}; | ||
| 7 | use nostr_connect::client::NostrConnect; | ||
| 8 | use nostr_sdk::{Keys, NostrSigner, PublicKey, ToBech32, Url}; | ||
| 9 | use qrcode::QrCode; | ||
| 10 | use tokio::sync::{oneshot, Mutex}; | ||
| 11 | |||
| 12 | use super::{ | ||
| 13 | key_encryption::decrypt_key, | ||
| 14 | print_logged_in_as, | ||
| 15 | user::{get_user_details, UserRef}, | ||
| 16 | SignerInfo, SignerInfoSource, | ||
| 17 | }; | ||
| 18 | #[cfg(not(test))] | ||
| 19 | use crate::client::Client; | ||
| 20 | #[cfg(test)] | ||
| 21 | use crate::client::MockConnect; | ||
| 22 | use crate::{ | ||
| 23 | cli_interactor::{ | ||
| 24 | Interactor, InteractorPrompt, Printer, PromptChoiceParms, PromptConfirmParms, | ||
| 25 | PromptInputParms, PromptPasswordParms, | ||
| 26 | }, | ||
| 27 | client::Connect, | ||
| 28 | git::{remove_git_config_item, save_git_config_item, Repo, RepoActions}, | ||
| 29 | }; | ||
| 30 | |||
| 31 | pub async fn fresh_login_or_signup( | ||
| 32 | git_repo: &Option<&Repo>, | ||
| 33 | #[cfg(test)] client: Option<&MockConnect>, | ||
| 34 | #[cfg(not(test))] client: Option<&Client>, | ||
| 35 | save_local: bool, | ||
| 36 | ) -> Result<(Arc<dyn NostrSigner>, UserRef, SignerInfoSource)> { | ||
| 37 | let (signer, public_key, signer_info, source) = loop { | ||
| 38 | match Interactor::default().choice( | ||
| 39 | PromptChoiceParms::default() | ||
| 40 | .with_prompt("login to nostr") | ||
| 41 | .with_default(0) | ||
| 42 | .with_choices(vec![ | ||
| 43 | "secret key (nsec / ncryptsec)".to_string(), | ||
| 44 | "nostr connect (remote signer)".to_string(), | ||
| 45 | "create account".to_string(), | ||
| 46 | "help".to_string(), | ||
| 47 | ]) | ||
| 48 | .dont_report(), | ||
| 49 | )? { | ||
| 50 | 0 => match get_fresh_nsec_signer().await { | ||
| 51 | Ok(Some(res)) => break res, | ||
| 52 | Ok(None) => continue, | ||
| 53 | Err(e) => { | ||
| 54 | eprintln!("error getting fresh signer from nsec: {e}"); | ||
| 55 | continue; | ||
| 56 | } | ||
| 57 | }, | ||
| 58 | 1 => match get_fresh_nip46_signer(client).await { | ||
| 59 | Ok(Some(res)) => break res, | ||
| 60 | Ok(None) => continue, | ||
| 61 | Err(e) => { | ||
| 62 | eprintln!("error getting fresh nip46 signer: {e}"); | ||
| 63 | continue; | ||
| 64 | } | ||
| 65 | }, | ||
| 66 | 2 => { | ||
| 67 | eprintln!("TODO create account..."); | ||
| 68 | continue; | ||
| 69 | } | ||
| 70 | _ => { | ||
| 71 | display_login_help_content(); | ||
| 72 | continue; | ||
| 73 | } | ||
| 74 | } | ||
| 75 | }; | ||
| 76 | let _ = save_to_git_config(git_repo, &signer_info, !save_local); | ||
| 77 | let user_ref = get_user_details( | ||
| 78 | &public_key, | ||
| 79 | client, | ||
| 80 | if let Some(git_repo) = git_repo { | ||
| 81 | Some(git_repo.get_path()?) | ||
| 82 | } else { | ||
| 83 | None | ||
| 84 | }, | ||
| 85 | false, | ||
| 86 | ) | ||
| 87 | .await?; | ||
| 88 | print_logged_in_as(&user_ref, client.is_none(), &source)?; | ||
| 89 | Ok((signer, user_ref, source)) | ||
| 90 | } | ||
| 91 | |||
| 92 | pub async fn get_fresh_nsec_signer() -> Result< | ||
| 93 | Option<( | ||
| 94 | Arc<dyn NostrSigner>, | ||
| 95 | PublicKey, | ||
| 96 | SignerInfo, | ||
| 97 | SignerInfoSource, | ||
| 98 | )>, | ||
| 99 | > { | ||
| 100 | loop { | ||
| 101 | let input = Interactor::default() | ||
| 102 | .input( | ||
| 103 | PromptInputParms::default() | ||
| 104 | .with_prompt("nsec") | ||
| 105 | .optional() | ||
| 106 | .dont_report(), | ||
| 107 | ) | ||
| 108 | .context("failed to get nsec input from interactor")?; | ||
| 109 | let (keys, signer_info) = if input.contains("ncryptsec") { | ||
| 110 | let password = Interactor::default() | ||
| 111 | .password( | ||
| 112 | PromptPasswordParms::default() | ||
| 113 | .with_prompt("password") | ||
| 114 | .dont_report(), | ||
| 115 | ) | ||
| 116 | .context("failed to get password input from interactor.password")?; | ||
| 117 | let keys = if let Ok(keys) = decrypt_key(&input, password.clone().as_str()) | ||
| 118 | .context("failed to decrypt ncryptsec with provided password") | ||
| 119 | { | ||
| 120 | keys | ||
| 121 | } else { | ||
| 122 | show_prompt_error( | ||
| 123 | "invalid ncryptsec and password combination", | ||
| 124 | &shorten_string(&input), | ||
| 125 | ); | ||
| 126 | match Interactor::default().choice( | ||
| 127 | PromptChoiceParms::default() | ||
| 128 | .with_default(0) | ||
| 129 | .with_choices(vec!["try again with nsec".to_string(), "back".to_string()]) | ||
| 130 | .dont_report(), | ||
| 131 | )? { | ||
| 132 | 0 => continue, | ||
| 133 | _ => break Ok(None), | ||
| 134 | } | ||
| 135 | }; | ||
| 136 | let npub = Some(keys.public_key().to_bech32()?); | ||
| 137 | let signer_info = if Interactor::default() | ||
| 138 | .confirm(PromptConfirmParms::default().with_prompt("remember details?"))? | ||
| 139 | || !Interactor::default().confirm(PromptConfirmParms::default().with_prompt( | ||
| 140 | "you will be prompted for password to decrypt your ncryptsec at every git push. are you sure?", | ||
| 141 | ))? { | ||
| 142 | SignerInfo::Nsec { | ||
| 143 | nsec: keys.secret_key().to_bech32()?, | ||
| 144 | password: None, | ||
| 145 | npub, | ||
| 146 | } | ||
| 147 | } else { | ||
| 148 | show_prompt_success("nsec", &shorten_string(&input)); | ||
| 149 | SignerInfo::Nsec { | ||
| 150 | nsec: input, | ||
| 151 | password: Some(password), | ||
| 152 | npub, | ||
| 153 | } | ||
| 154 | }; | ||
| 155 | (keys, signer_info) | ||
| 156 | } else if let Ok(keys) = nostr::Keys::from_str(&input) { | ||
| 157 | let nsec = keys.secret_key().to_bech32()?; | ||
| 158 | show_prompt_success("nsec", &shorten_string(&nsec)); | ||
| 159 | let signer_info = SignerInfo::Nsec { | ||
| 160 | nsec, | ||
| 161 | password: None, | ||
| 162 | npub: Some(keys.public_key().to_bech32()?), | ||
| 163 | }; | ||
| 164 | (keys, signer_info) | ||
| 165 | } else { | ||
| 166 | show_prompt_error("invalid nsec", &shorten_string(&input)); | ||
| 167 | match Interactor::default().choice( | ||
| 168 | PromptChoiceParms::default() | ||
| 169 | .with_default(0) | ||
| 170 | .with_choices(vec!["try again with nsec".to_string(), "back".to_string()]) | ||
| 171 | .dont_report(), | ||
| 172 | )? { | ||
| 173 | 0 => continue, | ||
| 174 | _ => break Ok(None), | ||
| 175 | } | ||
| 176 | }; | ||
| 177 | |||
| 178 | let public_key = keys.public_key(); | ||
| 179 | |||
| 180 | break Ok(Some(( | ||
| 181 | Arc::new(keys), | ||
| 182 | public_key, | ||
| 183 | signer_info, | ||
| 184 | // TODO factor in source | ||
| 185 | SignerInfoSource::GitGlobal, | ||
| 186 | ))); | ||
| 187 | } | ||
| 188 | } | ||
| 189 | |||
| 190 | fn show_prompt_success(label: &str, value: &str) { | ||
| 191 | eprintln!("{}", { | ||
| 192 | let mut s = String::new(); | ||
| 193 | let _ = ColorfulTheme::default().format_input_prompt_selection(&mut s, label, value); | ||
| 194 | s | ||
| 195 | }); | ||
| 196 | } | ||
| 197 | |||
| 198 | fn show_prompt_error(label: &str, value: &str) { | ||
| 199 | eprintln!("{}", { | ||
| 200 | let mut s = String::new(); | ||
| 201 | let _ = ColorfulTheme::default().format_error( | ||
| 202 | &mut s, | ||
| 203 | &format!( | ||
| 204 | "{label}: {}", | ||
| 205 | if value.is_empty() { | ||
| 206 | "empty".to_string() | ||
| 207 | } else { | ||
| 208 | shorten_string(&format!("\"{}\"", &value)) | ||
| 209 | } | ||
| 210 | ), | ||
| 211 | ); | ||
| 212 | s | ||
| 213 | }); | ||
| 214 | } | ||
| 215 | |||
| 216 | fn shorten_string(s: &str) -> String { | ||
| 217 | if s.len() < 15 { | ||
| 218 | s.to_string() | ||
| 219 | } else { | ||
| 220 | format!("{}...", &s[..15]) | ||
| 221 | } | ||
| 222 | } | ||
| 223 | |||
| 224 | pub async fn get_fresh_nip46_signer( | ||
| 225 | #[cfg(test)] client: Option<&MockConnect>, | ||
| 226 | #[cfg(not(test))] client: Option<&Client>, | ||
| 227 | ) -> Result< | ||
| 228 | Option<( | ||
| 229 | Arc<dyn NostrSigner>, | ||
| 230 | PublicKey, | ||
| 231 | SignerInfo, | ||
| 232 | SignerInfoSource, | ||
| 233 | )>, | ||
| 234 | > { | ||
| 235 | let (app_key, nostr_connect_url) = generate_nostr_connect_app(client)?; | ||
| 236 | let printer = Arc::new(Mutex::new(Printer::default())); | ||
| 237 | let signer_choice = Interactor::default().choice( | ||
| 238 | PromptChoiceParms::default() | ||
| 239 | .with_prompt("login to nostr with remote signer") | ||
| 240 | .with_default(0) | ||
| 241 | .with_choices(vec![ | ||
| 242 | "show QR code to scan in signer app".to_string(), | ||
| 243 | "show nostrconnect:// url to paste into signer".to_string(), | ||
| 244 | "use NIP-05 address to connect to signer".to_string(), | ||
| 245 | "paste in bunker:// url from signer app".to_string(), | ||
| 246 | "back".to_string(), | ||
| 247 | ]) | ||
| 248 | .dont_report(), | ||
| 249 | )?; | ||
| 250 | let url = match signer_choice { | ||
| 251 | 0 | 1 => nostr_connect_url, | ||
| 252 | 2 => { | ||
| 253 | let mut error = None; | ||
| 254 | loop { | ||
| 255 | let input = Interactor::default() | ||
| 256 | .input( | ||
| 257 | PromptInputParms::default().with_prompt(if let Some(error) = error { | ||
| 258 | format!("error: {}. try again with NIP-05 address", error) | ||
| 259 | } else { | ||
| 260 | "NIP-05 address".to_string() | ||
| 261 | }), | ||
| 262 | ) | ||
| 263 | .context("failed to get NIP-05 address input from interactor")?; | ||
| 264 | match fetch_nip46_uri_from_nip05(&input).await { | ||
| 265 | Ok(url) => break url, | ||
| 266 | Err(e) => error = Some(e), | ||
| 267 | } | ||
| 268 | } | ||
| 269 | } | ||
| 270 | 3 => { | ||
| 271 | let mut error = None; | ||
| 272 | loop { | ||
| 273 | let input = Interactor::default() | ||
| 274 | .input( | ||
| 275 | PromptInputParms::default().with_prompt(if let Some(error) = error { | ||
| 276 | format!("error: {}. try again with bunker url", error) | ||
| 277 | } else { | ||
| 278 | "bunker url".to_string() | ||
| 279 | }), | ||
| 280 | ) | ||
| 281 | .context("failed to get bunker url input from interactor")?; | ||
| 282 | match NostrConnectURI::parse(&input) { | ||
| 283 | Ok(url) => break url, | ||
| 284 | Err(e) => error = Some(e), | ||
| 285 | } | ||
| 286 | } | ||
| 287 | } | ||
| 288 | _ => return Ok(None), | ||
| 289 | }; | ||
| 290 | |||
| 291 | { | ||
| 292 | let printer_clone = Arc::clone(&printer); | ||
| 293 | let mut printer_locked = printer_clone.lock().await; | ||
| 294 | match signer_choice { | ||
| 295 | 0 => { | ||
| 296 | printer_locked | ||
| 297 | .println("login to nostr with remote signer via nostr connect".to_string()); | ||
| 298 | printer_locked.println("scan QR code in signer app (eg Amber):".to_string()); | ||
| 299 | printer_locked.printlns(generate_qr(&url.to_string())?); | ||
| 300 | printer_locked | ||
| 301 | .println("scan QR code in signer app or press any key to abort...".to_string()); | ||
| 302 | } | ||
| 303 | 1 => { | ||
| 304 | printer_locked | ||
| 305 | .println("login to nostr with remote signer via nostr connect".to_string()); | ||
| 306 | printer_locked.println("".to_string()); | ||
| 307 | printer_locked.println_with_custom_formatting( | ||
| 308 | format!("{}", Style::new().bold().apply_to(url.to_string()),), | ||
| 309 | url.to_string(), | ||
| 310 | ); | ||
| 311 | printer_locked.println("".to_string()); | ||
| 312 | printer_locked | ||
| 313 | .println("paste url into signer app or press any key to abort...".to_string()); | ||
| 314 | } | ||
| 315 | _ => { | ||
| 316 | printer_locked.println( | ||
| 317 | "add / approve in your signer or press any key to abort... ".to_string(), | ||
| 318 | ); | ||
| 319 | } | ||
| 320 | } | ||
| 321 | } | ||
| 322 | |||
| 323 | let (signer, user_public_key, bunker_url) = | ||
| 324 | listen_for_remote_signer(&app_key, &url, printer).await?; | ||
| 325 | let signer_info = SignerInfo::Bunker { | ||
| 326 | bunker_uri: bunker_url.to_string(), | ||
| 327 | bunker_app_key: app_key.secret_key().to_secret_hex(), | ||
| 328 | npub: Some(user_public_key.to_bech32()?), | ||
| 329 | }; | ||
| 330 | Ok(Some(( | ||
| 331 | signer, | ||
| 332 | user_public_key, | ||
| 333 | signer_info, | ||
| 334 | SignerInfoSource::GitGlobal, | ||
| 335 | ))) | ||
| 336 | } | ||
| 337 | |||
| 338 | pub fn generate_nostr_connect_app( | ||
| 339 | #[cfg(test)] client: Option<&MockConnect>, | ||
| 340 | #[cfg(not(test))] client: Option<&Client>, | ||
| 341 | ) -> Result<(Keys, NostrConnectURI)> { | ||
| 342 | let app_key = Keys::generate(); | ||
| 343 | let relays = if let Some(client) = client { | ||
| 344 | client | ||
| 345 | .get_fallback_signer_relays() | ||
| 346 | .iter() | ||
| 347 | .flat_map(|s| Url::parse(s)) | ||
| 348 | .collect::<Vec<Url>>() | ||
| 349 | } else { | ||
| 350 | vec![] | ||
| 351 | }; | ||
| 352 | let nostr_connect_url = NostrConnectURI::client(app_key.public_key(), relays.clone(), "ngit"); | ||
| 353 | Ok((app_key, nostr_connect_url)) | ||
| 354 | } | ||
| 355 | |||
| 356 | pub async fn fetch_nip46_uri_from_nip05(nip05: &str) -> Result<NostrConnectURI> { | ||
| 357 | let term = console::Term::stderr(); | ||
| 358 | term.write_line("contacting login service provider...")?; | ||
| 359 | let res = nip05::profile(&nip05, None).await; | ||
| 360 | term.clear_last_lines(1)?; | ||
| 361 | match res { | ||
| 362 | Ok(profile) => { | ||
| 363 | if profile.nip46.is_empty() { | ||
| 364 | eprintln!("nip05 provider isn't configured for remote login"); | ||
| 365 | bail!("nip05 provider isn't configured for remote login") | ||
| 366 | } | ||
| 367 | Ok(NostrConnectURI::Bunker { | ||
| 368 | remote_signer_public_key: profile.public_key, | ||
| 369 | relays: profile.nip46, | ||
| 370 | secret: None, | ||
| 371 | }) | ||
| 372 | } | ||
| 373 | Err(error) => { | ||
| 374 | eprintln!("error contacting login service provider: {error}"); | ||
| 375 | Err(error).context("error contacting login service provider") | ||
| 376 | } | ||
| 377 | } | ||
| 378 | } | ||
| 379 | |||
| 380 | pub async fn listen_for_remote_signer( | ||
| 381 | app_key: &Keys, | ||
| 382 | nostr_connect_url: &NostrConnectURI, | ||
| 383 | printer: Arc<Mutex<Printer>>, | ||
| 384 | ) -> Result<(Arc<dyn NostrSigner>, PublicKey, NostrConnectURI)> { | ||
| 385 | let (tx, rx) = oneshot::channel(); | ||
| 386 | let printer_clone = Arc::clone(&printer); | ||
| 387 | let app_key = app_key.clone(); | ||
| 388 | let nostr_connect_url_clone = nostr_connect_url.clone(); | ||
| 389 | let qr_listener = tokio::spawn(async move { | ||
| 390 | if let Ok(nostr_connect) = NostrConnect::new( | ||
| 391 | nostr_connect_url_clone, | ||
| 392 | app_key, | ||
| 393 | Duration::from_secs(10 * 60), | ||
| 394 | None, | ||
| 395 | ) { | ||
| 396 | let signer: Arc<dyn NostrSigner> = Arc::new(nostr_connect); | ||
| 397 | if let Ok(pub_key) = signer.get_public_key().await { | ||
| 398 | let mut printer_locked = printer_clone.lock().await; | ||
| 399 | printer_locked.clear_all(); | ||
| 400 | |||
| 401 | printer_locked.println_with_custom_formatting( | ||
| 402 | format!( | ||
| 403 | "{}", | ||
| 404 | Style::new().bold().apply_to("connected to remote signer"), | ||
| 405 | ), | ||
| 406 | "connected to remote signer".to_string(), | ||
| 407 | ); | ||
| 408 | printer_locked.println("press any key to continue...".to_string()); | ||
| 409 | let _ = tx.send(Some((signer, pub_key))); | ||
| 410 | } else { | ||
| 411 | let _ = tx.send(None); | ||
| 412 | } | ||
| 413 | } | ||
| 414 | }); | ||
| 415 | let _ = console::Term::stderr().read_char(); | ||
| 416 | qr_listener.abort(); | ||
| 417 | let printer_clone = Arc::clone(&printer); | ||
| 418 | let mut printer = printer_clone.lock().await; | ||
| 419 | printer.clear_all(); | ||
| 420 | |||
| 421 | if let Some((signer, public_key)) = rx.await? { | ||
| 422 | let bunker_url = NostrConnectURI::Bunker { | ||
| 423 | // TODO the remote signer pubkey may not be the user pubkey | ||
| 424 | remote_signer_public_key: public_key, | ||
| 425 | relays: nostr_connect_url.relays(), | ||
| 426 | secret: nostr_connect_url.secret(), | ||
| 427 | }; | ||
| 428 | Ok((signer, public_key, bunker_url)) | ||
| 429 | } else { | ||
| 430 | bail!("failed to get signer") | ||
| 431 | } | ||
| 432 | } | ||
| 433 | |||
| 434 | fn generate_qr(data: &str) -> Result<Vec<String>> { | ||
| 435 | let mut lines = vec![]; | ||
| 436 | let qr = | ||
| 437 | QrCode::new(data.as_bytes()).context("failed to create QR of nostrconnect login url")?; | ||
| 438 | let colors = qr.to_colors(); | ||
| 439 | let rows: Vec<&[qrcode::Color]> = colors.chunks(qr.width()).collect(); | ||
| 440 | for (row, data) in rows.iter().enumerate() { | ||
| 441 | let odd = row % 2 != 0; | ||
| 442 | if odd { | ||
| 443 | continue; | ||
| 444 | } | ||
| 445 | let mut line = String::new(); | ||
| 446 | for (col, color) in data.iter().enumerate() { | ||
| 447 | let top = color; | ||
| 448 | let mut bottom = qrcode::Color::Light; | ||
| 449 | if let Some(next_row_data) = rows.get(row + 1) { | ||
| 450 | if let Some(color) = next_row_data.get(col) { | ||
| 451 | bottom = *color; | ||
| 452 | } | ||
| 453 | } | ||
| 454 | line.push(if *top == qrcode::Color::Dark { | ||
| 455 | if bottom == qrcode::Color::Dark { | ||
| 456 | '█' | ||
| 457 | } else { | ||
| 458 | '▀' | ||
| 459 | } | ||
| 460 | } else if bottom == qrcode::Color::Dark { | ||
| 461 | '▄' | ||
| 462 | } else { | ||
| 463 | ' ' | ||
| 464 | }); | ||
| 465 | } | ||
| 466 | lines.push(line); | ||
| 467 | } | ||
| 468 | Ok(lines) | ||
| 469 | } | ||
| 470 | |||
| 471 | fn save_to_git_config( | ||
| 472 | git_repo: &Option<&Repo>, | ||
| 473 | signer_info: &SignerInfo, | ||
| 474 | global: bool, | ||
| 475 | ) -> Result<()> { | ||
| 476 | if let Err(error) = silently_save_to_git_config(git_repo, signer_info, global).context(format!( | ||
| 477 | "failed to save login details to {} git config", | ||
| 478 | if global { "global" } else { "local" } | ||
| 479 | )) { | ||
| 480 | eprintln!("Error: {:?}", error); | ||
| 481 | match signer_info { | ||
| 482 | SignerInfo::Nsec { | ||
| 483 | nsec, | ||
| 484 | password: _, | ||
| 485 | npub: _, | ||
| 486 | } => { | ||
| 487 | if nsec.contains("ncryptsec") { | ||
| 488 | eprintln!("consider manually setting git config nostr.nsec to: {nsec}"); | ||
| 489 | } else { | ||
| 490 | eprintln!("consider manually setting git config nostr.nsec"); | ||
| 491 | } | ||
| 492 | } | ||
| 493 | SignerInfo::Bunker { | ||
| 494 | bunker_uri, | ||
| 495 | bunker_app_key, | ||
| 496 | npub: _, | ||
| 497 | } => { | ||
| 498 | eprintln!("consider manually setting git config as follows:"); | ||
| 499 | eprintln!("nostr.bunker-uri: {bunker_uri}"); | ||
| 500 | eprintln!("nostr.bunker-app-key: {bunker_app_key}"); | ||
| 501 | } | ||
| 502 | } | ||
| 503 | if global { | ||
| 504 | save_to_git_config(git_repo, signer_info, false)? | ||
| 505 | } | ||
| 506 | Err(error) | ||
| 507 | } else { | ||
| 508 | eprintln!( | ||
| 509 | "{}", | ||
| 510 | if global { | ||
| 511 | "saved login details to global git config" | ||
| 512 | } else { | ||
| 513 | "saved login details to local git config. you are only logged in to this local repository." | ||
| 514 | } | ||
| 515 | ); | ||
| 516 | Ok(()) | ||
| 517 | } | ||
| 518 | } | ||
| 519 | |||
| 520 | fn silently_save_to_git_config( | ||
| 521 | git_repo: &Option<&Repo>, | ||
| 522 | signer_info: &SignerInfo, | ||
| 523 | global: bool, | ||
| 524 | ) -> Result<()> { | ||
| 525 | if global { | ||
| 526 | // remove local login otherwise it will override global next time ngit is called | ||
| 527 | if let Some(git_repo) = git_repo { | ||
| 528 | git_repo.remove_git_config_item("nostr.npub", false)?; | ||
| 529 | git_repo.remove_git_config_item("nostr.nsec", false)?; | ||
| 530 | git_repo.remove_git_config_item("nostr.bunker-uri", false)?; | ||
| 531 | git_repo.remove_git_config_item("nostr.bunker-app-key", false)?; | ||
| 532 | } | ||
| 533 | } | ||
| 534 | |||
| 535 | let git_repo = if global { | ||
| 536 | &None | ||
| 537 | } else if git_repo.is_none() { | ||
| 538 | bail!("cannot update local git config wihout git_repo object") | ||
| 539 | } else { | ||
| 540 | git_repo | ||
| 541 | }; | ||
| 542 | |||
| 543 | let npub_to_save; | ||
| 544 | match signer_info { | ||
| 545 | SignerInfo::Nsec { | ||
| 546 | nsec, | ||
| 547 | password: _, | ||
| 548 | npub, | ||
| 549 | } => { | ||
| 550 | npub_to_save = npub; | ||
| 551 | save_git_config_item(git_repo, "nostr.nsec", nsec)?; | ||
| 552 | remove_git_config_item(git_repo, "nostr.bunker-uri")?; | ||
| 553 | remove_git_config_item(git_repo, "nostr.bunker-app-key")?; | ||
| 554 | } | ||
| 555 | SignerInfo::Bunker { | ||
| 556 | bunker_uri, | ||
| 557 | bunker_app_key, | ||
| 558 | npub, | ||
| 559 | } => { | ||
| 560 | npub_to_save = npub; | ||
| 561 | remove_git_config_item(git_repo, "nostr.nsec")?; | ||
| 562 | save_git_config_item(git_repo, "nostr.bunker-uri", bunker_uri)?; | ||
| 563 | save_git_config_item(git_repo, "nostr.bunker-app-key", bunker_app_key)?; | ||
| 564 | } | ||
| 565 | } | ||
| 566 | if let Some(npub) = npub_to_save { | ||
| 567 | save_git_config_item(git_repo, "nostr.npub", npub)?; | ||
| 568 | } else { | ||
| 569 | remove_git_config_item(git_repo, "nostr.npub")?; | ||
| 570 | } | ||
| 571 | Ok(()) | ||
| 572 | } | ||
| 573 | |||
| 574 | fn display_login_help_content() { | ||
| 575 | let mut printer = Printer::default(); | ||
| 576 | let title_style = Style::new().bold().fg(console::Color::Yellow); | ||
| 577 | printer.println("|==============================|".to_owned()); | ||
| 578 | // printer.println("| |".to_owned()); | ||
| 579 | printer.println_with_custom_formatting( | ||
| 580 | format!( | ||
| 581 | "| {} |", | ||
| 582 | title_style.apply_to("nostr login / sign up help") | ||
| 583 | ), | ||
| 584 | "| nostr login / sign up help |".to_string(), | ||
| 585 | ); | ||
| 586 | // printer.println("| |".to_owned()); | ||
| 587 | printer.println("|==============================|".to_owned()); | ||
| 588 | printer.printlns(vec![ | ||
| 589 | "".to_string(), | ||
| 590 | "login / sign up help content should go here...".to_string(), | ||
| 591 | "press any key to see the login / signup menu again...".to_string(), | ||
| 592 | ]); | ||
| 593 | let _ = Term::stdout().read_char(); | ||
| 594 | printer.clear_all(); | ||
| 595 | } | ||
diff --git a/src/lib/login/key_encryption.rs b/src/lib/login/key_encryption.rs index b50b507..efb38d1 100644 --- a/src/lib/login/key_encryption.rs +++ b/src/lib/login/key_encryption.rs | |||
| @@ -1,23 +1,5 @@ | |||
| 1 | use anyhow::Result; | 1 | use anyhow::Result; |
| 2 | use nostr::{prelude::*, Keys}; | 2 | use nostr::prelude::*; |
| 3 | |||
| 4 | pub fn encrypt_key(keys: &Keys, password: &str) -> Result<String> { | ||
| 5 | let log2_rounds: u8 = if password.len() > 20 { | ||
| 6 | // we have enough of entropy - no need to spend CPU time adding much more | ||
| 7 | 1 | ||
| 8 | } else { | ||
| 9 | println!("this may take a few seconds..."); | ||
| 10 | // default (scrypt::Params::RECOMMENDED_LOG_N) is 17 but 30s is too long to wait | ||
| 11 | 15 | ||
| 12 | }; | ||
| 13 | Ok(nostr::nips::nip49::EncryptedSecretKey::new( | ||
| 14 | keys.secret_key(), | ||
| 15 | password, | ||
| 16 | log2_rounds, | ||
| 17 | KeySecurity::Medium, | ||
| 18 | )? | ||
| 19 | .to_bech32()?) | ||
| 20 | } | ||
| 21 | 3 | ||
| 22 | pub fn decrypt_key(encrypted_key: &str, password: &str) -> Result<nostr::Keys> { | 4 | pub fn decrypt_key(encrypted_key: &str, password: &str) -> Result<nostr::Keys> { |
| 23 | let encrypted_key = nostr::nips::nip49::EncryptedSecretKey::from_bech32(encrypted_key)?; | 5 | let encrypted_key = nostr::nips::nip49::EncryptedSecretKey::from_bech32(encrypted_key)?; |
| @@ -34,6 +16,24 @@ mod tests { | |||
| 34 | 16 | ||
| 35 | use super::*; | 17 | use super::*; |
| 36 | 18 | ||
| 19 | pub fn encrypt_key(keys: &Keys, password: &str) -> Result<String> { | ||
| 20 | let log2_rounds: u8 = if password.len() > 20 { | ||
| 21 | // we have enough of entropy - no need to spend CPU time adding much more | ||
| 22 | 1 | ||
| 23 | } else { | ||
| 24 | println!("this may take a few seconds..."); | ||
| 25 | // default (scrypt::Params::RECOMMENDED_LOG_N) is 17 but 30s is too long to wait | ||
| 26 | 15 | ||
| 27 | }; | ||
| 28 | Ok(nostr::nips::nip49::EncryptedSecretKey::new( | ||
| 29 | keys.secret_key(), | ||
| 30 | password, | ||
| 31 | log2_rounds, | ||
| 32 | KeySecurity::Medium, | ||
| 33 | )? | ||
| 34 | .to_bech32()?) | ||
| 35 | } | ||
| 36 | |||
| 37 | #[test] | 37 | #[test] |
| 38 | fn encrypt_key_produces_string_prefixed_with() -> Result<()> { | 38 | fn encrypt_key_produces_string_prefixed_with() -> Result<()> { |
| 39 | let s = encrypt_key(&nostr::Keys::generate(), TEST_PASSWORD)?; | 39 | let s = encrypt_key(&nostr::Keys::generate(), TEST_PASSWORD)?; |
diff --git a/src/lib/login/mod.rs b/src/lib/login/mod.rs index 0e00170..b45bc1d 100644 --- a/src/lib/login/mod.rs +++ b/src/lib/login/mod.rs | |||
| @@ -1,129 +1,65 @@ | |||
| 1 | use std::{collections::HashSet, path::Path, str::FromStr, sync::Arc, time::Duration}; | 1 | use std::{path::Path, sync::Arc}; |
| 2 | 2 | ||
| 3 | use anyhow::{bail, Context, Result}; | 3 | use anyhow::Result; |
| 4 | use console::Style; | 4 | use fresh::fresh_login_or_signup; |
| 5 | use dialoguer::theme::{ColorfulTheme, Theme}; | 5 | use nostr::PublicKey; |
| 6 | use nostr::{ | 6 | use nostr_sdk::{NostrSigner, Timestamp, ToBech32}; |
| 7 | nips::{nip05, nip46::NostrConnectURI}, | ||
| 8 | PublicKey, | ||
| 9 | }; | ||
| 10 | use nostr_connect::client::NostrConnect; | ||
| 11 | use nostr_sdk::{ | ||
| 12 | Alphabet, FromBech32, JsonUtil, Keys, Kind, NostrSigner, SingleLetterTag, Timestamp, ToBech32, | ||
| 13 | Url, | ||
| 14 | }; | ||
| 15 | use qrcode::QrCode; | ||
| 16 | use tokio::sync::{oneshot, Mutex}; | ||
| 17 | 7 | ||
| 18 | #[cfg(not(test))] | 8 | #[cfg(not(test))] |
| 19 | use crate::client::Client; | 9 | use crate::client::Client; |
| 20 | #[cfg(test)] | 10 | #[cfg(test)] |
| 21 | use crate::client::MockConnect; | 11 | use crate::client::MockConnect; |
| 22 | use crate::{ | 12 | use crate::git::{Repo, RepoActions}; |
| 23 | cli_interactor::{ | ||
| 24 | Interactor, InteractorPrompt, Printer, PromptConfirmParms, PromptInputParms, | ||
| 25 | PromptPasswordParms, | ||
| 26 | }, | ||
| 27 | client::{fetch_public_key, get_event_from_global_cache, Connect}, | ||
| 28 | git::{Repo, RepoActions}, | ||
| 29 | }; | ||
| 30 | 13 | ||
| 14 | pub mod existing; | ||
| 31 | mod key_encryption; | 15 | mod key_encryption; |
| 32 | use key_encryption::{decrypt_key, encrypt_key}; | 16 | use existing::load_existing_login; |
| 33 | mod user; | 17 | pub mod user; |
| 34 | use user::{UserMetadata, UserRef, UserRelayRef, UserRelays}; | 18 | use user::UserRef; |
| 35 | 19 | pub mod fresh; | |
| 36 | /// handles the encrpytion and storage of key material | 20 | |
| 37 | #[allow(clippy::too_many_arguments)] | 21 | pub async fn login_or_signup( |
| 38 | pub async fn launch( | 22 | git_repo: &Option<&Repo>, |
| 39 | git_repo: &Repo, | 23 | signer_info: &Option<SignerInfo>, |
| 40 | bunker_uri: &Option<String>, | ||
| 41 | bunker_app_key: &Option<String>, | ||
| 42 | nsec: &Option<String>, | ||
| 43 | password: &Option<String>, | 24 | password: &Option<String>, |
| 44 | #[cfg(test)] client: Option<&MockConnect>, | 25 | #[cfg(test)] client: Option<&MockConnect>, |
| 45 | #[cfg(not(test))] client: Option<&Client>, | 26 | #[cfg(not(test))] client: Option<&Client>, |
| 46 | change_user: bool, | 27 | ) -> Result<(Arc<dyn NostrSigner>, UserRef, SignerInfoSource)> { |
| 47 | silent: bool, | 28 | let res = |
| 48 | ) -> Result<(Arc<dyn NostrSigner>, UserRef)> { | 29 | load_existing_login(git_repo, signer_info, password, &None, client, false, true).await; |
| 49 | if let Ok((signer, public_key)) = match get_signer_without_prompts( | 30 | if res.is_ok() { |
| 50 | git_repo, | 31 | res |
| 51 | bunker_uri, | ||
| 52 | bunker_app_key, | ||
| 53 | nsec, | ||
| 54 | password, | ||
| 55 | change_user, | ||
| 56 | ) | ||
| 57 | .await | ||
| 58 | { | ||
| 59 | Ok((signer, public_key)) => Ok((signer, public_key)), | ||
| 60 | Err(error) => { | ||
| 61 | if error | ||
| 62 | .to_string() | ||
| 63 | .eq("git config item nostr.nsec is an ncryptsec") | ||
| 64 | { | ||
| 65 | eprintln!( | ||
| 66 | "login as {}", | ||
| 67 | if let Ok(public_key) = PublicKey::from_bech32( | ||
| 68 | get_config_item(git_repo, "nostr.npub").unwrap_or("unknown".to_string()), | ||
| 69 | ) { | ||
| 70 | if let Ok(user_ref) = | ||
| 71 | get_user_details(&public_key, client, git_repo.get_path()?, silent) | ||
| 72 | .await | ||
| 73 | { | ||
| 74 | user_ref.metadata.name | ||
| 75 | } else { | ||
| 76 | "unknown ncryptsec".to_string() | ||
| 77 | } | ||
| 78 | } else { | ||
| 79 | "unknown ncryptsec".to_string() | ||
| 80 | } | ||
| 81 | ); | ||
| 82 | loop { | ||
| 83 | // prompt for password | ||
| 84 | let password = Interactor::default() | ||
| 85 | .password(PromptPasswordParms::default().with_prompt("password")) | ||
| 86 | .context("failed to get password input from interactor.password")?; | ||
| 87 | if let Ok(keys) = get_keys_with_password(git_repo, &password) { | ||
| 88 | break Ok((Arc::new(keys) as Arc<dyn NostrSigner>, None)); | ||
| 89 | } | ||
| 90 | eprintln!("incorrect password"); | ||
| 91 | } | ||
| 92 | } else { | ||
| 93 | if nsec.is_some() { | ||
| 94 | bail!(error); | ||
| 95 | } | ||
| 96 | Err(error) | ||
| 97 | } | ||
| 98 | } | ||
| 99 | } { | ||
| 100 | let user_ref = get_user_details( | ||
| 101 | // Note: if rust-nostr NostrConnect::new() were updated to accept user public key as | ||
| 102 | // requested then the added complexity added in this commit can be undone | ||
| 103 | &(if let Some(public_key) = public_key { | ||
| 104 | public_key | ||
| 105 | } else { | ||
| 106 | signer | ||
| 107 | .get_public_key() | ||
| 108 | .await | ||
| 109 | .context("cannot get public key from signer")? | ||
| 110 | }), | ||
| 111 | client, | ||
| 112 | git_repo.get_path()?, | ||
| 113 | silent, | ||
| 114 | ) | ||
| 115 | .await?; | ||
| 116 | |||
| 117 | if !silent { | ||
| 118 | print_logged_in_as(&user_ref, client.is_none())?; | ||
| 119 | } | ||
| 120 | Ok((signer, user_ref)) | ||
| 121 | } else { | 32 | } else { |
| 122 | fresh_login(git_repo, client, change_user).await | 33 | fresh_login_or_signup(git_repo, client, false).await |
| 123 | } | 34 | } |
| 124 | } | 35 | } |
| 125 | 36 | ||
| 126 | fn print_logged_in_as(user_ref: &UserRef, offline_mode: bool) -> Result<()> { | 37 | #[derive(Clone)] |
| 38 | pub enum SignerInfo { | ||
| 39 | Nsec { | ||
| 40 | nsec: String, | ||
| 41 | password: Option<String>, | ||
| 42 | npub: Option<String>, | ||
| 43 | }, | ||
| 44 | Bunker { | ||
| 45 | bunker_uri: String, | ||
| 46 | bunker_app_key: String, | ||
| 47 | npub: Option<String>, | ||
| 48 | }, | ||
| 49 | } | ||
| 50 | |||
| 51 | #[derive(PartialEq, Clone)] | ||
| 52 | pub enum SignerInfoSource { | ||
| 53 | GitLocal, | ||
| 54 | GitGlobal, | ||
| 55 | CommandLineArguments, | ||
| 56 | } | ||
| 57 | |||
| 58 | fn print_logged_in_as( | ||
| 59 | user_ref: &UserRef, | ||
| 60 | offline_mode: bool, | ||
| 61 | source: &SignerInfoSource, | ||
| 62 | ) -> Result<()> { | ||
| 127 | if !offline_mode && user_ref.metadata.created_at.eq(&Timestamp::from(0)) { | 63 | if !offline_mode && user_ref.metadata.created_at.eq(&Timestamp::from(0)) { |
| 128 | eprintln!("cannot find profile..."); | 64 | eprintln!("cannot find profile..."); |
| 129 | } else if !offline_mode && user_ref.metadata.name.eq(&user_ref.public_key.to_bech32()?) { | 65 | } else if !offline_mode && user_ref.metadata.name.eq(&user_ref.public_key.to_bech32()?) { |
| @@ -133,703 +69,21 @@ fn print_logged_in_as(user_ref: &UserRef, offline_mode: bool) -> Result<()> { | |||
| 133 | "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience." | 69 | "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience." |
| 134 | ); | 70 | ); |
| 135 | } | 71 | } |
| 136 | eprintln!("logged in as {}", user_ref.metadata.name); | 72 | eprintln!( |
| 137 | Ok(()) | 73 | "logged in as {}{}", |
| 138 | } | 74 | user_ref.metadata.name, |
| 139 | 75 | match source { | |
| 140 | async fn get_signer_without_prompts( | 76 | SignerInfoSource::CommandLineArguments => " via cli arguments", |
| 141 | git_repo: &Repo, | 77 | SignerInfoSource::GitLocal => " just to local repository", |
| 142 | bunker_uri: &Option<String>, | 78 | SignerInfoSource::GitGlobal => "", |
| 143 | bunker_app_key: &Option<String>, | ||
| 144 | nsec: &Option<String>, | ||
| 145 | password: &Option<String>, | ||
| 146 | save_local: bool, | ||
| 147 | ) -> Result<(Arc<dyn NostrSigner>, Option<PublicKey>)> { | ||
| 148 | if let Some(nsec) = nsec { | ||
| 149 | Ok(( | ||
| 150 | Arc::new(get_keys_from_nsec(git_repo, nsec, password, save_local)?), | ||
| 151 | None, | ||
| 152 | )) | ||
| 153 | } else if let Some(password) = password { | ||
| 154 | Ok((Arc::new(get_keys_with_password(git_repo, password)?), None)) | ||
| 155 | } else if let Some(bunker_uri) = bunker_uri { | ||
| 156 | if let Some(bunker_app_key) = bunker_app_key { | ||
| 157 | let signer = get_nip46_signer_from_uri_and_key(bunker_uri, bunker_app_key) | ||
| 158 | .await | ||
| 159 | .context("failed to connect with remote signer")?; | ||
| 160 | if save_local { | ||
| 161 | save_to_git_config( | ||
| 162 | git_repo, | ||
| 163 | &signer.get_public_key().await?.to_bech32()?, | ||
| 164 | &None, | ||
| 165 | &Some((bunker_uri.to_string(),bunker_app_key.to_string())), | ||
| 166 | false, | ||
| 167 | ) | ||
| 168 | .context("failed to save bunker details local git config nostr.bunker-uri and nostr.bunker-app-key")?; | ||
| 169 | } | ||
| 170 | Ok((signer, None)) | ||
| 171 | } else { | ||
| 172 | bail!( | ||
| 173 | "bunker-app-key parameter must be provided alongside bunker-uri. if unknown, login interactively." | ||
| 174 | ) | ||
| 175 | } | ||
| 176 | } else if !save_local { | ||
| 177 | get_signer_with_git_config_nsec_or_bunker_without_prompts(git_repo).await | ||
| 178 | } else { | ||
| 179 | bail!("user wants prompts to specify new keys") | ||
| 180 | } | ||
| 181 | } | ||
| 182 | |||
| 183 | fn get_keys_from_nsec( | ||
| 184 | git_repo: &Repo, | ||
| 185 | nsec: &String, | ||
| 186 | password: &Option<String>, | ||
| 187 | save_local: bool, | ||
| 188 | ) -> Result<nostr::Keys> { | ||
| 189 | #[allow(unused_assignments)] | ||
| 190 | let mut s = String::new(); | ||
| 191 | let keys = if nsec.contains("ncryptsec") { | ||
| 192 | s = nsec.to_string(); | ||
| 193 | decrypt_key( | ||
| 194 | nsec, | ||
| 195 | password | ||
| 196 | .clone() | ||
| 197 | .context("password must be supplied when using ncryptsec as nsec parameter")? | ||
| 198 | .as_str(), | ||
| 199 | ) | ||
| 200 | .context("failed to decrypt key with provided password") | ||
| 201 | .context("failed to decrypt ncryptsec supplied as nsec with password")? | ||
| 202 | } else { | ||
| 203 | s = nsec.to_string(); | ||
| 204 | nostr::Keys::from_str(nsec).context("invalid nsec parameter")? | ||
| 205 | }; | ||
| 206 | if save_local { | ||
| 207 | if let Some(password) = password { | ||
| 208 | s = encrypt_key(&keys, password)?; | ||
| 209 | } | ||
| 210 | save_to_git_config( | ||
| 211 | git_repo, | ||
| 212 | &keys.public_key().to_bech32()?, | ||
| 213 | &Some(s), | ||
| 214 | &None, | ||
| 215 | false, | ||
| 216 | ) | ||
| 217 | .context("failed to save encrypted nsec in local git config nostr.nsec")?; | ||
| 218 | } | ||
| 219 | Ok(keys) | ||
| 220 | } | ||
| 221 | |||
| 222 | fn save_to_git_config( | ||
| 223 | git_repo: &Repo, | ||
| 224 | npub: &str, | ||
| 225 | nsec: &Option<String>, | ||
| 226 | bunker: &Option<(String, String)>, | ||
| 227 | global: bool, | ||
| 228 | ) -> Result<()> { | ||
| 229 | if let Err(error) = silently_save_to_git_config(git_repo, npub, nsec, bunker, global) { | ||
| 230 | eprintln!( | ||
| 231 | "failed to save login details to {} git config", | ||
| 232 | if global { "global" } else { "local" } | ||
| 233 | ); | ||
| 234 | if let Some(nsec) = nsec { | ||
| 235 | if nsec.contains("ncryptsec") { | ||
| 236 | eprintln!("manually set git config nostr.nsec to: {nsec}"); | ||
| 237 | } else { | ||
| 238 | eprintln!("manually set git config nostr.nsec"); | ||
| 239 | } | ||
| 240 | } | ||
| 241 | if let Some(bunker) = bunker { | ||
| 242 | eprintln!("manually set git config as follows:"); | ||
| 243 | eprintln!("nostr.bunker-uri: {}", bunker.0); | ||
| 244 | eprintln!("nostr.bunker-app-key: {}", bunker.1); | ||
| 245 | } | ||
| 246 | Err(error) | ||
| 247 | } else { | ||
| 248 | eprintln!( | ||
| 249 | "saved login details to {} git config", | ||
| 250 | if global { "global" } else { "local" } | ||
| 251 | ); | ||
| 252 | Ok(()) | ||
| 253 | } | ||
| 254 | } | ||
| 255 | fn silently_save_to_git_config( | ||
| 256 | git_repo: &Repo, | ||
| 257 | npub: &str, | ||
| 258 | nsec: &Option<String>, | ||
| 259 | bunker: &Option<(String, String)>, | ||
| 260 | global: bool, | ||
| 261 | ) -> Result<()> { | ||
| 262 | // must do this first otherwise it might remove the global items just added | ||
| 263 | if global { | ||
| 264 | git_repo.remove_git_config_item("nostr.npub", false)?; | ||
| 265 | git_repo.remove_git_config_item("nostr.nsec", false)?; | ||
| 266 | git_repo.remove_git_config_item("nostr.bunker-uri", false)?; | ||
| 267 | git_repo.remove_git_config_item("nostr.bunker-app-key", false)?; | ||
| 268 | } | ||
| 269 | if let Some(bunker) = bunker { | ||
| 270 | git_repo.remove_git_config_item("nostr.nsec", global)?; | ||
| 271 | git_repo.save_git_config_item("nostr.bunker-uri", &bunker.0, global)?; | ||
| 272 | git_repo.save_git_config_item("nostr.bunker-app-key", &bunker.1, global)?; | ||
| 273 | } | ||
| 274 | if let Some(nsec) = nsec { | ||
| 275 | git_repo.save_git_config_item("nostr.nsec", nsec, global)?; | ||
| 276 | git_repo.remove_git_config_item("nostr.bunker-uri", global)?; | ||
| 277 | git_repo.remove_git_config_item("nostr.bunker-app-key", global)?; | ||
| 278 | } | ||
| 279 | git_repo.save_git_config_item("nostr.npub", npub, global) | ||
| 280 | } | ||
| 281 | |||
| 282 | fn get_keys_with_password(git_repo: &Repo, password: &str) -> Result<nostr::Keys> { | ||
| 283 | decrypt_key( | ||
| 284 | &git_repo | ||
| 285 | .get_git_config_item("nostr.nsec", None) | ||
| 286 | .context("failed get git config")? | ||
| 287 | .context("git config item nostr.nsec doesn't exist so cannot decrypt it")?, | ||
| 288 | password, | ||
| 289 | ) | ||
| 290 | .context("failed to decrypt stored nsec key with provided password") | ||
| 291 | } | ||
| 292 | |||
| 293 | async fn get_nip46_signer_from_uri_and_key( | ||
| 294 | uri: &str, | ||
| 295 | app_key: &str, | ||
| 296 | ) -> Result<Arc<dyn NostrSigner>> { | ||
| 297 | let term = console::Term::stderr(); | ||
| 298 | term.write_line("connecting to remote signer...")?; | ||
| 299 | let uri = NostrConnectURI::parse(uri)?; | ||
| 300 | let signer = Arc::new(NostrConnect::new( | ||
| 301 | uri, | ||
| 302 | nostr::Keys::from_str(app_key).context("invalid app key")?, | ||
| 303 | Duration::from_secs(10 * 60), | ||
| 304 | None, | ||
| 305 | )?); | ||
| 306 | term.clear_last_lines(1)?; | ||
| 307 | Ok(signer) | ||
| 308 | } | ||
| 309 | |||
| 310 | async fn get_signer_with_git_config_nsec_or_bunker_without_prompts( | ||
| 311 | git_repo: &Repo, | ||
| 312 | ) -> Result<(Arc<dyn NostrSigner>, Option<PublicKey>)> { | ||
| 313 | if let Ok(local_nsec) = &git_repo | ||
| 314 | .get_git_config_item("nostr.nsec", Some(false)) | ||
| 315 | .context("failed get local git config")? | ||
| 316 | .context("git local config item nostr.nsec doesn't exist") | ||
| 317 | { | ||
| 318 | if local_nsec.contains("ncryptsec") { | ||
| 319 | bail!("git global config item nostr.nsec is an ncryptsec") | ||
| 320 | } | ||
| 321 | Ok(( | ||
| 322 | Arc::new(nostr::Keys::from_str(local_nsec).context("invalid nsec parameter")?), | ||
| 323 | None, | ||
| 324 | )) | ||
| 325 | } else if let Ok((uri, app_key, npub)) = | ||
| 326 | get_git_config_bunker_uri_and_app_key(git_repo, Some(false)) | ||
| 327 | { | ||
| 328 | Ok(( | ||
| 329 | get_nip46_signer_from_uri_and_key(&uri, &app_key).await?, | ||
| 330 | if let Ok(pubic_key) = PublicKey::parse(npub) { | ||
| 331 | Some(pubic_key) | ||
| 332 | } else { | ||
| 333 | None | ||
| 334 | }, | ||
| 335 | )) | ||
| 336 | } else if let Ok(global_nsec) = &git_repo | ||
| 337 | .get_git_config_item("nostr.nsec", Some(true)) | ||
| 338 | .context("failed get global git config")? | ||
| 339 | .context("git global config item nostr.nsec doesn't exist") | ||
| 340 | { | ||
| 341 | if global_nsec.contains("ncryptsec") { | ||
| 342 | bail!("git global config item nostr.nsec is an ncryptsec") | ||
| 343 | } | ||
| 344 | Ok(( | ||
| 345 | Arc::new(nostr::Keys::from_str(global_nsec).context("invalid nsec parameter")?), | ||
| 346 | None, | ||
| 347 | )) | ||
| 348 | } else if let Ok((uri, app_key, npub)) = | ||
| 349 | get_git_config_bunker_uri_and_app_key(git_repo, Some(true)) | ||
| 350 | { | ||
| 351 | Ok(( | ||
| 352 | get_nip46_signer_from_uri_and_key(&uri, &app_key).await?, | ||
| 353 | if let Ok(pubic_key) = PublicKey::parse(npub) { | ||
| 354 | Some(pubic_key) | ||
| 355 | } else { | ||
| 356 | None | ||
| 357 | }, | ||
| 358 | )) | ||
| 359 | } else { | ||
| 360 | bail!("cannot get nsec or bunker from git config") | ||
| 361 | } | ||
| 362 | } | ||
| 363 | |||
| 364 | fn get_git_config_bunker_uri_and_app_key( | ||
| 365 | git_repo: &Repo, | ||
| 366 | global: Option<bool>, | ||
| 367 | ) -> Result<(String, String, String)> { | ||
| 368 | Ok(( | ||
| 369 | git_repo | ||
| 370 | .get_git_config_item("nostr.bunker-uri", global) | ||
| 371 | .context("failed get local git config")? | ||
| 372 | .context("git local config item nostr.bunker-uri doesn't exist")? | ||
| 373 | .to_string(), | ||
| 374 | git_repo | ||
| 375 | .get_git_config_item("nostr.bunker-app-key", global) | ||
| 376 | .context("failed get local git config")? | ||
| 377 | .context("git local config item nostr.bunker-app-key doesn't exist")? | ||
| 378 | .to_string(), | ||
| 379 | git_repo | ||
| 380 | .get_git_config_item("nostr.npub", global) | ||
| 381 | .context("failed get local git config")? | ||
| 382 | .context("git local config item nostr.npub doesn't exist")? | ||
| 383 | .to_string(), | ||
| 384 | )) | ||
| 385 | } | ||
| 386 | |||
| 387 | async fn fresh_login( | ||
| 388 | git_repo: &Repo, | ||
| 389 | #[cfg(test)] client: Option<&MockConnect>, | ||
| 390 | #[cfg(not(test))] client: Option<&Client>, | ||
| 391 | always_save: bool, | ||
| 392 | ) -> Result<(Arc<dyn NostrSigner>, UserRef)> { | ||
| 393 | let app_key = Keys::generate(); | ||
| 394 | let app_key_secret = app_key.secret_key().to_secret_hex(); | ||
| 395 | let relays = if let Some(client) = client { | ||
| 396 | client | ||
| 397 | .get_fallback_signer_relays() | ||
| 398 | .iter() | ||
| 399 | .flat_map(|s| Url::parse(s)) | ||
| 400 | .collect::<Vec<Url>>() | ||
| 401 | } else { | ||
| 402 | vec![] | ||
| 403 | }; | ||
| 404 | let offline = client.is_none(); | ||
| 405 | let nostr_connect_url = NostrConnectURI::client(app_key.public_key(), relays.clone(), "ngit"); | ||
| 406 | let qr = generate_qr(&nostr_connect_url.to_string())?; | ||
| 407 | |||
| 408 | let printer = Arc::new(Mutex::new(Printer::default())); | ||
| 409 | if !offline { | ||
| 410 | let printer_clone = Arc::clone(&printer); | ||
| 411 | let mut printer_locked = printer_clone.lock().await; | ||
| 412 | printer_locked.printlns(qr); | ||
| 413 | printer_locked.println(format!( | ||
| 414 | "scan QR or paste into remote signer: {nostr_connect_url}" | ||
| 415 | )); | ||
| 416 | printer_locked.println_with_custom_formatting( | ||
| 417 | { | ||
| 418 | let mut s = String::new(); | ||
| 419 | let _ = ColorfulTheme::default().format_confirm_prompt( | ||
| 420 | &mut s, | ||
| 421 | "login with nsec / bunker url / nostr address instead", | ||
| 422 | Some(true), | ||
| 423 | ); | ||
| 424 | s | ||
| 425 | }, | ||
| 426 | "? login with nsec / bunker url / nostr address instead? (y/n) › yes".to_string(), | ||
| 427 | ); | ||
| 428 | } | ||
| 429 | |||
| 430 | let (tx, rx) = oneshot::channel(); | ||
| 431 | let printer_clone = Arc::clone(&printer); | ||
| 432 | |||
| 433 | let qr_listener = tokio::spawn(async move { | ||
| 434 | if offline { | ||
| 435 | return; | ||
| 436 | } | ||
| 437 | if let Ok(nostr_connect) = NostrConnect::new( | ||
| 438 | nostr_connect_url.clone(), | ||
| 439 | app_key.clone(), | ||
| 440 | Duration::from_secs(10 * 60), | ||
| 441 | None, | ||
| 442 | ) { | ||
| 443 | let signer: Arc<dyn NostrSigner> = Arc::new(nostr_connect); | ||
| 444 | if let Ok(pub_key) = fetch_public_key(&signer).await { | ||
| 445 | let mut printer_locked = printer_clone.lock().await; | ||
| 446 | printer_locked.clear_all(); | ||
| 447 | |||
| 448 | printer_locked.println_with_custom_formatting( | ||
| 449 | format!( | ||
| 450 | "{}", | ||
| 451 | Style::new().bold().apply_to("connected to remote signer"), | ||
| 452 | ), | ||
| 453 | "connected to remote signer".to_string(), | ||
| 454 | ); | ||
| 455 | printer_locked.println("press any key to continue...".to_string()); | ||
| 456 | let _ = tx.send(Some((signer, pub_key))); | ||
| 457 | } | ||
| 458 | } | ||
| 459 | }); | ||
| 460 | if !offline { | ||
| 461 | let _ = console::Term::stderr().read_char(); | ||
| 462 | } | ||
| 463 | qr_listener.abort(); | ||
| 464 | let printer_clone = Arc::clone(&printer); | ||
| 465 | let mut printer = printer_clone.lock().await; | ||
| 466 | printer.clear_all(); | ||
| 467 | |||
| 468 | let (signer, public_key) = { | ||
| 469 | if let Ok(Some((signer, public_key))) = rx.await { | ||
| 470 | let bunker_url = NostrConnectURI::Bunker { | ||
| 471 | remote_signer_public_key: public_key, | ||
| 472 | relays: relays.clone(), | ||
| 473 | secret: None, | ||
| 474 | }; | ||
| 475 | if let Err(error) = save_bunker( | ||
| 476 | git_repo, | ||
| 477 | &public_key, | ||
| 478 | &bunker_url.to_string(), | ||
| 479 | &app_key_secret, | ||
| 480 | always_save, | ||
| 481 | ) { | ||
| 482 | eprintln!("{error}"); | ||
| 483 | } | ||
| 484 | (signer, public_key) | ||
| 485 | } else { | ||
| 486 | let mut public_key: Option<PublicKey> = None; | ||
| 487 | // prompt for nsec | ||
| 488 | let mut prompt = "login with nsec / bunker url / nostr address"; | ||
| 489 | let signer: Arc<dyn NostrSigner> = loop { | ||
| 490 | let input = Interactor::default() | ||
| 491 | .input(PromptInputParms::default().with_prompt(prompt)) | ||
| 492 | .context("failed to get nsec input from interactor")?; | ||
| 493 | if let Ok(keys) = nostr::Keys::from_str(&input) { | ||
| 494 | if let Err(error) = save_keys(git_repo, &keys, always_save) { | ||
| 495 | eprintln!("{error}"); | ||
| 496 | } | ||
| 497 | break Arc::new(keys); | ||
| 498 | } | ||
| 499 | let uri = if let Ok(uri) = NostrConnectURI::parse(&input) { | ||
| 500 | uri | ||
| 501 | } else if input.contains('@') { | ||
| 502 | if let Ok(uri) = fetch_nip46_uri_from_nip05(&input).await { | ||
| 503 | uri | ||
| 504 | } else { | ||
| 505 | prompt = "failed. try again with nostr address / bunker uri / nsec"; | ||
| 506 | continue; | ||
| 507 | } | ||
| 508 | } else { | ||
| 509 | prompt = "invalid. try again with nostr address / bunker uri / nsec"; | ||
| 510 | continue; | ||
| 511 | }; | ||
| 512 | match get_nip46_signer_from_uri_and_key(&uri.to_string(), &app_key_secret).await { | ||
| 513 | Ok(signer) => { | ||
| 514 | let pub_key = fetch_public_key(&signer).await?; | ||
| 515 | if let Err(error) = save_bunker( | ||
| 516 | git_repo, | ||
| 517 | &pub_key, | ||
| 518 | &uri.to_string(), | ||
| 519 | &app_key_secret, | ||
| 520 | always_save, | ||
| 521 | ) { | ||
| 522 | eprintln!("{error}"); | ||
| 523 | } | ||
| 524 | public_key = Some(pub_key); | ||
| 525 | break signer; | ||
| 526 | } | ||
| 527 | Err(_) => { | ||
| 528 | prompt = "failed. try again with nostr address / bunker uri / nsec"; | ||
| 529 | } | ||
| 530 | } | ||
| 531 | }; | ||
| 532 | let public_key = if let Some(public_key) = public_key { | ||
| 533 | public_key | ||
| 534 | } else { | ||
| 535 | signer.get_public_key().await? | ||
| 536 | }; | ||
| 537 | (signer, public_key) | ||
| 538 | } | ||
| 539 | }; | ||
| 540 | // lookup profile | ||
| 541 | let user_ref = get_user_details(&public_key, client, git_repo.get_path()?, false).await?; | ||
| 542 | print_logged_in_as(&user_ref, client.is_none())?; | ||
| 543 | Ok((signer, user_ref)) | ||
| 544 | } | ||
| 545 | |||
| 546 | fn generate_qr(data: &str) -> Result<Vec<String>> { | ||
| 547 | let mut lines = vec![]; | ||
| 548 | let qr = | ||
| 549 | QrCode::new(data.as_bytes()).context("failed to create QR of nostrconnect login url")?; | ||
| 550 | let colors = qr.to_colors(); | ||
| 551 | let rows: Vec<&[qrcode::Color]> = colors.chunks(qr.width()).collect(); | ||
| 552 | for (row, data) in rows.iter().enumerate() { | ||
| 553 | let odd = row % 2 != 0; | ||
| 554 | if odd { | ||
| 555 | continue; | ||
| 556 | } | ||
| 557 | let mut line = String::new(); | ||
| 558 | for (col, color) in data.iter().enumerate() { | ||
| 559 | let top = color; | ||
| 560 | let mut bottom = qrcode::Color::Light; | ||
| 561 | if let Some(next_row_data) = rows.get(row + 1) { | ||
| 562 | if let Some(color) = next_row_data.get(col) { | ||
| 563 | bottom = *color; | ||
| 564 | } | ||
| 565 | } | ||
| 566 | line.push(if *top == qrcode::Color::Dark { | ||
| 567 | if bottom == qrcode::Color::Dark { | ||
| 568 | '█' | ||
| 569 | } else { | ||
| 570 | '▀' | ||
| 571 | } | ||
| 572 | } else if bottom == qrcode::Color::Dark { | ||
| 573 | '▄' | ||
| 574 | } else { | ||
| 575 | ' ' | ||
| 576 | }); | ||
| 577 | } | ||
| 578 | lines.push(line); | ||
| 579 | } | ||
| 580 | Ok(lines) | ||
| 581 | } | ||
| 582 | |||
| 583 | pub async fn fetch_nip46_uri_from_nip05(nip05: &str) -> Result<NostrConnectURI> { | ||
| 584 | let term = console::Term::stderr(); | ||
| 585 | term.write_line("contacting login service provider...")?; | ||
| 586 | let res = nip05::profile(&nip05, None).await; | ||
| 587 | term.clear_last_lines(1)?; | ||
| 588 | match res { | ||
| 589 | Ok(profile) => { | ||
| 590 | if profile.nip46.is_empty() { | ||
| 591 | eprintln!("nip05 provider isn't configured for remote login"); | ||
| 592 | bail!("nip05 provider isn't configured for remote login") | ||
| 593 | } | ||
| 594 | Ok(NostrConnectURI::Bunker { | ||
| 595 | remote_signer_public_key: profile.public_key, | ||
| 596 | relays: profile.nip46, | ||
| 597 | secret: None, | ||
| 598 | }) | ||
| 599 | } | 79 | } |
| 600 | Err(error) => { | 80 | ); |
| 601 | eprintln!("error contacting login service provider: {error}"); | ||
| 602 | Err(error).context("error contacting login service provider") | ||
| 603 | } | ||
| 604 | } | ||
| 605 | } | ||
| 606 | |||
| 607 | fn save_bunker( | ||
| 608 | git_repo: &Repo, | ||
| 609 | public_key: &PublicKey, | ||
| 610 | uri: &str, | ||
| 611 | app_key: &str, | ||
| 612 | always_save: bool, | ||
| 613 | ) -> Result<()> { | ||
| 614 | if always_save | ||
| 615 | || Interactor::default() | ||
| 616 | .confirm(PromptConfirmParms::default().with_prompt("save login details?"))? | ||
| 617 | { | ||
| 618 | let global = !Interactor::default().confirm( | ||
| 619 | PromptConfirmParms::default() | ||
| 620 | .with_prompt("save login just for this repository?") | ||
| 621 | .with_default(false), | ||
| 622 | )?; | ||
| 623 | let npub = public_key.to_bech32()?; | ||
| 624 | if let Err(error) = save_to_git_config( | ||
| 625 | git_repo, | ||
| 626 | &npub, | ||
| 627 | &None, | ||
| 628 | &Some((uri.to_string(), app_key.to_string())), | ||
| 629 | global, | ||
| 630 | ) { | ||
| 631 | if global { | ||
| 632 | if Interactor::default().confirm( | ||
| 633 | PromptConfirmParms::default() | ||
| 634 | .with_prompt("save in repository git config?") | ||
| 635 | .with_default(true), | ||
| 636 | )? { | ||
| 637 | save_to_git_config( | ||
| 638 | git_repo, | ||
| 639 | &npub, | ||
| 640 | &None, | ||
| 641 | &Some((uri.to_string(), app_key.to_string())), | ||
| 642 | false, | ||
| 643 | )?; | ||
| 644 | } | ||
| 645 | } else { | ||
| 646 | Err(error)?; | ||
| 647 | } | ||
| 648 | }; | ||
| 649 | } | ||
| 650 | Ok(()) | ||
| 651 | } | ||
| 652 | |||
| 653 | fn save_keys(git_repo: &Repo, keys: &nostr::Keys, always_save: bool) -> Result<()> { | ||
| 654 | if always_save | ||
| 655 | || Interactor::default() | ||
| 656 | .confirm(PromptConfirmParms::default().with_prompt("save login details?"))? | ||
| 657 | { | ||
| 658 | let global = !Interactor::default().confirm( | ||
| 659 | PromptConfirmParms::default() | ||
| 660 | .with_prompt("just for this repository?") | ||
| 661 | .with_default(false), | ||
| 662 | )?; | ||
| 663 | |||
| 664 | let encrypt = Interactor::default().confirm( | ||
| 665 | PromptConfirmParms::default() | ||
| 666 | .with_prompt("require password?") | ||
| 667 | .with_default(false), | ||
| 668 | )?; | ||
| 669 | |||
| 670 | let npub = keys.public_key().to_bech32()?; | ||
| 671 | let nsec_string = if encrypt { | ||
| 672 | let password = Interactor::default() | ||
| 673 | .password( | ||
| 674 | PromptPasswordParms::default() | ||
| 675 | .with_prompt("encrypt with password") | ||
| 676 | .with_confirm(), | ||
| 677 | ) | ||
| 678 | .context("failed to get password input from interactor.password")?; | ||
| 679 | encrypt_key(keys, &password)? | ||
| 680 | } else { | ||
| 681 | keys.secret_key().to_bech32()? | ||
| 682 | }; | ||
| 683 | |||
| 684 | if let Err(error) = | ||
| 685 | save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, global) | ||
| 686 | { | ||
| 687 | if global { | ||
| 688 | if Interactor::default().confirm( | ||
| 689 | PromptConfirmParms::default() | ||
| 690 | .with_prompt("save in repository git config?") | ||
| 691 | .with_default(true), | ||
| 692 | )? { | ||
| 693 | save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, false)?; | ||
| 694 | } | ||
| 695 | } else { | ||
| 696 | eprintln!("{error}"); | ||
| 697 | Err(error)?; | ||
| 698 | } | ||
| 699 | }; | ||
| 700 | }; | ||
| 701 | Ok(()) | 81 | Ok(()) |
| 702 | } | 82 | } |
| 703 | 83 | ||
| 704 | fn get_config_item(git_repo: &Repo, name: &str) -> Result<String> { | ||
| 705 | git_repo | ||
| 706 | .get_git_config_item(name, None) | ||
| 707 | .context("failed get git config")? | ||
| 708 | .context(format!("git config item {name} doesn't exist")) | ||
| 709 | } | ||
| 710 | |||
| 711 | fn extract_user_metadata( | ||
| 712 | public_key: &nostr::PublicKey, | ||
| 713 | events: &[nostr::Event], | ||
| 714 | ) -> Result<UserMetadata> { | ||
| 715 | let event = events | ||
| 716 | .iter() | ||
| 717 | .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key)) | ||
| 718 | .max_by_key(|e| e.created_at); | ||
| 719 | |||
| 720 | let metadata: Option<nostr::Metadata> = if let Some(event) = event { | ||
| 721 | Some( | ||
| 722 | nostr::Metadata::from_json(event.content.clone()) | ||
| 723 | .context("metadata cannot be found in kind 0 event content")?, | ||
| 724 | ) | ||
| 725 | } else { | ||
| 726 | None | ||
| 727 | }; | ||
| 728 | |||
| 729 | Ok(UserMetadata { | ||
| 730 | name: if let Some(metadata) = metadata { | ||
| 731 | if let Some(n) = metadata.name { | ||
| 732 | n | ||
| 733 | } else if let Some(n) = metadata.custom.get("displayName") { | ||
| 734 | // strip quote marks that custom.get() adds | ||
| 735 | let binding = n.to_string(); | ||
| 736 | let mut chars = binding.chars(); | ||
| 737 | chars.next(); | ||
| 738 | chars.next_back(); | ||
| 739 | chars.as_str().to_string() | ||
| 740 | } else if let Some(n) = metadata.display_name { | ||
| 741 | n | ||
| 742 | } else { | ||
| 743 | public_key.to_bech32()? | ||
| 744 | } | ||
| 745 | } else { | ||
| 746 | public_key.to_bech32()? | ||
| 747 | }, | ||
| 748 | created_at: if let Some(event) = event { | ||
| 749 | event.created_at | ||
| 750 | } else { | ||
| 751 | Timestamp::from(0) | ||
| 752 | }, | ||
| 753 | }) | ||
| 754 | } | ||
| 755 | |||
| 756 | fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event]) -> UserRelays { | ||
| 757 | let event = events | ||
| 758 | .iter() | ||
| 759 | .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key)) | ||
| 760 | .max_by_key(|e| e.created_at); | ||
| 761 | |||
| 762 | UserRelays { | ||
| 763 | relays: if let Some(event) = event { | ||
| 764 | event | ||
| 765 | .tags | ||
| 766 | .iter() | ||
| 767 | .filter(|t| { | ||
| 768 | t.kind() | ||
| 769 | .eq(&nostr::TagKind::SingleLetter(SingleLetterTag::lowercase( | ||
| 770 | Alphabet::R, | ||
| 771 | ))) | ||
| 772 | }) | ||
| 773 | .map(|t| UserRelayRef { | ||
| 774 | url: t.as_slice()[1].clone(), | ||
| 775 | read: t.as_slice().len() == 2 || t.as_slice()[2].eq("read"), | ||
| 776 | write: t.as_slice().len() == 2 || t.as_slice()[2].eq("write"), | ||
| 777 | }) | ||
| 778 | .collect() | ||
| 779 | } else { | ||
| 780 | vec![] | ||
| 781 | }, | ||
| 782 | created_at: if let Some(event) = event { | ||
| 783 | event.created_at | ||
| 784 | } else { | ||
| 785 | Timestamp::from(0) | ||
| 786 | }, | ||
| 787 | } | ||
| 788 | } | ||
| 789 | |||
| 790 | async fn get_user_details( | ||
| 791 | public_key: &PublicKey, | ||
| 792 | #[cfg(test)] client: Option<&crate::client::MockConnect>, | ||
| 793 | #[cfg(not(test))] client: Option<&Client>, | ||
| 794 | git_repo_path: &Path, | ||
| 795 | cache_only: bool, | ||
| 796 | ) -> Result<UserRef> { | ||
| 797 | if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { | ||
| 798 | Ok(user_ref) | ||
| 799 | } else { | ||
| 800 | let empty = UserRef { | ||
| 801 | public_key: public_key.to_owned(), | ||
| 802 | metadata: extract_user_metadata(public_key, &[])?, | ||
| 803 | relays: extract_user_relays(public_key, &[]), | ||
| 804 | }; | ||
| 805 | if cache_only { | ||
| 806 | Ok(empty) | ||
| 807 | } else if let Some(client) = client { | ||
| 808 | let term = console::Term::stderr(); | ||
| 809 | term.write_line("searching for profile...")?; | ||
| 810 | let (_, progress_reporter) = client | ||
| 811 | .fetch_all( | ||
| 812 | git_repo_path, | ||
| 813 | &HashSet::new(), | ||
| 814 | &HashSet::from_iter(vec![*public_key]), | ||
| 815 | ) | ||
| 816 | .await?; | ||
| 817 | if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { | ||
| 818 | progress_reporter.clear()?; | ||
| 819 | // if std::env::var("NGITTEST").is_err() {term.clear_last_lines(1)?;} | ||
| 820 | Ok(user_ref) | ||
| 821 | } else { | ||
| 822 | Ok(empty) | ||
| 823 | } | ||
| 824 | } else { | ||
| 825 | Ok(empty) | ||
| 826 | } | ||
| 827 | } | ||
| 828 | } | ||
| 829 | |||
| 830 | // None: in the edge case where the user is logged in via cli arguments rather | 84 | // None: in the edge case where the user is logged in via cli arguments rather |
| 831 | // than from git config this may be wrong. TODO: fix this | 85 | // than from git config this may be wrong. TODO: fix this |
| 832 | pub async fn get_logged_in_user(git_repo_path: &Path) -> Result<Option<PublicKey>> { | 86 | pub async fn get_likely_logged_in_user(git_repo_path: &Path) -> Result<Option<PublicKey>> { |
| 833 | let git_repo = Repo::from_path(&git_repo_path.to_path_buf())?; | 87 | let git_repo = Repo::from_path(&git_repo_path.to_path_buf())?; |
| 834 | Ok( | 88 | Ok( |
| 835 | if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? { | 89 | if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? { |
| @@ -844,31 +98,6 @@ pub async fn get_logged_in_user(git_repo_path: &Path) -> Result<Option<PublicKey | |||
| 844 | ) | 98 | ) |
| 845 | } | 99 | } |
| 846 | 100 | ||
| 847 | pub async fn get_user_ref_from_cache( | ||
| 848 | git_repo_path: &Path, | ||
| 849 | public_key: &PublicKey, | ||
| 850 | ) -> Result<UserRef> { | ||
| 851 | let filters = vec![ | ||
| 852 | nostr::Filter::default() | ||
| 853 | .author(*public_key) | ||
| 854 | .kind(Kind::Metadata), | ||
| 855 | nostr::Filter::default() | ||
| 856 | .author(*public_key) | ||
| 857 | .kind(Kind::RelayList), | ||
| 858 | ]; | ||
| 859 | |||
| 860 | let events = get_event_from_global_cache(git_repo_path, filters.clone()).await?; | ||
| 861 | |||
| 862 | if events.is_empty() { | ||
| 863 | bail!("no metadata and profile list in cache for selected public key"); | ||
| 864 | } | ||
| 865 | Ok(UserRef { | ||
| 866 | public_key: public_key.to_owned(), | ||
| 867 | metadata: extract_user_metadata(public_key, &events)?, | ||
| 868 | relays: extract_user_relays(public_key, &events), | ||
| 869 | }) | ||
| 870 | } | ||
| 871 | |||
| 872 | pub fn get_curent_user(git_repo: &Repo) -> Result<Option<PublicKey>> { | 101 | pub fn get_curent_user(git_repo: &Repo) -> Result<Option<PublicKey>> { |
| 873 | Ok( | 102 | Ok( |
| 874 | if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? { | 103 | if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? { |
diff --git a/src/lib/login/user.rs b/src/lib/login/user.rs index 46652db..4456308 100644 --- a/src/lib/login/user.rs +++ b/src/lib/login/user.rs | |||
| @@ -1,7 +1,16 @@ | |||
| 1 | use std::{collections::HashSet, path::Path}; | ||
| 2 | |||
| 3 | use anyhow::{bail, Context, Result}; | ||
| 1 | use nostr::PublicKey; | 4 | use nostr::PublicKey; |
| 2 | use nostr_sdk::Timestamp; | 5 | use nostr_sdk::{Alphabet, JsonUtil, Kind, SingleLetterTag, Timestamp, ToBech32}; |
| 3 | use serde::{self, Deserialize, Serialize}; | 6 | use serde::{self, Deserialize, Serialize}; |
| 4 | 7 | ||
| 8 | #[cfg(not(test))] | ||
| 9 | use crate::client::Client; | ||
| 10 | #[cfg(test)] | ||
| 11 | use crate::client::MockConnect; | ||
| 12 | use crate::client::{get_event_from_global_cache, Connect}; | ||
| 13 | |||
| 5 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] | 14 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] |
| 6 | pub struct UserRef { | 15 | pub struct UserRef { |
| 7 | pub public_key: PublicKey, | 16 | pub public_key: PublicKey, |
| @@ -37,3 +46,147 @@ pub struct UserRelayRef { | |||
| 37 | pub read: bool, | 46 | pub read: bool, |
| 38 | pub write: bool, | 47 | pub write: bool, |
| 39 | } | 48 | } |
| 49 | |||
| 50 | pub async fn get_user_details( | ||
| 51 | public_key: &PublicKey, | ||
| 52 | #[cfg(test)] client: Option<&MockConnect>, | ||
| 53 | #[cfg(not(test))] client: Option<&Client>, | ||
| 54 | git_repo_path: Option<&Path>, | ||
| 55 | cache_only: bool, | ||
| 56 | ) -> Result<UserRef> { | ||
| 57 | if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { | ||
| 58 | Ok(user_ref) | ||
| 59 | } else { | ||
| 60 | let empty = UserRef { | ||
| 61 | public_key: public_key.to_owned(), | ||
| 62 | metadata: extract_user_metadata(public_key, &[])?, | ||
| 63 | relays: extract_user_relays(public_key, &[]), | ||
| 64 | }; | ||
| 65 | if cache_only { | ||
| 66 | Ok(empty) | ||
| 67 | } else if let Some(client) = client { | ||
| 68 | let term = console::Term::stderr(); | ||
| 69 | term.write_line("searching for profile...")?; | ||
| 70 | let (_, progress_reporter) = client | ||
| 71 | .fetch_all( | ||
| 72 | git_repo_path, | ||
| 73 | &HashSet::new(), | ||
| 74 | &HashSet::from_iter(vec![*public_key]), | ||
| 75 | ) | ||
| 76 | .await?; | ||
| 77 | if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { | ||
| 78 | progress_reporter.clear()?; | ||
| 79 | // if std::env::var("NGITTEST").is_err() {term.clear_last_lines(1)?;} | ||
| 80 | Ok(user_ref) | ||
| 81 | } else { | ||
| 82 | Ok(empty) | ||
| 83 | } | ||
| 84 | } else { | ||
| 85 | Ok(empty) | ||
| 86 | } | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | pub async fn get_user_ref_from_cache( | ||
| 91 | git_repo_path: Option<&Path>, | ||
| 92 | public_key: &PublicKey, | ||
| 93 | ) -> Result<UserRef> { | ||
| 94 | let filters = vec![ | ||
| 95 | nostr::Filter::default() | ||
| 96 | .author(*public_key) | ||
| 97 | .kind(Kind::Metadata), | ||
| 98 | nostr::Filter::default() | ||
| 99 | .author(*public_key) | ||
| 100 | .kind(Kind::RelayList), | ||
| 101 | ]; | ||
| 102 | |||
| 103 | let events = get_event_from_global_cache(git_repo_path, filters.clone()).await?; | ||
| 104 | |||
| 105 | if events.is_empty() { | ||
| 106 | bail!("no metadata and profile list in cache for selected public key"); | ||
| 107 | } | ||
| 108 | Ok(UserRef { | ||
| 109 | public_key: public_key.to_owned(), | ||
| 110 | metadata: extract_user_metadata(public_key, &events)?, | ||
| 111 | relays: extract_user_relays(public_key, &events), | ||
| 112 | }) | ||
| 113 | } | ||
| 114 | |||
| 115 | pub fn extract_user_metadata( | ||
| 116 | public_key: &nostr::PublicKey, | ||
| 117 | events: &[nostr::Event], | ||
| 118 | ) -> Result<UserMetadata> { | ||
| 119 | let event = events | ||
| 120 | .iter() | ||
| 121 | .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key)) | ||
| 122 | .max_by_key(|e| e.created_at); | ||
| 123 | |||
| 124 | let metadata: Option<nostr::Metadata> = if let Some(event) = event { | ||
| 125 | Some( | ||
| 126 | nostr::Metadata::from_json(event.content.clone()) | ||
| 127 | .context("metadata cannot be found in kind 0 event content")?, | ||
| 128 | ) | ||
| 129 | } else { | ||
| 130 | None | ||
| 131 | }; | ||
| 132 | |||
| 133 | Ok(UserMetadata { | ||
| 134 | name: if let Some(metadata) = metadata { | ||
| 135 | if let Some(n) = metadata.name { | ||
| 136 | n | ||
| 137 | } else if let Some(n) = metadata.custom.get("displayName") { | ||
| 138 | // strip quote marks that custom.get() adds | ||
| 139 | let binding = n.to_string(); | ||
| 140 | let mut chars = binding.chars(); | ||
| 141 | chars.next(); | ||
| 142 | chars.next_back(); | ||
| 143 | chars.as_str().to_string() | ||
| 144 | } else if let Some(n) = metadata.display_name { | ||
| 145 | n | ||
| 146 | } else { | ||
| 147 | public_key.to_bech32()? | ||
| 148 | } | ||
| 149 | } else { | ||
| 150 | public_key.to_bech32()? | ||
| 151 | }, | ||
| 152 | created_at: if let Some(event) = event { | ||
| 153 | event.created_at | ||
| 154 | } else { | ||
| 155 | Timestamp::from(0) | ||
| 156 | }, | ||
| 157 | }) | ||
| 158 | } | ||
| 159 | |||
| 160 | pub fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event]) -> UserRelays { | ||
| 161 | let event = events | ||
| 162 | .iter() | ||
| 163 | .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key)) | ||
| 164 | .max_by_key(|e| e.created_at); | ||
| 165 | |||
| 166 | UserRelays { | ||
| 167 | relays: if let Some(event) = event { | ||
| 168 | event | ||
| 169 | .tags | ||
| 170 | .iter() | ||
| 171 | .filter(|t| { | ||
| 172 | t.kind() | ||
| 173 | .eq(&nostr::TagKind::SingleLetter(SingleLetterTag::lowercase( | ||
| 174 | Alphabet::R, | ||
| 175 | ))) | ||
| 176 | }) | ||
| 177 | .map(|t| UserRelayRef { | ||
| 178 | url: t.as_slice()[1].clone(), | ||
| 179 | read: t.as_slice().len() == 2 || t.as_slice()[2].eq("read"), | ||
| 180 | write: t.as_slice().len() == 2 || t.as_slice()[2].eq("write"), | ||
| 181 | }) | ||
| 182 | .collect() | ||
| 183 | } else { | ||
| 184 | vec![] | ||
| 185 | }, | ||
| 186 | created_at: if let Some(event) = event { | ||
| 187 | event.created_at | ||
| 188 | } else { | ||
| 189 | Timestamp::from(0) | ||
| 190 | }, | ||
| 191 | } | ||
| 192 | } | ||