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/encryption.rs | 247 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 src/key_handling/encryption.rs (limited to 'src/key_handling/encryption.rs') 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()); + } +} -- cgit v1.2.3