diff --git a/src/config.rs b/src/config.rs index 4be1cb49a..e7a00f7a0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,17 +9,33 @@ use std::{ time::{Duration, Instant, SystemTime}, }; -use anyhow::Result; +use anyhow::{anyhow, Result}; use bytes::Bytes; use rand::Rng; use regex::Regex; use serde as de; use serde_derive::{Deserialize, Serialize}; use serde_json; -use sha2::{Digest, Sha256}; use sodiumoxide::base64; use sodiumoxide::crypto::sign; +mod permanent_password; + +pub use permanent_password::{ + compute_permanent_password_h1, decode_permanent_password_h1_from_storage, + local_permanent_password_storage_is_usable_for_auth, + preset_permanent_password_storage_is_usable_for_auth, ENCRYPT_MAX_LEN, +}; +use permanent_password::{ + decode_permanent_password_h1_from_hashed_storage, decrypt_permanent_password_str_or_original, + encode_permanent_password_encrypted_storage_from_h1, normalize_preset_password_storage, + password_is_empty_or_not_hashed, permanent_password_storage_is_hashed, + preset_permanent_password_storage_matches_plain, DEFAULT_SALT_LEN, PASSWORD_ENC_VERSION, + PERMANENT_PASSWORD_H1_LEN, +}; +#[cfg(test)] +use permanent_password::{is_permanent_password_hashed_storage, PERMANENT_PASSWORD_ENC_VERSION}; + use crate::{ compress::{compress, decompress}, log, @@ -39,57 +55,6 @@ pub const READ_TIMEOUT: u64 = 18_000; pub const REG_INTERVAL: i64 = 15_000; pub const COMPRESS_LEVEL: i32 = 3; const SERIAL: i32 = 3; -const PASSWORD_ENC_VERSION: &str = "00"; -pub const ENCRYPT_MAX_LEN: usize = 128; // used for password, pin, etc, not for all - -const PERMANENT_PASSWORD_HASH_PREFIX: &str = "01"; -const PERMANENT_PASSWORD_H1_LEN: usize = 32; -const DEFAULT_SALT_LEN: usize = 32; - -fn is_permanent_password_hashed_storage(v: &str) -> bool { - decode_permanent_password_h1_from_storage(v).is_some() -} - -pub fn compute_permanent_password_h1( - password: &str, - salt: &str, -) -> [u8; PERMANENT_PASSWORD_H1_LEN] { - let mut hasher = Sha256::new(); - hasher.update(password.as_bytes()); - hasher.update(salt.as_bytes()); - let out = hasher.finalize(); - let mut h1 = [0u8; PERMANENT_PASSWORD_H1_LEN]; - h1.copy_from_slice(&out[..PERMANENT_PASSWORD_H1_LEN]); - h1 -} - -fn constant_time_eq_32(a: &[u8; 32], b: &[u8; 32]) -> bool { - sodiumoxide::utils::memcmp(a, b) -} - -fn encode_permanent_password_storage_from_h1(h1: &[u8; PERMANENT_PASSWORD_H1_LEN]) -> String { - PERMANENT_PASSWORD_HASH_PREFIX.to_owned() + &base64::encode(h1, base64::Variant::Original) -} - -pub fn decode_permanent_password_h1_from_storage( - storage: &str, -) -> Option<[u8; PERMANENT_PASSWORD_H1_LEN]> { - let encoded = storage.strip_prefix(PERMANENT_PASSWORD_HASH_PREFIX)?; - - let v = base64::decode(encoded.as_bytes(), base64::Variant::Original).ok()?; - if v.len() != PERMANENT_PASSWORD_H1_LEN { - return None; - } - let mut h1 = [0u8; PERMANENT_PASSWORD_H1_LEN]; - h1.copy_from_slice(&v[..PERMANENT_PASSWORD_H1_LEN]); - Some(h1) -} - -// If password is empty or not hashed storage, it's safe to update salt. -fn password_is_empty_or_not_hashed(permanent_password_storage: &str) -> bool { - permanent_password_storage.is_empty() - || !is_permanent_password_hashed_storage(permanent_password_storage) -} #[cfg(target_os = "macos")] lazy_static::lazy_static! { @@ -637,7 +602,10 @@ impl Config { fn load() -> Config { let mut config = Config::load_::(""); let mut store = false; - store |= Self::migrate_permanent_password_to_hashed_storage(&mut config); + match Self::migrate_permanent_password_to_encrypted_hashed_storage(&mut config) { + Ok(changed) => store |= changed, + Err(err) => log::error!("Failed to migrate permanent password storage: {err}"), + } let mut id_valid = false; let (id, encrypted, store2) = decrypt_str_or_original(&config.enc_id, PASSWORD_ENC_VERSION); if encrypted { @@ -676,44 +644,87 @@ impl Config { config } - fn migrate_permanent_password_to_hashed_storage(config: &mut Config) -> bool { - if config.password.is_empty() || is_permanent_password_hashed_storage(&config.password) { - return false; + fn migrate_permanent_password_to_encrypted_hashed_storage(config: &mut Config) -> Result { + if config.password.is_empty() { + return Ok(false); } if config.password.starts_with(PASSWORD_ENC_VERSION) { - let (plain, decrypted, looks_like_plaintext) = - decrypt_str_or_original(&config.password, PASSWORD_ENC_VERSION); - // `decrypt_str_or_original` returns (value, decrypted_ok, should_store). - // If the value looks like an encrypted payload ("00" + base64 with MAC) but cannot be - // decrypted on this machine, it is most likely copied from another device or corrupted. - // In normal single-machine setups this should be extremely rare, so keep it as-is. - if !decrypted && !looks_like_plaintext { - return false; + return Self::migrate_encrypted_or_00_prefixed_password(config); + } + let (decrypted_storage, decrypted, _) = + decrypt_permanent_password_str_or_original(&config.password); + if decrypted { + Self::ensure_permanent_password_hash_salt(config)?; + if decode_permanent_password_h1_from_hashed_storage(&decrypted_storage).is_some() { + return Ok(false); } - if config.salt.is_empty() { - config.salt = Config::get_auto_password(DEFAULT_SALT_LEN); - } - if is_permanent_password_hashed_storage(&plain) { - config.password = plain; - return true; - } - let h1 = compute_permanent_password_h1(&plain, &config.salt); - config.password = encode_permanent_password_storage_from_h1(&h1); - return true; + return Err(anyhow!("Invalid permanent password encrypted hash storage")); } + Self::ensure_permanent_password_salt(config); + let h1 = compute_permanent_password_h1(&config.password, &config.salt); + Self::set_permanent_password_h1_storage(config, &h1) + } + + fn migrate_encrypted_or_00_prefixed_password(config: &mut Config) -> Result { + let (plain, decrypted, looks_like_plaintext) = + decrypt_str_or_original(&config.password, PASSWORD_ENC_VERSION); + // If the value looks like an encrypted payload but cannot be decrypted on this + // machine, it is most likely copied from another device or corrupted. + if !decrypted && !looks_like_plaintext { + return Ok(false); + } + Self::ensure_permanent_password_salt(config); + let h1 = compute_permanent_password_h1(&plain, &config.salt); + Self::set_permanent_password_h1_storage(config, &h1) + } + + fn ensure_permanent_password_hash_salt(config: &Config) -> Result<()> { + if config.salt.is_empty() { + return Err(anyhow!( + "Permanent password hash storage requires a non-empty salt" + )); + } + Ok(()) + } + + fn ensure_permanent_password_salt(config: &mut Config) { if config.salt.is_empty() { config.salt = Config::get_auto_password(DEFAULT_SALT_LEN); } - let h1 = compute_permanent_password_h1(&config.password, &config.salt); - config.password = encode_permanent_password_storage_from_h1(&h1); - true + } + + fn set_permanent_password_h1_storage( + config: &mut Config, + h1: &[u8; PERMANENT_PASSWORD_H1_LEN], + ) -> Result { + let Some(storage) = encode_permanent_password_encrypted_storage_from_h1(h1) else { + return Err(anyhow!("Failed to encrypt permanent password hash storage")); + }; + config.password = storage; + Ok(true) + } + + fn prepare_config_for_store(config: &mut Config) { + match Self::migrate_permanent_password_to_encrypted_hashed_storage(config) { + Ok(_) => {} + Err(err) => { + // This path is for unrecoverable permanent-password storage, such as + // hashed storage without its salt. Keep unrelated config writes working, + // but handle future transient migration errors separately. + log::error!( + "Clearing invalid permanent password storage before storing config: {err}" + ); + config.password.clear(); + config.salt.clear(); + } + } } fn store(&self) { let mut config = self.clone(); - Self::migrate_permanent_password_to_hashed_storage(&mut config); + Self::prepare_config_for_store(&mut config); config.enc_id = encrypt_str_or_original(&config.id, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); config.id = "".to_owned(); Config::store_(&config, ""); @@ -1269,47 +1280,52 @@ impl Config { log::info!("id updated from {} to {}", id, new_id); } - pub fn set_permanent_password(password: &str) { + /// Sets the local permanent password. + /// + /// Returns `true` when the password is accepted or already matches the effective + /// preset password. Returns `false` when changing the password is disabled or + /// the new password cannot be prepared for storage. + pub fn set_permanent_password(password: &str) -> bool { if Self::is_disable_change_permanent_password() { - return; + return false; } - if HARD_SETTINGS - .read() - .unwrap() - .get("password") - .map_or(false, |v| v == password) + let (preset_storage, preset_salt) = Self::get_preset_password_storage_and_salt(); + if preset_permanent_password_storage_matches_plain(&preset_storage, &preset_salt, password) { if CONFIG.read().unwrap().password.is_empty() { - return; + return true; } } let mut config = CONFIG.write().unwrap(); let stored = if password.is_empty() { - String::new() + Some(String::new()) } else { Self::compute_permanent_password_storage_for_update(&mut config, password) }; + let Some(stored) = stored else { + log::error!("Failed to compute permanent password storage; refusing update"); + return false; + }; if stored == config.password { - return; + return true; } config.password = stored; config.store(); Self::clear_trusted_devices(); + true } fn compute_permanent_password_storage_for_update( config: &mut Config, password: &str, - ) -> String { + ) -> Option { // Keep salt stable for user-initiated permanent password updates. // Salt should only change when service->user sync updates storage and salt as a pair. - if config.salt.is_empty() { - config.salt = Config::get_auto_password(DEFAULT_SALT_LEN); - } + Self::ensure_permanent_password_salt(config); let h1 = compute_permanent_password_h1(password, &config.salt); - encode_permanent_password_storage_from_h1(&h1) + encode_permanent_password_encrypted_storage_from_h1(&h1) } /// Returns the locally persisted permanent password storage and salt (NOT the hard/preset one). @@ -1328,62 +1344,99 @@ impl Config { salt: &str, ) -> crate::ResultType { let mut config = CONFIG.write().unwrap(); + if !Self::apply_permanent_password_storage_for_sync(&mut config, storage, salt)? { + return Ok(false); + } + + config.store(); + Self::clear_trusted_devices(); + Ok(true) + } + + fn apply_permanent_password_storage_for_sync( + config: &mut Config, + storage: &str, + salt: &str, + ) -> Result { + if storage.is_empty() { + if config.password.is_empty() && config.salt.is_empty() { + return Ok(false); + } + config.password.clear(); + config.salt.clear(); + return Ok(true); + } + if salt.is_empty() { + return Err(anyhow!( + "Refusing to persist permanent password storage without salt" + )); + } + if decode_permanent_password_h1_from_storage(storage).is_none() { + log::error!("Rejecting non-current permanent password storage sync payload"); + return Err(anyhow!("Invalid permanent password storage sync payload")); + } if config.password == storage && config.salt == salt { return Ok(false); } config.password = storage.to_owned(); config.salt = salt.to_owned(); - config.store(); - Self::clear_trusted_devices(); Ok(true) } - /// Returns true if `input` (candidate plaintext) matches the currently effective permanent password. - pub fn matches_permanent_password_plain(input: &str) -> bool { - if input.is_empty() { - return false; + pub fn has_permanent_password() -> bool { + let (local_storage, local_salt) = Self::get_local_permanent_password_storage_and_salt(); + if !local_storage.is_empty() { + return local_permanent_password_storage_is_usable_for_auth( + &local_storage, + &local_salt, + ); } - - let config = CONFIG.read().unwrap(); - let storage = config.password.clone(); - let salt = config.salt.clone(); - drop(config); - - if storage.is_empty() { - return HARD_SETTINGS - .read() - .unwrap() - .get("password") - .map_or(false, |v| v == input); - } - - if let Some(stored_h1) = decode_permanent_password_h1_from_storage(&storage) { - if salt.is_empty() { - log::error!("Salt is empty but permanent password is hashed"); - return false; - } - let h1 = compute_permanent_password_h1(input, &salt); - return constant_time_eq_32(&h1, &stored_h1); - } - - log::warn!("Permanent password storage is not hashed; verifying as plaintext"); - storage == input + Self::has_usable_preset_password() } - pub fn has_permanent_password() -> bool { - if !CONFIG.read().unwrap().password.is_empty() { - return true; + fn has_usable_preset_password() -> bool { + let (preset_storage, preset_salt) = Self::get_preset_password_storage_and_salt(); + preset_permanent_password_storage_is_usable_for_auth(&preset_storage, &preset_salt) + } + + pub fn is_using_preset_password() -> bool { + let (local_storage, _) = Self::get_local_permanent_password_storage_and_salt(); + local_storage.is_empty() && Self::has_usable_preset_password() + } + + pub fn get_preset_password_storage_and_salt() -> (String, String) { + let hard_settings = HARD_SETTINGS.read().unwrap(); + let storage = hard_settings.get("password").cloned().unwrap_or_default(); + let salt = hard_settings.get("salt").cloned().unwrap_or_default(); + (normalize_preset_password_storage(storage, &salt), salt) + } + + pub fn get_effective_permanent_password_salt() -> String { + let (local_storage, local_salt) = Self::get_local_permanent_password_storage_and_salt(); + if !local_storage.is_empty() { + if local_permanent_password_storage_is_usable_for_auth(&local_storage, &local_salt) { + return Self::get_salt(); + } + return String::new(); } - HARD_SETTINGS - .read() - .unwrap() - .get("password") - .map_or(false, |v| !v.is_empty()) + let (preset_storage, preset_salt) = Self::get_preset_password_storage_and_salt(); + if !preset_salt.is_empty() { + if permanent_password_storage_is_hashed(&preset_storage) { + return preset_salt; + } + return String::new(); + } + Self::get_salt() + } + + fn has_usable_local_permanent_password() -> bool { + let (local_storage, local_salt) = Self::get_local_permanent_password_storage_and_salt(); + local_permanent_password_storage_is_usable_for_auth(&local_storage, &local_salt) } pub fn has_local_permanent_password() -> bool { - !CONFIG.read().unwrap().password.is_empty() + Self::has_usable_local_permanent_password() } // This shouldn't happen under normal circumstances because the salt @@ -1600,7 +1653,11 @@ impl Config { // TODO: `Config::set()` does not invalidate trusted devices when permanent password/salt changes. // This matches historical behavior, but may need revisiting in a separate PR. - pub fn set(cfg: Config) -> bool { + pub fn set(mut cfg: Config) -> bool { + if let Err(err) = Self::migrate_permanent_password_to_encrypted_hashed_storage(&mut cfg) { + log::error!("Refusing to set config with invalid permanent password storage: {err}"); + return false; + } let mut lock = CONFIG.write().unwrap(); if *lock == cfg { return false; @@ -2852,7 +2909,8 @@ pub mod keys { pub const OPTION_ENABLE_RECORD_SESSION: &str = "enable-record-session"; pub const OPTION_ENABLE_BLOCK_INPUT: &str = "enable-block-input"; pub const OPTION_ENABLE_PRIVACY_MODE: &str = "enable-privacy-mode"; - pub const OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW: &str = "enable-perm-change-in-accept-window"; + pub const OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW: &str = + "enable-perm-change-in-accept-window"; pub const OPTION_ALLOW_REMOTE_CONFIG_MODIFICATION: &str = "allow-remote-config-modification"; pub const OPTION_ALLOW_NUMERNIC_ONE_TIME_PASSWORD: &str = "allow-numeric-one-time-password"; pub const OPTION_ENABLE_LAN_DISCOVERY: &str = "enable-lan-discovery"; @@ -3216,6 +3274,43 @@ impl Status { mod tests { use super::*; + static CONFIG_STATE_TEST_LOCK: Mutex<()> = Mutex::new(()); + + struct ConfigStateTestGuard { + original_config: Config, + original_hard_settings: HashMap, + } + + impl ConfigStateTestGuard { + fn new(config: Config, hard_settings: HashMap) -> Self { + let original_config = Config::get(); + let original_hard_settings = HARD_SETTINGS.read().unwrap().clone(); + *CONFIG.write().unwrap() = config; + *HARD_SETTINGS.write().unwrap() = hard_settings; + Self { + original_config, + original_hard_settings, + } + } + } + + impl Drop for ConfigStateTestGuard { + fn drop(&mut self) { + *CONFIG.write().unwrap() = self.original_config.clone(); + *HARD_SETTINGS.write().unwrap() = self.original_hard_settings.clone(); + } + } + + fn with_config_and_hard_settings( + config: Config, + hard_settings: HashMap, + test: impl FnOnce() -> R, + ) -> R { + let _guard = CONFIG_STATE_TEST_LOCK.lock().unwrap(); + let _state_guard = ConfigStateTestGuard::new(config, hard_settings); + test() + } + #[test] fn test_serialize() { let cfg: Config = Default::default(); @@ -3227,47 +3322,266 @@ mod tests { } #[test] - fn test_permanent_password_h1_storage_roundtrip() { + fn test_hbbs_00_hashed_preset_password_storage_matches_plain_with_salt() { let salt = "salt123"; - let password = "p@ssw0rd"; - let h1 = compute_permanent_password_h1(password, salt); - let stored = encode_permanent_password_storage_from_h1(&h1); - assert!(stored.starts_with(PERMANENT_PASSWORD_HASH_PREFIX)); - assert!(is_permanent_password_hashed_storage(&stored)); - let decoded = decode_permanent_password_h1_from_storage(&stored).unwrap(); - assert_eq!(&decoded[..], &h1[..]); + let h1 = compute_permanent_password_h1("p@ssw0rd", salt); + let storage = "00".to_owned() + &base64::encode(h1, base64::Variant::Original); + let hard_settings = HashMap::from([ + ("password".to_owned(), storage), + ("salt".to_owned(), salt.to_owned()), + ]); + + with_config_and_hard_settings(Config::default(), hard_settings, || { + assert!(Config::has_permanent_password()); + assert!(Config::has_usable_preset_password()); + assert!(Config::is_using_preset_password()); + assert_eq!(Config::get_effective_permanent_password_salt(), salt); + }); } #[test] - fn test_migrate_plaintext_permanent_password_to_hashed_storage() { + fn test_legacy_plain_preset_password_with_00_hash_shape_without_salt_keeps_old_behavior() { + let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); + let storage = "00".to_owned() + &base64::encode(h1, base64::Variant::Original); + let hard_settings = HashMap::from([("password".to_owned(), storage.clone())]); + + let mut config = Config::default(); + config.salt = "local1".to_owned(); + + with_config_and_hard_settings(config, hard_settings, || { + assert!(Config::has_permanent_password()); + assert!(Config::has_usable_preset_password()); + assert!(Config::is_using_preset_password()); + assert_eq!(Config::get_effective_permanent_password_salt(), "local1"); + }); + } + + #[test] + fn test_local_hashed_permanent_password_without_salt_is_not_reported_as_set() { + let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); + let mut config = Config::default(); + config.password = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + + with_config_and_hard_settings(config, HashMap::new(), || { + assert!(!Config::has_permanent_password()); + assert!(!Config::has_local_permanent_password()); + assert!(!Config::is_using_preset_password()); + }); + } + + #[test] + fn test_invalid_local_hashed_password_does_not_generate_effective_salt() { + let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); + let mut config = Config::default(); + config.password = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + + with_config_and_hard_settings(config, HashMap::new(), || { + assert_eq!(Config::get_effective_permanent_password_salt(), ""); + assert_eq!( + Config::get_local_permanent_password_storage_and_salt().1, + "" + ); + }); + } + + #[test] + fn test_legacy_plain_preset_password_uses_local_salt_for_challenge() { + let mut config = Config::default(); + config.salt = "local1".to_owned(); + let hard_settings = HashMap::from([("password".to_owned(), "legacy-password".to_owned())]); + + with_config_and_hard_settings(config, hard_settings, || { + assert_eq!(Config::get_effective_permanent_password_salt(), "local1"); + assert!(Config::has_permanent_password()); + assert!(Config::is_using_preset_password()); + }); + } + + #[test] + fn test_malformed_preset_password_with_salt_is_not_usable() { + for storage in ["01secret", "00not-a-valid-hash"] { + let hard_settings = HashMap::from([ + ("password".to_owned(), storage.to_owned()), + ("salt".to_owned(), "preset-salt".to_owned()), + ]); + + with_config_and_hard_settings(Config::default(), hard_settings, || { + assert_eq!(Config::get_effective_permanent_password_salt(), ""); + assert_eq!( + Config::get_local_permanent_password_storage_and_salt().1, + "" + ); + assert!(!Config::has_permanent_password()); + assert!(!Config::is_using_preset_password()); + }); + } + } + + #[test] + fn test_migrate_plaintext_permanent_password_to_encrypted_hashed_storage() { let mut cfg = Config::default(); cfg.password = "p@ssw0rd".to_owned(); cfg.salt = "".to_owned(); - let changed = Config::migrate_permanent_password_to_hashed_storage(&mut cfg); + let changed = + Config::migrate_permanent_password_to_encrypted_hashed_storage(&mut cfg).unwrap(); assert!(changed); - assert!(is_permanent_password_hashed_storage(&cfg.password)); - assert_eq!(cfg.salt.chars().count(), DEFAULT_SALT_LEN); + assert!(cfg.password.starts_with(PERMANENT_PASSWORD_ENC_VERSION)); + assert!(!is_permanent_password_hashed_storage(&cfg.password)); + assert_eq!(cfg.salt.chars().count(), 32); - let stored_h1 = decode_permanent_password_h1_from_storage(&cfg.password).unwrap(); + let (inner, decrypted, _) = decrypt_permanent_password_str_or_original(&cfg.password); + assert!(decrypted); + assert!(is_permanent_password_hashed_storage(&inner)); + let stored_h1 = decode_permanent_password_h1_from_hashed_storage(&inner).unwrap(); let expected_h1 = compute_permanent_password_h1("p@ssw0rd", &cfg.salt); assert_eq!(stored_h1, expected_h1); } #[test] - fn test_migrate_plaintext_with_00_prefix_permanent_password_to_hashed_storage() { + fn test_migrate_plaintext_with_00_prefix_permanent_password_to_encrypted_hashed_storage() { let mut cfg = Config::default(); cfg.password = "00secret".to_owned(); cfg.salt = "".to_owned(); - let changed = Config::migrate_permanent_password_to_hashed_storage(&mut cfg); + let changed = + Config::migrate_permanent_password_to_encrypted_hashed_storage(&mut cfg).unwrap(); assert!(changed); - assert!(is_permanent_password_hashed_storage(&cfg.password)); + assert!(cfg.password.starts_with(PERMANENT_PASSWORD_ENC_VERSION)); + assert!(!is_permanent_password_hashed_storage(&cfg.password)); assert!(!cfg.salt.is_empty()); - let stored_h1 = decode_permanent_password_h1_from_storage(&cfg.password).unwrap(); + let (inner, decrypted, _) = decrypt_permanent_password_str_or_original(&cfg.password); + assert!(decrypted); + assert!(is_permanent_password_hashed_storage(&inner)); + let stored_h1 = decode_permanent_password_h1_from_hashed_storage(&inner).unwrap(); let expected_h1 = compute_permanent_password_h1("00secret", &cfg.salt); assert_eq!(stored_h1, expected_h1); } + #[test] + fn test_migrate_rejects_encrypted_hashed_permanent_password_without_salt() { + let mut cfg = Config::default(); + let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); + cfg.password = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + let original_password = cfg.password.clone(); + + assert!(Config::migrate_permanent_password_to_encrypted_hashed_storage(&mut cfg).is_err()); + assert_eq!(cfg.password, original_password); + assert!(cfg.salt.is_empty()); + } + + #[test] + fn test_prepare_store_clears_invalid_permanent_password_and_keeps_unrelated_fields() { + let mut cfg = Config::default(); + let invalid_payload = + crate::password_security::symmetric_crypt(b"not-a-hash", true).unwrap(); + cfg.password = PERMANENT_PASSWORD_ENC_VERSION.to_owned() + + &base64::encode(invalid_payload, base64::Variant::Original); + cfg.id = "123456789".to_owned(); + + Config::prepare_config_for_store(&mut cfg); + assert!(cfg.password.is_empty()); + assert!(cfg.salt.is_empty()); + assert_eq!(cfg.id, "123456789"); + } + + #[test] + fn test_permanent_password_sync_treats_same_encrypted_hash_as_unchanged() { + let mut cfg = Config::default(); + cfg.salt = "salt123".to_owned(); + let h1 = compute_permanent_password_h1("p@ssw0rd", &cfg.salt); + let encrypted_hash_storage = + encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + cfg.password = encrypted_hash_storage.clone(); + assert!(!Config::migrate_permanent_password_to_encrypted_hashed_storage(&mut cfg).unwrap()); + + assert!(!Config::apply_permanent_password_storage_for_sync( + &mut cfg, + &encrypted_hash_storage, + "salt123" + ) + .unwrap()); + } + + #[test] + fn test_permanent_password_sync_stores_incoming_encrypted_hash_when_local_empty() { + let salt = "salt123"; + let h1 = compute_permanent_password_h1("p@ssw0rd", salt); + let incoming = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + let mut cfg = Config::default(); + + assert!( + Config::apply_permanent_password_storage_for_sync(&mut cfg, &incoming, salt).unwrap() + ); + assert_eq!(cfg.password, incoming); + assert_eq!(cfg.salt, salt); + } + + #[test] + fn test_permanent_password_sync_rejects_non_current_storage_payloads() { + let invalid_payload = vec![42u8; sodiumoxide::crypto::secretbox::MACBYTES + 1]; + let invalid_storage = PERMANENT_PASSWORD_ENC_VERSION.to_owned() + + &base64::encode(invalid_payload, base64::Variant::Original); + let encrypted_legacy_plaintext = + encrypt_str_or_original("legacy-secret", PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); + + let encrypted = crate::password_security::symmetric_crypt(b"not-a-hash", true).unwrap(); + let encrypted_non_hash = PERMANENT_PASSWORD_ENC_VERSION.to_owned() + + &base64::encode(encrypted, base64::Variant::Original); + for storage in [ + "00secret", + &encrypted_legacy_plaintext, + &invalid_storage, + "01invalid", + &encrypted_non_hash, + ] { + let mut cfg = Config::default(); + assert!(Config::apply_permanent_password_storage_for_sync( + &mut cfg, storage, "salt123" + ) + .is_err()); + assert!(cfg.password.is_empty()); + assert!(cfg.salt.is_empty()); + } + + let mut cfg = Config::default(); + cfg.password = invalid_storage.clone(); + cfg.salt = "salt123".to_owned(); + assert!(Config::apply_permanent_password_storage_for_sync( + &mut cfg, + &invalid_storage, + "salt123" + ) + .is_err()); + assert_eq!(cfg.password, invalid_storage); + assert_eq!(cfg.salt, "salt123"); + } + + #[test] + fn test_permanent_password_sync_rejects_non_empty_storage_without_salt() { + let mut cfg = Config::default(); + let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); + let incoming = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + + assert!( + Config::apply_permanent_password_storage_for_sync(&mut cfg, &incoming, "").is_err() + ); + assert!(cfg.password.is_empty()); + assert!(cfg.salt.is_empty()); + } + + #[test] + fn test_permanent_password_sync_empty_storage_clears_existing_password() { + let salt = "salt123"; + let h1 = compute_permanent_password_h1("p@ssw0rd", salt); + let mut cfg = Config::default(); + cfg.password = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + cfg.salt = salt.to_owned(); + + assert!(Config::apply_permanent_password_storage_for_sync(&mut cfg, "", "").unwrap()); + assert!(cfg.password.is_empty()); + assert!(cfg.salt.is_empty()); + } + #[test] fn test_overwrite_settings() { DEFAULT_SETTINGS diff --git a/src/config/permanent_password.rs b/src/config/permanent_password.rs new file mode 100644 index 000000000..16edc7a0f --- /dev/null +++ b/src/config/permanent_password.rs @@ -0,0 +1,415 @@ +use sha2::{Digest, Sha256}; +use sodiumoxide::base64; + +use crate::{ + log, + password_security::{decrypt_str_or_original, symmetric_crypt}, +}; + +pub(super) const PASSWORD_ENC_VERSION: &str = "00"; +pub(super) const PERMANENT_PASSWORD_ENC_VERSION: &str = "01"; +pub(super) const PERMANENT_PASSWORD_HASH_PREFIX: &str = "00"; +const HBBS_PRESET_PASSWORD_HASH_PREFIX: &str = "00"; +pub(super) const PERMANENT_PASSWORD_H1_LEN: usize = 32; +pub(super) const DEFAULT_SALT_LEN: usize = 32; +pub const ENCRYPT_MAX_LEN: usize = 128; // used for password, pin, etc, not for all +const VERSION_LEN: usize = 2; + +#[cfg(test)] +pub(super) fn is_permanent_password_hashed_storage(v: &str) -> bool { + decode_permanent_password_h1_from_hashed_storage(v).is_some() +} + +pub fn compute_permanent_password_h1( + password: &str, + salt: &str, +) -> [u8; PERMANENT_PASSWORD_H1_LEN] { + let mut hasher = Sha256::new(); + hasher.update(password.as_bytes()); + hasher.update(salt.as_bytes()); + let out = hasher.finalize(); + let mut h1 = [0u8; PERMANENT_PASSWORD_H1_LEN]; + h1.copy_from_slice(&out[..PERMANENT_PASSWORD_H1_LEN]); + h1 +} + +pub(super) fn constant_time_eq_32(a: &[u8; 32], b: &[u8; 32]) -> bool { + sodiumoxide::utils::memcmp(a, b) +} + +pub(super) fn encode_permanent_password_storage_from_h1( + h1: &[u8; PERMANENT_PASSWORD_H1_LEN], +) -> String { + PERMANENT_PASSWORD_HASH_PREFIX.to_owned() + &base64::encode(h1, base64::Variant::Original) +} + +pub(super) fn encode_permanent_password_encrypted_storage_from_h1( + h1: &[u8; PERMANENT_PASSWORD_H1_LEN], +) -> Option { + let hashed_storage = encode_permanent_password_storage_from_h1(h1); + encrypt_permanent_password_storage(&hashed_storage) +} + +pub(super) fn decode_permanent_password_h1_from_hashed_storage( + storage: &str, +) -> Option<[u8; PERMANENT_PASSWORD_H1_LEN]> { + decode_password_h1_after_prefix(storage, PERMANENT_PASSWORD_HASH_PREFIX) +} + +fn decode_hbbs_preset_password_h1_from_storage( + storage: &str, +) -> Option<[u8; PERMANENT_PASSWORD_H1_LEN]> { + decode_password_h1_after_prefix(storage, HBBS_PRESET_PASSWORD_HASH_PREFIX) +} + +fn decode_password_h1_after_prefix( + storage: &str, + prefix: &str, +) -> Option<[u8; PERMANENT_PASSWORD_H1_LEN]> { + let encoded = storage.strip_prefix(prefix)?; + + let v = base64::decode(encoded.as_bytes(), base64::Variant::Original).ok()?; + if v.len() != PERMANENT_PASSWORD_H1_LEN { + return None; + } + let mut h1 = [0u8; PERMANENT_PASSWORD_H1_LEN]; + h1.copy_from_slice(&v[..PERMANENT_PASSWORD_H1_LEN]); + Some(h1) +} + +fn encrypt_permanent_password_storage(storage: &str) -> Option { + if storage.chars().count() > ENCRYPT_MAX_LEN { + return None; + } + let encrypted = symmetric_crypt(storage.as_bytes(), true).ok()?; + Some( + PERMANENT_PASSWORD_ENC_VERSION.to_owned() + + &base64::encode(encrypted, base64::Variant::Original), + ) +} + +pub(super) fn decrypt_permanent_password_str_or_original(storage: &str) -> (String, bool, bool) { + if storage.len() > VERSION_LEN && storage.starts_with(PERMANENT_PASSWORD_ENC_VERSION) { + if let Ok(decoded) = base64::decode( + &storage.as_bytes()[VERSION_LEN..], + base64::Variant::Original, + ) { + if let Ok(v) = symmetric_crypt(&decoded, false) { + return (String::from_utf8_lossy(&v).to_string(), true, false); + } + } + } + (storage.to_owned(), false, !storage.is_empty()) +} + +pub(super) fn normalize_preset_password_storage(storage: String, salt: &str) -> String { + if salt.is_empty() { + return storage; + } + if let Some(h1) = decode_hbbs_preset_password_h1_from_storage(&storage) { + if let Some(storage) = encode_permanent_password_encrypted_storage_from_h1(&h1) { + return storage; + } + log::error!("Failed to encrypt preset permanent password hash storage"); + return String::new(); + } + storage +} + +pub(super) fn permanent_password_storage_is_usable_for_auth(storage: &str, salt: &str) -> bool { + if storage.is_empty() { + return false; + } + + if decode_permanent_password_h1_from_storage(storage).is_some() { + return !salt.is_empty(); + } + if storage.starts_with(PERMANENT_PASSWORD_ENC_VERSION) { + log::error!("Permanent password storage looks current but cannot be decoded as a hash"); + return false; + } + + let (_, decrypted, looks_like_plaintext) = + decrypt_str_or_original(storage, PASSWORD_ENC_VERSION); + if storage.starts_with(PASSWORD_ENC_VERSION) && !decrypted && !looks_like_plaintext { + log::error!("Permanent password storage looks encrypted but cannot be decrypted"); + return false; + } + true +} + +pub fn preset_permanent_password_storage_is_usable_for_auth(storage: &str, salt: &str) -> bool { + if storage.is_empty() { + return false; + } + if salt.is_empty() { + return true; + } + decode_permanent_password_h1_from_storage(storage).is_some() +} + +pub fn local_permanent_password_storage_is_usable_for_auth(storage: &str, salt: &str) -> bool { + if storage.starts_with(PERMANENT_PASSWORD_ENC_VERSION) + && decode_permanent_password_h1_from_storage(storage).is_none() + { + log::error!( + "Local permanent password storage looks encrypted but cannot be decoded as a hash" + ); + return false; + } + permanent_password_storage_is_usable_for_auth(storage, salt) +} + +pub(super) fn permanent_password_storage_is_hashed(storage: &str) -> bool { + decode_permanent_password_h1_from_storage(storage).is_some() +} + +#[cfg(test)] +fn permanent_password_storage_matches_plain(storage: &str, salt: &str, input: &str) -> bool { + if storage.is_empty() || input.is_empty() { + return false; + } + if !permanent_password_storage_is_usable_for_auth(storage, salt) { + return false; + } + if let Some(stored_h1) = decode_permanent_password_h1_from_storage(storage) { + if salt.is_empty() { + log::error!("Salt is empty but permanent password storage is hashed"); + return false; + } + let h1 = compute_permanent_password_h1(input, salt); + return constant_time_eq_32(&h1, &stored_h1); + } + storage == input +} + +pub(super) fn preset_permanent_password_storage_matches_plain( + storage: &str, + salt: &str, + input: &str, +) -> bool { + if storage.is_empty() || input.is_empty() { + return false; + } + if salt.is_empty() { + return storage == input; + } + let Some(stored_h1) = decode_permanent_password_h1_from_storage(storage) else { + return false; + }; + let h1 = compute_permanent_password_h1(input, salt); + constant_time_eq_32(&h1, &stored_h1) +} + +#[cfg(test)] +fn local_permanent_password_storage_matches_plain(storage: &str, salt: &str, input: &str) -> bool { + if !local_permanent_password_storage_is_usable_for_auth(storage, salt) { + return false; + } + permanent_password_storage_matches_plain(storage, salt, input) +} + +pub fn decode_permanent_password_h1_from_storage( + storage: &str, +) -> Option<[u8; PERMANENT_PASSWORD_H1_LEN]> { + if storage.starts_with(PERMANENT_PASSWORD_ENC_VERSION) { + let (hashed_storage, decrypted, _) = decrypt_permanent_password_str_or_original(storage); + if !decrypted { + return None; + } + return decode_permanent_password_h1_from_hashed_storage(&hashed_storage); + } + None +} + +// If password is empty or not hashed storage, it's safe to update salt. +pub(super) fn password_is_empty_or_not_hashed(permanent_password_storage: &str) -> bool { + if permanent_password_storage.is_empty() { + return true; + } + if decode_permanent_password_h1_from_storage(permanent_password_storage).is_some() { + return false; + } + let (_, decrypted, should_store) = + decrypt_str_or_original(permanent_password_storage, PASSWORD_ENC_VERSION); + decrypted || should_store +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::password_security::encrypt_str_or_original; + + fn encode_hbbs_preset_password_storage_from_h1(h1: &[u8; PERMANENT_PASSWORD_H1_LEN]) -> String { + HBBS_PRESET_PASSWORD_HASH_PREFIX.to_owned() + &base64::encode(h1, base64::Variant::Original) + } + + #[test] + fn test_permanent_password_h1_storage_roundtrip() { + let salt = "salt123"; + let password = "p@ssw0rd"; + let h1 = compute_permanent_password_h1(password, salt); + let stored = encode_permanent_password_storage_from_h1(&h1); + assert!(stored.starts_with(HBBS_PRESET_PASSWORD_HASH_PREFIX)); + assert!(is_permanent_password_hashed_storage(&stored)); + let decoded = decode_permanent_password_h1_from_hashed_storage(&stored).unwrap(); + assert_eq!(&decoded[..], &h1[..]); + } + + #[test] + fn test_permanent_password_encrypted_storage_uses_01_outer_and_00_inner() { + let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); + let storage = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + + assert!(storage.starts_with(PERMANENT_PASSWORD_ENC_VERSION)); + assert!(!is_permanent_password_hashed_storage(&storage)); + + let (inner, decrypted, should_store) = decrypt_permanent_password_str_or_original(&storage); + assert!(decrypted); + assert!(!should_store); + assert!(inner.starts_with(HBBS_PRESET_PASSWORD_HASH_PREFIX)); + assert_eq!( + decode_permanent_password_h1_from_storage(&storage), + Some(h1) + ); + } + + #[test] + fn test_encrypted_hashed_password_storage_matches_plain_with_salt() { + let salt = "salt123"; + let h1 = compute_permanent_password_h1("p@ssw0rd", salt); + let storage = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + + assert!(permanent_password_storage_is_usable_for_auth( + &storage, salt + )); + assert!(permanent_password_storage_matches_plain( + &storage, salt, "p@ssw0rd" + )); + assert!(!permanent_password_storage_matches_plain( + &storage, salt, "wrong" + )); + } + + #[test] + fn test_hbbs_00_hashed_preset_password_storage_is_normalized() { + let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); + let storage = encode_hbbs_preset_password_storage_from_h1(&h1); + + assert_eq!( + normalize_preset_password_storage(storage, "salt123"), + encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap() + ); + } + + #[test] + fn test_hbbs_00_shaped_preset_password_without_salt_stays_plaintext() { + let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); + let storage = encode_hbbs_preset_password_storage_from_h1(&h1); + + assert_eq!( + normalize_preset_password_storage(storage.clone(), ""), + storage + ); + assert!(preset_permanent_password_storage_is_usable_for_auth( + &storage, "" + )); + assert!(preset_permanent_password_storage_matches_plain( + &storage, "", &storage + )); + assert!(!preset_permanent_password_storage_matches_plain( + &storage, "", "p@ssw0rd" + )); + } + + #[test] + fn test_hashed_preset_password_storage_without_salt_is_not_usable() { + let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); + let storage = encode_permanent_password_storage_from_h1(&h1); + + assert!(!permanent_password_storage_is_usable_for_auth(&storage, "")); + assert!(!permanent_password_storage_matches_plain( + &storage, "", "p@ssw0rd" + )); + } + + #[test] + fn test_legacy_plain_preset_password_without_salt_keeps_old_behavior() { + let storage = "01not-a-valid-hash"; + + assert!(preset_permanent_password_storage_is_usable_for_auth( + storage, "" + )); + assert!(preset_permanent_password_storage_matches_plain( + storage, + "", + "01not-a-valid-hash" + )); + } + + #[test] + fn test_malformed_preset_password_with_salt_is_not_usable_for_auth() { + for storage in ["01not-a-valid-hash", "00not-a-valid-hash"] { + assert!(!preset_permanent_password_storage_is_usable_for_auth( + storage, + "preset-salt" + )); + assert!(!preset_permanent_password_storage_matches_plain( + storage, + "preset-salt", + storage + )); + } + } + + #[test] + fn test_invalid_current_version_storage_is_not_usable_for_auth() { + let encrypted = symmetric_crypt(b"not-a-hash", true).unwrap(); + let encrypted_non_hash = PERMANENT_PASSWORD_ENC_VERSION.to_owned() + + &base64::encode(encrypted, base64::Variant::Original); + + for storage in ["01invalid", &encrypted_non_hash] { + assert!(!permanent_password_storage_is_usable_for_auth( + storage, "salt123" + )); + assert!(!permanent_password_storage_matches_plain( + storage, "salt123", storage + )); + assert!(!local_permanent_password_storage_is_usable_for_auth( + storage, "salt123" + )); + assert!(!local_permanent_password_storage_matches_plain( + storage, "salt123", storage + )); + } + } + + #[test] + fn test_legacy_plain_preset_password_that_decodes_as_hash_requires_salt() { + let h1 = compute_permanent_password_h1("plain-looking-hash", "salt123"); + let storage = encode_permanent_password_storage_from_h1(&h1); + + assert!(!permanent_password_storage_is_usable_for_auth(&storage, "")); + assert!(!permanent_password_storage_matches_plain( + &storage, "", &storage + )); + } + + #[test] + fn test_password_is_empty_or_not_hashed_accepts_plaintext_and_decryptable_legacy_plaintext() { + let storage = + encrypt_str_or_original("legacy-secret", PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); + + assert!(password_is_empty_or_not_hashed("00secret")); + assert!(password_is_empty_or_not_hashed(&storage)); + } + + #[test] + fn test_password_is_empty_or_not_hashed_treats_locked_00_storage_as_hashed() { + let invalid_payload = vec![42u8; sodiumoxide::crypto::secretbox::MACBYTES + 1]; + let locked_storage = PASSWORD_ENC_VERSION.to_owned() + + &base64::encode(invalid_payload, base64::Variant::Original); + + assert!(!password_is_empty_or_not_hashed(&locked_storage)); + } +}