mirror of
https://github.com/rustdesk/hbb_common.git
synced 2026-05-25 17:19:15 +00:00
428 lines
15 KiB
Rust
428 lines
15 KiB
Rust
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_hbbs_preset_password_h1_from_storage(
|
|
storage: &str,
|
|
) -> Option<[u8; PERMANENT_PASSWORD_H1_LEN]> {
|
|
decode_password_h1_after_prefix(storage, HBBS_PRESET_PASSWORD_HASH_PREFIX)
|
|
}
|
|
|
|
fn decode_password_h1_after_prefix(
|
|
storage: &str,
|
|
prefix: &str,
|
|
) -> Option<[u8; PERMANENT_PASSWORD_H1_LEN]> {
|
|
let encoded = storage.strip_prefix(prefix)?;
|
|
|
|
let v = base64::decode(encoded.as_bytes(), base64::Variant::Original).ok()?;
|
|
if v.len() != PERMANENT_PASSWORD_H1_LEN {
|
|
return None;
|
|
}
|
|
let mut h1 = [0u8; PERMANENT_PASSWORD_H1_LEN];
|
|
h1.copy_from_slice(&v[..PERMANENT_PASSWORD_H1_LEN]);
|
|
Some(h1)
|
|
}
|
|
|
|
fn encrypt_permanent_password_storage(storage: &str) -> Option<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(super) fn permanent_password_storage_is_usable_for_auth(storage: &str, salt: &str) -> bool {
|
|
if storage.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
if decode_permanent_password_h1_from_storage(storage).is_some() {
|
|
return !salt.is_empty();
|
|
}
|
|
if storage.starts_with(PERMANENT_PASSWORD_ENC_VERSION) {
|
|
log::error!("Permanent password storage looks current but cannot be decoded as a hash");
|
|
return false;
|
|
}
|
|
|
|
let (_, decrypted, looks_like_plaintext) =
|
|
decrypt_str_or_original(storage, PASSWORD_ENC_VERSION);
|
|
if storage.starts_with(PASSWORD_ENC_VERSION) && !decrypted && !looks_like_plaintext {
|
|
log::error!("Permanent password storage looks encrypted but cannot be decrypted");
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
|
|
pub fn preset_permanent_password_storage_is_usable_for_auth(storage: &str, salt: &str) -> bool {
|
|
if storage.is_empty() {
|
|
return false;
|
|
}
|
|
if salt.is_empty() {
|
|
return true;
|
|
}
|
|
decode_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_hbbs_preset_password_h1_from_storage(storage)
|
|
}
|
|
|
|
pub fn local_permanent_password_storage_is_usable_for_auth(storage: &str, salt: &str) -> bool {
|
|
if storage.starts_with(PERMANENT_PASSWORD_ENC_VERSION)
|
|
&& decode_permanent_password_h1_from_storage(storage).is_none()
|
|
{
|
|
log::error!(
|
|
"Local permanent password storage looks encrypted but cannot be decoded as a hash"
|
|
);
|
|
return false;
|
|
}
|
|
permanent_password_storage_is_usable_for_auth(storage, salt)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn permanent_password_storage_matches_plain(storage: &str, salt: &str, input: &str) -> bool {
|
|
if storage.is_empty() || input.is_empty() {
|
|
return false;
|
|
}
|
|
if !permanent_password_storage_is_usable_for_auth(storage, salt) {
|
|
return false;
|
|
}
|
|
if let Some(stored_h1) = decode_permanent_password_h1_from_storage(storage) {
|
|
if salt.is_empty() {
|
|
log::error!("Salt is empty but permanent password storage is hashed");
|
|
return false;
|
|
}
|
|
let h1 = compute_permanent_password_h1(input, salt);
|
|
return constant_time_eq_32(&h1, &stored_h1);
|
|
}
|
|
storage == input
|
|
}
|
|
|
|
pub(super) fn preset_permanent_password_storage_matches_plain(
|
|
storage: &str,
|
|
salt: &str,
|
|
input: &str,
|
|
) -> bool {
|
|
if storage.is_empty() || input.is_empty() {
|
|
return false;
|
|
}
|
|
if salt.is_empty() {
|
|
return storage == input;
|
|
}
|
|
let Some(stored_h1) = decode_preset_password_h1_from_storage(storage) else {
|
|
return false;
|
|
};
|
|
let h1 = compute_permanent_password_h1(input, salt);
|
|
constant_time_eq_32(&h1, &stored_h1)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn local_permanent_password_storage_matches_plain(storage: &str, salt: &str, input: &str) -> bool {
|
|
if !local_permanent_password_storage_is_usable_for_auth(storage, salt) {
|
|
return false;
|
|
}
|
|
permanent_password_storage_matches_plain(storage, salt, input)
|
|
}
|
|
|
|
pub fn decode_permanent_password_h1_from_storage(
|
|
storage: &str,
|
|
) -> Option<[u8; PERMANENT_PASSWORD_H1_LEN]> {
|
|
if storage.starts_with(PERMANENT_PASSWORD_ENC_VERSION) {
|
|
let (hashed_storage, decrypted, _) = decrypt_permanent_password_str_or_original(storage);
|
|
if !decrypted {
|
|
return None;
|
|
}
|
|
return decode_permanent_password_h1_from_hashed_storage(&hashed_storage);
|
|
}
|
|
None
|
|
}
|
|
|
|
// If password is empty or not hashed storage, it's safe to update salt.
|
|
pub(super) fn password_is_empty_or_not_hashed(permanent_password_storage: &str) -> bool {
|
|
if permanent_password_storage.is_empty() {
|
|
return true;
|
|
}
|
|
if decode_permanent_password_h1_from_storage(permanent_password_storage).is_some() {
|
|
return false;
|
|
}
|
|
let (_, decrypted, should_store) =
|
|
decrypt_str_or_original(permanent_password_storage, PASSWORD_ENC_VERSION);
|
|
decrypted || should_store
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::password_security::encrypt_str_or_original;
|
|
|
|
fn encode_hbbs_preset_password_storage_from_h1(h1: &[u8; PERMANENT_PASSWORD_H1_LEN]) -> String {
|
|
HBBS_PRESET_PASSWORD_HASH_PREFIX.to_owned() + &base64::encode(h1, base64::Variant::Original)
|
|
}
|
|
|
|
#[test]
|
|
fn test_permanent_password_h1_storage_roundtrip() {
|
|
let salt = "salt123";
|
|
let password = "p@ssw0rd";
|
|
let h1 = compute_permanent_password_h1(password, salt);
|
|
let stored = encode_permanent_password_storage_from_h1(&h1);
|
|
assert!(stored.starts_with(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!(permanent_password_storage_is_usable_for_auth(
|
|
&storage, salt
|
|
));
|
|
assert!(permanent_password_storage_matches_plain(
|
|
&storage, salt, "p@ssw0rd"
|
|
));
|
|
assert!(!permanent_password_storage_matches_plain(
|
|
&storage, salt, "wrong"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_hbbs_00_hashed_preset_password_storage_is_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!(!permanent_password_storage_is_usable_for_auth(&storage, ""));
|
|
assert!(!permanent_password_storage_matches_plain(
|
|
&storage, "", "p@ssw0rd"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_legacy_plain_preset_password_without_salt_keeps_old_behavior() {
|
|
let storage = "01not-a-valid-hash";
|
|
|
|
assert!(preset_permanent_password_storage_is_usable_for_auth(
|
|
storage, ""
|
|
));
|
|
assert!(preset_permanent_password_storage_matches_plain(
|
|
storage,
|
|
"",
|
|
"01not-a-valid-hash"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_malformed_preset_password_with_salt_is_not_usable_for_auth() {
|
|
for storage in ["01not-a-valid-hash", "00not-a-valid-hash"] {
|
|
assert!(!preset_permanent_password_storage_is_usable_for_auth(
|
|
storage,
|
|
"preset-salt"
|
|
));
|
|
assert!(!preset_permanent_password_storage_matches_plain(
|
|
storage,
|
|
"preset-salt",
|
|
storage
|
|
));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_current_version_storage_is_not_usable_for_auth() {
|
|
let encrypted = symmetric_crypt(b"not-a-hash", true).unwrap();
|
|
let encrypted_non_hash = PERMANENT_PASSWORD_ENC_VERSION.to_owned()
|
|
+ &base64::encode(encrypted, base64::Variant::Original);
|
|
|
|
for storage in ["01invalid", &encrypted_non_hash] {
|
|
assert!(!permanent_password_storage_is_usable_for_auth(
|
|
storage, "salt123"
|
|
));
|
|
assert!(!permanent_password_storage_matches_plain(
|
|
storage, "salt123", storage
|
|
));
|
|
assert!(!local_permanent_password_storage_is_usable_for_auth(
|
|
storage, "salt123"
|
|
));
|
|
assert!(!local_permanent_password_storage_matches_plain(
|
|
storage, "salt123", storage
|
|
));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_legacy_plain_preset_password_that_decodes_as_hash_requires_salt() {
|
|
let h1 = compute_permanent_password_h1("plain-looking-hash", "salt123");
|
|
let storage = encode_permanent_password_storage_from_h1(&h1);
|
|
|
|
assert!(!permanent_password_storage_is_usable_for_auth(&storage, ""));
|
|
assert!(!permanent_password_storage_matches_plain(
|
|
&storage, "", &storage
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_password_is_empty_or_not_hashed_accepts_plaintext_and_decryptable_legacy_plaintext() {
|
|
let storage =
|
|
encrypt_str_or_original("legacy-secret", PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN);
|
|
|
|
assert!(password_is_empty_or_not_hashed("00secret"));
|
|
assert!(password_is_empty_or_not_hashed(&storage));
|
|
}
|
|
|
|
#[test]
|
|
fn test_password_is_empty_or_not_hashed_treats_locked_00_storage_as_hashed() {
|
|
let invalid_payload = vec![42u8; sodiumoxide::crypto::secretbox::MACBYTES + 1];
|
|
let locked_storage = PASSWORD_ENC_VERSION.to_owned()
|
|
+ &base64::encode(invalid_payload, base64::Variant::Original);
|
|
|
|
assert!(!password_is_empty_or_not_hashed(&locked_storage));
|
|
}
|
|
}
|