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. --- tests/login.rs | 334 +++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 292 insertions(+), 42 deletions(-) (limited to 'tests') diff --git a/tests/login.rs b/tests/login.rs index a7e1889..a75608d 100644 --- a/tests/login.rs +++ b/tests/login.rs @@ -3,6 +3,9 @@ use serial_test::serial; use test_utils::*; static EXPECTED_NSEC_PROMPT: &str = "login with nsec (or hex private key)"; +static EXPECTED_SET_PASSWORD_PROMPT: &str = "encrypt with password"; +static EXPECTED_SET_PASSWORD_CONFIRM_PROMPT: &str = "confirm password"; +static EXPECTED_PASSWORD_PROMPT: &str = "password"; fn standard_login() -> Result { let mut p = CliTester::new(["login"]); @@ -10,6 +13,10 @@ fn standard_login() -> Result { p.expect_input_eventually(EXPECTED_NSEC_PROMPT)? .succeeds_with(TEST_KEY_1_NSEC)?; + p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)? + .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? + .succeeds_with(TEST_PASSWORD)?; + p.expect_end_eventually()?; Ok(p) } @@ -19,11 +26,10 @@ mod when_first_time_login { #[test] #[serial] - fn prompts_for_nsec() -> Result<()> { - with_fresh_config(|| { - standard_login()?; - Ok(()) - }) + fn prompts_for_nsec_and_password() -> Result<()> { + before()?; + standard_login()?; + after() } #[test] @@ -35,36 +41,137 @@ mod when_first_time_login { p.expect_input(EXPECTED_NSEC_PROMPT)? .succeeds_with(TEST_KEY_1_NSEC)?; - p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NSEC).as_str()) + p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)? + .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? + .succeeds_with(TEST_PASSWORD)?; + + p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) }) } #[test] #[serial] - fn next_time_returns_logged_in_as_npub() -> Result<()> { + fn succeeds_with_hex_secret_key_in_place_of_nsec() -> Result<()> { + with_fresh_config(|| { + let mut p = CliTester::new(["login"]); + + p.expect_input(EXPECTED_NSEC_PROMPT)? + .succeeds_with(TEST_KEY_1_SK_HEX)?; + + p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)? + .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? + .succeeds_with(TEST_PASSWORD)?; + + p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) + }) + } + + mod when_invalid_nsec { + use super::*; + + #[test] + #[serial] + fn prompts_for_nsec_until_valid() -> Result<()> { + with_fresh_config(|| { + let invalid_nsec_response = + "invalid nsec. try again with nsec (or hex private key)"; + + let mut p = CliTester::new(["login"]); + + p.expect_input(EXPECTED_NSEC_PROMPT)? + // this behaviour is intentional. rejecting the response with dialoguer hides + // the original input from the user so they cannot see the + // mistake they made. + .succeeds_with(TEST_INVALID_NSEC)?; + + p.expect_input(invalid_nsec_response)? + .succeeds_with(TEST_INVALID_NSEC)?; + + p.expect_input(invalid_nsec_response)? + .succeeds_with(TEST_KEY_1_NSEC)?; + + p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)? + .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? + .succeeds_with(TEST_PASSWORD)?; + + p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) + }) + } + } +} + +mod when_second_time_login { + use super::*; + + #[test] + #[serial] + fn prints_login_as_npub() -> Result<()> { with_fresh_config(|| { standard_login()?.exit()?; CliTester::new(["login"]) - .expect(format!("logged in as {}\r\n", TEST_KEY_1_NSEC).as_str())? + .expect(format!("login as {}\r\n", TEST_KEY_1_NPUB).as_str())? .exit() }) } + + #[test] + #[serial] + fn prompts_for_password_and_succeeds_with_logged_in_as_npub() -> Result<()> { + with_fresh_config(|| { + standard_login()?.exit()?; + + let mut p = CliTester::new(["login"]); + + p.expect(format!("login as {}\r\n", TEST_KEY_1_NPUB).as_str())? + .expect_password(EXPECTED_PASSWORD_PROMPT)? + .succeeds_with(TEST_PASSWORD)?; + + p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) + }) + } + + #[test] + #[serial] + fn when_invalid_password_exit_with_error() -> Result<()> { + with_fresh_config(|| { + standard_login()?.exit()?; + + let mut p = CliTester::new(["login"]); + + p.expect(format!("login as {}\r\n", TEST_KEY_1_NPUB).as_str())? + .expect_password(EXPECTED_PASSWORD_PROMPT)? + .succeeds_with(TEST_INVALID_PASSWORD)?; + p.expect_end_with(format!("Error: failed to log in as {}\r\n\r\nCaused by:\r\n 0: failed to decrypt key with provided password\r\n 1: failed to decrypt\r\n", TEST_KEY_1_NPUB).as_str()) + }) + } } -mod when_called_with_nsec_parameter { +mod when_called_with_nsec_parameter_only { use super::*; #[test] #[serial] fn valid_nsec_param_succeeds_without_prompts() -> Result<()> { with_fresh_config(|| { - CliTester::new(["--nsec", TEST_KEY_2_NSEC, "login"]) - .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_2_NSEC).as_str())?; + CliTester::new(["login", "--nsec", TEST_KEY_1_NSEC]) + .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) + }) + } + + #[test] + #[serial] + fn forgets_identity() -> Result<()> { + with_fresh_config(|| { + CliTester::new(["login", "--nsec", TEST_KEY_1_NSEC]) + .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str())?; - CliTester::new(["login"]) - .expect(format!("logged in as {}\r\n", TEST_KEY_2_NSEC).as_str())? - .exit() + let mut p = CliTester::new(["login"]); + + p.expect_input(EXPECTED_NSEC_PROMPT)? + .succeeds_with(TEST_KEY_1_NSEC)?; + + p.exit() }) } @@ -77,69 +184,212 @@ mod when_called_with_nsec_parameter { with_fresh_config(|| { standard_login()?.exit()?; - CliTester::new(["--nsec", TEST_KEY_2_NSEC, "login"]) - .expect(format!("logged in as {}\r\n", TEST_KEY_1_NSEC).as_str())? - .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_2_NSEC).as_str())?; - - CliTester::new(["login"]) - .expect(format!("logged in as {}\r\n", TEST_KEY_2_NSEC).as_str())? - .exit() + CliTester::new(["login", "--nsec", TEST_KEY_2_NSEC]) + .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_2_NPUB).as_str()) }) } } + #[test] + #[serial] + fn invalid_nsec_param_fails_without_prompts() -> Result<()> { + with_fresh_config(|| { + CliTester::new(["login", "--nsec", TEST_INVALID_NSEC]).expect_end_with( + "Error: invalid nsec parameter\r\n\r\nCaused by:\r\n Invalid secret key\r\n", + ) + }) + } } -mod when_logged_in { +mod when_called_with_nsec_and_password_parameter { use super::*; #[test] #[serial] - fn returns_logged_in_as_npub() -> Result<()> { + fn valid_nsec_param_succeeds_without_prompts() -> Result<()> { with_fresh_config(|| { - standard_login()?.exit()?; + CliTester::new([ + "login", + "--nsec", + TEST_KEY_1_NSEC, + "--password", + TEST_PASSWORD, + ]) + .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) + }) + } + + #[test] + #[serial] + fn remembers_identity() -> Result<()> { + with_fresh_config(|| { + CliTester::new([ + "login", + "--nsec", + TEST_KEY_1_NSEC, + "--password", + TEST_PASSWORD, + ]) + .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str())?; CliTester::new(["login"]) - .expect(format!("logged in as {}\r\n", TEST_KEY_1_NSEC).as_str())? + .expect(format!("login as {}\r\n", TEST_KEY_1_NPUB).as_str())? .exit() }) } #[test] #[serial] - fn prompts_to_log_in_with_different_nsec() -> Result<()> { + fn parameters_can_be_called_globally() -> Result<()> { with_fresh_config(|| { - standard_login()?.exit()?; - - let mut p = CliTester::new(["login"]); - p.expect(format!("logged in as {}\r\n", TEST_KEY_1_NSEC).as_str())?; - - p.expect_input(EXPECTED_NSEC_PROMPT)? - .succeeds_with(TEST_KEY_2_NSEC)?; - - p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_2_NSEC).as_str()) + CliTester::new([ + "--nsec", + TEST_KEY_1_NSEC, + "--password", + TEST_PASSWORD, + "login", + ]) + .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) }) } + mod when_logging_in_as_different_nsec { use super::*; #[test] #[serial] - fn confirmed_as_logged_in_as_additional_user() -> Result<()> { + fn valid_nsec_param_succeeds_without_prompts_and_logs_in() -> Result<()> { with_fresh_config(|| { standard_login()?.exit()?; - let mut p = CliTester::new(["login"]); - p.expect(format!("logged in as {}\r\n", TEST_KEY_1_NSEC).as_str())?; + CliTester::new([ + "login", + "--nsec", + TEST_KEY_2_NSEC, + "--password", + TEST_PASSWORD, + ]) + .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_2_NPUB).as_str()) + }) + } - p.expect_input(EXPECTED_NSEC_PROMPT)? - .succeeds_with(TEST_KEY_2_NSEC)?; + #[test] + #[serial] + fn remembers_identity() -> Result<()> { + with_fresh_config(|| { + standard_login()?.exit()?; - p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_2_NSEC).as_str())?; + CliTester::new([ + "login", + "--nsec", + TEST_KEY_2_NSEC, + "--password", + TEST_PASSWORD, + ]) + .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_2_NPUB).as_str())?; CliTester::new(["login"]) - .expect(format!("logged in as {}\r\n", TEST_KEY_2_NSEC).as_str())? + .expect(format!("login as {}\r\n", TEST_KEY_2_NPUB).as_str())? .exit() }) } } + + mod when_provided_with_new_password { + use super::*; + + #[test] + #[serial] + fn password_changes() -> Result<()> { + with_fresh_config(|| { + standard_login()?.exit()?; + + CliTester::new([ + "login", + "--nsec", + TEST_KEY_1_NSEC, + "--password", + TEST_INVALID_PASSWORD, + ]) + .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str())?; + + CliTester::new(["--password", TEST_INVALID_PASSWORD, "login"]) + .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) + }) + } + } + + #[test] + #[serial] + fn invalid_nsec_param_fails_without_prompts() -> Result<()> { + with_fresh_config(|| { + CliTester::new([ + "login", + "--nsec", + TEST_INVALID_NSEC, + "--password", + TEST_PASSWORD, + ]) + .expect_end_with( + "Error: invalid nsec parameter\r\n\r\nCaused by:\r\n Invalid secret key\r\n", + ) + }) + } +} + +mod when_called_with_password_parameter_only { + use super::*; + + #[test] + #[serial] + fn when_nsec_stored_logs_in_without_prompts() -> Result<()> { + with_fresh_config(|| { + standard_login()?.exit()?; + + CliTester::new(["login", "--password", TEST_PASSWORD]) + .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) + }) + } + + #[test] + #[serial] + fn when_no_nsec_stored_logs_error() -> Result<()> { + with_fresh_config(|| { + CliTester::new(["login", "--password", TEST_PASSWORD]) + .expect_end_with("Error: no nsec available to decrypt with specified password\r\n") + }) + } +} + +mod when_weak_password { + use super::*; + + #[test] + #[serial] + // combined into a single test as it is computationally expensive to run + fn warns_it_might_take_a_few_seconds_then_succeeds_then_second_login_prompts_for_password_then_warns_again_then_succeeds() + -> Result<()> { + with_fresh_config(|| { + let mut p = CliTester::new_with_timeout(10000, ["login"]); + p.expect_input(EXPECTED_NSEC_PROMPT)? + .succeeds_with(TEST_KEY_1_NSEC)?; + + p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)? + .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? + .succeeds_with(TEST_WEAK_PASSWORD)?; + + p.expect("this may take a few seconds...\r\n")?; + + p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str())?; + + p = CliTester::new_with_timeout(10000, ["login"]); + + p.expect(format!("login as {}\r\n", TEST_KEY_1_NPUB).as_str())? + .expect_password(EXPECTED_PASSWORD_PROMPT)? + .succeeds_with(TEST_WEAK_PASSWORD)?; + + p.expect("this may take a few seconds...\r\n")?; + + p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) + }) + } } -- cgit v1.2.3