Files
PostfixAdminRS/src/passwords.rs
T
2026-11-04 08:55:53 +01:00

948 lines
32 KiB
Rust

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<char> = 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<String> {
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<String> {
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<String> {
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<Option<String>> {
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<String> {
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<String> {
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<String> {
unix_crypt::hash(password).map_err(|e| AppError::config(format!("{label} failed: {e}")))
}
fn hash_php_crypt(specification: &str, password: &str) -> AppResult<String> {
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<String> {
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<String> {
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<u32>) -> AppResult<String> {
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<u32>) -> AppResult<String> {
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<bool> {
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<bool> {
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<String> {
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<Command> {
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<Option<u32>> {
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<u32> {
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"));
}
}