From 96660a90e4cd296a2922d7a547de4cd9d0b1928b Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 1 Sep 2023 00:00:00 +0000 Subject: feat(login) password login using encrypted nsec Enables the user to only handle the nsec upon first use of the tool by encrypting it with a password and storing it on disk in an application cache. The approach to encryption draws heavily from that used by the gossip nostr client. - unencrypted nsec is zeroed from memory - a salt is used to defend against rainbow tables - computationally expensive key stretching defends against brute-force attacks of passwords with low entropy. There is UX trade-off between decryption speed and key-stretching computation. This UX challenge is exacerbated in a cli tool as decryption must take place more regularly. Thought was put into the selected n_log and a heavily reduced value is provided for long passwords where security benefits are smaller. A more granular reducing in computation was also considered by rejected to avoided to revealing just how weak a password is as most weak passwords are reused. --- src/cli_interactor.rs | 31 ++++- src/config.rs | 111 +++++++++-------- src/key_handling/encryption.rs | 247 ++++++++++++++++++++++++++++++++++++++ src/key_handling/mod.rs | 1 + src/key_handling/users.rs | 262 ++++++++++++++++++++++++++++++++++++----- src/login.rs | 83 +++++++++++-- src/main.rs | 5 +- src/sub_commands/login.rs | 3 +- 8 files changed, 659 insertions(+), 84 deletions(-) create mode 100644 src/key_handling/encryption.rs (limited to 'src') diff --git a/src/cli_interactor.rs b/src/cli_interactor.rs index 2f28aee..d7de087 100644 --- a/src/cli_interactor.rs +++ b/src/cli_interactor.rs @@ -1,5 +1,5 @@ -use anyhow::{bail, Result}; -use dialoguer::{theme::ColorfulTheme, Input}; +use anyhow::Result; +use dialoguer::{theme::ColorfulTheme, Input, Password}; #[cfg(test)] use mockall::*; @@ -11,6 +11,7 @@ pub struct Interactor { #[cfg_attr(test, automock)] pub trait InteractorPrompt { fn input(&self, parms: PromptInputParms) -> Result; + fn password(&self, parms: PromptPasswordParms) -> Result; } impl InteractorPrompt for Interactor { fn input(&self, parms: PromptInputParms) -> Result { @@ -19,6 +20,15 @@ impl InteractorPrompt for Interactor { .interact_text()?; Ok(input) } + fn password(&self, parms: PromptPasswordParms) -> Result { + let mut p = Password::with_theme(&self.theme); + p.with_prompt(parms.prompt); + if parms.confirm { + p.with_confirmation("confirm password", "passwords didnt match..."); + } + let pass: String = p.interact()?; + Ok(pass) + } } #[derive(Default)] @@ -32,3 +42,20 @@ impl PromptInputParms { self } } + +#[derive(Default)] +pub struct PromptPasswordParms { + pub prompt: String, + pub confirm: bool, +} + +impl PromptPasswordParms { + pub fn with_prompt>(mut self, prompt: S) -> Self { + self.prompt = prompt.into(); + self + } + pub const fn with_confirm(mut self) -> Self { + self.confirm = true; + self + } +} diff --git a/src/config.rs b/src/config.rs index b26dea0..f410934 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,6 +4,7 @@ use anyhow::{anyhow, Context, Result}; use directories::ProjectDirs; #[cfg(test)] use mockall::*; +use nostr::secp256k1::XOnlyPublicKey; use serde::{self, Deserialize, Serialize}; #[derive(Default)] @@ -59,7 +60,7 @@ impl ConfigManagement for ConfigManager { } } -#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] #[allow(clippy::module_name_repetitions)] pub struct MyConfig { pub version: u8, @@ -68,44 +69,64 @@ pub struct MyConfig { #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct UserRef { - pub nsec: String, + pub public_key: XOnlyPublicKey, + pub encrypted_key: String, } #[cfg(test)] mod tests { use anyhow::Result; use serial_test::serial; - use test_utils::*; 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<()> { - with_fresh_config(|| { - assert_eq!(ConfigManager.load()?, MyConfig::default()); - - Ok(()) - }) + 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<()> { - with_fresh_config(|| { - let c = ConfigManager; - let new_config = MyConfig { - version: 255, - ..MyConfig::default() - }; - c.save(&new_config)?; - assert_eq!(c.load()?, new_config); - - Ok(()) - }) + 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(()) } } @@ -115,38 +136,36 @@ mod tests { #[test] #[serial] fn when_config_file_doesnt_config_is_saved() -> Result<()> { - with_fresh_config(|| { - let c = ConfigManager; - let new_config = MyConfig { - version: 255, - ..MyConfig::default() - }; - c.save(&new_config)?; - assert_eq!(c.load()?, new_config); - - Ok(()) - }) + 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<()> { - with_fresh_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()?, new_config); - - Ok(()) - }) + 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 new file mode 100644 index 0000000..0ef7f69 --- /dev/null +++ b/src/key_handling/encryption.rs @@ -0,0 +1,247 @@ +use anyhow::{anyhow, bail, ensure, Context, Result}; +use chacha20poly1305::{ + aead::{rand_core::RngCore, Aead, AeadCore, KeyInit, OsRng, Payload}, + XChaCha20Poly1305, +}; +#[cfg(test)] +use mockall::*; +use nostr::{prelude::*, Keys}; +use rand::{distributions::Alphanumeric, thread_rng, Rng}; +use zeroize::Zeroize; + +#[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; + /// generates a long random string + fn random_token(&self) -> String; +} + +/// approach and code adapted from nostr gossip client +impl EncryptDecrypt for Encryptor { + fn encrypt_key(&self, keys: &Keys, password: &str) -> Result { + // Generate a random 16-byte salt + let salt = { + let mut salt: [u8; 16] = [0; 16]; + OsRng.fill_bytes(&mut salt); + salt + }; + + let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng); + + let log2_rounds: u8 = if password.len() > 20 { + // we have enough of entropy - no need to spend CPU time adding much more + 1 + } else { + // default (scrypt::Params::RECOMMENDED_LOG_N) is 17 but 30s is too long to wait + 15 + }; + + let associated_data: Vec = vec![1]; + + let ciphertext = { + let cipher = { + let symmetric_key = password_to_key(password, &salt, log2_rounds) + .context("failed create encryption key from password")?; + XChaCha20Poly1305::new((&symmetric_key).into()) + }; + cipher + .encrypt( + &nonce, + Payload { + msg: keys + .secret_key() + .context( + "supplied key should reveal secret key. Is this a public key only?", + )? + .display_secret() + .to_string() + .as_bytes(), + aad: &associated_data, + }, + ) + .map_err(|_| anyhow!("ChaChaPoly1305 failed to encrypt nsec with password"))? + }; + // Combine salt, IV and ciphertext + let mut concatenation: Vec = Vec::new(); + concatenation.push(0x1); // 1 byte version number + concatenation.push(log2_rounds); // 1 byte for scrypt N (rounds) + concatenation.extend(salt); // 16 bytes of salt + concatenation.extend(nonce); // 24 bytes of nonce + concatenation.extend(associated_data); // 1 byte of key security + concatenation.extend(ciphertext); // 48 bytes of ciphertext expected + // Total length is 91 = 1 + 1 + 16 + 24 + 1 + 48 + + bech32::encode( + "ncryptsec", + concatenation.to_base32(), + bech32::Variant::Bech32, + ) + .context("encrypted nsec failed to encode") + } + + fn decrypt_key(&self, encrypted_key: &str, password: &str) -> Result { + let data = + bech32::decode(encrypted_key).context("failed to decode encrypted key as bech32")?; + if data.0 != "ncryptsec" { + bail!("encrypted key is in the wrong format - it doesnt start with ncryptsec"); + } + let concatenation = Vec::::from_base32(&data.1) + .context("failed to convert bech32::decode output to Vec")?; + + // Break into parts + let version: u8 = concatenation[0]; + ensure!(version == 0x1, "encryption version is incorrect"); + let log2_rounds: u8 = concatenation[1]; + let salt: [u8; 16] = concatenation[2..2 + 16].try_into()?; + let nonce = &concatenation[2 + 16..2 + 16 + 24]; + let associated_data = &concatenation[(2 + 16 + 24)..=(2 + 16 + 24)]; + let ciphertext = &concatenation[2 + 16 + 24 + 1..]; + + let cipher = { + let symmetric_key = password_to_key(password, &salt, log2_rounds)?; + XChaCha20Poly1305::new((&symmetric_key).into()) + }; + + let payload = Payload { + msg: ciphertext, + aad: associated_data, + }; + + let mut inner_secret = cipher + .decrypt(nonce.into(), payload) + .map_err(|_| anyhow!("failed to decrypt"))?; + + if associated_data.is_empty() { + bail!("invalid encrypted key"); + } + + let key = Keys::from_sk_str( + std::str::from_utf8(&inner_secret).context("inner secret is not [u8]")?, + ) + .context("incorrect password. Key decrypted with password did not produce a valid nsec.")?; + + inner_secret.zeroize(); + + Ok(key) + } + + fn random_token(&self) -> String { + thread_rng() + .sample_iter(&Alphanumeric) + .take(32) + .map(char::from) + .collect() + } +} + +/// uses scrypt to stretch password into key +fn password_to_key(password: &str, salt: &[u8; 16], log_n: u8) -> Result<[u8; 32]> { + let params = scrypt::Params::new(log_n, 8, 1, 32) + .context("scrypt failed to generate params to stretch password")?; + let mut key: [u8; 32] = [0; 32]; + if log_n > 14 { + println!("this may take a few seconds..."); + } + + scrypt::scrypt(password.as_bytes(), salt, ¶ms, &mut key) + .context("scrypt failed to stretch password")?; + Ok(key) +} + +#[cfg(test)] +mod tests { + use test_utils::*; + + use super::*; + + #[test] + fn encrypt_key_produces_string_prefixed_with() -> Result<()> { + let s = Encryptor.encrypt_key(&nostr::Keys::generate(), TEST_PASSWORD)?; + assert!(s.starts_with("ncryptsec")); + Ok(()) + } + + #[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)?; + + assert_eq!( + format!( + "{}", + TEST_KEY_1_KEYS.secret_key().unwrap().to_bech32().unwrap() + ), + format!( + "{}", + decrypted_key.secret_key().unwrap().to_bech32().unwrap() + ), + ); + Ok(()) + } + + #[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)?; + + assert_eq!( + format!( + "{}", + TEST_KEY_1_KEYS.secret_key().unwrap().to_bech32().unwrap() + ), + format!( + "{}", + decrypted_key.secret_key().unwrap().to_bech32().unwrap() + ), + ); + Ok(()) + } + + #[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)?; + + assert_eq!( + format!("{}", key.secret_key().unwrap().to_bech32().unwrap()), + format!("{}", newkey.secret_key().unwrap().to_bech32().unwrap()), + ); + Ok(()) + } + + #[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)?; + + assert_eq!( + format!("{}", key.secret_key().unwrap().to_bech32().unwrap()), + format!("{}", newkey.secret_key().unwrap().to_bech32().unwrap()), + ); + Ok(()) + } + + #[test] + fn password_to_key_returns_ok_with_standard_password() { + let salt = { + let mut salt: [u8; 16] = [0; 16]; + OsRng.fill_bytes(&mut salt); + salt + }; + + let log2_rounds: u8 = 1; + + assert!(password_to_key(TEST_PASSWORD, &salt, log2_rounds).is_ok()); + } +} diff --git a/src/key_handling/mod.rs b/src/key_handling/mod.rs index 913bd46..bcb10df 100644 --- a/src/key_handling/mod.rs +++ b/src/key_handling/mod.rs @@ -1 +1,2 @@ +pub mod encryption; pub mod users; diff --git a/src/key_handling/users.rs b/src/key_handling/users.rs index bd1748a..1d2cc34 100644 --- a/src/key_handling/users.rs +++ b/src/key_handling/users.rs @@ -1,47 +1,90 @@ use anyhow::{Context, Result}; +use nostr::prelude::*; +use zeroize::Zeroize; +use super::encryption::{EncryptDecrypt, Encryptor}; use crate::{ - cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, - config::{ConfigManagement, ConfigManager, MyConfig, UserRef}, + cli_interactor::{Interactor, InteractorPrompt, PromptInputParms, PromptPasswordParms}, + config::{self, ConfigManagement, ConfigManager}, }; #[derive(Default)] pub struct UserManager { config_manager: ConfigManager, interactor: Interactor, + encryptor: Encryptor, } pub trait UserManagement { - fn add(&self, nsec: &Option) -> Result<()>; + fn add(&self, nsec: &Option, password: &Option) -> Result; } #[cfg(test)] use duplicate::duplicate_item; #[cfg_attr(test, duplicate_item(UserManager; [UserManager]; [self::tests::MockUserManager]))] impl UserManagement for UserManager { - fn add(&self, nsec: &Option) -> Result<()> { - let nsec = match nsec.clone() { - Some(nsec) => nsec, + 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_sk_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 - .input( - PromptInputParms::default().with_prompt("login with nsec (or hex private key)"), + .password( + PromptPasswordParms::default() + .with_prompt("encrypt with password") + .with_confirm(), ) - .context("failed to get nsec input from interactor.input")?, + .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(); + + let user_ref = config::UserRef { + public_key: keys.public_key(), + encrypted_key: encrypted_secret_key, + }; + + // 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(&keys.public_key())) + .collect(); + cfg.users.push(user_ref); self.config_manager - .save(&MyConfig { - users: vec![UserRef { - nsec: nsec.to_string(), - }], - ..MyConfig::default() - }) + .save(&cfg) .context("failed to save application configuration with new user details in")?; - println!("logged in as {nsec}"); - - Ok(()) + Ok(keys) } } @@ -50,12 +93,17 @@ mod tests { use test_utils::*; use super::*; - use crate::{cli_interactor::MockInteractorPrompt, config::MockConfigManagement}; + 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 { @@ -70,28 +118,88 @@ mod tests { 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 } } - mod when_nsec_is_passed { + 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(); - - let _ = m.add(&Some(TEST_KEY_1_NSEC.into())); + 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() { + fn prompt_for_nsec_and_password() { let mut m = MockUserManager::default().add_return_expected_responses(); m.interactor = MockInteractorPrompt::new(); @@ -100,12 +208,31 @@ mod tests { .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); + let _ = m.add(&None, &None); } #[test] - fn stored_in_config() { + 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(); @@ -114,10 +241,91 @@ mod tests { .returning(|| Ok(MyConfig::default())); m.config_manager .expect_save() - .withf(|cfg| cfg.users.len().eq(&1) && cfg.users[0].nsec.eq(TEST_KEY_1_NSEC)) + .withf(|cfg| { + cfg.users.len().eq(&1) + && cfg.users[0].encrypted_key.eq(TEST_KEY_1_ENCRYPTED) + }) .returning(|_| Ok(())); - let _ = m.add(&None); + 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_sk_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 { + public_key: TEST_KEY_1_KEYS.public_key(), + // different key to TEST_KEY_1_ENCYPTED + encrypted_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 { + public_key: TEST_KEY_2_KEYS.public_key(), + encrypted_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); + } } } } diff --git a/src/login.rs b/src/login.rs index da19a75..a6ce76d 100644 --- a/src/login.rs +++ b/src/login.rs @@ -1,16 +1,85 @@ -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; +use nostr::prelude::{FromSkStr, ToBech32}; +use zeroize::Zeroize; use crate::{ + cli_interactor::{Interactor, InteractorPrompt, PromptPasswordParms}, config::{ConfigManagement, ConfigManager}, - key_handling::users::{UserManagement, UserManager}, + key_handling::{ + encryption::{EncryptDecrypt, Encryptor}, + users::{UserManagement, UserManager}, + }, }; -pub fn launch(nsec: &Option) -> Result<()> { +/// handles the encrpytion and storage of key material +pub fn launch(nsec: &Option, password: &Option) -> Result { + // if nsec parameter + if let Some(nsec_unwrapped) = nsec { + // get key or fail without prompts + let key = nostr::Keys::from_sk_str(nsec_unwrapped).context("invalid nsec parameter")?; + println!( + "logged in as {}", + &key.public_key() + .to_bech32() + .context("public key should always produce bech32")? + ); + + // if password, add user to enable password login in future + if password.is_some() { + UserManager::default() + .add(nsec, password) + .context("could not store identity")?; + } + return Ok(key); + } + + // if encrypted nsec stored, attempt password let cfg = ConfigManager .load() .context("failed to load application config")?; - if !cfg.users.is_empty() { - println!("logged in as {}", cfg.users[0].nsec); - } - UserManager::default().add(nsec) + let key = if let Some(user) = cfg.users.last() { + let mut pass = if let Some(p) = password.clone() { + p + } else { + println!( + "login as {}", + &user + .public_key + .to_bech32() + .context("public key should always produce bech32")? + ); + 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 + .public_key + .to_bech32() + .context("public key should always produce bech32")? + ))? + } else { + // no nsec but password supplied + if password.is_some() { + bail!("no nsec available to decrypt with specified password"); + } + // otherwise add new user with nsec and password prompts + UserManager::default() + .add(nsec, password) + .context("failed to add user")? + }; + println!( + "logged in as {}", + &key.public_key() + .to_bech32() + .context("public key should always produce bech32")? + ); + Ok(key) } diff --git a/src/main.rs b/src/main.rs index d16f1a3..e6eac32 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,8 +17,11 @@ pub struct Cli { #[command(subcommand)] command: Commands, /// nsec or hex private key - #[arg(short, long)] + #[arg(short, long, global = true)] nsec: Option, + /// password to decrypt nsec + #[arg(short, long, global = true)] + password: Option, } #[derive(Subcommand)] diff --git a/src/sub_commands/login.rs b/src/sub_commands/login.rs index d61f578..5391024 100644 --- a/src/sub_commands/login.rs +++ b/src/sub_commands/login.rs @@ -7,5 +7,6 @@ use crate::{login, Cli}; pub struct SubCommandArgs; pub fn launch(args: &Cli, _command_args: &SubCommandArgs) -> Result<()> { - login::launch(&args.nsec) + let _ = login::launch(&args.nsec, &args.password)?; + Ok(()) } -- cgit v1.2.3