first commit
This commit is contained in:
commit
2eb8aac027
91 files changed
+20139
No files matched your search
@@ -0,0 +1,919 @@
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user