mirror of
https://github.com/rustdesk/hbb_common.git
synced 2026-05-25 17:19:15 +00:00
Compare commits
13 Commits
9043c15acc
...
822701e416
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
822701e416 | ||
|
|
728b73d540 | ||
|
|
ce1f7b5f9a | ||
|
|
73ab2c37ae | ||
|
|
2f032ad525 | ||
|
|
8e0c8ab939 | ||
|
|
9e00fa2762 | ||
|
|
0c218232d1 | ||
|
|
f94992f6ca | ||
|
|
e7af2e8488 | ||
|
|
cfa877d28c | ||
|
|
cf108c131e | ||
|
|
3f30a92913 |
682
src/config.rs
682
src/config.rs
@@ -9,17 +9,29 @@ use std::{
|
|||||||
time::{Duration, Instant, SystemTime},
|
time::{Duration, Instant, SystemTime},
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{anyhow, Result};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use regex::Regex;
|
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;
|
||||||
|
|
||||||
|
mod permanent_password;
|
||||||
|
|
||||||
|
pub use permanent_password::{
|
||||||
|
compute_permanent_password_h1, decode_permanent_password_h1_from_storage,
|
||||||
|
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, password_is_empty_or_not_hashed,
|
||||||
|
preset_permanent_password_storage_matches_plain, DEFAULT_SALT_LEN, PASSWORD_ENC_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
compress::{compress, decompress},
|
compress::{compress, decompress},
|
||||||
log,
|
log,
|
||||||
@@ -39,57 +51,6 @@ pub const READ_TIMEOUT: u64 = 18_000;
|
|||||||
pub const REG_INTERVAL: i64 = 15_000;
|
pub const REG_INTERVAL: i64 = 15_000;
|
||||||
pub const COMPRESS_LEVEL: i32 = 3;
|
pub const COMPRESS_LEVEL: i32 = 3;
|
||||||
const SERIAL: 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")]
|
#[cfg(target_os = "macos")]
|
||||||
lazy_static::lazy_static! {
|
lazy_static::lazy_static! {
|
||||||
@@ -637,7 +598,9 @@ 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;
|
||||||
store |= Self::migrate_permanent_password_to_hashed_storage(&mut config);
|
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 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 {
|
||||||
@@ -676,44 +639,77 @@ impl Config {
|
|||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
fn migrate_permanent_password_to_hashed_storage(config: &mut Config) -> bool {
|
fn validate_or_decrypt_permanent_password_storage(config: &mut Config) -> Result<()> {
|
||||||
if config.password.is_empty() || is_permanent_password_hashed_storage(&config.password) {
|
if config.password.is_empty() {
|
||||||
return false;
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.password.starts_with(PASSWORD_ENC_VERSION) {
|
if config.password.starts_with(PASSWORD_ENC_VERSION) {
|
||||||
let (plain, decrypted, looks_like_plaintext) =
|
let (plain, decrypted, should_store) =
|
||||||
decrypt_str_or_original(&config.password, PASSWORD_ENC_VERSION);
|
decrypt_str_or_original(&config.password, PASSWORD_ENC_VERSION);
|
||||||
// `decrypt_str_or_original` returns (value, decrypted_ok, should_store).
|
if decrypted {
|
||||||
// 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;
|
config.password = plain;
|
||||||
return true;
|
return Ok(());
|
||||||
}
|
}
|
||||||
let h1 = compute_permanent_password_h1(&plain, &config.salt);
|
if !should_store {
|
||||||
config.password = encode_permanent_password_storage_from_h1(&h1);
|
return Err(anyhow!("Invalid permanent password encrypted hash storage"));
|
||||||
return true;
|
}
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(());
|
||||||
|
}
|
||||||
|
return Err(anyhow!("Invalid permanent password encrypted hash storage"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
if config.salt.is_empty() {
|
||||||
config.salt = Config::get_auto_password(DEFAULT_SALT_LEN);
|
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 prepare_config_for_store(config: &mut Config) {
|
||||||
|
match Self::validate_or_decrypt_permanent_password_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) {
|
fn store(&self) {
|
||||||
let mut config = self.clone();
|
let mut config = self.clone();
|
||||||
Self::migrate_permanent_password_to_hashed_storage(&mut config);
|
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.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, "");
|
||||||
@@ -1269,47 +1265,52 @@ impl Config {
|
|||||||
log::info!("id updated from {} to {}", id, new_id);
|
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() {
|
if Self::is_disable_change_permanent_password() {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if HARD_SETTINGS
|
let (preset_storage, preset_salt) = Self::get_preset_password_storage_and_salt();
|
||||||
.read()
|
if preset_permanent_password_storage_matches_plain(&preset_storage, &preset_salt, password)
|
||||||
.unwrap()
|
|
||||||
.get("password")
|
|
||||||
.map_or(false, |v| v == password)
|
|
||||||
{
|
{
|
||||||
if CONFIG.read().unwrap().password.is_empty() {
|
if CONFIG.read().unwrap().password.is_empty() {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut config = CONFIG.write().unwrap();
|
let mut config = CONFIG.write().unwrap();
|
||||||
|
|
||||||
let stored = if password.is_empty() {
|
let stored = if password.is_empty() {
|
||||||
String::new()
|
Some(String::new())
|
||||||
} else {
|
} else {
|
||||||
Self::compute_permanent_password_storage_for_update(&mut config, password)
|
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 {
|
if stored == config.password {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
config.password = stored;
|
config.password = stored;
|
||||||
config.store();
|
config.store();
|
||||||
Self::clear_trusted_devices();
|
Self::clear_trusted_devices();
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compute_permanent_password_storage_for_update(
|
fn compute_permanent_password_storage_for_update(
|
||||||
config: &mut Config,
|
config: &mut Config,
|
||||||
password: &str,
|
password: &str,
|
||||||
) -> String {
|
) -> Option<String> {
|
||||||
// Keep salt stable for user-initiated permanent password updates.
|
// Keep salt stable for user-initiated permanent password updates.
|
||||||
// Salt should only change when service->user sync updates storage and salt as a pair.
|
// Salt should only change when service->user sync updates storage and salt as a pair.
|
||||||
if config.salt.is_empty() {
|
Self::ensure_permanent_password_salt(config);
|
||||||
config.salt = Config::get_auto_password(DEFAULT_SALT_LEN);
|
|
||||||
}
|
|
||||||
let h1 = compute_permanent_password_h1(password, &config.salt);
|
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).
|
/// Returns the locally persisted permanent password storage and salt (NOT the hard/preset one).
|
||||||
@@ -1328,62 +1329,97 @@ impl Config {
|
|||||||
salt: &str,
|
salt: &str,
|
||||||
) -> crate::ResultType<bool> {
|
) -> crate::ResultType<bool> {
|
||||||
let mut config = CONFIG.write().unwrap();
|
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<bool> {
|
||||||
|
if storage.is_empty() {
|
||||||
|
if config.password.is_empty() && (salt.is_empty() || config.salt == salt) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
config.password.clear();
|
||||||
|
if !salt.is_empty() {
|
||||||
|
config.salt = salt.to_owned();
|
||||||
|
}
|
||||||
|
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 {
|
if config.password == storage && config.salt == salt {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
config.password = storage.to_owned();
|
config.password = storage.to_owned();
|
||||||
config.salt = salt.to_owned();
|
config.salt = salt.to_owned();
|
||||||
config.store();
|
|
||||||
Self::clear_trusted_devices();
|
|
||||||
Ok(true)
|
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 {
|
pub fn has_permanent_password() -> bool {
|
||||||
if !CONFIG.read().unwrap().password.is_empty() {
|
let (local_storage, local_salt) = Self::get_local_permanent_password_storage_and_salt();
|
||||||
return true;
|
if !local_storage.is_empty() {
|
||||||
|
return local_permanent_password_storage_is_usable_for_auth(
|
||||||
|
&local_storage,
|
||||||
|
&local_salt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
HARD_SETTINGS
|
Self::has_usable_preset_password()
|
||||||
.read()
|
}
|
||||||
.unwrap()
|
|
||||||
.get("password")
|
fn has_usable_preset_password() -> bool {
|
||||||
.map_or(false, |v| !v.is_empty())
|
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();
|
||||||
|
(storage, 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();
|
||||||
|
}
|
||||||
|
let (preset_storage, preset_salt) = Self::get_preset_password_storage_and_salt();
|
||||||
|
if !preset_salt.is_empty() {
|
||||||
|
if preset_permanent_password_storage_is_usable_for_auth(&preset_storage, &preset_salt) {
|
||||||
|
return preset_salt;
|
||||||
|
}
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
Self::get_salt()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_local_permanent_password() -> bool {
|
pub fn has_local_permanent_password() -> bool {
|
||||||
!CONFIG.read().unwrap().password.is_empty()
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// This shouldn't happen under normal circumstances because the salt
|
// This shouldn't happen under normal circumstances because the salt
|
||||||
@@ -2852,7 +2888,8 @@ pub mod keys {
|
|||||||
pub const OPTION_ENABLE_RECORD_SESSION: &str = "enable-record-session";
|
pub const OPTION_ENABLE_RECORD_SESSION: &str = "enable-record-session";
|
||||||
pub const OPTION_ENABLE_BLOCK_INPUT: &str = "enable-block-input";
|
pub const OPTION_ENABLE_BLOCK_INPUT: &str = "enable-block-input";
|
||||||
pub const OPTION_ENABLE_PRIVACY_MODE: &str = "enable-privacy-mode";
|
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_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_ALLOW_NUMERNIC_ONE_TIME_PASSWORD: &str = "allow-numeric-one-time-password";
|
||||||
pub const OPTION_ENABLE_LAN_DISCOVERY: &str = "enable-lan-discovery";
|
pub const OPTION_ENABLE_LAN_DISCOVERY: &str = "enable-lan-discovery";
|
||||||
@@ -3214,7 +3251,44 @@ impl Status {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::{permanent_password::PERMANENT_PASSWORD_ENC_VERSION, *};
|
||||||
|
|
||||||
|
static CONFIG_STATE_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
struct ConfigStateTestGuard {
|
||||||
|
original_config: Config,
|
||||||
|
original_hard_settings: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigStateTestGuard {
|
||||||
|
fn new(config: Config, hard_settings: HashMap<String, String>) -> 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<R>(
|
||||||
|
config: Config,
|
||||||
|
hard_settings: HashMap<String, String>,
|
||||||
|
test: impl FnOnce() -> R,
|
||||||
|
) -> R {
|
||||||
|
let _guard = CONFIG_STATE_TEST_LOCK.lock().unwrap();
|
||||||
|
let _state_guard = ConfigStateTestGuard::new(config, hard_settings);
|
||||||
|
test()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_serialize() {
|
fn test_serialize() {
|
||||||
@@ -3227,45 +3301,329 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_permanent_password_h1_storage_roundtrip() {
|
fn test_hbbs_00_hashed_preset_password_storage_matches_plain_with_salt() {
|
||||||
let salt = "salt123";
|
let salt = "salt123";
|
||||||
let password = "p@ssw0rd";
|
let h1 = compute_permanent_password_h1("p@ssw0rd", salt);
|
||||||
let h1 = compute_permanent_password_h1(password, salt);
|
let storage = "00".to_owned() + &base64::encode(h1, base64::Variant::Original);
|
||||||
let stored = encode_permanent_password_storage_from_h1(&h1);
|
let hard_settings = HashMap::from([
|
||||||
assert!(stored.starts_with(PERMANENT_PASSWORD_HASH_PREFIX));
|
("password".to_owned(), storage),
|
||||||
assert!(is_permanent_password_hashed_storage(&stored));
|
("salt".to_owned(), salt.to_owned()),
|
||||||
let decoded = decode_permanent_password_h1_from_storage(&stored).unwrap();
|
]);
|
||||||
assert_eq!(&decoded[..], &h1[..]);
|
|
||||||
|
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]
|
#[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_validate_or_decrypt_keeps_plaintext_permanent_password_unchanged() {
|
||||||
let mut cfg = Config::default();
|
let mut cfg = Config::default();
|
||||||
cfg.password = "p@ssw0rd".to_owned();
|
cfg.password = "p@ssw0rd".to_owned();
|
||||||
cfg.salt = "".to_owned();
|
cfg.salt = "".to_owned();
|
||||||
let changed = Config::migrate_permanent_password_to_hashed_storage(&mut cfg);
|
Config::validate_or_decrypt_permanent_password_storage(&mut cfg).unwrap();
|
||||||
assert!(changed);
|
assert_eq!(cfg.password, "p@ssw0rd");
|
||||||
assert!(is_permanent_password_hashed_storage(&cfg.password));
|
assert!(cfg.salt.is_empty());
|
||||||
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]
|
#[test]
|
||||||
fn test_migrate_plaintext_with_00_prefix_permanent_password_to_hashed_storage() {
|
fn test_validate_or_decrypt_decrypts_00_permanent_password_without_forcing_store() {
|
||||||
let mut cfg = Config::default();
|
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();
|
cfg.salt = "".to_owned();
|
||||||
let changed = Config::migrate_permanent_password_to_hashed_storage(&mut cfg);
|
Config::validate_or_decrypt_permanent_password_storage(&mut cfg).unwrap();
|
||||||
assert!(changed);
|
assert_eq!(cfg.password, "legacy-secret");
|
||||||
assert!(is_permanent_password_hashed_storage(&cfg.password));
|
assert!(cfg.salt.is_empty());
|
||||||
assert!(!cfg.salt.is_empty());
|
}
|
||||||
|
|
||||||
let stored_h1 = decode_permanent_password_h1_from_storage(&cfg.password).unwrap();
|
#[test]
|
||||||
let expected_h1 = compute_permanent_password_h1("00secret", &cfg.salt);
|
fn test_validate_or_decrypt_rejects_corrupted_00_permanent_password_storage() {
|
||||||
assert_eq!(stored_h1, expected_h1);
|
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::validate_or_decrypt_permanent_password_storage(&mut cfg).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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::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_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();
|
||||||
|
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_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_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::validate_or_decrypt_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();
|
||||||
|
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();
|
||||||
|
Config::validate_or_decrypt_permanent_password_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_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]
|
#[test]
|
||||||
|
|||||||
412
src/config/permanent_password.rs
Normal file
412
src/config/permanent_password.rs
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
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<String> {
|
||||||
|
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_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<String> {
|
||||||
|
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 fn local_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) {
|
||||||
|
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) =
|
||||||
|
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_preset_password_h1_from_storage(storage).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_preset_password_h1_from_storage(
|
||||||
|
storage: &str,
|
||||||
|
) -> Option<[u8; PERMANENT_PASSWORD_H1_LEN]> {
|
||||||
|
decode_password_h1_after_prefix(storage, HBBS_PRESET_PASSWORD_HASH_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn local_permanent_password_storage_matches_plain(storage: &str, salt: &str, input: &str) -> bool {
|
||||||
|
if storage.is_empty() || input.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
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_preset_password_h1_from_storage(storage) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let h1 = compute_permanent_password_h1(input, salt);
|
||||||
|
constant_time_eq_32(&h1, &stored_h1)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
if decode_permanent_password_h1_from_storage(permanent_password_storage).is_some() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
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 || looks_like_plaintext
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(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[..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(PERMANENT_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!(local_permanent_password_storage_is_usable_for_auth(
|
||||||
|
&storage, salt
|
||||||
|
));
|
||||||
|
assert!(local_permanent_password_storage_matches_plain(
|
||||||
|
&storage, salt, "p@ssw0rd"
|
||||||
|
));
|
||||||
|
assert!(!local_permanent_password_storage_matches_plain(
|
||||||
|
&storage, salt, "wrong"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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!(decode_preset_password_h1_from_storage(&storage), Some(h1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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_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!(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!(!local_permanent_password_storage_is_usable_for_auth(
|
||||||
|
&storage, ""
|
||||||
|
));
|
||||||
|
assert!(!local_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);
|
||||||
|
|
||||||
|
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]
|
||||||
|
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!(!local_permanent_password_storage_is_usable_for_auth(
|
||||||
|
&storage, ""
|
||||||
|
));
|
||||||
|
assert!(!local_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));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user