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/config.rs | 189 +------ src/key_handling/encryption.rs | 77 +-- src/key_handling/mod.rs | 1 - src/key_handling/users.rs | 1174 ---------------------------------------- src/login.rs | 461 ++++++++++++---- src/sub_commands/init.rs | 9 +- src/sub_commands/login.rs | 9 +- src/sub_commands/push.rs | 9 +- src/sub_commands/send.rs | 9 +- 9 files changed, 429 insertions(+), 1509 deletions(-) delete mode 100644 src/key_handling/users.rs (limited to 'src') diff --git a/src/config.rs b/src/config.rs index 7fca446..56619b8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,107 +1,19 @@ -use std::{fs::File, io::BufReader}; - -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, Result}; use directories::ProjectDirs; -#[cfg(test)] -use mockall::*; -use nostr::{PublicKey, ToBech32}; +use nostr::PublicKey; use serde::{self, Deserialize, Serialize}; -#[derive(Default)] -#[allow(clippy::module_name_repetitions)] -pub struct ConfigManager; - -#[cfg_attr(test, automock)] -#[allow(clippy::module_name_repetitions)] -pub trait ConfigManagement { - fn load(&self) -> Result; - fn save(&self, cfg: &MyConfig) -> Result<()>; -} - pub fn get_dirs() -> Result { ProjectDirs::from("", "CodeCollaboration", "ngit").ok_or(anyhow!( "should find operating system home directories with rust-directories crate" )) } -impl ConfigManagement for ConfigManager { - fn load(&self) -> Result { - let config_path = get_dirs()?.config_dir().join("config.json"); - if config_path.exists() { - let file = - File::open(config_path).context("should open application configuration file")?; - let reader = BufReader::new(file); - let config: MyConfig = serde_json::from_reader(reader) - .context("should read config from config file with serde_json")?; - Ok(config) - } else { - Ok(MyConfig::default()) - } - } - fn save(&self, cfg: &MyConfig) -> Result<()> { - let dirs = get_dirs()?; - let config_path = dirs.config_dir().join("config.json"); - let file = if config_path.exists() { - std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(config_path) - .context( - "should open application configuration file with write and truncate options", - )? - } else { - std::fs::create_dir_all(dirs.config_dir()) - .context("should create application config directories")?; - std::fs::File::create(config_path).context("should create application config file")? - }; - serde_json::to_writer_pretty(file, cfg) - .context("should write configuration to config file with serde_json") - } -} - -#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] -#[allow(clippy::module_name_repetitions)] -pub struct MyConfig { - pub version: u8, - pub users: Vec, -} - #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct UserRef { pub public_key: PublicKey, - pub encrypted_key: String, pub metadata: UserMetadata, pub relays: UserRelays, - pub last_checked: u64, -} - -impl UserRef { - pub fn new(public_key: PublicKey, encrypted_key: String) -> Self { - Self { - public_key, - encrypted_key, - relays: UserRelays { - relays: vec![], - created_at: 0, - }, - metadata: UserMetadata { - #[allow(clippy::expect_used)] - name: public_key - .to_bech32() - .expect("public key should always produce bech32"), - // name: format!( - // "{}", - // public_key - // .to_bech32() - // .expect("public key should always produce bech32"), - // ) - // .as_str()[..10].to_string(), - created_at: 0, - }, - last_checked: 0, - } - } } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] @@ -132,100 +44,3 @@ pub struct UserRelayRef { pub read: bool, pub write: bool, } - -#[cfg(test)] -mod tests { - use anyhow::Result; - use serial_test::serial; - - use super::*; - - fn backup_existing_config() -> Result<()> { - let config_path = get_dirs()?.config_dir().join("config.json"); - let backup_config_path = get_dirs()?.config_dir().join("config-backup.json"); - if config_path.exists() { - std::fs::rename(config_path, backup_config_path)?; - } - Ok(()) - } - - fn restore_config_backup() -> Result<()> { - let config_path = get_dirs()?.config_dir().join("config.json"); - let backup_config_path = get_dirs()?.config_dir().join("config-backup.json"); - if config_path.exists() { - std::fs::remove_file(&config_path)?; - } - if backup_config_path.exists() { - std::fs::rename(backup_config_path, config_path)?; - } - Ok(()) - } - - mod load { - use super::*; - - #[test] - #[serial] - fn when_config_file_doesnt_exist_defaults_are_returned() -> Result<()> { - backup_existing_config()?; - let c = ConfigManager; - assert_eq!(c.load()?, MyConfig::default()); - restore_config_backup()?; - Ok(()) - } - - #[test] - #[serial] - fn when_config_file_exists_it_is_returned() -> Result<()> { - backup_existing_config()?; - let c = ConfigManager; - let new_config = MyConfig { - version: 255, - ..MyConfig::default() - }; - c.save(&new_config)?; - assert_eq!(c.load()?, new_config); - restore_config_backup()?; - Ok(()) - } - } - - mod save { - use super::*; - - #[test] - #[serial] - fn when_config_file_doesnt_config_is_saved() -> Result<()> { - backup_existing_config()?; - let c = ConfigManager; - let new_config = MyConfig { - version: 255, - ..MyConfig::default() - }; - c.save(&new_config)?; - assert_eq!(c.load().unwrap(), new_config); - restore_config_backup()?; - Ok(()) - } - - #[test] - #[serial] - fn when_config_file_exists_new_config_is_saved() -> Result<()> { - backup_existing_config()?; - let c = ConfigManager; - let config = MyConfig { - version: 255, - ..MyConfig::default() - }; - c.save(&config)?; - let new_config = MyConfig { - version: 254, - ..MyConfig::default() - }; - c.save(&new_config)?; - assert_eq!(c.load().unwrap(), new_config); - restore_config_backup()?; - Ok(()) - } - } -} diff --git a/src/key_handling/encryption.rs b/src/key_handling/encryption.rs index 3f4ee41..3841d50 100644 --- a/src/key_handling/encryption.rs +++ b/src/key_handling/encryption.rs @@ -1,46 +1,31 @@ use anyhow::Result; -#[cfg(test)] -use mockall::*; use nostr::{prelude::*, Keys}; -#[derive(Default)] -pub struct Encryptor; - -#[cfg_attr(test, automock)] -pub trait EncryptDecrypt { - /// requires less CPU time if the password is long - fn encrypt_key(&self, keys: &Keys, password: &str) -> Result; - fn decrypt_key(&self, encrypted_key: &str, password: &str) -> Result; +pub fn encrypt_key(keys: &Keys, password: &str) -> Result { + let log2_rounds: u8 = if password.len() > 20 { + // we have enough of entropy - no need to spend CPU time adding much more + 1 + } else { + println!("this may take a few seconds..."); + // default (scrypt::Params::RECOMMENDED_LOG_N) is 17 but 30s is too long to wait + 15 + }; + Ok(nostr::nips::nip49::EncryptedSecretKey::new( + keys.secret_key()?, + password, + log2_rounds, + KeySecurity::Medium, + )? + .to_bech32()?) } -/// approach and code adapted from nostr gossip client -impl EncryptDecrypt for Encryptor { - fn encrypt_key(&self, keys: &Keys, password: &str) -> Result { - let log2_rounds: u8 = if password.len() > 20 { - // we have enough of entropy - no need to spend CPU time adding much more - 1 - } else { - println!("this may take a few seconds..."); - // default (scrypt::Params::RECOMMENDED_LOG_N) is 17 but 30s is too long to wait - 15 - }; - Ok(nostr::nips::nip49::EncryptedSecretKey::new( - keys.secret_key()?, - password, - log2_rounds, - KeySecurity::Medium, - )? - .to_bech32()?) - } - - fn decrypt_key(&self, encrypted_key: &str, password: &str) -> Result { - let encrypted_key = nostr::nips::nip49::EncryptedSecretKey::from_bech32(encrypted_key)?; - // to request that log_n gets exposed - if encrypted_key.log_n() > 14 { - println!("this may take a few seconds..."); - } - Ok(nostr::Keys::new(encrypted_key.to_secret_key(password)?)) +pub fn decrypt_key(encrypted_key: &str, password: &str) -> Result { + let encrypted_key = nostr::nips::nip49::EncryptedSecretKey::from_bech32(encrypted_key)?; + // to request that log_n gets exposed + if encrypted_key.log_n() > 14 { + println!("this may take a few seconds..."); } + Ok(nostr::Keys::new(encrypted_key.to_secret_key(password)?)) } #[cfg(test)] @@ -51,7 +36,7 @@ mod tests { #[test] fn encrypt_key_produces_string_prefixed_with() -> Result<()> { - let s = Encryptor.encrypt_key(&nostr::Keys::generate(), TEST_PASSWORD)?; + let s = encrypt_key(&nostr::Keys::generate(), TEST_PASSWORD)?; assert!(s.starts_with("ncryptsec")); Ok(()) } @@ -59,8 +44,7 @@ mod tests { #[test] // ensures password encryption hasn't changed fn decrypts_with_strong_password_from_reference_string() -> Result<()> { - let encryptor = Encryptor; - let decrypted_key = encryptor.decrypt_key(TEST_KEY_1_ENCRYPTED, TEST_PASSWORD)?; + let decrypted_key = decrypt_key(TEST_KEY_1_ENCRYPTED, TEST_PASSWORD)?; assert_eq!( format!( @@ -78,8 +62,7 @@ mod tests { #[test] // ensures password encryption hasn't changed fn decrypts_with_weak_password_from_reference_string() -> Result<()> { - let encryptor = Encryptor; - let decrypted_key = encryptor.decrypt_key(TEST_KEY_1_ENCRYPTED_WEAK, TEST_WEAK_PASSWORD)?; + let decrypted_key = decrypt_key(TEST_KEY_1_ENCRYPTED_WEAK, TEST_WEAK_PASSWORD)?; assert_eq!( format!( @@ -96,10 +79,9 @@ mod tests { #[test] fn decrypts_key_encrypted_using_encrypt_key() -> Result<()> { - let encryptor = Encryptor; let key = nostr::Keys::generate(); - let s = encryptor.encrypt_key(&key, TEST_PASSWORD)?; - let newkey = encryptor.decrypt_key(s.as_str(), TEST_PASSWORD)?; + let s = encrypt_key(&key, TEST_PASSWORD)?; + let newkey = decrypt_key(s.as_str(), TEST_PASSWORD)?; assert_eq!( format!("{}", key.secret_key().unwrap().to_bech32().unwrap()), @@ -110,10 +92,9 @@ mod tests { #[test] fn decrypt_key_successfully_decrypts_key_encrypted_using_encrypt_key() -> Result<()> { - let encryptor = Encryptor; let key = nostr::Keys::generate(); - let s = encryptor.encrypt_key(&key, TEST_PASSWORD)?; - let newkey = encryptor.decrypt_key(s.as_str(), TEST_PASSWORD)?; + let s = encrypt_key(&key, TEST_PASSWORD)?; + let newkey = decrypt_key(s.as_str(), TEST_PASSWORD)?; assert_eq!( format!("{}", key.secret_key().unwrap().to_bech32().unwrap()), diff --git a/src/key_handling/mod.rs b/src/key_handling/mod.rs index bcb10df..81c4253 100644 --- a/src/key_handling/mod.rs +++ b/src/key_handling/mod.rs @@ -1,2 +1 @@ pub mod encryption; -pub mod users; diff --git a/src/key_handling/users.rs b/src/key_handling/users.rs deleted file mode 100644 index a79a977..0000000 --- a/src/key_handling/users.rs +++ /dev/null @@ -1,1174 +0,0 @@ -use std::{str::FromStr, time::SystemTime}; - -use anyhow::{Context, Result}; -use async_trait::async_trait; -use nostr::prelude::*; -use zeroize::Zeroize; - -use super::encryption::{EncryptDecrypt, Encryptor}; -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; -use crate::{ - cli_interactor::{Interactor, InteractorPrompt, PromptInputParms, PromptPasswordParms}, - client::Connect, - config::{ - self, ConfigManagement, ConfigManager, UserMetadata, UserRef, UserRelayRef, UserRelays, - }, -}; - -#[derive(Default)] -pub struct UserManager { - config_manager: ConfigManager, - interactor: Interactor, - encryptor: Encryptor, -} - -#[async_trait] -pub trait UserManagement { - fn add(&self, nsec: &Option, password: &Option) -> Result; - async fn get_user( - &self, - #[cfg(test)] client: &MockConnect, - #[cfg(not(test))] client: &Client, - public_key: &PublicKey, - after: u64, - ) -> Result; - fn get_user_from_cache(&self, public_key: &PublicKey) -> Result; - fn add_user_to_config( - &self, - public_key: PublicKey, - encrypted_secret_key: Option, - overwrite: bool, - ) -> Result<()>; -} - -#[cfg(test)] -use duplicate::duplicate_item; -#[cfg_attr(test, duplicate_item(UserManager; [UserManager]; [self::tests::MockUserManager]))] -#[async_trait] -impl UserManagement for UserManager { - fn add(&self, nsec: &Option, password: &Option) -> Result { - let mut prompt = "login with nsec (or hex private key)"; - let keys = loop { - let pk = match nsec.clone() { - Some(nsec) => nsec, - None => self - .interactor - .input(PromptInputParms::default().with_prompt(prompt)) - .context("failed to get nsec input from interactor")?, - }; - match Keys::from_str(&pk) { - Ok(key) => { - break key; - } - Err(e) => { - if nsec.is_some() { - return Err(e).context( - "invalid nsec - supplied parameter could not be converted into a nostr private key", - ); - } - prompt = "invalid nsec. try again with nsec (or hex private key)"; - } - } - }; - - let mut pass = match password.clone() { - Some(pass) => pass, - None => self - .interactor - .password( - PromptPasswordParms::default() - .with_prompt("encrypt with password") - .with_confirm(), - ) - .context("failed to get password input from interactor.password")?, - }; - - let encrypted_secret_key = self - .encryptor - .encrypt_key(&keys, &pass) - .context("failed to encrypt nsec with password.")?; - pass.zeroize(); - - self.add_user_to_config(keys.public_key(), Some(encrypted_secret_key), true)?; - - Ok(keys) - } - - fn add_user_to_config( - &self, - public_key: PublicKey, - encrypted_secret_key: Option, - overwrite: bool, - ) -> Result<()> { - let user_ref = config::UserRef::new(public_key, encrypted_secret_key.unwrap_or_default()); - - let mut cfg = self.config_manager.load().context("failed to load application config to find and remove any old versions of the user's encrypted key")?; - // don't overwrite unless specified - if !overwrite - && cfg - .users - .clone() - .into_iter() - .any(|r| r.public_key.eq(&public_key)) - { - return Ok(()); - } - // if overwrite remove any duplicate entries for key before adding it to config - cfg.users = cfg - .users - .clone() - .into_iter() - .filter(|r| !r.public_key.eq(&public_key)) - .collect(); - cfg.users.push(user_ref); - self.config_manager - .save(&cfg) - .context("failed to save application configuration with new user details in") - } - - fn get_user_from_cache(&self, public_key: &PublicKey) -> Result { - let cfg = self - .config_manager - .load() - .context("failed to load application config")?; - Ok(cfg - .users - .iter() - .find(|u| u.public_key.eq(public_key)) - .context(format!("pubkey isn't a current user: {public_key}"))? - .clone()) - } - /// get UserRef fetching most recent user relays and metadata infomation - /// from - #[allow(clippy::too_many_lines)] - async fn get_user( - &self, - #[cfg(test)] client: &MockConnect, - #[cfg(not(test))] client: &Client, - public_key: &PublicKey, - use_cache_unless_checked_more_than_x_secs_ago: u64, - ) -> Result { - let cfg = self - .config_manager - .load() - .context("failed to load application config")?; - let mut user_ref = cfg - .users - .iter() - .find(|u| u.public_key.eq(public_key)) - .context(format!("pubkey isn't a current user: {public_key}"))? - .clone(); - // return cache if last fetched was within X minutes - if !unix_timestamp_after_now_plus_secs( - user_ref.last_checked, - use_cache_unless_checked_more_than_x_secs_ago, - ) { - return Ok(user_ref); - } - - let mut relays_to_search = if user_ref.relays.write().is_empty() { - client.get_fallback_relays().clone() - } else { - user_ref.relays.write() - }; - - let mut relays_searched: Vec = vec![]; - - loop { - for r in &relays_to_search { - if !relays_searched.iter().any(|sr| r.eq(sr)) { - relays_searched.push(r.clone()); - } - } - - let events: Vec = match client - .get_events( - relays_to_search, - vec![ - nostr::Filter::default() - .author(*public_key) - .since(nostr::Timestamp::from(user_ref.metadata.created_at + 1)) - .kind(Kind::Metadata), - nostr::Filter::default() - .author(*public_key) - .since(nostr::Timestamp::from(user_ref.relays.created_at + 1)) - .kind(Kind::RelayList), - ], - ) - .await - { - Ok(events) => events, - Err(_) => { - return Ok(user_ref.clone()); - } - }; - - user_ref.last_checked = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .context("system time should be after the year 1970")? - .as_secs(); - - if let Some(new_metadata_event) = events - .iter() - .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key)) - .max_by_key(|e| e.created_at) - { - if new_metadata_event.created_at.as_u64() > user_ref.metadata.created_at { - let metadata = nostr::Metadata::from_json(new_metadata_event.content.clone()) - .context("metadata cannot be found in kind 0 event content")?; - user_ref.metadata = UserMetadata { - name: 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 { - user_ref.metadata.name - }, - created_at: new_metadata_event.created_at.as_u64(), - }; - } - }; - - if let Some(new_relays_event) = events - .iter() - .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key)) - .max_by_key(|e| e.created_at) - { - if new_relays_event.created_at.as_u64() > user_ref.relays.created_at { - let new_relay_list = UserRelays { - relays: new_relays_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(), - created_at: new_relays_event.created_at.as_u64(), - }; - let new_relays: Vec = new_relay_list - .write() - .iter() - .filter(|r| !relays_searched.iter().any(|or| r.eq(&or))) - .map(std::clone::Clone::clone) - .collect(); - user_ref.relays = new_relay_list; - - if !new_relays.is_empty() { - relays_to_search = new_relays; - continue; - } - } - }; - - // remove any duplicate entries for key before adding it to config - let mut cfg = self.config_manager.load().context("failed to load application config to find and remove any old versions of the user's encrypted key")?; - cfg.users = cfg - .users - .clone() - .into_iter() - .filter(|r| !r.public_key.eq(public_key)) - .collect(); - cfg.users.push(user_ref.clone()); - self.config_manager - .save(&cfg) - .context("failed to save application configuration with new user details in")?; - break; - } - Ok(user_ref) - } -} - -fn unix_timestamp_after_now_plus_secs(timestamp: u64, secs: u64) -> bool { - if let Ok(now) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { - now.as_secs() > (timestamp + secs) - } else { - true - } -} - -#[cfg(test)] -mod tests { - use nostr; - use test_utils::*; - - use super::*; - use crate::{ - cli_interactor::MockInteractorPrompt, - config::{MockConfigManagement, MyConfig, UserRef}, - key_handling::encryption::MockEncryptDecrypt, - }; - - #[derive(Default)] - pub struct MockUserManager { - pub config_manager: MockConfigManagement, - pub interactor: MockInteractorPrompt, - pub encryptor: MockEncryptDecrypt, - } - - mod add { - use super::*; - - impl MockUserManager { - fn add_return_expected_responses(mut self) -> Self { - self.config_manager - .expect_load() - .returning(|| Ok(MyConfig::default())); - self.config_manager.expect_save().returning(|_| Ok(())); - self.interactor - .expect_input() - .returning(|_| Ok(TEST_KEY_1_NSEC.into())); - self.interactor - .expect_password() - .returning(|_| Ok(TEST_PASSWORD.into())); - self.encryptor - .expect_encrypt_key() - .returning(|_, _| Ok(TEST_KEY_1_ENCRYPTED.into())); - self - } - } - - fn reuable_user_isnt_prompted(nsec: &str) { - let mut m = MockUserManager::default().add_return_expected_responses(); - m.interactor = MockInteractorPrompt::default(); - m.interactor.expect_input().never(); - m.interactor.expect_password().never(); - let _ = m.add(&Some(nsec.into()), &Some(TEST_PASSWORD.to_string())); - } - - fn reuable_config_isnt_modified(nsec: &str) { - let mut m = MockUserManager::default(); - m.config_manager.expect_save().never(); - let _ = m.add(&Some(nsec.into()), &Some(TEST_PASSWORD.to_string())); - } - - mod when_valid_nsec_and_password_is_passed { - use super::*; - - #[test] - fn user_isnt_prompted() { - reuable_user_isnt_prompted(TEST_KEY_1_NSEC); - } - - #[test] - fn results_in_correct_keys() { - let mut m = MockUserManager::default().add_return_expected_responses(); - m.interactor = MockInteractorPrompt::default(); - m.interactor.expect_input().never(); - m.interactor.expect_password().never(); - let r = m.add( - &Some(TEST_KEY_1_NSEC.into()), - &Some(TEST_PASSWORD.to_string()), - ); - assert!(r.is_ok(), "should result in keys"); - assert!( - r.is_ok_and(|k| k - .secret_key() - .is_ok_and(|k| k.display_secret().to_string().eq(TEST_KEY_1_SK_HEX))), - "keys should reflect nsec" - ); - } - } - mod when_invalid_nsec_is_passed_with_password { - use super::*; - - #[test] - fn user_isnt_prompted() { - reuable_user_isnt_prompted(TEST_INVALID_NSEC); - } - - #[test] - fn config_isnt_modified() { - reuable_config_isnt_modified(TEST_INVALID_NSEC); - } - - #[test] - fn results_in_an_error() { - let m = MockUserManager::default(); - assert!( - m.add( - &Some(TEST_INVALID_NSEC.into()), - &Some(TEST_PASSWORD.to_string()) - ) - .is_err(), - "should result in an error" - ); - } - } - mod when_no_nsec_is_passed { - use super::*; - - #[test] - fn prompt_for_nsec_and_password() { - let mut m = MockUserManager::default().add_return_expected_responses(); - - m.interactor = MockInteractorPrompt::new(); - m.interactor - .expect_input() - .once() - .withf(|p| p.prompt.eq("login with nsec (or hex private key)")) - .returning(|_| Ok(TEST_KEY_1_NSEC.into())); - m.interactor - .expect_password() - .once() - .withf(|p| p.prompt.eq("encrypt with password")) - .returning(|_| Ok(TEST_KEY_1_NSEC.into())); - - let _ = m.add(&None, &None); - } - - #[test] - fn results_in_correct_keys() { - let m = MockUserManager::default().add_return_expected_responses(); - - let r = m.add(&None, &None); - assert!(r.is_ok(), "should result in keys"); - assert!( - r.is_ok_and(|k| k - .secret_key() - .is_ok_and(|k| k.display_secret().to_string().eq(TEST_KEY_1_SK_HEX))), - "keys should reflect nsec" - ); - } - - #[test] - fn stores_encrypted_key_in_config() { - let mut m = MockUserManager::default().add_return_expected_responses(); - - m.config_manager = MockConfigManagement::new(); - m.config_manager - .expect_load() - .returning(|| Ok(MyConfig::default())); - m.config_manager - .expect_save() - .withf(|cfg| { - cfg.users.len().eq(&1) - && cfg.users[0].encrypted_key.eq(TEST_KEY_1_ENCRYPTED) - }) - .returning(|_| Ok(())); - - let _ = m.add(&None, &None); - } - - #[test] - fn stored_key_encrypted_with_password() { - let mut m = MockUserManager::default().add_return_expected_responses(); - - m.encryptor = MockEncryptDecrypt::new(); - m.encryptor - .expect_encrypt_key() - .once() - .withf(|k, p| { - k.eq(&Keys::from_str(TEST_KEY_1_NSEC).unwrap()) && p.eq(TEST_PASSWORD) - }) - .returning(|_, _| Ok(TEST_KEY_1_ENCRYPTED.into())); - - let _ = m.add(&None, &None); - } - - mod when_user_key_already_stored { - use super::*; - use crate::config::UserRef; - - /// key overwritten as password may have changed - #[test] - fn key_not_saved_as_duplicate_but_encrypted_key_overwritten() { - let mut m = MockUserManager::default().add_return_expected_responses(); - - m.config_manager = MockConfigManagement::default(); - m.config_manager.expect_load().returning(|| { - Ok(MyConfig { - users: vec![UserRef::new( - TEST_KEY_1_KEYS.public_key(), - TEST_KEY_2_ENCRYPTED.into(), - )], - ..MyConfig::default() - }) - }); - m.config_manager - .expect_save() - .withf(|cfg| { - cfg.users.len() == 1 - && cfg.users[0].encrypted_key.eq(TEST_KEY_1_ENCRYPTED) - }) - .returning(|_| Ok(())); - - let _ = m.add(&None, &None); - } - } - - mod when_multiple_users_added { - use super::*; - - #[test] - fn both_user_keys_are_stored() { - let mut m = MockUserManager::default().add_return_expected_responses(); - - m.config_manager = MockConfigManagement::default(); - m.config_manager.expect_load().returning(|| { - Ok(MyConfig { - users: vec![UserRef::new( - TEST_KEY_2_KEYS.public_key(), - TEST_KEY_2_ENCRYPTED.into(), - )], - ..MyConfig::default() - }) - }); - m.config_manager - .expect_save() - .withf(|cfg| { - cfg.users.len() == 2 - // latest user stored at end of array - && cfg.users[1].encrypted_key.eq(TEST_KEY_1_ENCRYPTED) - }) - .returning(|_| Ok(())); - - let _ = m.add(&None, &None); - } - } - } - } - - fn now_timestamp() -> u64 { - SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap() - .as_secs() - } - fn roughly_now(timestamp: u64) -> bool { - let now = now_timestamp(); - timestamp < now + 100 && timestamp > now - 100 - } - - mod get_user { - use anyhow::anyhow; - - use super::*; - use crate::client::MockConnect; - - fn generate_relaylist_event() -> nostr::Event { - nostr::event::EventBuilder::new( - nostr::Kind::RelayList, - "", - [ - nostr::Tag::from_standardized(nostr::TagStandard::RelayMetadata { - relay_url: nostr::Url::from_str("wss://fredswrite1.relay/").unwrap(), - metadata: Some(RelayMetadata::Write), - }), - nostr::Tag::from_standardized(nostr::TagStandard::RelayMetadata { - relay_url: nostr::Url::from_str("wss://fredsread1.relay/").unwrap(), - metadata: Some(RelayMetadata::Read), - }), - nostr::Tag::from_standardized(nostr::TagStandard::RelayMetadata { - relay_url: nostr::Url::from_str("wss://fredsreadwrite.relay/").unwrap(), - metadata: None, - }), - ], - ) - .to_event(&TEST_KEY_1_KEYS) - .unwrap() - } - - fn generate_relaylist_event_user_2() -> nostr::Event { - nostr::event::EventBuilder::new( - nostr::Kind::RelayList, - "", - [ - nostr::Tag::from_standardized(nostr::TagStandard::RelayMetadata { - relay_url: nostr::Url::from_str("wss://carolswrite1.relay/").unwrap(), - metadata: Some(RelayMetadata::Write), - }), - nostr::Tag::from_standardized(nostr::TagStandard::RelayMetadata { - relay_url: nostr::Url::from_str("wss://carolsread1.relay/").unwrap(), - metadata: Some(RelayMetadata::Read), - }), - nostr::Tag::from_standardized(nostr::TagStandard::RelayMetadata { - relay_url: nostr::Url::from_str("wss://carolsreadwrite.relay/").unwrap(), - metadata: None, - }), - ], - ) - .to_event(&TEST_KEY_2_KEYS) - .unwrap() - } - - fn fallback_relays() -> Vec { - vec!["ws://fallback1".to_string(), "ws://fallback2".to_string()].clone() - } - - fn generate_mock_client() -> MockConnect { - let mut client = ::default(); - client - .expect_get_fallback_relays() - .return_const(fallback_relays()); - client - } - - fn generate_standard_config() -> MyConfig { - MyConfig { - users: vec![UserRef { - public_key: TEST_KEY_1_KEYS.public_key(), - encrypted_key: TEST_KEY_1_ENCRYPTED.to_string(), - metadata: UserMetadata { - name: "Fred".to_string(), - created_at: 10, - }, - relays: UserRelays { - relays: vec![ - UserRelayRef { - url: "ws://existingread".to_string(), - read: true, - write: false, - }, - UserRelayRef { - url: "ws://existingreadwrite".to_string(), - read: true, - write: true, - }, - UserRelayRef { - url: "ws://existingwrite".to_string(), - read: false, - write: true, - }, - ], - created_at: 10, - }, - last_checked: now_timestamp() - (60 * 60), // 1h ago - }], - ..MyConfig::default() - } - .clone() - } - - fn expected_userrelayrefs_write1() -> UserRelayRef { - UserRelayRef { - url: "wss://fredswrite1.relay/".into(), - read: false, - write: true, - } - .clone() - } - - fn expected_userrelayrefs_read_write1() -> UserRelayRef { - UserRelayRef { - url: "wss://fredsreadwrite.relay/".into(), - read: true, - write: true, - } - .clone() - } - - fn expected_userrelayrefs() -> Vec { - vec![ - expected_userrelayrefs_write1(), - UserRelayRef { - url: "wss://fredsread1.relay/".into(), - read: true, - write: false, - }, - expected_userrelayrefs_read_write1(), - ] - } - - mod when_within_caching_time_window { - use super::*; - - #[tokio::test] - async fn returns_cached_details_without_checking_relays_or_updaing_config() -> Result<()> - { - let mut m = MockUserManager::default(); - let client = generate_mock_client(); - m.config_manager - .expect_load() - .returning(|| Ok(generate_standard_config())); - let res = m - .get_user( - &client, - &TEST_KEY_1_KEYS.public_key(), - 24 * 60 * 60, // within 24 hours - ) - .await?; - assert_eq!(res.metadata.name, "Fred"); - assert_eq!(res.relays.relays[0].url, "ws://existingread"); - Ok(()) - } - } - - mod returns_userref_with_latest_details_from_events_on_relays { - use super::*; - - #[tokio::test] - async fn name() -> Result<()> { - let mut m = MockUserManager::default(); - let mut client = generate_mock_client(); - m.config_manager - .expect_load() - .returning(|| Ok(generate_standard_config())); - m.config_manager.expect_save().returning(|_| Ok(())); - client - .expect_get_events() - .returning(|_, _| Ok(vec![generate_test_key_1_metadata_event("fred")])); - - let res = m - .get_user( - &client, - &TEST_KEY_1_KEYS.public_key(), - 5 * 60, // 5 mins ago - ) - .await?; - assert_eq!(res.metadata.name, "fred"); - Ok(()) - } - - #[tokio::test] - async fn name_ignoring_other_users_events() -> Result<()> { - let mut m = MockUserManager::default(); - let mut client = generate_mock_client(); - m.config_manager - .expect_load() - .returning(|| Ok(generate_standard_config())); - m.config_manager.expect_save().returning(|_| Ok(())); - client.expect_get_events().returning(|_, _| { - Ok(vec![ - generate_test_key_2_metadata_event("carole"), - generate_test_key_1_metadata_event_old("fred"), - ]) - }); - - let res = m - .get_user( - &client, - &TEST_KEY_1_KEYS.public_key(), - 5 * 60, // 5 mins ago - ) - .await?; - assert_eq!(res.metadata.name, "fred"); - Ok(()) - } - - #[tokio::test] - async fn relays() -> Result<()> { - let mut m = MockUserManager::default(); - let mut client = generate_mock_client(); - m.config_manager - .expect_load() - .returning(|| Ok(generate_standard_config())); - m.config_manager.expect_save().returning(|_| Ok(())); - client.expect_get_events().returning(|_, _| { - Ok(vec![ - generate_test_key_1_metadata_event("fred"), - generate_relaylist_event(), - ]) - }); - - let res = m - .get_user( - &client, - &TEST_KEY_1_KEYS.public_key(), - 5 * 60, // 5 mins ago - ) - .await?; - assert_eq!(res.relays.relays, expected_userrelayrefs(),); - Ok(()) - } - - #[tokio::test] - async fn relays_ignoring_other_users_events() -> Result<()> { - let mut m = MockUserManager::default(); - let mut client = generate_mock_client(); - m.config_manager - .expect_load() - .returning(|| Ok(generate_standard_config())); - m.config_manager.expect_save().returning(|_| Ok(())); - client.expect_get_events().returning(|_, _| { - Ok(vec![ - make_event_old_or_change_user( - generate_relaylist_event(), - &TEST_KEY_1_KEYS, - 10000, - ), - generate_relaylist_event_user_2(), - ]) - }); - - let res = m - .get_user( - &client, - &TEST_KEY_1_KEYS.public_key(), - 5 * 60, // 5 mins ago - ) - .await?; - assert_eq!(res.relays.relays, expected_userrelayrefs(),); - Ok(()) - } - } - - mod saves_updates_to_config { - use super::*; - - #[tokio::test] - async fn saves_name_to_config() -> Result<()> { - let mut m = MockUserManager::default(); - let mut client = generate_mock_client(); - m.config_manager - .expect_load() - .returning(|| Ok(generate_standard_config())); - m.config_manager - .expect_save() - .once() - .withf(|cfg| cfg.users[0].metadata.name.eq("fred")) - .returning(|_| Ok(())); - client - .expect_get_events() - .returning(|_, _| Ok(vec![generate_test_key_1_metadata_event("fred")])); - - let _ = m - .get_user( - &client, - &TEST_KEY_1_KEYS.public_key(), - 5 * 60, // 5 mins ago - ) - .await?; - Ok(()) - } - - #[tokio::test] - async fn updates_metadata_created_at() -> Result<()> { - let mut m = MockUserManager::default(); - let mut client = generate_mock_client(); - m.config_manager - .expect_load() - .returning(|| Ok(generate_standard_config())); - m.config_manager - .expect_save() - .once() - .withf(|cfg| roughly_now(cfg.users[0].metadata.created_at)) - .returning(|_| Ok(())); - client - .expect_get_events() - .returning(|_, _| Ok(vec![generate_test_key_1_metadata_event("fred")])); - - let _ = m - .get_user( - &client, - &TEST_KEY_1_KEYS.public_key(), - 5 * 60, // 5 mins ago - ) - .await?; - Ok(()) - } - - #[tokio::test] - async fn saves_relays_to_config() -> Result<()> { - let mut m = MockUserManager::default(); - let mut client = generate_mock_client(); - m.config_manager - .expect_load() - .returning(|| Ok(generate_standard_config())); - m.config_manager - .expect_save() - .once() - .withf(|cfg| expected_userrelayrefs().eq(&cfg.users[0].relays.relays)) - .returning(|_| Ok(())); - client - .expect_get_events() - .returning(|_, _| Ok(vec![generate_relaylist_event()])); - - let _ = m - .get_user( - &client, - &TEST_KEY_1_KEYS.public_key(), - 5 * 60, // 5 mins ago - ) - .await?; - Ok(()) - } - - #[tokio::test] - async fn updates_relays_created_at() -> Result<()> { - let mut m = MockUserManager::default(); - let mut client = generate_mock_client(); - m.config_manager - .expect_load() - .returning(|| Ok(generate_standard_config())); - m.config_manager - .expect_save() - .once() - .withf(|cfg| roughly_now(cfg.users[0].relays.created_at)) - .returning(|_| Ok(())); - client - .expect_get_events() - .returning(|_, _| Ok(vec![generate_relaylist_event()])); - - let _ = m - .get_user( - &client, - &TEST_KEY_1_KEYS.public_key(), - 5 * 60, // 5 mins ago - ) - .await?; - Ok(()) - } - - #[tokio::test] - async fn when_no_changes_updates_last_updated() -> Result<()> { - let mut m = MockUserManager::default(); - let mut client = generate_mock_client(); - m.config_manager - .expect_load() - .returning(|| Ok(generate_standard_config())); - m.config_manager - .expect_save() - .once() - .withf(|cfg| roughly_now(cfg.users[0].last_checked)) - .returning(|_| Ok(())); - client.expect_get_events().returning(|_, _| Ok(vec![])); - - let _ = m - .get_user( - &client, - &TEST_KEY_1_KEYS.public_key(), - 5 * 60, // 5 mins ago - ) - .await?; - Ok(()) - } - - #[tokio::test] - async fn when_changes_updates_last_updated() -> Result<()> { - let mut m = MockUserManager::default(); - let mut client = generate_mock_client(); - m.config_manager - .expect_load() - .returning(|| Ok(generate_standard_config())); - m.config_manager - .expect_save() - .once() - .withf(|cfg| roughly_now(cfg.users[0].last_checked)) - .returning(|_| Ok(())); - client - .expect_get_events() - .returning(|_, _| Ok(vec![generate_test_key_1_metadata_event("fred")])); - - let _ = m - .get_user( - &client, - &TEST_KEY_1_KEYS.public_key(), - 5 * 60, // 5 mins ago - ) - .await?; - Ok(()) - } - } - - mod fetches_from_correct_relays { - use super::*; - #[tokio::test] - async fn when_userref_write_relays_present_fetches_only_from_them() -> Result<()> { - let mut m = MockUserManager::default(); - let mut client = generate_mock_client(); - m.config_manager - .expect_load() - .returning(|| Ok(generate_standard_config())); - m.config_manager.expect_save().returning(|_| Ok(())); - client - .expect_get_events() - .once() - .withf(move |relays, _filters| { - vec![ - "ws://existingreadwrite".to_string(), - "ws://existingwrite".to_string(), - ] - .eq(relays) - }) - .returning(|_, _| Ok(vec![])); - - let _ = m - .get_user( - &client, - &TEST_KEY_1_KEYS.public_key(), - 5 * 60, // 5 mins ago - ) - .await?; - Ok(()) - } - - #[tokio::test] - async fn when_userref_write_relays_not_present_fetches_from_fallback_relays() - -> Result<()> { - let mut m = MockUserManager::default(); - let mut client = generate_mock_client(); - m.config_manager.expect_load().returning(|| { - Ok(MyConfig { - users: vec![UserRef { - relays: UserRelays { - relays: vec![], - created_at: 0, - }, - ..generate_standard_config().users[0].clone() - }], - ..generate_standard_config() - }) - }); - m.config_manager.expect_save().returning(|_| Ok(())); - client - .expect_get_events() - .once() - .withf(move |relays, _filters| fallback_relays().eq(relays)) - .returning(|_, _| Ok(vec![])); - - let _ = m - .get_user( - &client, - &TEST_KEY_1_KEYS.public_key(), - 5 * 60, // 5 mins ago - ) - .await?; - Ok(()) - } - - mod fetches_from_new_relays_discovered_in_incoming_relay_list { - use super::*; - - #[tokio::test] - async fn when_all_relays_in_list_are_new_finds_name() -> Result<()> { - let mut m = MockUserManager::default(); - let mut client = generate_mock_client(); - m.config_manager.expect_load().returning(|| { - Ok(MyConfig { - users: vec![UserRef { - relays: UserRelays { - relays: vec![], - created_at: 0, - }, - ..generate_standard_config().users[0].clone() - }], - ..generate_standard_config() - }) - }); - m.config_manager.expect_save().returning(|_| Ok(())); - client - .expect_get_events() - .times(2) - .withf(move |relays, _filters| { - fallback_relays().eq(relays) - || UserRelays { - relays: expected_userrelayrefs(), - created_at: 0, - } - .write() - .eq(relays) - }) - .returning(|relays, _| { - if fallback_relays().eq(&relays) { - Ok(vec![generate_relaylist_event()]) - } else if (UserRelays { - relays: expected_userrelayrefs(), - created_at: 0, - }) - .write() - .eq(&relays) - { - Ok(vec![generate_test_key_1_metadata_event("fred")]) - } else { - Ok(vec![]) - } - }); - - let res = m - .get_user( - &client, - &TEST_KEY_1_KEYS.public_key(), - 5 * 60, // 5 mins ago - ) - .await?; - assert_eq!(res.metadata.name, "fred"); - Ok(()) - } - - #[tokio::test] - async fn only_fetches_from_newly_added_relays() -> Result<()> { - let mut m = MockUserManager::default(); - let mut client = generate_mock_client(); - m.config_manager.expect_load().returning(|| { - Ok(MyConfig { - users: vec![UserRef { - relays: UserRelays { - relays: vec![expected_userrelayrefs_write1()], - created_at: 0, - }, - ..generate_standard_config().users[0].clone() - }], - ..generate_standard_config() - }) - }); - m.config_manager.expect_save().returning(|_| Ok(())); - client - .expect_get_events() - .times(2) - .withf(move |relays, _filters| { - vec![expected_userrelayrefs_write1().url].eq(relays) - || vec![expected_userrelayrefs_read_write1().url].eq(relays) - }) - .returning(|relays, _| { - if vec![expected_userrelayrefs_write1().url].eq(&relays) { - Ok(vec![generate_relaylist_event()]) - } else if vec![expected_userrelayrefs_read_write1().url].eq(&relays) { - Ok(vec![generate_test_key_1_metadata_event("fred")]) - } else { - Ok(vec![]) - } - }); - - let res = m - .get_user( - &client, - &TEST_KEY_1_KEYS.public_key(), - 5 * 60, // 5 mins ago - ) - .await?; - assert_eq!(res.metadata.name, "fred"); - Ok(()) - } - } - } - - #[tokio::test] - async fn when_failed_to_fetch_events_returns_cached_details() -> Result<()> { - let mut m = MockUserManager::default(); - let mut client = generate_mock_client(); - m.config_manager - .expect_load() - .returning(|| Ok(generate_standard_config())); - client - .expect_get_events() - .returning(|_, _| Err(anyhow!("test error"))); - - let res = m - .get_user( - &client, - &TEST_KEY_1_KEYS.public_key(), - 5 * 60, // 10 mins ago - ) - .await?; - assert_eq!(res.metadata.name, "Fred"); - Ok(()) - } - } -} 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) } diff --git a/src/sub_commands/init.rs b/src/sub_commands/init.rs index edaca15..4d1bdfb 100644 --- a/src/sub_commands/init.rs +++ b/src/sub_commands/init.rs @@ -59,7 +59,14 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { #[cfg(test)] let mut client = ::default(); - let (keys, user_ref) = login::launch(&cli_args.nsec, &cli_args.password, Some(&client)).await?; + let (keys, user_ref) = login::launch( + &git_repo, + &cli_args.nsec, + &cli_args.password, + Some(&client), + false, + ) + .await?; client.set_keys(&keys).await; diff --git a/src/sub_commands/login.rs b/src/sub_commands/login.rs index 43ce480..e71d431 100644 --- a/src/sub_commands/login.rs +++ b/src/sub_commands/login.rs @@ -1,11 +1,11 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use clap; #[cfg(not(test))] use crate::client::Client; #[cfg(test)] use crate::client::MockConnect; -use crate::{client::Connect, login, Cli}; +use crate::{client::Connect, git::Repo, login, Cli}; #[derive(clap::Args)] pub struct SubCommandArgs { @@ -15,8 +15,9 @@ pub struct SubCommandArgs { } pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { + let git_repo = Repo::discover().context("cannot find a git repository")?; if command_args.offline { - login::launch(&args.nsec, &args.password, None).await?; + login::launch(&git_repo, &args.nsec, &args.password, None, true).await?; Ok(()) } else { #[cfg(not(test))] @@ -24,7 +25,7 @@ pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { #[cfg(test)] let client = ::default(); - login::launch(&args.nsec, &args.password, Some(&client)).await?; + login::launch(&git_repo, &args.nsec, &args.password, Some(&client), true).await?; client.disconnect().await?; Ok(()) } diff --git a/src/sub_commands/push.rs b/src/sub_commands/push.rs index fefe102..ade2ff8 100644 --- a/src/sub_commands/push.rs +++ b/src/sub_commands/push.rs @@ -148,7 +148,14 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { ahead.len() ); - let (keys, user_ref) = login::launch(&cli_args.nsec, &cli_args.password, Some(&client)).await?; + let (keys, user_ref) = login::launch( + &git_repo, + &cli_args.nsec, + &cli_args.password, + Some(&client), + false, + ) + .await?; client.set_keys(&keys).await; diff --git a/src/sub_commands/send.rs b/src/sub_commands/send.rs index c8d900c..8971d8b 100644 --- a/src/sub_commands/send.rs +++ b/src/sub_commands/send.rs @@ -178,7 +178,14 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { } else { None }; - let (keys, user_ref) = login::launch(&cli_args.nsec, &cli_args.password, Some(&client)).await?; + let (keys, user_ref) = login::launch( + &git_repo, + &cli_args.nsec, + &cli_args.password, + Some(&client), + false, + ) + .await?; client.set_keys(&keys).await; -- cgit v1.2.3