From 3f30a92913a20b316a06aedf52702cc714ca6b03 Mon Sep 17 00:00:00 2001 From: fufesou Date: Wed, 13 May 2026 21:59:37 +0800 Subject: [PATCH 01/12] refact(password): encrypt Signed-off-by: fufesou --- src/config.rs | 620 +++++++++++++++++++++++-------- src/config/permanent_password.rs | 415 +++++++++++++++++++++ 2 files changed, 882 insertions(+), 153 deletions(-) create mode 100644 src/config/permanent_password.rs 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)); + } +} From cf108c131ec4a570a85bd76230da8835814a87d5 Mon Sep 17 00:00:00 2001 From: fufesou Date: Wed, 20 May 2026 10:53:26 +0800 Subject: [PATCH 02/12] refact(password): decode preset password --- src/config/permanent_password.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/config/permanent_password.rs b/src/config/permanent_password.rs index 16edc7a0f..d01bbd667 100644 --- a/src/config/permanent_password.rs +++ b/src/config/permanent_password.rs @@ -145,7 +145,14 @@ pub fn preset_permanent_password_storage_is_usable_for_auth(storage: &str, salt: if salt.is_empty() { return true; } - decode_permanent_password_h1_from_storage(storage).is_some() + decode_preset_password_h1_from_storage(storage).is_some() +} + +fn decode_preset_password_h1_from_storage( + storage: &str, +) -> Option<[u8; PERMANENT_PASSWORD_H1_LEN]> { + decode_permanent_password_h1_from_storage(storage) + .or_else(|| decode_hbbs_preset_password_h1_from_storage(storage)) } pub fn local_permanent_password_storage_is_usable_for_auth(storage: &str, salt: &str) -> bool { @@ -194,7 +201,7 @@ pub(super) fn preset_permanent_password_storage_matches_plain( if salt.is_empty() { return storage == input; } - let Some(stored_h1) = decode_permanent_password_h1_from_storage(storage) else { + let Some(stored_h1) = decode_preset_password_h1_from_storage(storage) else { return false; }; let h1 = compute_permanent_password_h1(input, salt); @@ -250,7 +257,7 @@ mod tests { 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!(stored.starts_with(PERMANENT_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[..]); @@ -302,6 +309,23 @@ mod tests { ); } + #[test] + fn test_hbbs_00_hashed_preset_password_storage_matches_plain_with_salt() { + let salt = "salt123"; + let h1 = compute_permanent_password_h1("p@ssw0rd", salt); + let storage = encode_hbbs_preset_password_storage_from_h1(&h1); + + assert!(preset_permanent_password_storage_is_usable_for_auth( + &storage, salt + )); + assert!(preset_permanent_password_storage_matches_plain( + &storage, salt, "p@ssw0rd" + )); + assert!(!preset_permanent_password_storage_matches_plain( + &storage, salt, "wrong" + )); + } + #[test] fn test_hbbs_00_shaped_preset_password_without_salt_stays_plaintext() { let h1 = compute_permanent_password_h1("p@ssw0rd", "salt123"); From cfa877d28cc1e3e5962c30af32d88df40b0d43a5 Mon Sep 17 00:00:00 2001 From: fufesou Date: Wed, 20 May 2026 11:08:43 +0800 Subject: [PATCH 03/12] trivial changes Signed-off-by: fufesou --- src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index e7a00f7a0..648bb7100 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3427,7 +3427,7 @@ mod tests { assert!(changed); assert!(cfg.password.starts_with(PERMANENT_PASSWORD_ENC_VERSION)); assert!(!is_permanent_password_hashed_storage(&cfg.password)); - assert_eq!(cfg.salt.chars().count(), 32); + assert_eq!(cfg.salt.chars().count(), DEFAULT_SALT_LEN); let (inner, decrypted, _) = decrypt_permanent_password_str_or_original(&cfg.password); assert!(decrypted); From e7af2e84888b6c1b9ab21a4229ad3b314392de83 Mon Sep 17 00:00:00 2001 From: fufesou Date: Wed, 20 May 2026 14:32:54 +0800 Subject: [PATCH 04/12] trivial changes Signed-off-by: fufesou --- src/config/permanent_password.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/permanent_password.rs b/src/config/permanent_password.rs index d01bbd667..0bda60de5 100644 --- a/src/config/permanent_password.rs +++ b/src/config/permanent_password.rs @@ -274,7 +274,7 @@ mod tests { 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!(inner.starts_with(PERMANENT_PASSWORD_HASH_PREFIX)); assert_eq!( decode_permanent_password_h1_from_storage(&storage), Some(h1) From f94992f6cad2f0b3ed6840a3400ef46e1fdc745b Mon Sep 17 00:00:00 2001 From: fufesou Date: Wed, 20 May 2026 16:57:16 +0800 Subject: [PATCH 05/12] refact(password): simplify preset password Signed-off-by: fufesou --- src/config.rs | 9 +++--- src/config/permanent_password.rs | 48 ++++++++++++-------------------- 2 files changed, 22 insertions(+), 35 deletions(-) diff --git a/src/config.rs b/src/config.rs index 648bb7100..fe26db49d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -23,13 +23,12 @@ 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, + decode_preset_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, + encode_permanent_password_encrypted_storage_from_h1, password_is_empty_or_not_hashed, preset_permanent_password_storage_matches_plain, DEFAULT_SALT_LEN, PASSWORD_ENC_VERSION, PERMANENT_PASSWORD_H1_LEN, }; @@ -1409,7 +1408,7 @@ impl Config { 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) + (storage, salt) } pub fn get_effective_permanent_password_salt() -> String { @@ -1422,7 +1421,7 @@ impl Config { } 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) { + if preset_permanent_password_storage_is_usable_for_auth(&preset_storage, &preset_salt) { return preset_salt; } return String::new(); diff --git a/src/config/permanent_password.rs b/src/config/permanent_password.rs index 0bda60de5..4901c2fcc 100644 --- a/src/config/permanent_password.rs +++ b/src/config/permanent_password.rs @@ -102,20 +102,6 @@ pub(super) fn decrypt_permanent_password_str_or_original(storage: &str) -> (Stri (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; @@ -148,11 +134,10 @@ pub fn preset_permanent_password_storage_is_usable_for_auth(storage: &str, salt: decode_preset_password_h1_from_storage(storage).is_some() } -fn decode_preset_password_h1_from_storage( +pub fn decode_preset_password_h1_from_storage( storage: &str, ) -> Option<[u8; PERMANENT_PASSWORD_H1_LEN]> { - decode_permanent_password_h1_from_storage(storage) - .or_else(|| decode_hbbs_preset_password_h1_from_storage(storage)) + decode_hbbs_preset_password_h1_from_storage(storage) } pub fn local_permanent_password_storage_is_usable_for_auth(storage: &str, salt: &str) -> bool { @@ -167,10 +152,6 @@ pub fn local_permanent_password_storage_is_usable_for_auth(storage: &str, salt: 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() { @@ -299,14 +280,11 @@ mod tests { } #[test] - fn test_hbbs_00_hashed_preset_password_storage_is_normalized() { + fn test_hbbs_00_hashed_preset_password_storage_is_decoded_for_preset_auth() { 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() - ); + assert_eq!(decode_preset_password_h1_from_storage(&storage), Some(h1)); } #[test] @@ -326,15 +304,25 @@ mod tests { )); } + #[test] + fn test_encrypted_hash_storage_is_not_accepted_as_preset_storage() { + let salt = "salt123"; + let h1 = compute_permanent_password_h1("p@ssw0rd", salt); + let storage = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + + assert!(!preset_permanent_password_storage_is_usable_for_auth( + &storage, salt + )); + assert!(!preset_permanent_password_storage_matches_plain( + &storage, salt, "p@ssw0rd" + )); + } + #[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, "" )); From 0c218232d1178946a0228e785a7f014cb7c6ba63 Mon Sep 17 00:00:00 2001 From: fufesou Date: Wed, 20 May 2026 17:27:27 +0800 Subject: [PATCH 06/12] refact(password): set cfg, do not early return if the password is invalid Signed-off-by: fufesou --- src/config.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/config.rs b/src/config.rs index fe26db49d..ddb8cd5eb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1653,10 +1653,7 @@ 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(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; - } + Self::prepare_config_for_store(&mut cfg); let mut lock = CONFIG.write().unwrap(); if *lock == cfg { return false; @@ -3483,6 +3480,25 @@ mod tests { assert_eq!(cfg.id, "123456789"); } + #[test] + fn test_set_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(); + + with_config_and_hard_settings(Config::default(), HashMap::new(), || { + assert!(Config::set(cfg)); + + let updated = Config::get(); + assert!(updated.password.is_empty()); + assert!(updated.salt.is_empty()); + assert_eq!(updated.id, "123456789"); + }); + } + #[test] fn test_permanent_password_sync_treats_same_encrypted_hash_as_unchanged() { let mut cfg = Config::default(); From 9e00fa2762fe317137885d6e81255997ff424748 Mon Sep 17 00:00:00 2001 From: fufesou Date: Wed, 20 May 2026 18:26:18 +0800 Subject: [PATCH 07/12] refact(password): check invalid with 01 when set salt Signed-off-by: fufesou --- src/config/permanent_password.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/config/permanent_password.rs b/src/config/permanent_password.rs index 4901c2fcc..cbef417dc 100644 --- a/src/config/permanent_password.rs +++ b/src/config/permanent_password.rs @@ -218,9 +218,12 @@ pub(super) fn password_is_empty_or_not_hashed(permanent_password_storage: &str) if decode_permanent_password_h1_from_storage(permanent_password_storage).is_some() { return false; } - let (_, decrypted, should_store) = + if permanent_password_storage.starts_with(PERMANENT_PASSWORD_ENC_VERSION) { + return false; + } + let (_, decrypted, looks_like_plaintext) = decrypt_str_or_original(permanent_password_storage, PASSWORD_ENC_VERSION); - decrypted || should_store + decrypted || looks_like_plaintext } #[cfg(test)] @@ -424,4 +427,9 @@ mod tests { assert!(!password_is_empty_or_not_hashed(&locked_storage)); } + + #[test] + fn test_password_is_empty_or_not_hashed_treats_invalid_01_storage_as_hashed() { + assert!(!password_is_empty_or_not_hashed("01not-a-valid-hash")); + } } From 8e0c8ab939a45e9d10992a4675ed144f2f821208 Mon Sep 17 00:00:00 2001 From: fufesou Date: Thu, 21 May 2026 15:04:32 +0800 Subject: [PATCH 08/12] refact(password): simplify Signed-off-by: fufesou --- src/config.rs | 6 +--- src/config/permanent_password.rs | 58 +++++++++----------------------- 2 files changed, 16 insertions(+), 48 deletions(-) diff --git a/src/config.rs b/src/config.rs index ddb8cd5eb..2ea99a863 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1429,15 +1429,11 @@ impl Config { Self::get_salt() } - fn has_usable_local_permanent_password() -> bool { + pub fn has_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 { - Self::has_usable_local_permanent_password() - } - // This shouldn't happen under normal circumstances because the salt // should be automatically generated when migrating to hash storage. // Actually, it is better to avoid calling set_salt at all. diff --git a/src/config/permanent_password.rs b/src/config/permanent_password.rs index cbef417dc..fd3d43d0c 100644 --- a/src/config/permanent_password.rs +++ b/src/config/permanent_password.rs @@ -56,12 +56,6 @@ pub(super) fn decode_permanent_password_h1_from_hashed_storage( 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, @@ -102,7 +96,7 @@ pub(super) fn decrypt_permanent_password_str_or_original(storage: &str) -> (Stri (storage.to_owned(), false, !storage.is_empty()) } -pub(super) fn permanent_password_storage_is_usable_for_auth(storage: &str, salt: &str) -> bool { +pub fn local_permanent_password_storage_is_usable_for_auth(storage: &str, salt: &str) -> bool { if storage.is_empty() { return false; } @@ -137,27 +131,15 @@ pub fn preset_permanent_password_storage_is_usable_for_auth(storage: &str, salt: pub fn decode_preset_password_h1_from_storage( storage: &str, ) -> Option<[u8; PERMANENT_PASSWORD_H1_LEN]> { - decode_hbbs_preset_password_h1_from_storage(storage) -} - -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) + decode_password_h1_after_prefix(storage, HBBS_PRESET_PASSWORD_HASH_PREFIX) } #[cfg(test)] -fn permanent_password_storage_matches_plain(storage: &str, salt: &str, input: &str) -> bool { +fn local_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) { + if !local_permanent_password_storage_is_usable_for_auth(storage, salt) { return false; } if let Some(stored_h1) = decode_permanent_password_h1_from_storage(storage) { @@ -189,14 +171,6 @@ pub(super) fn preset_permanent_password_storage_matches_plain( 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]> { @@ -271,13 +245,13 @@ mod tests { 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( + assert!(local_permanent_password_storage_is_usable_for_auth( &storage, salt )); - assert!(permanent_password_storage_matches_plain( + assert!(local_permanent_password_storage_matches_plain( &storage, salt, "p@ssw0rd" )); - assert!(!permanent_password_storage_matches_plain( + assert!(!local_permanent_password_storage_matches_plain( &storage, salt, "wrong" )); } @@ -342,8 +316,10 @@ mod tests { 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( + assert!(!local_permanent_password_storage_is_usable_for_auth( + &storage, "" + )); + assert!(!local_permanent_password_storage_matches_plain( &storage, "", "p@ssw0rd" )); } @@ -384,12 +360,6 @@ mod tests { + &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" )); @@ -404,8 +374,10 @@ mod tests { 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( + assert!(!local_permanent_password_storage_is_usable_for_auth( + &storage, "" + )); + assert!(!local_permanent_password_storage_matches_plain( &storage, "", &storage )); } From 2f032ad525111dfe4d8a61dbac3936887e4e17a5 Mon Sep 17 00:00:00 2001 From: fufesou Date: Fri, 22 May 2026 18:53:02 +0800 Subject: [PATCH 09/12] refact(password): early return error on invalid password Signed-off-by: fufesou --- src/config.rs | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/config.rs b/src/config.rs index 2ea99a863..904df567d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -30,10 +30,10 @@ use permanent_password::{ decode_permanent_password_h1_from_hashed_storage, decrypt_permanent_password_str_or_original, encode_permanent_password_encrypted_storage_from_h1, password_is_empty_or_not_hashed, preset_permanent_password_storage_matches_plain, DEFAULT_SALT_LEN, PASSWORD_ENC_VERSION, - PERMANENT_PASSWORD_H1_LEN, + PERMANENT_PASSWORD_ENC_VERSION, PERMANENT_PASSWORD_H1_LEN, }; #[cfg(test)] -use permanent_password::{is_permanent_password_hashed_storage, PERMANENT_PASSWORD_ENC_VERSION}; +use permanent_password::is_permanent_password_hashed_storage; use crate::{ compress::{compress, decompress}, @@ -653,6 +653,9 @@ impl Config { } let (decrypted_storage, decrypted, _) = decrypt_permanent_password_str_or_original(&config.password); + if config.password.starts_with(PERMANENT_PASSWORD_ENC_VERSION) && !decrypted { + return Err(anyhow!("Invalid permanent password encrypted hash storage")); + } if decrypted { Self::ensure_permanent_password_hash_salt(config)?; if decode_permanent_password_h1_from_hashed_storage(&decrypted_storage).is_some() { @@ -3463,17 +3466,25 @@ mod tests { #[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(); + for password in [ + { + let invalid_payload = + crate::password_security::symmetric_crypt(b"not-a-hash", true).unwrap(); + PERMANENT_PASSWORD_ENC_VERSION.to_owned() + + &base64::encode(invalid_payload, base64::Variant::Original) + }, + format!("{PERMANENT_PASSWORD_ENC_VERSION}invalid"), + ] { + let mut cfg = Config::default(); + cfg.password = password; + cfg.salt = "salt123".to_owned(); + 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"); + Config::prepare_config_for_store(&mut cfg); + assert!(cfg.password.is_empty()); + assert!(cfg.salt.is_empty()); + assert_eq!(cfg.id, "123456789"); + } } #[test] From 73ab2c37ae1c62985af5629e5f261b5d2d27a9a6 Mon Sep 17 00:00:00 2001 From: fufesou Date: Fri, 22 May 2026 21:32:33 +0800 Subject: [PATCH 10/12] refact(password): do not auto migrate to version 01 Signed-off-by: fufesou --- src/config.rs | 208 ++++++++++++++++--------------- src/config/permanent_password.rs | 24 ++-- 2 files changed, 120 insertions(+), 112 deletions(-) diff --git a/src/config.rs b/src/config.rs index 904df567d..37b698f24 100644 --- a/src/config.rs +++ b/src/config.rs @@ -30,10 +30,7 @@ use permanent_password::{ decode_permanent_password_h1_from_hashed_storage, decrypt_permanent_password_str_or_original, encode_permanent_password_encrypted_storage_from_h1, password_is_empty_or_not_hashed, preset_permanent_password_storage_matches_plain, DEFAULT_SALT_LEN, PASSWORD_ENC_VERSION, - PERMANENT_PASSWORD_ENC_VERSION, PERMANENT_PASSWORD_H1_LEN, }; -#[cfg(test)] -use permanent_password::is_permanent_password_hashed_storage; use crate::{ compress::{compress, decompress}, @@ -601,9 +598,8 @@ impl Config { fn load() -> Config { let mut config = Config::load_::(""); let mut store = false; - 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}"), + if let Err(err) = Self::normalize_permanent_password_storage(&mut config) { + log::error!("Failed to normalize permanent password storage: {err}"); } let mut id_valid = false; let (id, encrypted, store2) = decrypt_str_or_original(&config.enc_id, PASSWORD_ENC_VERSION); @@ -643,43 +639,35 @@ impl Config { config } - fn migrate_permanent_password_to_encrypted_hashed_storage(config: &mut Config) -> Result { + fn normalize_permanent_password_storage(config: &mut Config) -> Result<()> { if config.password.is_empty() { - return Ok(false); + return Ok(()); } if config.password.starts_with(PASSWORD_ENC_VERSION) { - return Self::migrate_encrypted_or_00_prefixed_password(config); + let (plain, decrypted, should_store) = + decrypt_str_or_original(&config.password, PASSWORD_ENC_VERSION); + if decrypted { + config.password = plain; + return Ok(()); + } + if !should_store { + return Err(anyhow!("Invalid permanent password encrypted hash storage")); + } + return Ok(()); } + let (decrypted_storage, decrypted, _) = decrypt_permanent_password_str_or_original(&config.password); - if config.password.starts_with(PERMANENT_PASSWORD_ENC_VERSION) && !decrypted { - return Err(anyhow!("Invalid permanent password encrypted hash storage")); - } if decrypted { Self::ensure_permanent_password_hash_salt(config)?; if decode_permanent_password_h1_from_hashed_storage(&decrypted_storage).is_some() { - return Ok(false); + return Ok(()); } 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) + Ok(()) } fn ensure_permanent_password_hash_salt(config: &Config) -> Result<()> { @@ -697,19 +685,8 @@ impl Config { } } - 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) { + match Self::normalize_permanent_password_storage(config) { Ok(_) => {} Err(err) => { // This path is for unrecoverable permanent-password storage, such as @@ -727,6 +704,12 @@ impl Config { fn store(&self) { let mut config = self.clone(); Self::prepare_config_for_store(&mut config); + if !config.password.is_empty() + && decode_permanent_password_h1_from_storage(&config.password).is_none() + { + config.password = + encrypt_str_or_original(&config.password, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); + } config.enc_id = encrypt_str_or_original(&config.id, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); config.id = "".to_owned(); Config::store_(&config, ""); @@ -1651,8 +1634,7 @@ 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(mut cfg: Config) -> bool { - Self::prepare_config_for_store(&mut cfg); + pub fn set(cfg: Config) -> bool { let mut lock = CONFIG.write().unwrap(); if *lock == cfg { return false; @@ -3267,7 +3249,7 @@ impl Status { #[cfg(test)] mod tests { - use super::*; + use super::{permanent_password::PERMANENT_PASSWORD_ENC_VERSION, *}; static CONFIG_STATE_TEST_LOCK: Mutex<()> = Mutex::new(()); @@ -3413,99 +3395,121 @@ mod tests { } #[test] - fn test_migrate_plaintext_permanent_password_to_encrypted_hashed_storage() { + fn test_normalize_keeps_plaintext_permanent_password_unchanged() { let mut cfg = Config::default(); cfg.password = "p@ssw0rd".to_owned(); cfg.salt = "".to_owned(); - let changed = - Config::migrate_permanent_password_to_encrypted_hashed_storage(&mut cfg).unwrap(); - assert!(changed); - assert!(cfg.password.starts_with(PERMANENT_PASSWORD_ENC_VERSION)); - assert!(!is_permanent_password_hashed_storage(&cfg.password)); - assert_eq!(cfg.salt.chars().count(), DEFAULT_SALT_LEN); - - 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); + Config::normalize_permanent_password_storage(&mut cfg).unwrap(); + assert_eq!(cfg.password, "p@ssw0rd"); + assert!(cfg.salt.is_empty()); } #[test] - fn test_migrate_plaintext_with_00_prefix_permanent_password_to_encrypted_hashed_storage() { + fn test_normalize_decrypts_00_permanent_password_without_forcing_store() { let mut cfg = Config::default(); - cfg.password = "00secret".to_owned(); + let legacy_storage = + encrypt_str_or_original("legacy-secret", PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); + cfg.password = legacy_storage; cfg.salt = "".to_owned(); - let changed = - Config::migrate_permanent_password_to_encrypted_hashed_storage(&mut cfg).unwrap(); - assert!(changed); - assert!(cfg.password.starts_with(PERMANENT_PASSWORD_ENC_VERSION)); - assert!(!is_permanent_password_hashed_storage(&cfg.password)); - assert!(!cfg.salt.is_empty()); - - 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); + Config::normalize_permanent_password_storage(&mut cfg).unwrap(); + assert_eq!(cfg.password, "legacy-secret"); + assert!(cfg.salt.is_empty()); } #[test] - fn test_migrate_rejects_encrypted_hashed_permanent_password_without_salt() { + fn test_normalize_rejects_corrupted_00_permanent_password_storage() { + let legacy_storage = + encrypt_str_or_original("legacy-secret", PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); + let mut invalid_payload = base64::decode( + &legacy_storage.as_bytes()[PASSWORD_ENC_VERSION.len()..], + base64::Variant::Original, + ) + .unwrap(); + *invalid_payload.last_mut().unwrap() ^= 1; + + let mut cfg = Config::default(); + cfg.password = PASSWORD_ENC_VERSION.to_owned() + + &base64::encode(invalid_payload, base64::Variant::Original); + cfg.salt = "salt123".to_owned(); + + assert!(Config::normalize_permanent_password_storage(&mut cfg).is_err()); + } + + #[test] + fn test_normalize_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!(Config::normalize_permanent_password_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() { - for password in [ - { - let invalid_payload = - crate::password_security::symmetric_crypt(b"not-a-hash", true).unwrap(); - PERMANENT_PASSWORD_ENC_VERSION.to_owned() - + &base64::encode(invalid_payload, base64::Variant::Original) - }, - format!("{PERMANENT_PASSWORD_ENC_VERSION}invalid"), - ] { - let mut cfg = Config::default(); - cfg.password = password; - cfg.salt = "salt123".to_owned(); - 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_set_clears_invalid_permanent_password_and_keeps_unrelated_fields() { + fn test_set_does_not_normalize_permanent_password_storage_in_memory() { 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() + let invalid_storage = PERMANENT_PASSWORD_ENC_VERSION.to_owned() + &base64::encode(invalid_payload, base64::Variant::Original); + cfg.password = invalid_storage.clone(); cfg.id = "123456789".to_owned(); with_config_and_hard_settings(Config::default(), HashMap::new(), || { assert!(Config::set(cfg)); let updated = Config::get(); - assert!(updated.password.is_empty()); + assert_eq!(updated.password, invalid_storage); assert!(updated.salt.is_empty()); assert_eq!(updated.id, "123456789"); }); } + #[test] + fn test_set_does_not_convert_plaintext_permanent_password_to_storage_format_in_memory() { + let mut cfg = Config::default(); + cfg.password = "legacy-secret".to_owned(); + cfg.salt = "".to_owned(); + + with_config_and_hard_settings(Config::default(), HashMap::new(), || { + assert!(Config::set(cfg)); + + let updated = Config::get(); + assert!(!updated.password.starts_with(PASSWORD_ENC_VERSION)); + assert_eq!(updated.password, "legacy-secret"); + assert!(updated.salt.is_empty()); + }); + } + + #[test] + fn test_set_keeps_plaintext_permanent_password_with_current_prefix_in_memory() { + let mut cfg = Config::default(); + cfg.password = "01legacy-secret".to_owned(); + cfg.salt = "".to_owned(); + + with_config_and_hard_settings(Config::default(), HashMap::new(), || { + assert!(Config::set(cfg)); + + let updated = Config::get(); + assert_eq!(updated.password, "01legacy-secret"); + assert!(updated.salt.is_empty()); + }); + } + + #[test] + fn test_normalize_keeps_plaintext_permanent_password_with_current_prefix_and_long_base64() { + let mut cfg = Config::default(); + let plain = "01".to_owned() + &base64::encode([42u8; 24], base64::Variant::Original); + cfg.password = plain.clone(); + cfg.salt = "".to_owned(); + + Config::normalize_permanent_password_storage(&mut cfg).unwrap(); + assert_eq!(cfg.password, plain); + assert!(cfg.salt.is_empty()); + } + #[test] fn test_permanent_password_sync_treats_same_encrypted_hash_as_unchanged() { let mut cfg = Config::default(); @@ -3514,7 +3518,7 @@ mod tests { 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()); + Config::normalize_permanent_password_storage(&mut cfg).unwrap(); assert!(!Config::apply_permanent_password_storage_for_sync( &mut cfg, diff --git a/src/config/permanent_password.rs b/src/config/permanent_password.rs index fd3d43d0c..ffc738237 100644 --- a/src/config/permanent_password.rs +++ b/src/config/permanent_password.rs @@ -105,8 +105,11 @@ pub fn local_permanent_password_storage_is_usable_for_auth(storage: &str, salt: 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, _) = decrypt_permanent_password_str_or_original(storage); + if decrypted { + log::error!("Permanent password storage looks current but cannot be decoded as a hash"); + return false; + } } let (_, decrypted, looks_like_plaintext) = @@ -359,14 +362,15 @@ mod tests { let encrypted_non_hash = PERMANENT_PASSWORD_ENC_VERSION.to_owned() + &base64::encode(encrypted, base64::Variant::Original); - for storage in ["01invalid", &encrypted_non_hash] { - assert!(!local_permanent_password_storage_is_usable_for_auth( - storage, "salt123" - )); - assert!(!local_permanent_password_storage_matches_plain( - storage, "salt123", storage - )); - } + assert!(!local_permanent_password_storage_is_usable_for_auth( + &encrypted_non_hash, + "salt123" + )); + assert!(!local_permanent_password_storage_matches_plain( + &encrypted_non_hash, + "salt123", + &encrypted_non_hash + )); } #[test] From ce1f7b5f9a7aebe68af8d04a1588b75020588d42 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 23 May 2026 14:43:43 +0800 Subject: [PATCH 11/12] refact(password): clear password, do not clear salt Signed-off-by: fufesou --- src/config.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index 37b698f24..5b52db707 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1344,11 +1344,13 @@ impl Config { salt: &str, ) -> Result { if storage.is_empty() { - if config.password.is_empty() && config.salt.is_empty() { + if config.password.is_empty() && (salt.is_empty() || config.salt == salt) { return Ok(false); } config.password.clear(); - config.salt.clear(); + if !salt.is_empty() { + config.salt = salt.to_owned(); + } return Ok(true); } if salt.is_empty() { @@ -3605,7 +3607,22 @@ mod tests { assert!(Config::apply_permanent_password_storage_for_sync(&mut cfg, "", "").unwrap()); assert!(cfg.password.is_empty()); - assert!(cfg.salt.is_empty()); + assert_eq!(cfg.salt, salt); + } + + #[test] + fn test_permanent_password_sync_empty_storage_uses_incoming_salt() { + let old_salt = "old-salt"; + let h1 = compute_permanent_password_h1("p@ssw0rd", old_salt); + let mut cfg = Config::default(); + cfg.password = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); + cfg.salt = old_salt.to_owned(); + + assert!( + Config::apply_permanent_password_storage_for_sync(&mut cfg, "", "new-salt").unwrap() + ); + assert!(cfg.password.is_empty()); + assert_eq!(cfg.salt, "new-salt"); } #[test] From 728b73d540728650518f59a9f8a33d861c5f5f35 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 23 May 2026 15:13:16 +0800 Subject: [PATCH 12/12] refact(password): rename and comments --- src/config.rs | 33 ++++++++++++++++---------------- src/config/permanent_password.rs | 3 ++- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/config.rs b/src/config.rs index 5b52db707..cc34a0d06 100644 --- a/src/config.rs +++ b/src/config.rs @@ -598,8 +598,8 @@ impl Config { fn load() -> Config { let mut config = Config::load_::(""); let mut store = false; - if let Err(err) = Self::normalize_permanent_password_storage(&mut config) { - log::error!("Failed to normalize permanent password storage: {err}"); + if let Err(err) = Self::validate_or_decrypt_permanent_password_storage(&mut config) { + log::error!("Failed to validate or decrypt permanent password storage: {err}"); } let mut id_valid = false; let (id, encrypted, store2) = decrypt_str_or_original(&config.enc_id, PASSWORD_ENC_VERSION); @@ -639,7 +639,7 @@ impl Config { config } - fn normalize_permanent_password_storage(config: &mut Config) -> Result<()> { + fn validate_or_decrypt_permanent_password_storage(config: &mut Config) -> Result<()> { if config.password.is_empty() { return Ok(()); } @@ -686,7 +686,7 @@ impl Config { } fn prepare_config_for_store(config: &mut Config) { - match Self::normalize_permanent_password_storage(config) { + match Self::validate_or_decrypt_permanent_password_storage(config) { Ok(_) => {} Err(err) => { // This path is for unrecoverable permanent-password storage, such as @@ -3397,29 +3397,29 @@ mod tests { } #[test] - fn test_normalize_keeps_plaintext_permanent_password_unchanged() { + fn test_validate_or_decrypt_keeps_plaintext_permanent_password_unchanged() { let mut cfg = Config::default(); cfg.password = "p@ssw0rd".to_owned(); cfg.salt = "".to_owned(); - Config::normalize_permanent_password_storage(&mut cfg).unwrap(); + Config::validate_or_decrypt_permanent_password_storage(&mut cfg).unwrap(); assert_eq!(cfg.password, "p@ssw0rd"); assert!(cfg.salt.is_empty()); } #[test] - fn test_normalize_decrypts_00_permanent_password_without_forcing_store() { + fn test_validate_or_decrypt_decrypts_00_permanent_password_without_forcing_store() { let mut cfg = Config::default(); let legacy_storage = encrypt_str_or_original("legacy-secret", PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); cfg.password = legacy_storage; cfg.salt = "".to_owned(); - Config::normalize_permanent_password_storage(&mut cfg).unwrap(); + Config::validate_or_decrypt_permanent_password_storage(&mut cfg).unwrap(); assert_eq!(cfg.password, "legacy-secret"); assert!(cfg.salt.is_empty()); } #[test] - fn test_normalize_rejects_corrupted_00_permanent_password_storage() { + fn test_validate_or_decrypt_rejects_corrupted_00_permanent_password_storage() { let legacy_storage = encrypt_str_or_original("legacy-secret", PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); let mut invalid_payload = base64::decode( @@ -3434,23 +3434,23 @@ mod tests { + &base64::encode(invalid_payload, base64::Variant::Original); cfg.salt = "salt123".to_owned(); - assert!(Config::normalize_permanent_password_storage(&mut cfg).is_err()); + assert!(Config::validate_or_decrypt_permanent_password_storage(&mut cfg).is_err()); } #[test] - fn test_normalize_rejects_encrypted_hashed_permanent_password_without_salt() { + fn test_validate_or_decrypt_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::normalize_permanent_password_storage(&mut cfg).is_err()); + assert!(Config::validate_or_decrypt_permanent_password_storage(&mut cfg).is_err()); assert_eq!(cfg.password, original_password); assert!(cfg.salt.is_empty()); } #[test] - fn test_set_does_not_normalize_permanent_password_storage_in_memory() { + fn test_set_does_not_validate_or_decrypt_permanent_password_storage_in_memory() { let mut cfg = Config::default(); let invalid_payload = crate::password_security::symmetric_crypt(b"not-a-hash", true).unwrap(); @@ -3501,13 +3501,14 @@ mod tests { } #[test] - fn test_normalize_keeps_plaintext_permanent_password_with_current_prefix_and_long_base64() { + fn test_validate_or_decrypt_keeps_plaintext_permanent_password_with_current_prefix_and_long_base64( + ) { let mut cfg = Config::default(); let plain = "01".to_owned() + &base64::encode([42u8; 24], base64::Variant::Original); cfg.password = plain.clone(); cfg.salt = "".to_owned(); - Config::normalize_permanent_password_storage(&mut cfg).unwrap(); + Config::validate_or_decrypt_permanent_password_storage(&mut cfg).unwrap(); assert_eq!(cfg.password, plain); assert!(cfg.salt.is_empty()); } @@ -3520,7 +3521,7 @@ mod tests { let encrypted_hash_storage = encode_permanent_password_encrypted_storage_from_h1(&h1).unwrap(); cfg.password = encrypted_hash_storage.clone(); - Config::normalize_permanent_password_storage(&mut cfg).unwrap(); + Config::validate_or_decrypt_permanent_password_storage(&mut cfg).unwrap(); assert!(!Config::apply_permanent_password_storage_for_sync( &mut cfg, diff --git a/src/config/permanent_password.rs b/src/config/permanent_password.rs index ffc738237..5fbca2542 100644 --- a/src/config/permanent_password.rs +++ b/src/config/permanent_password.rs @@ -187,7 +187,8 @@ pub fn decode_permanent_password_h1_from_storage( None } -// If password is empty or not hashed storage, it's safe to update salt. +// Salt can be updated only when the password is empty, plaintext, or decryptable +// legacy storage. Current-prefixed storage is treated as salt-bound. pub(super) fn password_is_empty_or_not_hashed(permanent_password_storage: &str) -> bool { if permanent_password_storage.is_empty() { return true;