Merge pull request #507 from fufesou/fix/permanent-password-hash

refact(password): Store permanent password as hashed verifier
This commit is contained in:
RustDesk
2026-03-25 17:34:08 +08:00
committed by GitHub
2 changed files with 245 additions and 20 deletions

View File

@@ -16,6 +16,7 @@ use regex::Regex;
use serde as de; use serde as de;
use serde_derive::{Deserialize, Serialize}; use serde_derive::{Deserialize, Serialize};
use serde_json; use serde_json;
use sha2::{Digest, Sha256};
use sodiumoxide::base64; use sodiumoxide::base64;
use sodiumoxide::crypto::sign; use sodiumoxide::crypto::sign;
@@ -41,6 +42,55 @@ const SERIAL: i32 = 3;
const PASSWORD_ENC_VERSION: &str = "00"; const PASSWORD_ENC_VERSION: &str = "00";
pub const ENCRYPT_MAX_LEN: usize = 128; // used for password, pin, etc, not for all 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)
}
// 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")] #[cfg(target_os = "macos")]
lazy_static::lazy_static! { lazy_static::lazy_static! {
pub static ref ORG: RwLock<String> = RwLock::new("com.carriez".to_owned()); pub static ref ORG: RwLock<String> = RwLock::new("com.carriez".to_owned());
@@ -564,9 +614,7 @@ impl Config {
fn load() -> Config { fn load() -> Config {
let mut config = Config::load_::<Config>(""); let mut config = Config::load_::<Config>("");
let mut store = false; let mut store = false;
let (password, _, store1) = decrypt_str_or_original(&config.password, PASSWORD_ENC_VERSION); store |= Self::migrate_permanent_password_to_hashed_storage(&mut config);
config.password = password;
store |= store1;
let mut id_valid = false; let mut id_valid = false;
let (id, encrypted, store2) = decrypt_str_or_original(&config.enc_id, PASSWORD_ENC_VERSION); let (id, encrypted, store2) = decrypt_str_or_original(&config.enc_id, PASSWORD_ENC_VERSION);
if encrypted { if encrypted {
@@ -605,10 +653,44 @@ impl Config {
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);
// `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;
}
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;
}
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) { fn store(&self) {
let mut config = self.clone(); let mut config = self.clone();
config.password = Self::migrate_permanent_password_to_hashed_storage(&mut config);
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.enc_id = encrypt_str_or_original(&config.id, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN);
config.id = "".to_owned(); config.id = "".to_owned();
Config::store_(&config, ""); Config::store_(&config, "");
@@ -1154,38 +1236,135 @@ impl Config {
return; return;
} }
} }
let mut config = CONFIG.write().unwrap(); 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; return;
} }
config.password = password.into(); config.password = stored;
config.store(); config.store();
Self::clear_trusted_devices(); Self::clear_trusted_devices();
} }
pub fn get_permanent_password() -> String { fn compute_permanent_password_storage_for_update(
let mut password = CONFIG.read().unwrap().password.clone(); config: &mut Config,
if password.is_empty() { password: &str,
if let Some(v) = HARD_SETTINGS.read().unwrap().get("password") { ) -> String {
password = v.to_owned(); // 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);
} }
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.
pub fn set_permanent_password_storage_for_sync(
storage: &str,
salt: &str,
) -> crate::ResultType<bool> {
let mut config = CONFIG.write().unwrap();
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 has_local_permanent_password() -> bool {
!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) { pub fn set_salt(salt: &str) {
let mut config = CONFIG.write().unwrap(); let mut config = CONFIG.write().unwrap();
if salt == config.salt { if salt == config.salt {
return; return;
} }
if !password_is_empty_or_not_hashed(&config.password) {
if config.salt.is_empty() {
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");
return;
}
}
config.salt = salt.into(); config.salt = salt.into();
config.store(); config.store();
} }
pub fn get_salt() -> String { 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() { if salt.is_empty() {
salt = Config::get_auto_password(6); drop(config);
salt = Config::get_auto_password(DEFAULT_SALT_LEN);
Config::set_salt(&salt); Config::set_salt(&salt);
} }
salt salt
@@ -1372,6 +1551,8 @@ impl Config {
return CONFIG.read().unwrap().clone(); 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 { pub fn set(cfg: Config) -> bool {
let mut lock = CONFIG.write().unwrap(); let mut lock = CONFIG.write().unwrap();
if *lock == cfg { if *lock == cfg {
@@ -2742,10 +2923,12 @@ pub mod keys {
pub const OPTION_KEEP_SCREEN_ON: &str = "keep-screen-on"; pub const OPTION_KEEP_SCREEN_ON: &str = "keep-screen-on";
// Server-side: keep host system awake during incoming sessions (Security setting) // 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) // 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_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_GROUP_PANEL: &str = "disable-group-panel";
pub const OPTION_DISABLE_DISCOVERY_PANEL: &str = "disable-discovery-panel"; pub const OPTION_DISABLE_DISCOVERY_PANEL: &str = "disable-discovery-panel";
@@ -2988,6 +3171,48 @@ mod tests {
assert!(res.is_ok()); 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] #[test]
fn test_overwrite_settings() { fn test_overwrite_settings() {
DEFAULT_SETTINGS DEFAULT_SETTINGS

View File

@@ -71,7 +71,7 @@ pub fn permanent_enabled() -> bool {
pub fn has_valid_password() -> bool { pub fn has_valid_password() -> bool {
temporary_enabled() && !temporary_password().is_empty() 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 { pub fn approve_mode() -> ApproveMode {