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/key_handling/users.rs | 262 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 235 insertions(+), 27 deletions(-) (limited to 'src/key_handling/users.rs') 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); + } } } } -- cgit v1.2.3