diff --git a/examples/change_password_cli.rs b/examples/change_password_cli.rs index 65fc05f9..c5acf733 100644 --- a/examples/change_password_cli.rs +++ b/examples/change_password_cli.rs @@ -2,6 +2,7 @@ use std::io; use std::io::Write; use std::{env::args, str::FromStr}; +use bip39::{Language, Mnemonic, MnemonicType}; use rpassword::read_password; use shush_rs::{ExposeSecret, SecretString}; use tracing::{error, info}; @@ -46,4 +47,38 @@ async fn main() { Err(FsError::InvalidDataDirStructure) => error!("Invalid structure of data directory"), Err(err) => error!("Error: {err}"), } + + // Generate a 24-word recovery phrase + let mnemonic = Mnemonic::new(MnemonicType::Words24, Language::English); + let phrase = mnemonic.phrase(); + println!("Your recovery phrase is: {}", phrase); + + // Use the recovery phrase to change the password + print!("Enter recovery phrase: "); + io::stdout().flush().unwrap(); + let recovery_phrase = read_password().unwrap(); + let mnemonic = Mnemonic::from_phrase(&recovery_phrase, Language::English).unwrap(); + let seed = mnemonic.to_seed(""); + let new_password = SecretString::from_str(&hex::encode(seed)).unwrap(); + print!("Confirm new password: "); + io::stdout().flush().unwrap(); + let new_password2 = SecretString::from_str(&read_password().unwrap()).unwrap(); + if new_password.expose_secret() != new_password2.expose_secret() { + error!("Passwords do not match"); + return; + } + println!("Changing password using recovery phrase..."); + match EncryptedFs::passwd( + Path::new(&data_dir), + SecretString::from_str(&recovery_phrase).unwrap(), + new_password, + Cipher::ChaCha20Poly1305, + ) + .await + { + Ok(()) => info!("Password changed successfully using recovery phrase"), + Err(FsError::InvalidPassword) => error!("Invalid recovery phrase"), + Err(FsError::InvalidDataDirStructure) => error!("Invalid structure of data directory"), + Err(err) => error!("Error: {err}"), + } } diff --git a/src/crypto.rs b/src/crypto.rs index 72ca489a..0ee6b8cd 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -10,6 +10,7 @@ use base64::alphabet::STANDARD; use base64::engine::general_purpose::NO_PAD; use base64::engine::GeneralPurpose; use base64::{DecodeError, Engine}; +use bip39::{Language, Mnemonic, MnemonicType, Seed}; use hex::FromHexError; use num_format::{Locale, ToFormattedString}; use rand_chacha::rand_core::{CryptoRng, RngCore, SeedableRng}; @@ -380,6 +381,20 @@ where Ok(()) } +pub fn generate_recovery_phrase(language: Language) -> Mnemonic { + Mnemonic::new(MnemonicType::Words24, language) +} + +pub fn derive_key_from_recovery_phrase( + recovery_phrase: &str, + language: Language, +) -> Result> { + let mnemonic = Mnemonic::from_phrase(recovery_phrase, language) + .map_err(|err| Error::GenericString(err.to_string()))?; + let seed = mnemonic.to_seed(""); + Ok(SecretVec::new(Box::new(seed.as_bytes().to_vec()))) +} + #[cfg(test)] mod tests { use super::*; @@ -650,4 +665,19 @@ mod tests { assert!(result.is_err()); } + + #[test] + fn test_generate_recovery_phrase() { + let mnemonic = generate_recovery_phrase(Language::English); + let phrase = mnemonic.phrase(); + assert_eq!(phrase.split_whitespace().count(), 24); + } + + #[test] + fn test_derive_key_from_recovery_phrase() { + let mnemonic = generate_recovery_phrase(Language::English); + let phrase = mnemonic.phrase(); + let derived_key = derive_key_from_recovery_phrase(phrase, Language::English).unwrap(); + assert_eq!(derived_key.expose_secret().len(), 64); + } } diff --git a/src/encryptedfs.rs b/src/encryptedfs.rs index 7c2da5a4..d7dd5c8d 100644 --- a/src/encryptedfs.rs +++ b/src/encryptedfs.rs @@ -41,6 +41,7 @@ pub(crate) const CONTENTS_DIR: &str = "contents"; pub(crate) const SECURITY_DIR: &str = "security"; pub(crate) const KEY_ENC_FILENAME: &str = "key.enc"; pub(crate) const KEY_SALT_FILENAME: &str = "key.salt"; +pub(crate) const RECOVERY_PHRASE_KEY_ENC_FILENAME: &str = "recovery_phrase_key.enc"; pub(crate) const LS_DIR: &str = "ls"; pub(crate) const HASH_DIR: &str = "hash"; @@ -2158,6 +2159,65 @@ impl EncryptedFs { Ok(()) } + /// Change the password of the filesystem using the recovery phrase. + pub async fn passwd_with_recovery_phrase( + data_dir: &Path, + recovery_phrase: &str, + new_password: SecretString, + cipher: Cipher, + ) -> FsResult<()> { + check_structure(data_dir, false).await?; + // decrypt key using recovery phrase + let salt: Vec = bincode::deserialize_from(File::open( + data_dir.join(SECURITY_DIR).join(KEY_SALT_FILENAME), + )?)?; + let initial_key = crypto::derive_key_from_recovery_phrase(recovery_phrase, cipher, &salt)?; + let enc_file = data_dir.join(SECURITY_DIR).join(RECOVERY_PHRASE_KEY_ENC_FILENAME); + let reader = crypto::create_read(File::open(enc_file)?, cipher, &initial_key); + let key: Vec = + bincode::deserialize_from(reader).map_err(|_| FsError::InvalidPassword)?; + let key = SecretBox::new(Box::new(key)); + // encrypt it with a new key derived from new password + let new_key = crypto::derive_key(&new_password, cipher, &salt)?; + crypto::atomic_serialize_encrypt_into( + &data_dir.join(SECURITY_DIR).join(KEY_ENC_FILENAME), + &*key.expose_secret(), + cipher, + &new_key, + )?; + Ok(()) + } + + /// Regenerate the recovery phrase for the filesystem. + pub async fn regenerate_recovery_phrase( + data_dir: &Path, + password: SecretString, + old_recovery_phrase: &str, + new_recovery_phrase: &str, + cipher: Cipher, + ) -> FsResult<()> { + check_structure(data_dir, false).await?; + // decrypt key using old recovery phrase + let salt: Vec = bincode::deserialize_from(File::open( + data_dir.join(SECURITY_DIR).join(KEY_SALT_FILENAME), + )?)?; + let initial_key = crypto::derive_key_from_recovery_phrase(old_recovery_phrase, cipher, &salt)?; + let enc_file = data_dir.join(SECURITY_DIR).join(RECOVERY_PHRASE_KEY_ENC_FILENAME); + let reader = crypto::create_read(File::open(enc_file)?, cipher, &initial_key); + let key: Vec = + bincode::deserialize_from(reader).map_err(|_| FsError::InvalidPassword)?; + let key = SecretBox::new(Box::new(key)); + // encrypt it with a new key derived from new recovery phrase + let new_key = crypto::derive_key_from_recovery_phrase(new_recovery_phrase, cipher, &salt)?; + crypto::atomic_serialize_encrypt_into( + &data_dir.join(SECURITY_DIR).join(RECOVERY_PHRASE_KEY_ENC_FILENAME), + &*key.expose_secret(), + cipher, + &new_key, + )?; + Ok(()) + } + fn next_handle(&self) -> u64 { self.current_handle .fetch_add(1, std::sync::atomic::Ordering::SeqCst) diff --git a/src/run.rs b/src/run.rs index ab84b7fb..a76e9f85 100644 --- a/src/run.rs +++ b/src/run.rs @@ -190,6 +190,20 @@ fn get_cli_args() -> ArgMatches { .value_name("DATA_DIR") .help("Where to store the encrypted data"), ) + .arg( + Arg::new("recovery-phrase") + .long("recovery-phrase") + .short('r') + .value_name("RECOVERY_PHRASE") + .help("Use the recovery phrase to change the password"), + ) + .arg( + Arg::new("refresh-recovery-phrase") + .long("refresh-recovery-phrase") + .short('f') + .action(ArgAction::SetTrue) + .help("Regenerate the recovery phrase"), + ) ) .get_matches() } @@ -224,6 +238,82 @@ async fn async_main() -> Result<()> { async fn run_change_password(cipher: Cipher, matches: &ArgMatches) -> Result<()> { let data_dir: String = matches.get_one::("data-dir").unwrap().to_string(); + if matches.get_flag("refresh-recovery-phrase") { + // read password from stdin + print!("Enter password: "); + io::stdout().flush().unwrap(); + let password = SecretString::from_str(&read_password().unwrap()).unwrap(); + print!("Enter old recovery phrase: "); + io::stdout().flush().unwrap(); + let old_recovery_phrase = read_password().unwrap(); + print!("Enter new recovery phrase: "); + io::stdout().flush().unwrap(); + let new_recovery_phrase = read_password().unwrap(); + println!("Regenerating recovery phrase..."); + EncryptedFs::regenerate_recovery_phrase( + Path::new(&data_dir), + password, + &old_recovery_phrase, + &new_recovery_phrase, + cipher, + ) + .await + .map_err(|err| { + match err { + FsError::InvalidPassword => { + println!("Invalid password or recovery phrase"); + } + FsError::InvalidDataDirStructure => { + println!("Invalid structure of data directory"); + } + _ => { + error!(err = %err); + } + } + ExitStatusError::Failure(1) + })?; + println!("Recovery phrase regenerated successfully"); + return Ok(()); + } + + if let Some(recovery_phrase) = matches.get_one::("recovery-phrase") { + // read new password from stdin + print!("Enter new password: "); + io::stdout().flush().unwrap(); + let new_password = SecretString::from_str(&read_password().unwrap()).unwrap(); + print!("Confirm new password: "); + io::stdout().flush().unwrap(); + let new_password2 = SecretString::from_str(&read_password().unwrap()).unwrap(); + if new_password.expose_secret() != new_password2.expose_secret() { + println!("Passwords do not match"); + return Err(ExitStatusError::Failure(1).into()); + } + println!("Changing password using recovery phrase..."); + EncryptedFs::passwd_with_recovery_phrase( + Path::new(&data_dir), + recovery_phrase, + new_password, + cipher, + ) + .await + .map_err(|err| { + match err { + FsError::InvalidPassword => { + println!("Invalid recovery phrase"); + } + FsError::InvalidDataDirStructure => { + println!("Invalid structure of data directory"); + } + _ => { + error!(err = %err); + } + } + ExitStatusError::Failure(1) + })?; + println!("Password changed successfully using recovery phrase"); + return Ok(()); + } + // read password from stdin print!("Enter old password: "); io::stdout().flush().unwrap();