From 173ab188b326fbe78cfba4ab455a74619f4556bb Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 24 Jun 2024 09:39:18 +0100 Subject: feat(login): store in git config and use cache replace ngit yaml file config with: * nsec / ncryptsec / npub in git config in nostr.* namespace * sql database cache for metadata and relay events allow different logins to be used for different git repositories by storing login in local git config --- src/login.rs | 461 +++++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 369 insertions(+), 92 deletions(-) (limited to 'src/login.rs') diff --git a/src/login.rs b/src/login.rs index 4cdf3c1..58d1b87 100644 --- a/src/login.rs +++ b/src/login.rs @@ -2,130 +2,407 @@ use std::str::FromStr; use anyhow::{bail, Context, Result}; use nostr::PublicKey; -use zeroize::Zeroize; +use nostr_database::Order; +use nostr_sdk::{Alphabet, FromBech32, JsonUtil, Kind, NostrDatabase, SingleLetterTag, ToBech32}; +use nostr_sqlite::SQLiteDatabase; #[cfg(not(test))] use crate::client::Client; #[cfg(test)] use crate::client::MockConnect; use crate::{ - cli_interactor::{Interactor, InteractorPrompt, PromptPasswordParms}, - config::{ConfigManagement, ConfigManager, UserRef}, - key_handling::{ - encryption::{EncryptDecrypt, Encryptor}, - users::{UserManagement, UserManager}, + cli_interactor::{ + Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptPasswordParms, }, + client::Connect, + config::{get_dirs, UserMetadata, UserRef, UserRelayRef, UserRelays}, + git::{Repo, RepoActions}, + key_handling::encryption::{decrypt_key, encrypt_key}, }; /// handles the encrpytion and storage of key material pub async fn launch( + git_repo: &Repo, nsec: &Option, password: &Option, #[cfg(test)] client: Option<&MockConnect>, #[cfg(not(test))] client: Option<&Client>, + change_user: bool, ) -> Result<(nostr::Keys, UserRef)> { - // if nsec parameter - let key = if let Some(nsec_unwrapped) = nsec { - // get key or fail without prompts - let key = nostr::Keys::from_str(nsec_unwrapped).context("invalid nsec parameter")?; - - // if password, add user to enable password login in future - if password.is_some() { - UserManager::default() - .add(nsec, password) - .context("could not store identity")?; - } else { - UserManager::default().add_user_to_config(key.public_key(), None, false)?; + if let Ok(keys) = match get_keys_without_prompts(git_repo, nsec, password, change_user) { + Ok(keys) => Ok(keys), + Err(error) => { + if error + .to_string() + .eq("git config item nostr.nsec is an ncryptsec") + { + println!( + "login as {}", + if let Ok(public_key) = PublicKey::from_bech32( + get_config_item(git_repo, "nostr.npub") + .unwrap_or("unknown ncryptsec".to_string()), + ) { + if let Ok(user_ref) = get_user_details(&public_key, client).await { + user_ref.metadata.name + } else { + "unknown ncryptsec".to_string() + } + } else { + "unknown ncryptsec".to_string() + } + ); + loop { + // prompt for password + let password = Interactor::default() + .password(PromptPasswordParms::default().with_prompt("password")) + .context("failed to get password input from interactor.password")?; + if let Ok(keys) = get_keys_with_password(git_repo, &password) { + break Ok(keys); + } + println!("incorrect password"); + } + } else { + if nsec.is_some() { + bail!(error); + } + Err(error) + } } - key + } { + // get user ref + let user_ref = get_user_details(&keys.public_key(), client).await?; + print_logged_in_as(&user_ref, client.is_none())?; + Ok((keys, user_ref)) } else { - let cfg = ConfigManager - .load() - .context("failed to load application config")?; - // if encrypted nsec present - if cfg.users.last().is_some() && !cfg.users.last().unwrap().encrypted_key.is_empty() { - // unfortunately this line is unstable in rust: - // if let Some(user) = cfg.users.last() && !user.encrypted_key.is_empty() { - let user = cfg.users.last().unwrap(); - let mut pass = if let Some(p) = password.clone() { - p - } else { - println!("login as {}", &user.metadata.name); - Interactor::default() - .password(PromptPasswordParms::default().with_prompt("password")) - .context("failed to get password input from interactor.password")? - }; - - let key_result = Encryptor - .decrypt_key(&user.encrypted_key, pass.as_str()) - .context("failed to decrypt key with provided password"); - pass.zeroize(); - - key_result.context(format!("failed to log in as {}", &user.metadata.name))? + fresh_login(git_repo, client, change_user).await + } +} + +fn print_logged_in_as(user_ref: &UserRef, offline_mode: bool) -> Result<()> { + if !offline_mode && user_ref.metadata.created_at.eq(&0) { + println!("cannot find your account metadata (name, etc) on relays"); + } else if !offline_mode && user_ref.metadata.name.eq(&user_ref.public_key.to_bech32()?) { + println!("cannot extract account name from account metadata..."); + } else if !offline_mode && user_ref.relays.created_at.eq(&0) { + println!( + "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience." + ); + } + println!("logged in as {}", user_ref.metadata.name); + Ok(()) +} + +fn get_keys_without_prompts( + git_repo: &Repo, + nsec: &Option, + password: &Option, + save_local: bool, +) -> Result { + if let Some(nsec) = nsec { + get_keys_from_nsec(git_repo, nsec, password, save_local) + } else if let Some(password) = password { + get_keys_with_password(git_repo, password) + } else if !save_local { + get_keys_with_git_config_nsec_without_prompts(git_repo) + } else { + bail!("user wants prompts to specify new keys") + } +} + +fn get_keys_from_nsec( + git_repo: &Repo, + nsec: &String, + password: &Option, + save_local: bool, +) -> Result { + #[allow(unused_assignments)] + let mut s = String::new(); + let keys = if nsec.contains("ncryptsec") { + s = nsec.to_string(); + decrypt_key( + nsec, + password + .clone() + .context("password must be supplied when using ncryptsec as nsec parameter")? + .as_str(), + ) + .context("failed to decrypt key with provided password") + .context("failed to decrypt ncryptsec supplied as nsec with password")? + } else { + s = nsec.to_string(); + nostr::Keys::from_str(nsec).context("invalid nsec parameter")? + }; + if save_local { + if let Some(password) = password { + s = encrypt_key(&keys, password)?; } - // no encrypted nsec present - else { - // no nsec but password supplied - if password.is_some() { - bail!("no nsec available to decrypt with specified password"); + git_repo + .save_git_config_item("nostr.nsec", &s, false) + .context("failed to save encrypted nsec in local git config nostr.nsec")?; + git_repo.save_git_config_item("nostr.npub", &keys.public_key().to_bech32()?, false)?; + } + Ok(keys) +} + +fn get_keys_with_password(git_repo: &Repo, password: &str) -> Result { + decrypt_key( + &git_repo + .get_git_config_item("nostr.nsec", false) + .context("failed get git config")? + .context("git config item nostr.nsec doesn't exist so cannot decrypt it")?, + password, + ) + .context("failed to decrypt stored nsec key with provided password") +} + +fn get_keys_with_git_config_nsec_without_prompts(git_repo: &Repo) -> Result { + let nsec = &git_repo + .get_git_config_item("nostr.nsec", false) + .context("failed get git config")? + .context("git config item nostr.nsec doesn't exist")?; + if nsec.contains("ncryptsec") { + bail!("git config item nostr.nsec is an ncryptsec") + } + nostr::Keys::from_str(nsec).context("invalid nsec parameter") +} + +async fn fresh_login( + git_repo: &Repo, + #[cfg(test)] client: Option<&MockConnect>, + #[cfg(not(test))] client: Option<&Client>, + always_save: bool, +) -> Result<(nostr::Keys, UserRef)> { + // prompt for nsec + let mut prompt = "login with nsec"; + let keys = loop { + match nostr::Keys::from_str( + &Interactor::default() + .input(PromptInputParms::default().with_prompt(prompt)) + .context("failed to get nsec input from interactor")?, + ) { + Ok(key) => { + break key; + } + Err(_) => { + prompt = "invalid nsec. try again with nsec (or hex private key)"; } - // otherwise add new user with nsec and password prompts - UserManager::default() - .add(nsec, password) - .context("failed to add user")? } }; + // lookup profile + // save keys + if let Err(error) = save_keys(git_repo, &keys, always_save) { + println!("{error}"); + } + let user_ref = get_user_details(&keys.public_key(), client).await?; + print_logged_in_as(&user_ref, client.is_none())?; + Ok((keys, user_ref)) +} + +fn save_keys(git_repo: &Repo, keys: &nostr::Keys, always_save: bool) -> Result<()> { + let store = always_save + || Interactor::default() + .confirm(PromptConfirmParms::default().with_prompt("save login details?"))?; + + let global = !Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt("just for this repository?") + .with_default(false), + )?; + + let encrypt = Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt("require password?") + .with_default(false), + )?; - // get user details - let user_ref = if let Some(client) = client { - get_user_details(&key.public_key(), client).await? + if store { + let npub = keys.public_key().to_bech32()?; + let nsec_string = if encrypt { + let password = Interactor::default() + .password( + PromptPasswordParms::default() + .with_prompt("encrypt with password") + .with_confirm(), + ) + .context("failed to get password input from interactor.password")?; + encrypt_key(keys, &password)? + } else { + keys.secret_key()?.to_bech32()? + }; + + if let Err(error) = git_repo.save_git_config_item("nostr.nsec", &nsec_string, global) { + if global { + println!("failed to edit global git config instead"); + if Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt("save in repository git config?") + .with_default(true), + )? { + git_repo.save_git_config_item("nostr.nsec", &nsec_string, false)?; + git_repo.save_git_config_item("nostr.npub", &npub, false)?; + } + } else { + bail!(error) + } + } else { + git_repo.save_git_config_item("nostr.npub", &npub, global)?; + }; + }; + Ok(()) +} + +fn get_config_item(git_repo: &Repo, name: &str) -> Result { + git_repo + .get_git_config_item(name, false) + .context("failed get git config")? + .context(format!("git config item {name} doesn't exist")) +} + +fn extract_user_metadata( + public_key: &nostr::PublicKey, + events: &[nostr::Event], +) -> Result { + let event = events + .iter() + .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key)) + .max_by_key(|e| e.created_at); + + let metadata: Option = if let Some(event) = event { + Some( + nostr::Metadata::from_json(event.content.clone()) + .context("metadata cannot be found in kind 0 event content")?, + ) } else { - // this will get user details with name as npub - UserManager::default() - .get_user_from_cache(&key.public_key())? - .clone() + None }; - // print logged in - println!("logged in as {}", user_ref.metadata.name); + Ok(UserMetadata { + name: if let Some(metadata) = metadata { + if let Some(n) = metadata.name { + n + } else if let Some(n) = metadata.custom.get("displayName") { + // strip quote marks that custom.get() adds + let binding = n.to_string(); + let mut chars = binding.chars(); + chars.next(); + chars.next_back(); + chars.as_str().to_string() + } else if let Some(n) = metadata.display_name { + n + } else { + public_key.to_bech32()? + } + } else { + public_key.to_bech32()? + }, + created_at: if let Some(event) = event { + event.created_at.as_u64() + } else { + 0 + }, + }) +} - Ok((key, user_ref.clone())) +fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event]) -> UserRelays { + let event = events + .iter() + .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key)) + .max_by_key(|e| e.created_at); + + UserRelays { + relays: if let Some(event) = event { + event + .tags + .iter() + .filter(|t| { + t.kind() + .eq(&nostr::TagKind::SingleLetter(SingleLetterTag::lowercase( + Alphabet::R, + ))) + }) + .map(|t| UserRelayRef { + url: t.as_vec()[1].clone(), + read: t.as_vec().len() == 2 || t.as_vec()[2].eq("read"), + write: t.as_vec().len() == 2 || t.as_vec()[2].eq("write"), + }) + .collect() + } else { + vec![] + }, + created_at: if let Some(event) = event { + event.created_at.as_u64() + } else { + 0 + }, + } } async fn get_user_details( public_key: &PublicKey, - #[cfg(test)] client: &crate::client::MockConnect, - #[cfg(not(test))] client: &Client, + #[cfg(test)] client: Option<&crate::client::MockConnect>, + #[cfg(not(test))] client: Option<&Client>, ) -> Result { - let term = console::Term::stdout(); - term.write_line("searching for profile and relay updates...")?; - let user_manager = UserManager::default(); - let user_ref = user_manager - .get_user( - client, - public_key, - // use cache for 3 minutes - 3 * 60, - ) - .await?; - term.clear_last_lines(1)?; - if user_ref.metadata.created_at.eq(&0) { - println!("cannot find your account metadata (name, etc) on relays",); - // TODO use secondary fallback list of relays. - // TODO better reporting of what relays were checked and what the user - // here is a starter: - // cannot find account details on relays: - // - purplepages.xyz - // - fallbackrelay1 - // - ... - // would you like to: - // [-] proceed anyway - // - add custom fallback relays - } else if user_ref.relays.created_at.eq(&0) { - println!( - "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience." - ); - // TODO better guidance on how to do this + if client.is_some() { + println!("searching for profile and relay updates..."); } + let database = SQLiteDatabase::open(get_dirs()?.config_dir().join("cache.sqlite")).await?; + let mut events: Vec = vec![]; + let filters = vec![ + nostr::Filter::default() + .author(*public_key) + .kind(Kind::Metadata), + nostr::Filter::default() + .author(*public_key) + .kind(Kind::RelayList), + ]; + if let Ok(cached_events) = database.query(filters.clone(), Order::Asc).await { + for event in cached_events { + events.push(event); + } + } + let mut relays_to_search = if let Some(client) = client { + client.get_fallback_relays().clone() + } else { + vec![] + }; + let mut relays_searched = vec![]; + let user_ref = loop { + if let Some(client) = client { + for event in client + .get_events(relays_to_search.clone(), filters.clone()) + .await + .unwrap_or(vec![]) + { + let _ = database.save_event(&event).await; + events.push(event); + } + } + + #[allow(clippy::clone_on_copy)] + let user_ref = UserRef { + public_key: public_key.clone(), + metadata: extract_user_metadata(public_key, &events)?, + relays: extract_user_relays(public_key, &events), + }; + + if client.is_none() { + break user_ref; + } + for r in &relays_to_search { + relays_searched.push(r.clone()); + } + + relays_to_search = user_ref + .relays + .write() + .iter() + .filter(|r| !relays_searched.iter().any(|or| r.eq(&or))) + .map(std::clone::Clone::clone) + .collect(); + if !relays_to_search.is_empty() { + continue; + } + break user_ref; + }; Ok(user_ref) } -- cgit v1.2.3