use std::{process::Stdio, str::FromStr}; use argon2::{ Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version, password_hash::{SaltString, rand_core::OsRng}, }; use base64::{Engine as _, engine::general_purpose::STANDARD}; use md5::{Digest as _, Md5}; use pwhash::{ HashSetup, bcrypt::{self as pw_bcrypt, BcryptSetup, BcryptVariant}, sha1_crypt, sha256_crypt, sha512_crypt, unix, unix_crypt, }; use rand::RngCore; use sha1::Sha1; use sha2::{Sha256, Sha512}; use subtle::ConstantTimeEq; use tokio::{io::AsyncWriteExt, process::Command}; use crate::{ config::AppConfig, db::{Db, Dialect, Table, row_i64}, error::{AppError, AppResult}, }; const CRYPT64: &[u8; 64] = b"./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; /// Password schemes intentionally kept compatible with PostfixAdmin/Dovecot/Courier. /// Unknown `dovecot:METHOD` values are delegated to `doveadm pw` without a shell. pub const SUPPORTED_SCHEMES: &[&str] = &[ "ARGON2ID", "ARGON2ID.B64", "ARGON2I", "ARGON2I.B64", "BLF-CRYPT", "BLF-CRYPT.B64", "CRYPT", "SHA512-CRYPT", "SHA512-CRYPT.B64", "SHA512.B64", "SHA512", "SHA256-CRYPT", "SHA256-CRYPT.B64", "SHA256", "SHA1-CRYPT", "SHA1", "SHA1.B64", "SHA", "SSHA", "MD5-CRYPT", "md5crypt", "PLAIN-MD5", "MD5", "MD5RAW", "PLAIN", "CLEAR", "cleartext", "SYSTEM", "AUTHLIB", "courier:md5", "courier:md5raw", "courier:sha1", "courier:ssha", "courier:sha256", "php_crypt", "php_crypt:DES", "php_crypt:MD5", "php_crypt:BLOWFISH", "php_crypt:SHA256", "php_crypt:SHA512", "dovecot:METHOD", ]; pub fn supported_schemes() -> &'static [&'static str] { SUPPORTED_SCHEMES } pub fn validate_new_password(config: &AppConfig, password: &str) -> AppResult<()> { if password.chars().count() < config.password.min_length { return Err(AppError::bad_request(format!( "Password must contain at least {} characters", config.password.min_length ))); } let letters = password.chars().filter(|c| c.is_alphabetic()).count(); let digits = password.chars().filter(|c| c.is_ascii_digit()).count(); if letters < config.password.min_letters { return Err(AppError::bad_request(format!( "Password must contain at least {} letters", config.password.min_letters ))); } if digits < config.password.min_digits { return Err(AppError::bad_request(format!( "Password must contain at least {} digits", config.password.min_digits ))); } Ok(()) } /// Generate a random password that satisfies the configured policy /// (PostfixAdmin `generate_password()`). pub fn generate_password(config: &AppConfig) -> String { const LETTERS: &[u8] = b"abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"; const DIGITS: &[u8] = b"23456789"; let length = config .password .min_length .max(config.password.min_letters + config.password.min_digits) .max(12); let mut rng = rand::rng(); let mut pick = |set: &[u8]| set[(rng.next_u32() as usize) % set.len()] as char; let mut chars: Vec = Vec::with_capacity(length); for _ in 0..config.password.min_digits.max(2) { chars.push(pick(DIGITS)); } while chars.len() < length { chars.push(pick(LETTERS)); } // Fisher-Yates shuffle so the digits are not always in front. let mut rng = rand::rng(); for i in (1..chars.len()).rev() { let j = (rng.next_u32() as usize) % (i + 1); chars.swap(i, j); } chars.into_iter().collect() } /// Verify an existing PostfixAdmin hash. The format stored in the database wins /// over the currently configured format whenever it identifies itself. pub async fn verify(config: &AppConfig, password: &str, stored: &str, username: &str) -> bool { if password.is_empty() || stored.is_empty() { return false; } if let Some(result) = verify_native(password, stored) { return result; } let configured = config.password.scheme.trim(); let mechanism = configured.to_ascii_uppercase(); // Legacy PostfixAdmin formats without a self-identifying prefix. if (mechanism == "MD5" || mechanism == "MD5RAW") && is_hex_digest(stored, 32) { return ct_eq(hex_md5(password).as_bytes(), stored.as_bytes()); } let legacy_des = mechanism == "PHP_CRYPT:DES" || mechanism == "SYSTEM" || (mechanism == "AUTHLIB" && config .password .authlib_default_flavor .eq_ignore_ascii_case("crypt")); if legacy_des && stored.len() == 13 && stored.bytes().all(is_crypt_char) { return unix_crypt::verify(password, stored); } if matches!(mechanism.as_str(), "CLEAR" | "CLEARTEXT") { return ct_eq(password.as_bytes(), stored.as_bytes()); } let method = prefixed_method(stored) .map(|value| value.to_ascii_uppercase()) .or_else(|| mechanism.strip_prefix("DOVECOT:").map(str::to_owned)) .unwrap_or_else(|| mechanism.clone()); verify_with_doveadm(config, password, stored, &method, username) .await .unwrap_or(false) } pub async fn hash(config: &AppConfig, password: &str, username: &str) -> AppResult { validate_new_password(config, password)?; hash_unchecked(config, password, username).await } /// Hashes an application-internal bearer secret with Argon2id regardless of the /// mailbox password scheme. Used for single-use recovery tokens only. pub fn hash_internal_secret(secret: &str) -> AppResult { let salt = SaltString::generate(&mut OsRng); let hash = Argon2::default() .hash_password(secret.as_bytes(), &salt) .map_err(|e| AppError::config(format!("argon2 failed: {e}")))? .to_string(); Ok(format!("{{ARGON2ID}}{hash}")) } /// Implements PostfixAdmin `$CONF['encrypt']`-style schemes. pub async fn hash_unchecked( config: &AppConfig, password: &str, username: &str, ) -> AppResult { let configured = config.password.scheme.trim(); if configured.is_empty() { return Err(AppError::config("password.scheme cannot be empty")); } let mechanism = configured.to_ascii_uppercase(); if mechanism == "PHP_CRYPT" || mechanism.starts_with("PHP_CRYPT:") { return hash_php_crypt(configured, password); } if mechanism == "AUTHLIB" { return hash_authlib(config, password); } if let Some(flavor) = mechanism.strip_prefix("COURIER:") { return hash_courier(flavor, password); } if let Some(method) = mechanism.strip_prefix("DOVECOT:") { if let Some(value) = hash_native_named(method, password)? { return Ok(value); } return hash_with_doveadm(config, password, method, username).await; } if let Some(value) = hash_native_named(&mechanism, password)? { return Ok(value); } // PostfixAdmin historically delegates unsupported Dovecot-compatible methods. hash_with_doveadm(config, password, &mechanism, username).await } fn hash_native_named(mechanism: &str, password: &str) -> AppResult> { let value = match mechanism { "ARGON2ID" => format!("{{ARGON2ID}}{}", hash_argon(password, Algorithm::Argon2id)?), "ARGON2I" => format!("{{ARGON2I}}{}", hash_argon(password, Algorithm::Argon2i)?), "ARGON2ID.B64" => { let inner = hash_argon(password, Algorithm::Argon2id)?; format!("{{ARGON2ID.B64}}{}", STANDARD.encode(inner.as_bytes())) } "ARGON2I.B64" => { let inner = hash_argon(password, Algorithm::Argon2i)?; format!("{{ARGON2I.B64}}{}", STANDARD.encode(inner.as_bytes())) } "BLF-CRYPT" | "BCRYPT" => format!("{{BLF-CRYPT}}{}", bcrypt_hash(password, 10)?), "BLF-CRYPT.B64" => { let inner = bcrypt_hash(password, 10)?; format!("{{BLF-CRYPT.B64}}{}", STANDARD.encode(inner.as_bytes())) } "CRYPT" | "SYSTEM" => format!("{{CRYPT}}{}", bcrypt_hash(password, 10)?), "SHA512-CRYPT" => format!("{{SHA512-CRYPT}}{}", sha512_hash(password, None)?), "SHA512-CRYPT.B64" | "SHA512.B64" => { let inner = sha512_hash(password, None)?; format!("{{SHA512-CRYPT.B64}}{}", STANDARD.encode(inner.as_bytes())) } "SHA512" => format!( "{{SHA512}}{}", STANDARD.encode(Sha512::digest(password.as_bytes())) ), "SHA256-CRYPT" => format!("{{SHA256-CRYPT}}{}", sha256_hash(password, None)?), "SHA256-CRYPT.B64" => { let inner = sha256_hash(password, None)?; format!("{{SHA256-CRYPT.B64}}{}", STANDARD.encode(inner.as_bytes())) } "SHA256" => format!( "{{SHA256}}{}", STANDARD.encode(Sha256::digest(password.as_bytes())) ), "SHA1-CRYPT" => format!( "{{SHA1-CRYPT}}{}", sha1_crypt::hash(password) .map_err(|e| AppError::config(format!("SHA1-CRYPT failed: {e}")))? ), "SHA1" => format!( "{{SHA1}}{}", STANDARD.encode(Sha1::digest(password.as_bytes())) ), "SHA1.B64" => format!( "{{SHA1.B64}}{}", STANDARD.encode(Sha1::digest(password.as_bytes())) ), "SHA" => format!( "{{SHA}}{}", STANDARD.encode(Sha1::digest(password.as_bytes())) ), "SSHA" => hash_ssha(password), "MD5-CRYPT" => { let salt = random_crypt_salt(8); format!("{{MD5-CRYPT}}{}", md5_crypt(password, &salt)) } "MD5CRYPT" => { let salt = random_crypt_salt(8); md5_crypt(password, &salt) } "PLAIN-MD5" => format!("{{PLAIN-MD5}}{}", hex_md5(password)), // Historical `$CONF['encrypt'] = 'md5'` is unprefixed hex MD5. "MD5" => hex_md5(password), "MD5RAW" => format!("{{MD5RAW}}{}", hex_md5(password)), "PLAIN" => format!("{{PLAIN}}{password}"), "CLEAR" => format!("{{CLEAR}}{password}"), // Historical PostfixAdmin `cleartext` stores the password without a prefix. "CLEARTEXT" => password.to_owned(), _ => return Ok(None), }; Ok(Some(value)) } fn hash_courier(flavor: &str, password: &str) -> AppResult { let value = match flavor { "MD5" => format!( "{{MD5}}{}", STANDARD.encode(Md5::digest(password.as_bytes())) ), "MD5RAW" => format!("{{MD5RAW}}{}", hex_md5(password)), "SHA" | "SHA1" => format!( "{{SHA}}{}", STANDARD.encode(Sha1::digest(password.as_bytes())) ), "SSHA" => hash_ssha(password), "SHA256" => format!( "{{SHA256}}{}", STANDARD.encode(Sha256::digest(password.as_bytes())) ), _ => { return Err(AppError::config(format!( "unsupported Courier password flavor: {flavor}" ))); } }; Ok(value) } fn hash_authlib(config: &AppConfig, password: &str) -> AppResult { let flavor = config.password.authlib_default_flavor.trim(); let canonical = flavor.to_ascii_uppercase(); let body = match canonical.as_str() { "MD5RAW" => hex_md5(password), "MD5" => STANDARD.encode(Md5::digest(password.as_bytes())), "SHA" | "SHA1" => STANDARD.encode(Sha1::digest(password.as_bytes())), "CRYPT" => des_crypt_hash(password, "authlib crypt")?, _ => { return Err(AppError::config(format!( "authlib_default_flavor '{flavor}' is unsupported; use md5raw, md5, SHA or crypt" ))); } }; Ok(format!("{{{flavor}}}{body}")) } #[allow(deprecated)] fn des_crypt_hash(password: &str, label: &str) -> AppResult { unix_crypt::hash(password).map_err(|e| AppError::config(format!("{label} failed: {e}"))) } fn hash_php_crypt(specification: &str, password: &str) -> AppResult { let parts: Vec<&str> = specification.split(':').collect(); let method = parts .get(1) .copied() .filter(|s| !s.is_empty()) .unwrap_or("SHA512") .to_ascii_uppercase(); let difficulty = parts.get(2).copied().filter(|s| !s.is_empty()); let prefix = parts.get(3).copied().unwrap_or(""); let hash = match method.as_str() { "DES" => des_crypt_hash(password, "php_crypt:DES")?, "MD5" => md5_crypt(password, &random_crypt_salt(8)), "BLOWFISH" => { let cost = parse_range_u32(difficulty, 10, 4, 31, "BLOWFISH cost")?; bcrypt_hash(password, cost)? } "SHA256" => { let rounds = parse_optional_rounds(difficulty, 1000, 999_999_999, "SHA256 rounds")?; sha256_hash(password, rounds)? } "SHA512" => { let rounds = parse_optional_rounds(difficulty, 1000, 999_999_999, "SHA512 rounds")?; sha512_hash(password, rounds)? } _ => { return Err(AppError::config(format!( "unsupported php_crypt method: {method}" ))); } }; Ok(format!("{prefix}{hash}")) } fn hash_argon(password: &str, algorithm: Algorithm) -> AppResult { let params = if algorithm == Algorithm::Argon2i { Params::new(32_768, 4, 1, None) } else { Params::new(65_536, 3, 1, None) } .map_err(|e| AppError::config(format!("invalid Argon2 parameters: {e}")))?; let argon = Argon2::new(algorithm, Version::V0x13, params); let salt = SaltString::generate(&mut OsRng); argon .hash_password(password.as_bytes(), &salt) .map_err(|e| AppError::config(format!("Argon2 failed: {e}"))) .map(|hash| hash.to_string()) } fn bcrypt_hash(password: &str, cost: u32) -> AppResult { pw_bcrypt::hash_with( BcryptSetup { cost: Some(cost), variant: Some(BcryptVariant::V2y), ..Default::default() }, password, ) .map_err(|e| AppError::config(format!("bcrypt failed: {e}"))) } #[allow(deprecated)] // Legacy PostfixAdmin compatibility; SHA256-CRYPT is not recommended for new deployments. fn sha256_hash(password: &str, rounds: Option) -> AppResult { let result = match rounds { Some(rounds) => sha256_crypt::hash_with( HashSetup { salt: None, rounds: Some(rounds), }, password, ), None => sha256_crypt::hash(password), }; result.map_err(|e| AppError::config(format!("SHA256-CRYPT failed: {e}"))) } fn sha512_hash(password: &str, rounds: Option) -> AppResult { let result = match rounds { Some(rounds) => sha512_crypt::hash_with( HashSetup { salt: None, rounds: Some(rounds), }, password, ), None => sha512_crypt::hash(password), }; result.map_err(|e| AppError::config(format!("SHA512-CRYPT failed: {e}"))) } fn hash_ssha(password: &str) -> String { let mut salt = [0u8; 4]; rand::rng().fill_bytes(&mut salt); let mut digest = Sha1::new(); digest.update(password.as_bytes()); digest.update(salt); let mut bytes = digest.finalize().to_vec(); bytes.extend_from_slice(&salt); format!("{{SSHA}}{}", STANDARD.encode(bytes)) } /// Returns `Some(result)` when the stored representation is self-identifying. /// Returns `None` for legacy unprefixed formats that require configuration. fn verify_native(password: &str, stored: &str) -> Option { if let Some(payload) = stored .strip_prefix("{PLAIN}") .or_else(|| stored.strip_prefix("{CLEAR}")) .or_else(|| stored.strip_prefix("{CLEARTEXT}")) { return Some(ct_eq(password.as_bytes(), payload.as_bytes())); } if stored.starts_with("$1$") || stored.starts_with("$2") || stored.starts_with("$5$") || stored.starts_with("$6$") || stored.starts_with("$sha1$") || stored.starts_with('_') { return Some(unix::verify(password, stored)); } let (method, payload) = split_prefixed(stored)?; let method = method.to_ascii_uppercase(); let result = match method.as_str() { "PLAIN" | "CLEAR" | "CLEARTEXT" => ct_eq(password.as_bytes(), payload.as_bytes()), "MD5-CRYPT" => md5_crypt_verify(password, payload), "BLF-CRYPT" | "BCRYPT" => pw_bcrypt::verify(password, payload), "CRYPT" => unix::verify(password, payload), "SHA1-CRYPT" => sha1_crypt::verify(password, payload), "SHA256-CRYPT" => sha256_crypt::verify(password, payload), "SHA512-CRYPT" => sha512_crypt::verify(password, payload), "BLF-CRYPT.B64" => verify_b64_unix(password, payload, UnixKind::Bcrypt), "SHA256-CRYPT.B64" => verify_b64_unix(password, payload, UnixKind::Sha256), "SHA512-CRYPT.B64" => verify_b64_unix(password, payload, UnixKind::Sha512), "MD5" => ct_eq( STANDARD.encode(Md5::digest(password.as_bytes())).as_bytes(), payload.as_bytes(), ), "MD5RAW" | "PLAIN-MD5" => ct_eq(hex_md5(password).as_bytes(), payload.as_bytes()), "SHA" | "SHA1" | "SHA1.B64" => ct_eq( STANDARD .encode(Sha1::digest(password.as_bytes())) .as_bytes(), payload.as_bytes(), ), "SHA256" => ct_eq( STANDARD .encode(Sha256::digest(password.as_bytes())) .as_bytes(), payload.as_bytes(), ), "SHA512" => ct_eq( STANDARD .encode(Sha512::digest(password.as_bytes())) .as_bytes(), payload.as_bytes(), ), "SSHA" => verify_ssha(password, payload), "ARGON2I" | "ARGON2ID" | "ARGON2D" => verify_argon(password, payload), "ARGON2I.B64" | "ARGON2ID.B64" | "ARGON2D.B64" => STANDARD .decode(payload) .ok() .and_then(|raw| String::from_utf8(raw).ok()) .is_some_and(|hash| verify_argon(password, &hash)), _ => return None, }; Some(result) } enum UnixKind { Bcrypt, Sha256, Sha512, } fn verify_b64_unix(password: &str, payload: &str, kind: UnixKind) -> bool { let Ok(raw) = STANDARD.decode(payload) else { return false; }; let Ok(hash) = String::from_utf8(raw) else { return false; }; match kind { UnixKind::Bcrypt => pw_bcrypt::verify(password, &hash), UnixKind::Sha256 => sha256_crypt::verify(password, &hash), UnixKind::Sha512 => sha512_crypt::verify(password, &hash), } } fn verify_argon(password: &str, payload: &str) -> bool { let Ok(parsed) = PasswordHash::new(payload) else { return false; }; Argon2::default() .verify_password(password.as_bytes(), &parsed) .is_ok() } fn verify_ssha(password: &str, payload: &str) -> bool { let Ok(decoded) = STANDARD.decode(payload) else { return false; }; if decoded.len() < 21 { return false; } let (expected, salt) = decoded.split_at(20); let mut digest = Sha1::new(); digest.update(password.as_bytes()); digest.update(salt); let actual = digest.finalize(); ct_eq(&actual[..], expected) } fn is_crypt_char(byte: u8) -> bool { byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'/') } fn split_prefixed(stored: &str) -> Option<(&str, &str)> { let rest = stored.strip_prefix('{')?; let end = rest.find('}')?; let method = &rest[..end]; if method.is_empty() { return None; } Some((method, &rest[end + 1..])) } fn prefixed_method(stored: &str) -> Option<&str> { split_prefixed(stored).map(|(method, _)| method) } async fn verify_with_doveadm( config: &AppConfig, password: &str, stored: &str, method: &str, username: &str, ) -> AppResult { validate_doveadm_method(method)?; let mut command = doveadm_command(config)?; command.args(["-s", method, "-t", stored]); if method.eq_ignore_ascii_case("DIGEST-MD5") { if username.is_empty() { return Err(AppError::config("dovecot:DIGEST-MD5 requires username")); } command.args(["-u", username]); } command .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = command.spawn()?; if let Some(mut stdin) = child.stdin.take() { stdin.write_all(password.as_bytes()).await?; stdin.write_all(b"\n").await?; } let output = child.wait_with_output().await?; Ok(output.status.success()) } async fn hash_with_doveadm( config: &AppConfig, password: &str, scheme: &str, username: &str, ) -> AppResult { validate_doveadm_method(scheme)?; let mut command = doveadm_command(config)?; command.args(["-s", scheme]); if scheme.eq_ignore_ascii_case("DIGEST-MD5") { if username.is_empty() { return Err(AppError::config("dovecot:DIGEST-MD5 requires username")); } command.args(["-u", username]); } command .stdin(Stdio::piped()) .stderr(Stdio::piped()) .stdout(Stdio::piped()); let mut child = command.spawn()?; if let Some(mut stdin) = child.stdin.take() { // doveadm getpass() asks twice when generating a hash. stdin.write_all(password.as_bytes()).await?; stdin.write_all(b"\n").await?; stdin.write_all(password.as_bytes()).await?; stdin.write_all(b"\n").await?; } let output = child.wait_with_output().await?; if !output.status.success() { return Err(AppError::config(format!( "doveadm pw failed: {}", String::from_utf8_lossy(&output.stderr).trim() ))); } let value = String::from_utf8_lossy(&output.stdout).trim().to_owned(); if value.is_empty() { return Err(AppError::config("doveadm pw returned an empty hash")); } Ok(value) } fn doveadm_command(config: &AppConfig) -> AppResult { let mut parts = config.password.doveadm.split_whitespace(); let program = parts .next() .ok_or_else(|| AppError::config("password.doveadm is empty"))?; let trailing: Vec<&str> = parts.collect(); let has_pw = trailing.contains(&"pw"); let mut command = Command::new(program); command.args(&trailing); if !has_pw { command.arg("pw"); } Ok(command) } fn validate_doveadm_method(method: &str) -> AppResult<()> { if method.is_empty() || !method .bytes() .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || matches!(b, b'-' | b'.')) { return Err(AppError::config(format!( "invalid doveadm password method: {method}" ))); } Ok(()) } /// Refresh the mailbox password-expiry timestamp using the parent domain policy. pub async fn refresh_mailbox_expiry(config: &AppConfig, db: &Db, username: &str) -> AppResult<()> { if !db.has_column(Table::Mailbox, "password_expiry").await? { return Ok(()); } let Some((_, domain)) = username.rsplit_once('@') else { return Ok(()); }; let days = if config.features.password_expiration && db.has_column(Table::Domain, "password_expiry").await? { let table = db.table(Table::Domain); let sql = db.q(&format!( "SELECT password_expiry FROM {table} WHERE domain=? LIMIT 1" )); sqlx::query(&sql) .bind(domain) .fetch_optional(db.pool()) .await? .as_ref() .map(|row| row_i64(row, "password_expiry")) .unwrap_or(0) } else { 0 }; let days = if (1..=36_500).contains(&days) { days } else { 0 }; let expr = if days == 0 { "'9999-12-31 23:59:59'".to_owned() } else { match db.dialect() { Dialect::MySql => format!("DATE_ADD(CURRENT_TIMESTAMP, INTERVAL {days} DAY)"), Dialect::Postgres => format!("CURRENT_TIMESTAMP + INTERVAL '{days} days'"), Dialect::Sqlite => format!("datetime('now','+{days} days')"), } }; let mailbox = db.table(Table::Mailbox); let sql = db.q(&format!( "UPDATE {mailbox} SET password_expiry={expr} WHERE username=?" )); sqlx::query(&sql).bind(username).execute(db.pool()).await?; Ok(()) } fn parse_optional_rounds( value: Option<&str>, min: u32, max: u32, label: &str, ) -> AppResult> { match value { None => Ok(None), Some(raw) => { let parsed = u32::from_str(raw) .map_err(|_| AppError::config(format!("invalid {label}: {raw}")))?; if !(min..=max).contains(&parsed) { return Err(AppError::config(format!( "invalid {label}: {parsed}; expected {min}..={max}" ))); } Ok(Some(parsed)) } } } fn parse_range_u32( value: Option<&str>, default: u32, min: u32, max: u32, label: &str, ) -> AppResult { Ok(parse_optional_rounds(value, min, max, label)?.unwrap_or(default)) } fn is_hex_digest(value: &str, len: usize) -> bool { value.len() == len && value.bytes().all(|b| b.is_ascii_hexdigit()) } fn hex_md5(password: &str) -> String { format!("{:x}", Md5::digest(password.as_bytes())) } fn ct_eq(a: &[u8], b: &[u8]) -> bool { a.len() == b.len() && bool::from(a.ct_eq(b)) } fn random_crypt_salt(len: usize) -> String { let mut rng = rand::rng(); let mut bytes = vec![0u8; len]; rng.fill_bytes(&mut bytes); bytes .into_iter() .map(|b| CRYPT64[(b as usize) & 63] as char) .collect() } pub fn md5_crypt_verify(password: &str, stored: &str) -> bool { let mut parts = stored.split('$'); if parts.next() != Some("") || parts.next() != Some("1") { return false; } let Some(salt) = parts.next() else { return false; }; ct_eq(md5_crypt(password, salt).as_bytes(), stored.as_bytes()) } /// Native MD5-CRYPT retained for exact PostfixAdmin 3.x/4.x compatibility. pub fn md5_crypt(password: &str, raw_salt: &str) -> String { let password = password.as_bytes(); let salt = raw_salt.split('$').next().unwrap_or(raw_salt).as_bytes(); let salt = &salt[..salt.len().min(8)]; let mut ctx = Md5::new(); ctx.update(password); ctx.update(b"$1$"); ctx.update(salt); let mut alt = Md5::new(); alt.update(password); alt.update(salt); alt.update(password); let alt = alt.finalize(); let mut remaining = password.len(); while remaining > 16 { ctx.update(&alt[..]); remaining -= 16; } ctx.update(&alt[..remaining]); let mut n = password.len(); while n > 0 { if n & 1 != 0 { ctx.update([0u8]); } else if let Some(first) = password.first() { ctx.update([*first]); } n >>= 1; } let mut digest: [u8; 16] = ctx.finalize().into(); for i in 0..1000usize { let mut loop_ctx = Md5::new(); if i & 1 != 0 { loop_ctx.update(password); } else { loop_ctx.update(digest); } if i % 3 != 0 { loop_ctx.update(salt); } if i % 7 != 0 { loop_ctx.update(password); } if i & 1 != 0 { loop_ctx.update(digest); } else { loop_ctx.update(password); } digest = loop_ctx.finalize().into(); } let mut encoded = String::with_capacity(22); b64_from_24(digest[0], digest[6], digest[12], 4, &mut encoded); b64_from_24(digest[1], digest[7], digest[13], 4, &mut encoded); b64_from_24(digest[2], digest[8], digest[14], 4, &mut encoded); b64_from_24(digest[3], digest[9], digest[15], 4, &mut encoded); b64_from_24(digest[4], digest[10], digest[5], 4, &mut encoded); b64_from_24(0, 0, digest[11], 2, &mut encoded); format!("$1${}${encoded}", String::from_utf8_lossy(salt)) } fn b64_from_24(b2: u8, b1: u8, b0: u8, count: usize, out: &mut String) { let mut value = (u32::from(b2) << 16) | (u32::from(b1) << 8) | u32::from(b0); for _ in 0..count { out.push(CRYPT64[(value & 0x3f) as usize] as char); value >>= 6; } } #[cfg(test)] mod tests { use super::*; #[test] fn md5crypt_known_vector() { let hash = md5_crypt("password", "hfT7jp2q"); assert_eq!(hash, "$1$hfT7jp2q$G3yf0NUx7mUkX.LIFWQxN."); assert!(md5_crypt_verify("password", &hash)); assert!(!md5_crypt_verify("wrong", &hash)); } #[test] fn verifies_postfixadmin_reference_vectors() { let cases = [ ("{SHA1}cojt0Pw//L6ToM8G41aOKFIWh7w=", true), ("{SHA1.B64}cojt0Pw//L6ToM8G41aOKFIWh7w=", true), ("{PLAIN}test123", true), ("{CLEAR}test123", true), ("{CLEARTEXT}test123", true), ("{SHA256}7NcYcNGWMxapfjrDQIyYNa2M8PPBvHA1J8MCZVNPda4=", true), ( "{SHA512}2u9JU7l4M2XK1mFSI3IFBsxGxRZ80Wq1APpZeqCP+WTrJPsZaH8012Zfd4/LbFNY/ApbgeFmLPkPc6JnHFP5kQ==", true, ), ("{PLAIN-MD5}cc03e747a6afbbcbf8be7668acfebee5", true), ("{MD5-CRYPT}$1$AIjpWveQ$2s3eEAbZiqkJhMYUIVR240", true), ( "{BLF-CRYPT}$2y$05$cEEZv2h/NtLXII.emi2TP.rMZyB7VRSkyToXWBqqz6cXDoyay166q", true, ), ( "{SHA512-CRYPT}$6$MViNQUSbWyXWL9wZ$63VsBU2a/ZFb9f/dK4EmaXABE9jAcNltR7y6a2tXLKoV5F5jMezno.2KpmtD3U0FDjfa7A.pkCluVMlZJ.F64.", true, ), ( "{CRYPT}$2y$05$ORqzr0AagWr25v3ixHD5QuMXympIoNTbipEFZz6aAmovGNoij2vDO", true, ), ( "{SHA256-CRYPT}$5$CFly6wzfn2az3U8j$EhfQPTdjpMGAisfCjCKektLke5GGEmtdLVaCZSmsKw2", true, ), ("{SSHA}ZkqrSEAhvd0FTHaK1IxAQCRa5LWbxGQY", true), ( "{ARGON2ID}$argon2id$v=19$m=65536,t=3,p=1$eaXP376O9/VxleLw9OQIxg$jOoDyECeRRV4eta3eSN/j0RdBgqaA1VBGAA/pbviI20", true, ), ( "{ARGON2I}$argon2i$v=19$m=32768,t=4,p=1$xoOcAGa27k0Sr6ZPbA9ODw$wl/KAZVmJooD/35IFG5oGwyQiAREXrLss5BPS1PDKfA", true, ), ( "{ARGON2ID.B64}JGFyZ29uMmlkJHY9MTkkbT02NTUzNix0PTMscD0xJEljdG9DWko1T04zWlYzM3I0TVMrNEEkMUVtNTJRWkdsRlJzNnBsRXpwVmtMeVd4dVNPRUZ2dUZnaVNhTmNlb08rOA==", true, ), ("{MD5}zAPnR6avu8v4vnZorP6+5Q==", true), ("{MD5RAW}cc03e747a6afbbcbf8be7668acfebee5", true), ]; for (stored, expected) in cases { assert_eq!(verify_native("test123", stored), Some(expected), "{stored}"); assert_eq!( verify_native("not-the-password", stored), Some(false), "{stored}" ); } } #[test] fn verifies_postfixadmin_b64_crypt_vectors() { let cases = [ "{SHA512-CRYPT.B64}JDYkR2JwY3NiZXNMWk9DdERXbiRYdXlhdEZTdy9oa3lyUFE0d24wenpGQTZrSlpTUE9QVWdPcjVRUC40bTRMTjEzdy81aWMvWTdDZllRMWVqSWlhNkd3Q2Z0ZnNjZEFpam9OWjl3OU5tLw==", "{SHA256-CRYPT.B64}JDUkUTZZS1ZzZS5sSVJoLndodCR6TWNOUVFVVkhtTmM1ME1SQk9TR3BEeGpRY2M1TzJTQ1lkbWhPN1YxeHlD", "{BLF-CRYPT.B64}JDJ5JDA1JEhlR0lBeGFHR2tNUGxjRWpyeFc0eU9oRjZZZ1NuTWVOTXFxNWp4bmFwVjUwdGU3c2x2L1VT", ]; for stored in cases { assert_eq!(verify_native("test123", stored), Some(true), "{stored}"); assert_eq!(verify_native("wrong", stored), Some(false), "{stored}"); } } #[test] fn verifies_legacy_php_crypt_des_vector() { assert!(unix_crypt::verify("test123", "VXAXutUnpVYg6")); assert!(!unix_crypt::verify("wrong", "VXAXutUnpVYg6")); } }