From d98d20896eff60b7beb70129f6fe9ecd503728af Mon Sep 17 00:00:00 2001 From: fufesou Date: Mon, 16 Mar 2026 21:31:57 +0800 Subject: [PATCH 01/18] refact(password): Store permanent password as hashed verifier Signed-off-by: fufesou --- src/config.rs | 387 +++++++++++++++++++++++++++++++++++++-- src/password_security.rs | 2 +- 2 files changed, 368 insertions(+), 21 deletions(-) diff --git a/src/config.rs b/src/config.rs index 31811d400..c37a54814 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,13 +9,14 @@ 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; @@ -41,6 +42,54 @@ 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 = 6; + +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) +} + +fn can_update_salt(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! { pub static ref ORG: RwLock = RwLock::new("com.carriez".to_owned()); @@ -564,9 +613,7 @@ impl Config { fn load() -> Config { let mut config = Config::load_::(""); let mut store = false; - let (password, _, store1) = decrypt_str_or_original(&config.password, PASSWORD_ENC_VERSION); - config.password = password; - store |= store1; + store |= Self::migrate_permanent_password_to_hashed_storage(&mut config); let mut id_valid = false; let (id, encrypted, store2) = decrypt_str_or_original(&config.enc_id, PASSWORD_ENC_VERSION); if encrypted { @@ -605,10 +652,36 @@ 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; + } + + if config.password.starts_with(PASSWORD_ENC_VERSION) { + let (plain, decrypted, looks_like_plaintext) = + decrypt_str_or_original(&config.password, PASSWORD_ENC_VERSION); + if !decrypted && !looks_like_plaintext { + return false; + } + if config.salt.is_empty() { + config.salt = Config::get_auto_password(DEFAULT_SALT_LEN); + } + let h1 = compute_permanent_password_h1(&plain, &config.salt); + config.password = encode_permanent_password_storage_from_h1(&h1); + return true; + } + + 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 store(&self) { let mut config = self.clone(); - config.password = - encrypt_str_or_original(&config.password, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); + Self::migrate_permanent_password_to_hashed_storage(&mut config); config.enc_id = encrypt_str_or_original(&config.id, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); config.id = "".to_owned(); Config::store_(&config, ""); @@ -1154,23 +1227,125 @@ impl Config { return; } } + let mut config = CONFIG.write().unwrap(); - if password == config.password { + let stored = if password.is_empty() { + String::new() + } else { + Self::compute_permanent_password_storage_for_update(&mut config, password) + }; + if stored == config.password { return; } - config.password = password.into(); + config.password = stored; config.store(); Self::clear_trusted_devices(); } - pub fn get_permanent_password() -> String { - let mut password = CONFIG.read().unwrap().password.clone(); - if password.is_empty() { - if let Some(v) = HARD_SETTINGS.read().unwrap().get("password") { - password = v.to_owned(); - } + fn compute_permanent_password_storage_for_update( + config: &mut Config, + password: &str, + ) -> String { + if config.salt.is_empty() { + // If salt is missing, we cannot keep an existing hashed verifier valid anyway. + // When updating the password, generate a new salt and store it with the new verifier. + log::warn!("Salt is empty; generating new salt for permanent password update"); + config.salt = Config::get_auto_password(DEFAULT_SALT_LEN); } - password + let h1 = compute_permanent_password_h1(password, &config.salt); + encode_permanent_password_storage_from_h1(&h1) + } + + /// Returns the locally persisted permanent password storage and salt (NOT the hard/preset one). + /// + /// This function is side-effect free: + /// - It does NOT call `get_salt()` (which may auto-generate salt). + /// - It returns a consistent snapshot under a single lock. + pub fn get_local_permanent_password_storage_and_salt() -> (String, String) { + let config = CONFIG.read().unwrap(); + (config.password.clone(), config.salt.clone()) + } + + /// Persist permanent password storage and salt from service->user config sync. + /// + /// This never accepts plaintext. `storage` must be empty or a valid hashed verifier storage. + pub fn set_permanent_password_storage_for_sync( + storage: &str, + salt: &str, + ) -> crate::ResultType { + let mut config = CONFIG.write().unwrap(); + + if storage.is_empty() { + if config.password.is_empty() { + return Ok(false); + } + config.password = String::new(); + config.store(); + Self::clear_trusted_devices(); + return Ok(true); + } + + if salt.is_empty() { + return Err(anyhow!( + "Refusing to persist hashed permanent password without salt" + )); + } + if decode_permanent_password_h1_from_storage(storage).is_none() { + return Err(anyhow!("Invalid hashed permanent password storage")); + } + + 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; + } + + 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 + } + + pub fn has_permanent_password() -> bool { + if !CONFIG.read().unwrap().password.is_empty() { + return true; + } + HARD_SETTINGS + .read() + .unwrap() + .get("password") + .map_or(false, |v| !v.is_empty()) } pub fn set_salt(salt: &str) { @@ -1178,14 +1353,24 @@ impl Config { if salt == config.salt { return; } + if !can_update_salt(&config.password) { + log::error!("Refusing to update salt because permanent password is hashed"); + return; + } config.salt = salt.into(); config.store(); } pub fn get_salt() -> String { - let mut salt = CONFIG.read().unwrap().salt.clone(); + let config = CONFIG.read().unwrap(); + let mut salt = config.salt.clone(); if salt.is_empty() { - salt = Config::get_auto_password(6); + if !can_update_salt(&config.password) { + log::error!("Salt is empty but permanent password is hashed"); + return String::new(); + } + drop(config); + salt = Config::get_auto_password(DEFAULT_SALT_LEN); Config::set_salt(&salt); } salt @@ -1373,7 +1558,9 @@ impl Config { } pub fn set(cfg: Config) -> bool { + let mut cfg = cfg; let mut lock = CONFIG.write().unwrap(); + Self::normalize_incoming_permanent_password_update(&lock, &mut cfg); if *lock == cfg { return false; } @@ -1388,6 +1575,56 @@ impl Config { true } + fn normalize_incoming_permanent_password_update(current: &Config, incoming: &mut Config) { + if !incoming.password.is_empty() + && is_permanent_password_hashed_storage(&incoming.password) + && incoming.salt.is_empty() + { + log::error!("Refusing to persist hashed permanent password without salt"); + incoming.password = current.password.clone(); + incoming.salt = current.salt.clone(); + return; + } + + let current_is_hashed = + !current.password.is_empty() && is_permanent_password_hashed_storage(¤t.password); + if !current_is_hashed { + return; + } + + // Once the permanent password is stored as a hashed verifier, treat salt as immutable. + // Allow hashed verifier updates when salt is unchanged, so service->user config sync can + // propagate permanent password changes without plaintext. + if incoming.salt != current.salt { + log::error!("Refusing to change salt for hashed permanent password via Config::set"); + incoming.password = current.password.clone(); + incoming.salt = current.salt.clone(); + return; + } + + // Keep the previous hard rule: don't allow clearing via config sync. + if incoming.password.is_empty() && !current.password.is_empty() { + log::error!("Refusing to clear hashed permanent password via Config::set"); + incoming.password = current.password.clone(); + incoming.salt = current.salt.clone(); + return; + } + + // Allow hashed->hashed verifier updates when salt matches. + if !incoming.password.is_empty() && is_permanent_password_hashed_storage(&incoming.password) + { + return; + } + + // Refuse any downgrade or plaintext overwrite attempts. + if incoming.password != current.password { + log::error!("Refusing to overwrite hashed permanent password via Config::set"); + incoming.password = current.password.clone(); + incoming.salt = current.salt.clone(); + return; + } + } + /// Invalidate KEY_PAIR cache if it differs from the new key_pair. /// Use None to invalidate the cache instead of Some(key_pair). /// If we use Some with an empty key_pair, get_key_pair() would always return @@ -2740,10 +2977,12 @@ pub mod keys { pub const OPTION_KEEP_SCREEN_ON: &str = "keep-screen-on"; // Server-side: keep host system awake during incoming sessions (Security setting) - pub const OPTION_KEEP_AWAKE_DURING_INCOMING_SESSIONS: &str = "keep-awake-during-incoming-sessions"; + pub const OPTION_KEEP_AWAKE_DURING_INCOMING_SESSIONS: &str = + "keep-awake-during-incoming-sessions"; - // Client-side: keep client system awake during outgoing sessions (General setting) - pub const OPTION_KEEP_AWAKE_DURING_OUTGOING_SESSIONS: &str = "keep-awake-during-outgoing-sessions"; + // Client-side: keep client system awake during outgoing sessions (General setting) + pub const OPTION_KEEP_AWAKE_DURING_OUTGOING_SESSIONS: &str = + "keep-awake-during-outgoing-sessions"; pub const OPTION_DISABLE_GROUP_PANEL: &str = "disable-group-panel"; pub const OPTION_DISABLE_DISCOVERY_PANEL: &str = "disable-discovery-panel"; @@ -2984,6 +3223,114 @@ mod tests { assert!(res.is_ok()); } + #[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(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[..]); + } + + #[test] + fn test_migrate_plaintext_permanent_password_to_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); + assert!(changed); + assert!(is_permanent_password_hashed_storage(&cfg.password)); + assert_eq!(cfg.salt.chars().count(), DEFAULT_SALT_LEN); + + let stored_h1 = decode_permanent_password_h1_from_storage(&cfg.password).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() { + 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); + assert!(changed); + 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 expected_h1 = compute_permanent_password_h1("00secret", &cfg.salt); + assert_eq!(stored_h1, expected_h1); + } + + #[test] + fn test_config_set_refuses_plain_password_when_current_is_hashed() { + let mut current = Config::default(); + current.salt = "salt12".to_owned(); + current.password = encode_permanent_password_storage_from_h1( + &compute_permanent_password_h1("old", ¤t.salt), + ); + + let mut incoming = current.clone(); + incoming.password = "plaintext".to_owned(); + + Config::normalize_incoming_permanent_password_update(¤t, &mut incoming); + assert_eq!(incoming.password, current.password); + assert_eq!(incoming.salt, current.salt); + } + + #[test] + fn test_config_set_refuses_clear_password_when_current_is_hashed() { + let mut current = Config::default(); + current.salt = "salt12".to_owned(); + current.password = encode_permanent_password_storage_from_h1( + &compute_permanent_password_h1("old", ¤t.salt), + ); + + let mut incoming = current.clone(); + incoming.password = "".to_owned(); + + Config::normalize_incoming_permanent_password_update(¤t, &mut incoming); + assert_eq!(incoming.password, current.password); + assert_eq!(incoming.salt, current.salt); + } + + #[test] + fn test_config_set_allows_replace_password_when_current_is_hashed_and_salt_unchanged() { + let mut current = Config::default(); + current.salt = "salt12".to_owned(); + current.password = encode_permanent_password_storage_from_h1( + &compute_permanent_password_h1("old", ¤t.salt), + ); + + let mut incoming = current.clone(); + incoming.password = encode_permanent_password_storage_from_h1( + &compute_permanent_password_h1("new", ¤t.salt), + ); + + Config::normalize_incoming_permanent_password_update(¤t, &mut incoming); + assert_eq!(incoming.salt, current.salt); + assert_ne!(incoming.password, current.password); + } + + #[test] + fn test_config_set_refuses_salt_change_when_password_unchanged_and_hashed() { + let mut current = Config::default(); + current.salt = "salt12".to_owned(); + current.password = encode_permanent_password_storage_from_h1( + &compute_permanent_password_h1("old", ¤t.salt), + ); + + let mut incoming = current.clone(); + incoming.salt = "DIFF00".to_owned(); + + Config::normalize_incoming_permanent_password_update(¤t, &mut incoming); + assert_eq!(incoming.password, current.password); + assert_eq!(incoming.salt, current.salt); + } + #[test] fn test_overwrite_settings() { DEFAULT_SETTINGS diff --git a/src/password_security.rs b/src/password_security.rs index 3d532b772..6471d7135 100644 --- a/src/password_security.rs +++ b/src/password_security.rs @@ -71,7 +71,7 @@ pub fn permanent_enabled() -> bool { pub fn has_valid_password() -> bool { temporary_enabled() && !temporary_password().is_empty() - || permanent_enabled() && !Config::get_permanent_password().is_empty() + || permanent_enabled() && Config::has_permanent_password() } pub fn approve_mode() -> ApproveMode { From b662b38d308178caa640d16e3aec1d5431efa7e6 Mon Sep 17 00:00:00 2001 From: fufesou Date: Thu, 19 Mar 2026 11:06:34 +0800 Subject: [PATCH 02/18] fix(password): comment TODO clear devices Signed-off-by: fufesou --- src/config.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/config.rs b/src/config.rs index c37a54814..2e802ad2b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -660,6 +660,10 @@ impl Config { 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; } @@ -1557,6 +1561,8 @@ impl Config { return CONFIG.read().unwrap().clone(); } + // 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 { let mut cfg = cfg; let mut lock = CONFIG.write().unwrap(); From 37802208aa28abb26e906de58ec6d1249d4f02b2 Mon Sep 17 00:00:00 2001 From: fufesou Date: Thu, 19 Mar 2026 11:54:20 +0800 Subject: [PATCH 03/18] fix(password): update salt in set_permanent_password() Signed-off-by: fufesou --- src/config.rs | 44 +++++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/src/config.rs b/src/config.rs index 2e802ad2b..e44a27fde 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1233,6 +1233,23 @@ impl Config { } let mut config = CONFIG.write().unwrap(); + + // If the permanent password is already stored as a hashed verifier, avoid rotating salt + // when the plaintext stays the same. Rotating salt on "no-op" updates would unnecessarily + // clear trusted devices and trigger config sync churn. + if !password.is_empty() + && !config.password.is_empty() + && is_permanent_password_hashed_storage(&config.password) + && !config.salt.is_empty() + { + if let Some(stored_h1) = decode_permanent_password_h1_from_storage(&config.password) { + let candidate_h1 = compute_permanent_password_h1(password, &config.salt); + if constant_time_eq_32(&candidate_h1, &stored_h1) { + return; + } + } + } + let stored = if password.is_empty() { String::new() } else { @@ -1250,12 +1267,9 @@ impl Config { config: &mut Config, password: &str, ) -> String { - if config.salt.is_empty() { - // If salt is missing, we cannot keep an existing hashed verifier valid anyway. - // When updating the password, generate a new salt and store it with the new verifier. - log::warn!("Salt is empty; generating new salt for permanent password update"); - config.salt = Config::get_auto_password(DEFAULT_SALT_LEN); - } + // Rotate salt on permanent password updates so the verifier changes even if the user + // reuses a previous password. (No-op updates are handled in `set_permanent_password()`.) + config.salt = Config::get_auto_password(DEFAULT_SALT_LEN); let h1 = compute_permanent_password_h1(password, &config.salt); encode_permanent_password_storage_from_h1(&h1) } @@ -1598,11 +1612,13 @@ impl Config { return; } - // Once the permanent password is stored as a hashed verifier, treat salt as immutable. - // Allow hashed verifier updates when salt is unchanged, so service->user config sync can - // propagate permanent password changes without plaintext. - if incoming.salt != current.salt { - log::error!("Refusing to change salt for hashed permanent password via Config::set"); + // Once the permanent password is stored as a hashed verifier, keep salt and verifier + // consistent as a pair. + // - Refuse salt-only changes (would break verification). + // - Allow hashed->hashed updates with salt rotation only when the verifier also changes, + // so service->user config sync can propagate password updates without plaintext. + if incoming.salt != current.salt && incoming.password == current.password { + log::error!("Refusing to change salt without updating hashed permanent password"); incoming.password = current.password.clone(); incoming.salt = current.salt.clone(); return; @@ -1616,8 +1632,10 @@ impl Config { return; } - // Allow hashed->hashed verifier updates when salt matches. - if !incoming.password.is_empty() && is_permanent_password_hashed_storage(&incoming.password) + // Allow hashed->hashed verifier updates (with optional salt rotation). + if !incoming.password.is_empty() + && is_permanent_password_hashed_storage(&incoming.password) + && !incoming.salt.is_empty() { return; } From 5d5f12a5ac2da638b0d732cfbd2ea4181fbeb71b Mon Sep 17 00:00:00 2001 From: fufesou Date: Thu, 19 Mar 2026 21:23:12 +0800 Subject: [PATCH 04/18] fix(password): guard set_permanent_password_storage_for_sync() Signed-off-by: fufesou --- src/config.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/config.rs b/src/config.rs index e44a27fde..558019772 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1312,6 +1312,19 @@ impl Config { return Err(anyhow!("Invalid hashed permanent password storage")); } + // For hashed permanent password storage, `storage` and `salt` must be consistent as a pair. + // + // In theory, it should be impossible to observe "same storage but different salt" for a + // correct sync source. However, accepting such an update would persist an invalid + // (storage, salt) pair and make permanent-password verification fail for all inputs + // (effective lockout) until the password is reset. The impact is high enough that a + // defensive check here is worthwhile even if it is rarely triggered in practice. + if config.password == storage && config.salt != salt { + return Err(anyhow!( + "Refusing to change salt without updating hashed permanent password storage" + )); + } + if config.password == storage && config.salt == salt { return Ok(false); } From 4435f190664853da93ef45269c31fccbad156bef Mon Sep 17 00:00:00 2001 From: fufesou Date: Fri, 20 Mar 2026 21:55:38 +0800 Subject: [PATCH 05/18] fix(password): do not update salt when updating permanent password Signed-off-by: fufesou --- src/config.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index 558019772..bd6088a45 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1267,9 +1267,11 @@ impl Config { config: &mut Config, password: &str, ) -> String { - // Rotate salt on permanent password updates so the verifier changes even if the user - // reuses a previous password. (No-op updates are handled in `set_permanent_password()`.) - config.salt = Config::get_auto_password(DEFAULT_SALT_LEN); + // 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); + } let h1 = compute_permanent_password_h1(password, &config.salt); encode_permanent_password_storage_from_h1(&h1) } @@ -1379,6 +1381,10 @@ impl Config { .map_or(false, |v| !v.is_empty()) } + pub fn has_local_permanent_password() -> bool { + !CONFIG.read().unwrap().password.is_empty() + } + pub fn set_salt(salt: &str) { let mut config = CONFIG.write().unwrap(); if salt == config.salt { From 70f22e69c82cac1fcfd3b83c141196680081fca0 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 21 Mar 2026 09:44:08 +0800 Subject: [PATCH 06/18] fix(password): Comments log error Signed-off-by: fufesou --- src/config.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/config.rs b/src/config.rs index b91ed4b5d..3c21425be 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1403,6 +1403,10 @@ impl Config { let mut salt = config.salt.clone(); if salt.is_empty() { if !can_update_salt(&config.password) { + // This shouldn't happen under normal circumstances because the salt + // should be automatically generated when migrating to hash storage. + // However, if it does occur, it's best to log the error, + // even though this will result in logging many duplicate error messages. log::error!("Salt is empty but permanent password is hashed"); return String::new(); } From 325b8841eaf0d620f0e09e675195ab6df6d724c6 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 22 Mar 2026 11:35:28 +0800 Subject: [PATCH 07/18] fix(password): remove has_local_permanent_password Signed-off-by: fufesou --- src/config.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/config.rs b/src/config.rs index 3c21425be..e54c675f0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1381,10 +1381,6 @@ impl Config { .map_or(false, |v| !v.is_empty()) } - pub fn has_local_permanent_password() -> bool { - !CONFIG.read().unwrap().password.is_empty() - } - pub fn set_salt(salt: &str) { let mut config = CONFIG.write().unwrap(); if salt == config.salt { From 6bc0ee0e8f03faaed6462af7da0303514fa02ba3 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 22 Mar 2026 15:36:22 +0800 Subject: [PATCH 08/18] fix(password): has_local_permanent_password() Signed-off-by: fufesou --- src/config.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/config.rs b/src/config.rs index e54c675f0..3c21425be 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1381,6 +1381,10 @@ impl Config { .map_or(false, |v| !v.is_empty()) } + pub fn has_local_permanent_password() -> bool { + !CONFIG.read().unwrap().password.is_empty() + } + pub fn set_salt(salt: &str) { let mut config = CONFIG.write().unwrap(); if salt == config.salt { From 71be0dcd8d9353f7f1e2904af7403c3cc0432e00 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 22 Mar 2026 17:08:12 +0800 Subject: [PATCH 09/18] fix(password): remove invalid check Signed-off-by: fufesou --- src/config.rs | 154 -------------------------------------------------- 1 file changed, 154 deletions(-) diff --git a/src/config.rs b/src/config.rs index 3c21425be..d7baaabff 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1234,22 +1234,6 @@ impl Config { let mut config = CONFIG.write().unwrap(); - // If the permanent password is already stored as a hashed verifier, avoid rotating salt - // when the plaintext stays the same. Rotating salt on "no-op" updates would unnecessarily - // clear trusted devices and trigger config sync churn. - if !password.is_empty() - && !config.password.is_empty() - && is_permanent_password_hashed_storage(&config.password) - && !config.salt.is_empty() - { - if let Some(stored_h1) = decode_permanent_password_h1_from_storage(&config.password) { - let candidate_h1 = compute_permanent_password_h1(password, &config.salt); - if constant_time_eq_32(&candidate_h1, &stored_h1) { - return; - } - } - } - let stored = if password.is_empty() { String::new() } else { @@ -1314,19 +1298,6 @@ impl Config { return Err(anyhow!("Invalid hashed permanent password storage")); } - // For hashed permanent password storage, `storage` and `salt` must be consistent as a pair. - // - // In theory, it should be impossible to observe "same storage but different salt" for a - // correct sync source. However, accepting such an update would persist an invalid - // (storage, salt) pair and make permanent-password verification fail for all inputs - // (effective lockout) until the password is reset. The impact is high enough that a - // defensive check here is worthwhile even if it is rarely triggered in practice. - if config.password == storage && config.salt != salt { - return Err(anyhow!( - "Refusing to change salt without updating hashed permanent password storage" - )); - } - if config.password == storage && config.salt == salt { return Ok(false); } @@ -1601,12 +1572,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(cfg: Config) -> bool { - let mut cfg = cfg; let mut lock = CONFIG.write().unwrap(); - Self::normalize_incoming_permanent_password_update(&lock, &mut cfg); - if *lock == cfg { - return false; - } *lock = cfg; lock.store(); // Drop CONFIG lock before acquiring KEY_PAIR lock to avoid potential deadlock. @@ -1618,60 +1584,6 @@ impl Config { true } - fn normalize_incoming_permanent_password_update(current: &Config, incoming: &mut Config) { - if !incoming.password.is_empty() - && is_permanent_password_hashed_storage(&incoming.password) - && incoming.salt.is_empty() - { - log::error!("Refusing to persist hashed permanent password without salt"); - incoming.password = current.password.clone(); - incoming.salt = current.salt.clone(); - return; - } - - let current_is_hashed = - !current.password.is_empty() && is_permanent_password_hashed_storage(¤t.password); - if !current_is_hashed { - return; - } - - // Once the permanent password is stored as a hashed verifier, keep salt and verifier - // consistent as a pair. - // - Refuse salt-only changes (would break verification). - // - Allow hashed->hashed updates with salt rotation only when the verifier also changes, - // so service->user config sync can propagate password updates without plaintext. - if incoming.salt != current.salt && incoming.password == current.password { - log::error!("Refusing to change salt without updating hashed permanent password"); - incoming.password = current.password.clone(); - incoming.salt = current.salt.clone(); - return; - } - - // Keep the previous hard rule: don't allow clearing via config sync. - if incoming.password.is_empty() && !current.password.is_empty() { - log::error!("Refusing to clear hashed permanent password via Config::set"); - incoming.password = current.password.clone(); - incoming.salt = current.salt.clone(); - return; - } - - // Allow hashed->hashed verifier updates (with optional salt rotation). - if !incoming.password.is_empty() - && is_permanent_password_hashed_storage(&incoming.password) - && !incoming.salt.is_empty() - { - return; - } - - // Refuse any downgrade or plaintext overwrite attempts. - if incoming.password != current.password { - log::error!("Refusing to overwrite hashed permanent password via Config::set"); - incoming.password = current.password.clone(); - incoming.salt = current.salt.clone(); - return; - } - } - /// Invalidate KEY_PAIR cache if it differs from the new key_pair. /// Use None to invalidate the cache instead of Some(key_pair). /// If we use Some with an empty key_pair, get_key_pair() would always return @@ -3314,72 +3226,6 @@ mod tests { assert_eq!(stored_h1, expected_h1); } - #[test] - fn test_config_set_refuses_plain_password_when_current_is_hashed() { - let mut current = Config::default(); - current.salt = "salt12".to_owned(); - current.password = encode_permanent_password_storage_from_h1( - &compute_permanent_password_h1("old", ¤t.salt), - ); - - let mut incoming = current.clone(); - incoming.password = "plaintext".to_owned(); - - Config::normalize_incoming_permanent_password_update(¤t, &mut incoming); - assert_eq!(incoming.password, current.password); - assert_eq!(incoming.salt, current.salt); - } - - #[test] - fn test_config_set_refuses_clear_password_when_current_is_hashed() { - let mut current = Config::default(); - current.salt = "salt12".to_owned(); - current.password = encode_permanent_password_storage_from_h1( - &compute_permanent_password_h1("old", ¤t.salt), - ); - - let mut incoming = current.clone(); - incoming.password = "".to_owned(); - - Config::normalize_incoming_permanent_password_update(¤t, &mut incoming); - assert_eq!(incoming.password, current.password); - assert_eq!(incoming.salt, current.salt); - } - - #[test] - fn test_config_set_allows_replace_password_when_current_is_hashed_and_salt_unchanged() { - let mut current = Config::default(); - current.salt = "salt12".to_owned(); - current.password = encode_permanent_password_storage_from_h1( - &compute_permanent_password_h1("old", ¤t.salt), - ); - - let mut incoming = current.clone(); - incoming.password = encode_permanent_password_storage_from_h1( - &compute_permanent_password_h1("new", ¤t.salt), - ); - - Config::normalize_incoming_permanent_password_update(¤t, &mut incoming); - assert_eq!(incoming.salt, current.salt); - assert_ne!(incoming.password, current.password); - } - - #[test] - fn test_config_set_refuses_salt_change_when_password_unchanged_and_hashed() { - let mut current = Config::default(); - current.salt = "salt12".to_owned(); - current.password = encode_permanent_password_storage_from_h1( - &compute_permanent_password_h1("old", ¤t.salt), - ); - - let mut incoming = current.clone(); - incoming.salt = "DIFF00".to_owned(); - - Config::normalize_incoming_permanent_password_update(¤t, &mut incoming); - assert_eq!(incoming.password, current.password); - assert_eq!(incoming.salt, current.salt); - } - #[test] fn test_overwrite_settings() { DEFAULT_SETTINGS From fc9d521f0b31d9861b0f339a0a8ca18e5d0cb29a Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 22 Mar 2026 19:05:04 +0800 Subject: [PATCH 10/18] fix(password): sync config, check equal Signed-off-by: fufesou --- src/config.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/config.rs b/src/config.rs index d7baaabff..86799d164 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1573,6 +1573,9 @@ impl Config { // This matches historical behavior, but may need revisiting in a separate PR. pub fn set(cfg: Config) -> bool { let mut lock = CONFIG.write().unwrap(); + if *lock == cfg { + return false; + } *lock = cfg; lock.store(); // Drop CONFIG lock before acquiring KEY_PAIR lock to avoid potential deadlock. From ebf41b8524d06ac31870c4976e1044dd5e877416 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 22 Mar 2026 19:11:26 +0800 Subject: [PATCH 11/18] fix(password): remove unnecessary check Signed-off-by: fufesou --- src/config.rs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/config.rs b/src/config.rs index 86799d164..09c91d833 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1271,8 +1271,6 @@ impl Config { } /// Persist permanent password storage and salt from service->user config sync. - /// - /// This never accepts plaintext. `storage` must be empty or a valid hashed verifier storage. pub fn set_permanent_password_storage_for_sync( storage: &str, salt: &str, @@ -1289,15 +1287,6 @@ impl Config { return Ok(true); } - if salt.is_empty() { - return Err(anyhow!( - "Refusing to persist hashed permanent password without salt" - )); - } - if decode_permanent_password_h1_from_storage(storage).is_none() { - return Err(anyhow!("Invalid hashed permanent password storage")); - } - if config.password == storage && config.salt == salt { return Ok(false); } From ace5eedc33928c82210b879c95d27b5d1e60c74c Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 22 Mar 2026 19:41:48 +0800 Subject: [PATCH 12/18] fix(password): remove unnecessary check Signed-off-by: fufesou --- src/config.rs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/config.rs b/src/config.rs index 09c91d833..8baaa8d9e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1276,17 +1276,6 @@ impl Config { salt: &str, ) -> crate::ResultType { let mut config = CONFIG.write().unwrap(); - - if storage.is_empty() { - if config.password.is_empty() { - return Ok(false); - } - config.password = String::new(); - config.store(); - Self::clear_trusted_devices(); - return Ok(true); - } - if config.password == storage && config.salt == salt { return Ok(false); } From 01b192c068357b11d7621f00c797249c03c9806e Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 22 Mar 2026 19:45:37 +0800 Subject: [PATCH 13/18] fix(password): remoev unused import 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 8baaa8d9e..8667756db 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,7 +9,7 @@ use std::{ time::{Duration, Instant, SystemTime}, }; -use anyhow::{anyhow, Result}; +use anyhow::Result; use bytes::Bytes; use rand::Rng; use regex::Regex; From 6fcda1dcc4f143ced626ba371796090724da3333 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 22 Mar 2026 20:25:26 +0800 Subject: [PATCH 14/18] fix(password): get_salt() always return value Signed-off-by: fufesou --- src/config.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/config.rs b/src/config.rs index 8667756db..83176e78a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1356,8 +1356,7 @@ impl Config { // should be automatically generated when migrating to hash storage. // However, if it does occur, it's best to log the error, // even though this will result in logging many duplicate error messages. - log::error!("Salt is empty but permanent password is hashed"); - return String::new(); + log::warn!("Salt is empty but permanent password is hashed"); } drop(config); salt = Config::get_auto_password(DEFAULT_SALT_LEN); From 485e2f7e22b702a8dc7c1fc870f6164cc76f9d69 Mon Sep 17 00:00:00 2001 From: fufesou Date: Mon, 23 Mar 2026 12:18:42 +0800 Subject: [PATCH 15/18] fix(password): use password, best effort Signed-off-by: fufesou --- src/config.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/config.rs b/src/config.rs index 83176e78a..e986c23bc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -670,6 +670,10 @@ impl Config { 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; From 2d7b848516b126cf1b5bd955c4efe006c335b4ec Mon Sep 17 00:00:00 2001 From: fufesou Date: Mon, 23 Mar 2026 16:28:56 +0800 Subject: [PATCH 16/18] fix(password): set_salt(), check if is empty Signed-off-by: fufesou --- src/config.rs | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/config.rs b/src/config.rs index e986c23bc..eeaced127 100644 --- a/src/config.rs +++ b/src/config.rs @@ -85,7 +85,8 @@ pub fn decode_permanent_password_h1_from_storage( Some(h1) } -fn can_update_salt(permanent_password_storage: &str) -> bool { +// 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) } @@ -1343,9 +1344,19 @@ impl Config { if salt == config.salt { return; } - if !can_update_salt(&config.password) { - log::error!("Refusing to update salt because permanent password is hashed"); - return; + if !password_is_empty_or_not_hashed(&config.password) { + if config.salt.is_empty() { + log::error!( + "Refusing to set salt because permanent password is hashed and salt is empty" + ); + return; + } else { + // This shouldn't happen under normal circumstances because the salt + // should be automatically generated when migrating to hash storage. + // However, if it does occur, it's best to log the error, + // even though this will result in logging many duplicate error messages. + log::warn!("Salt is empty but permanent password is hashed"); + } } config.salt = salt.into(); config.store(); @@ -1355,13 +1366,6 @@ impl Config { let config = CONFIG.read().unwrap(); let mut salt = config.salt.clone(); if salt.is_empty() { - if !can_update_salt(&config.password) { - // This shouldn't happen under normal circumstances because the salt - // should be automatically generated when migrating to hash storage. - // However, if it does occur, it's best to log the error, - // even though this will result in logging many duplicate error messages. - log::warn!("Salt is empty but permanent password is hashed"); - } drop(config); salt = Config::get_auto_password(DEFAULT_SALT_LEN); Config::set_salt(&salt); From f8358bd17eece8e25ae7723237bc1cfc55f595cd Mon Sep 17 00:00:00 2001 From: fufesou Date: Mon, 23 Mar 2026 16:33:26 +0800 Subject: [PATCH 17/18] fix(password): set_salt(), check if is empty, fix condition Signed-off-by: fufesou --- src/config.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/config.rs b/src/config.rs index eeaced127..2922906f4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1346,16 +1346,16 @@ impl Config { } if !password_is_empty_or_not_hashed(&config.password) { if config.salt.is_empty() { - log::error!( - "Refusing to set salt because permanent password is hashed and salt is empty" - ); - return; - } else { // This shouldn't happen under normal circumstances because the salt // should be automatically generated when migrating to hash storage. // However, if it does occur, it's best to log the error, // even though this will result in logging many duplicate error messages. log::warn!("Salt is empty but permanent password is hashed"); + } else { + log::error!( + "Refusing to set salt because permanent password is hashed and salt is empty" + ); + return; } } config.salt = salt.into(); From 5519c4c28933d1ca7a4db8668c46b4b91194c762 Mon Sep 17 00:00:00 2001 From: fufesou Date: Mon, 23 Mar 2026 16:50:57 +0800 Subject: [PATCH 18/18] fix(password): set_salt, comments and logs Signed-off-by: fufesou --- src/config.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/config.rs b/src/config.rs index 2922906f4..eac5e6282 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1339,6 +1339,9 @@ impl Config { !CONFIG.read().unwrap().password.is_empty() } + // 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. pub fn set_salt(salt: &str) { let mut config = CONFIG.write().unwrap(); if salt == config.salt { @@ -1346,15 +1349,9 @@ impl Config { } if !password_is_empty_or_not_hashed(&config.password) { if config.salt.is_empty() { - // This shouldn't happen under normal circumstances because the salt - // should be automatically generated when migrating to hash storage. - // However, if it does occur, it's best to log the error, - // even though this will result in logging many duplicate error messages. - log::warn!("Salt is empty but permanent password is hashed"); + log::warn!("Salt is empty but permanent password is hashed and salt is empty"); } else { - log::error!( - "Refusing to set salt because permanent password is hashed and salt is empty" - ); + log::error!("Refusing to set salt because permanent password is hashed"); return; } }