Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
Directories, Directory, UnavailableDirectory,
|
||||
backend::{ldap::LdapDirectory, oidc::OpenIdDirectory, sql::SqlDirectory},
|
||||
};
|
||||
use registry::schema::{
|
||||
prelude::ObjectType,
|
||||
structs::{self, Authentication},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use store::registry::bootstrap::Bootstrap;
|
||||
|
||||
impl Directories {
|
||||
pub async fn build(bp: &mut Bootstrap) -> Self {
|
||||
let mut directories = HashMap::default();
|
||||
|
||||
for directory in bp.list_infallible::<structs::Directory>().await {
|
||||
let id = directory.id;
|
||||
let directory_type = directory.object.object_type();
|
||||
let result = match directory.object {
|
||||
structs::Directory::Ldap(directory) => LdapDirectory::open(directory).await,
|
||||
structs::Directory::Sql(directory) => {
|
||||
SqlDirectory::open(directory, &bp.data_store).await
|
||||
}
|
||||
structs::Directory::Oidc(directory) => OpenIdDirectory::open(directory).await,
|
||||
};
|
||||
|
||||
let directory = match result {
|
||||
Ok(directory) => directory,
|
||||
Err(err) => {
|
||||
bp.build_error(id, err.clone());
|
||||
Directory::Unavailable(UnavailableDirectory::new(directory_type, err))
|
||||
}
|
||||
};
|
||||
directories.insert(id.id().id() as u32, Arc::new(directory));
|
||||
}
|
||||
|
||||
let auth = bp.setting_infallible::<Authentication>().await;
|
||||
let default_directory = if let Some(directory_id) = auth.directory_id {
|
||||
match directories.get(&(directory_id.id() as u32)) {
|
||||
Some(default_directory) => default_directory.clone().into(),
|
||||
None => {
|
||||
bp.build_error(
|
||||
ObjectType::Authentication.singleton(),
|
||||
format!("Default directory with ID {} not found", directory_id),
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Directories {
|
||||
default_directory,
|
||||
directories,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{Account, Credentials, Directory, Recipient, backend::oidc::OidcDiscovery};
|
||||
use registry::schema::enums::DirectoryType;
|
||||
use trc::AddContext;
|
||||
|
||||
impl Directory {
|
||||
pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result<Account> {
|
||||
match &self {
|
||||
Directory::Ldap(store) => store.authenticate(credentials).await,
|
||||
Directory::Sql(store) => store.authenticate(credentials).await,
|
||||
Directory::OpenId(store) => store.authenticate(credentials).await,
|
||||
Directory::Unavailable(directory) => Err(directory.error()),
|
||||
}
|
||||
.caused_by(trc::location!())
|
||||
}
|
||||
|
||||
pub async fn recipient(&self, address: &str) -> trc::Result<Recipient> {
|
||||
match &self {
|
||||
Directory::Ldap(store) => store.recipient(address).await,
|
||||
Directory::Sql(store) => store.recipient(address).await,
|
||||
Directory::OpenId(_) => Ok(Recipient::Invalid), // OIDC directories do not support recipient lookups
|
||||
Directory::Unavailable(directory) => Err(directory.error()),
|
||||
}
|
||||
.caused_by(trc::location!())
|
||||
}
|
||||
|
||||
pub fn has_bearer_token_support(&self) -> bool {
|
||||
match &self {
|
||||
Directory::OpenId(_) => true,
|
||||
Directory::Unavailable(directory) => directory.directory_type() == DirectoryType::Oidc,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_lookup_recipients(&self) -> bool {
|
||||
match &self {
|
||||
Directory::OpenId(_) => false,
|
||||
Directory::Unavailable(directory) => directory.directory_type() != DirectoryType::Oidc,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn oidc_discovery_document(&self) -> Option<&OidcDiscovery> {
|
||||
match &self {
|
||||
Directory::OpenId(directory) => Some(&directory.discovery),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod config;
|
||||
pub mod dispatch;
|
||||
pub mod sasl;
|
||||
pub mod secret;
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::Credentials;
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
|
||||
impl Credentials {
|
||||
pub fn decode_sasl_challenge_plain(challenge: &[u8]) -> Option<Self> {
|
||||
let mut username = Vec::new();
|
||||
let mut secret = Vec::new();
|
||||
let mut arg_num = 0;
|
||||
for &ch in challenge {
|
||||
if ch != 0 {
|
||||
if arg_num == 1 {
|
||||
username.push(ch);
|
||||
} else if arg_num == 2 {
|
||||
secret.push(ch);
|
||||
}
|
||||
} else {
|
||||
arg_num += 1;
|
||||
}
|
||||
}
|
||||
|
||||
match (String::from_utf8(username), String::from_utf8(secret)) {
|
||||
(Ok(username), Ok(secret)) if !username.is_empty() && !secret.is_empty() => {
|
||||
Some(Credentials::Basic {
|
||||
username,
|
||||
secret,
|
||||
mfa_token: None,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_sasl_challenge_oauth(challenge: &[u8]) -> Option<Self> {
|
||||
extract_oauth_bearer(challenge)
|
||||
.map(|(token, username)| Credentials::Bearer { username, token })
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_oauth_bearer(bytes: &[u8]) -> Option<(String, Option<String>)> {
|
||||
let mut start_pos = 0;
|
||||
let eof = bytes.len().saturating_sub(1);
|
||||
let mut iter = bytes.iter().enumerate();
|
||||
let mut a = None;
|
||||
|
||||
while let Some((pos, ch)) = iter.next() {
|
||||
if *ch == b','
|
||||
&& bytes
|
||||
.get(pos + 1..pos + 3)
|
||||
.is_some_and(|s| s.eq_ignore_ascii_case(b"a="))
|
||||
{
|
||||
let from_pos = pos + 3;
|
||||
let mut to_pos = from_pos;
|
||||
for (pos, ch) in iter.by_ref() {
|
||||
if *ch == b',' || *ch == 1 {
|
||||
to_pos = pos;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if to_pos > from_pos {
|
||||
a = bytes
|
||||
.get(from_pos..to_pos)
|
||||
.and_then(|s| std::str::from_utf8(s).ok())
|
||||
.filter(|v| v.contains('@'));
|
||||
}
|
||||
} else {
|
||||
let is_separator = *ch == 1;
|
||||
if is_separator || pos == eof {
|
||||
if bytes
|
||||
.get(start_pos..start_pos + 12)
|
||||
.is_some_and(|s| s.eq_ignore_ascii_case(b"auth=Bearer "))
|
||||
{
|
||||
return bytes
|
||||
.get(start_pos + 12..if is_separator { pos } else { bytes.len() })
|
||||
.and_then(|s| std::str::from_utf8(s).ok())
|
||||
.map(|token| {
|
||||
(
|
||||
token.to_string(),
|
||||
a.map(|s| s.to_string())
|
||||
.or_else(|| extract_email_from_jwt(token)),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
start_pos = pos + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct JwtClaims {
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(default)]
|
||||
preferred_username: Option<String>,
|
||||
#[serde(default)]
|
||||
upn: Option<String>,
|
||||
#[serde(default)]
|
||||
unique_name: Option<String>,
|
||||
#[serde(default)]
|
||||
sub: Option<String>,
|
||||
}
|
||||
|
||||
fn extract_email_from_jwt(token: &str) -> Option<String> {
|
||||
let claims: JwtClaims =
|
||||
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(token.split('.').nth(1)?).ok()?).ok()?;
|
||||
[
|
||||
claims.email,
|
||||
claims.preferred_username,
|
||||
claims.upn,
|
||||
claims.unique_name,
|
||||
claims.sub,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|v| v.contains('@'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_oauth_bearer() {
|
||||
let input = b"auth=Bearer validtoken";
|
||||
let result = extract_oauth_bearer(input);
|
||||
assert_eq!(result, Some(("validtoken".to_string(), None)));
|
||||
|
||||
let input = b"auth=Invalid validtoken";
|
||||
let result = extract_oauth_bearer(input);
|
||||
assert_eq!(result, None);
|
||||
|
||||
let input = b"auth=Bearer";
|
||||
let result = extract_oauth_bearer(input);
|
||||
assert_eq!(result, None);
|
||||
|
||||
let input = b"";
|
||||
let result = extract_oauth_bearer(input);
|
||||
assert_eq!(result, None);
|
||||
|
||||
let input = b"auth=Bearer token1\x01auth=Bearer token2";
|
||||
let result = extract_oauth_bearer(input);
|
||||
assert_eq!(result, Some(("token1".to_string(), None)));
|
||||
|
||||
let input = b"auth=Bearer VALIDTOKEN";
|
||||
let result = extract_oauth_bearer(input);
|
||||
assert_eq!(result, Some(("VALIDTOKEN".to_string(), None)));
|
||||
|
||||
let input = b"auth=Bearer token with spaces";
|
||||
let result = extract_oauth_bearer(input);
|
||||
assert_eq!(result, Some(("token with spaces".to_string(), None)));
|
||||
|
||||
let input = b"auth=Bearer token_with_special_chars!@#";
|
||||
let result = extract_oauth_bearer(input);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(("token_with_special_chars!@#".to_string(), None))
|
||||
);
|
||||
|
||||
let input = "n,[email protected],\x01host=server.example.com\x01port=143\x01auth=Bearer vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==\x01\x01";
|
||||
let result = extract_oauth_bearer(input.as_bytes());
|
||||
assert_eq!(
|
||||
result,
|
||||
Some((
|
||||
"vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==".to_string(),
|
||||
Some("[email protected]".to_string())
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use argon2::Argon2;
|
||||
use argon2::PasswordHash;
|
||||
use argon2::PasswordHasher;
|
||||
use argon2::PasswordVerifier;
|
||||
use mail_builder::encoders::Base64Encoder;
|
||||
use mail_parser::decoders::base64::base64_decode;
|
||||
use pbkdf2::Pbkdf2;
|
||||
use pwhash::{bcrypt, bsdi_crypt, md5_crypt, sha1_crypt, sha256_crypt, sha512_crypt, unix_crypt};
|
||||
use registry::schema::enums::PasswordHashAlgorithm;
|
||||
use scrypt::Scrypt;
|
||||
use sha1::Digest;
|
||||
use sha1::Sha1;
|
||||
use sha2::Sha256;
|
||||
use sha2::Sha512;
|
||||
use tokio::sync::oneshot;
|
||||
use totp_rs::Totp;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SecretVerificationResult {
|
||||
Valid,
|
||||
Invalid,
|
||||
MissingMfaToken,
|
||||
}
|
||||
|
||||
pub async fn verify_mfa_secret_hash(
|
||||
totp_uri: Option<&str>,
|
||||
totp_token: Option<&str>,
|
||||
hashed_secret: &str,
|
||||
secret: &str,
|
||||
) -> trc::Result<SecretVerificationResult> {
|
||||
if let Some(totp_uri) = totp_uri {
|
||||
if let Some(totp_token) = totp_token {
|
||||
let result = verify_secret_hash(hashed_secret, secret.as_bytes()).await?
|
||||
&& Totp::from_url(totp_uri)
|
||||
.map_err(|err| {
|
||||
trc::AuthEvent::Error
|
||||
.reason(err)
|
||||
.details(totp_uri.to_string())
|
||||
})?
|
||||
.check_current(totp_token)
|
||||
.is_some();
|
||||
Ok(if result {
|
||||
SecretVerificationResult::Valid
|
||||
} else {
|
||||
SecretVerificationResult::Invalid
|
||||
})
|
||||
} else if !hashed_secret.is_empty()
|
||||
&& !secret.is_empty()
|
||||
&& verify_secret_hash(hashed_secret, secret.as_bytes()).await?
|
||||
{
|
||||
// Only let the client know if the TOTP code is missing
|
||||
// if the password is correct
|
||||
|
||||
Ok(SecretVerificationResult::MissingMfaToken)
|
||||
} else {
|
||||
Ok(SecretVerificationResult::Invalid)
|
||||
}
|
||||
} else if !hashed_secret.is_empty() && !secret.is_empty() {
|
||||
if verify_secret_hash(hashed_secret, secret.as_bytes()).await? {
|
||||
Ok(SecretVerificationResult::Valid)
|
||||
} else {
|
||||
Ok(SecretVerificationResult::Invalid)
|
||||
}
|
||||
} else {
|
||||
Ok(SecretVerificationResult::Invalid)
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_hash_prefix(hashed_secret: &str, secret: &[u8]) -> trc::Result<bool> {
|
||||
let is_argon = hashed_secret.starts_with("$argon2");
|
||||
let is_pbkdf2 = !is_argon && hashed_secret.starts_with("$pbkdf2");
|
||||
let is_scrypt = !is_argon && !is_pbkdf2 && hashed_secret.starts_with("$scrypt");
|
||||
|
||||
if is_argon || is_pbkdf2 || is_scrypt {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let secret = secret.to_vec();
|
||||
let hashed_secret = hashed_secret.to_string();
|
||||
|
||||
tokio::task::spawn_blocking(move || match PasswordHash::new(&hashed_secret) {
|
||||
Ok(hash) => {
|
||||
let result = if is_argon {
|
||||
Argon2::default().verify_password(&secret, &hash)
|
||||
} else if is_pbkdf2 {
|
||||
Pbkdf2::default().verify_password(&secret, &hash)
|
||||
} else {
|
||||
Scrypt::default().verify_password(&secret, &hash)
|
||||
};
|
||||
|
||||
tx.send(Ok(result.is_ok())).ok();
|
||||
}
|
||||
Err(err) => {
|
||||
tx.send(Err(trc::AuthEvent::Error
|
||||
.reason(err)
|
||||
.details(hashed_secret)))
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
|
||||
match rx.await {
|
||||
Ok(result) => result,
|
||||
Err(err) => Err(trc::EventType::Server(trc::ServerEvent::ThreadError)
|
||||
.caused_by(trc::location!())
|
||||
.reason(err)),
|
||||
}
|
||||
} else if hashed_secret.starts_with("$2") {
|
||||
// Blowfish crypt
|
||||
Ok(bcrypt::verify(secret, hashed_secret))
|
||||
} else if hashed_secret.starts_with("$6$") {
|
||||
// SHA-512 crypt
|
||||
Ok(sha512_crypt::verify(secret, hashed_secret))
|
||||
} else if hashed_secret.starts_with("$5$") {
|
||||
// SHA-256 crypt
|
||||
Ok(sha256_crypt::verify(secret, hashed_secret))
|
||||
} else if hashed_secret.starts_with("$sha1") {
|
||||
// SHA-1 crypt
|
||||
Ok(sha1_crypt::verify(secret, hashed_secret))
|
||||
} else if hashed_secret.starts_with("$1") {
|
||||
// MD5 based hash
|
||||
Ok(md5_crypt::verify(secret, hashed_secret))
|
||||
} else {
|
||||
Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details(hashed_secret.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn verify_secret_hash(hashed_secret: &str, secret: &[u8]) -> trc::Result<bool> {
|
||||
if hashed_secret.starts_with('$') {
|
||||
verify_hash_prefix(hashed_secret, secret).await
|
||||
} else if hashed_secret.starts_with('_') {
|
||||
// Enhanced DES-based hash
|
||||
Ok(bsdi_crypt::verify(secret, hashed_secret))
|
||||
} else if let Some(hashed_secret) = hashed_secret.strip_prefix('{') {
|
||||
if let Some((algo, hashed_secret)) = hashed_secret.split_once('}') {
|
||||
match algo.to_ascii_uppercase().as_str() {
|
||||
"ARGON2" | "ARGON2I" | "ARGON2ID" | "PBKDF2" => {
|
||||
verify_hash_prefix(hashed_secret, secret).await
|
||||
}
|
||||
"SHA" => {
|
||||
// SHA-1
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(secret);
|
||||
Ok(String::from_utf8(
|
||||
Base64Encoder::new()
|
||||
.encode(&hasher.finalize()[..])
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.unwrap()
|
||||
== hashed_secret)
|
||||
}
|
||||
"SSHA" => {
|
||||
// Salted SHA-1
|
||||
let decoded = base64_decode(hashed_secret.as_bytes()).unwrap_or_default();
|
||||
let hash = decoded.get(..20).unwrap_or_default();
|
||||
let salt = decoded.get(20..).unwrap_or_default();
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(secret);
|
||||
hasher.update(salt);
|
||||
Ok(&hasher.finalize()[..] == hash)
|
||||
}
|
||||
"SHA256" => {
|
||||
// Verify hash
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(secret);
|
||||
Ok(String::from_utf8(
|
||||
Base64Encoder::new()
|
||||
.encode(&hasher.finalize()[..])
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.unwrap()
|
||||
== hashed_secret)
|
||||
}
|
||||
"SSHA256" => {
|
||||
// Salted SHA-256
|
||||
let decoded = base64_decode(hashed_secret.as_bytes()).unwrap_or_default();
|
||||
let hash = decoded.get(..32).unwrap_or_default();
|
||||
let salt = decoded.get(32..).unwrap_or_default();
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(secret);
|
||||
hasher.update(salt);
|
||||
Ok(&hasher.finalize()[..] == hash)
|
||||
}
|
||||
"SHA512" => {
|
||||
// SHA-512
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(secret);
|
||||
Ok(String::from_utf8(
|
||||
Base64Encoder::new()
|
||||
.encode(&hasher.finalize()[..])
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.unwrap()
|
||||
== hashed_secret)
|
||||
}
|
||||
"SSHA512" => {
|
||||
// Salted SHA-512
|
||||
let decoded = base64_decode(hashed_secret.as_bytes()).unwrap_or_default();
|
||||
let hash = decoded.get(..64).unwrap_or_default();
|
||||
let salt = decoded.get(64..).unwrap_or_default();
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(secret);
|
||||
hasher.update(salt);
|
||||
Ok(&hasher.finalize()[..] == hash)
|
||||
}
|
||||
"MD5" => {
|
||||
// MD5
|
||||
let digest = md5::compute(secret);
|
||||
Ok(String::from_utf8(
|
||||
Base64Encoder::new().encode(&digest[..]).unwrap_or_default(),
|
||||
)
|
||||
.unwrap()
|
||||
== hashed_secret)
|
||||
}
|
||||
"CRYPT" => {
|
||||
if hashed_secret.starts_with('$') {
|
||||
verify_hash_prefix(hashed_secret, secret).await
|
||||
} else {
|
||||
// Unix crypt
|
||||
Ok(unix_crypt::verify(secret, hashed_secret))
|
||||
}
|
||||
}
|
||||
"PLAIN" | "CLEAR" => Ok(hashed_secret.as_bytes() == secret),
|
||||
_ => Err(trc::AuthEvent::Error
|
||||
.ctx(trc::Key::Reason, "Unsupported algorithm")
|
||||
.details(hashed_secret.to_string())),
|
||||
}
|
||||
} else {
|
||||
Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details(hashed_secret.to_string()))
|
||||
}
|
||||
} else if !hashed_secret.is_empty() {
|
||||
Ok(hashed_secret.as_bytes() == secret)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn hash_secret(algorithm: PasswordHashAlgorithm, secret: Vec<u8>) -> trc::Result<String> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = match algorithm {
|
||||
PasswordHashAlgorithm::Argon2id => {
|
||||
let hasher = Argon2::default();
|
||||
hasher
|
||||
.hash_password(secret.as_slice())
|
||||
.map(|h| h.to_string())
|
||||
}
|
||||
PasswordHashAlgorithm::Bcrypt => {
|
||||
return tx
|
||||
.send(bcrypt::hash(secret.as_slice()).map_err(|err| {
|
||||
trc::AuthEvent::Error
|
||||
.reason(err)
|
||||
.details("Bcrypt hash failed")
|
||||
}))
|
||||
.ok()
|
||||
.unwrap_or(());
|
||||
}
|
||||
PasswordHashAlgorithm::Scrypt => Scrypt::default()
|
||||
.hash_password(secret.as_slice())
|
||||
.map(|h| h.to_string()),
|
||||
PasswordHashAlgorithm::Pbkdf2 => Pbkdf2::default()
|
||||
.hash_password(secret.as_slice())
|
||||
.map(|h| h.to_string()),
|
||||
};
|
||||
|
||||
tx.send(result.map_err(|err| {
|
||||
trc::AuthEvent::Error
|
||||
.reason(err)
|
||||
.details("Password hash failed")
|
||||
}))
|
||||
.ok();
|
||||
});
|
||||
|
||||
match rx.await {
|
||||
Ok(result) => result,
|
||||
Err(err) => Err(trc::EventType::Server(trc::ServerEvent::ThreadError)
|
||||
.caused_by(trc::location!())
|
||||
.reason(err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_password_hash(s: &str) -> bool {
|
||||
if s.starts_with("$argon2") || s.starts_with("$pbkdf2") || s.starts_with("$scrypt") {
|
||||
is_complete_phc(s)
|
||||
} else if s.starts_with("$2") {
|
||||
is_bcrypt_format(s)
|
||||
} else if let Some(body) = s.strip_prefix("$1$") {
|
||||
is_md5_crypt(body)
|
||||
} else if let Some(body) = s.strip_prefix("$5$") {
|
||||
is_sha_crypt(body, 43)
|
||||
} else if let Some(body) = s.strip_prefix("$6$") {
|
||||
is_sha_crypt(body, 86)
|
||||
} else if let Some(body) = s.strip_prefix("$sha1$") {
|
||||
is_sha1_crypt(body)
|
||||
} else if s.starts_with('_') {
|
||||
is_unix_des_crypt(s)
|
||||
} else if let Some(rest) = s.strip_prefix('{') {
|
||||
rest.split_once('}')
|
||||
.map(|(scheme, body)| is_ldap_hash(scheme, body))
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn is_complete_phc(s: &str) -> bool {
|
||||
PasswordHash::new(s)
|
||||
.map(|h| h.hash.is_some() && h.salt.is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_crypt_b64(b: u8) -> bool {
|
||||
b.is_ascii_alphanumeric() || b == b'.' || b == b'/'
|
||||
}
|
||||
|
||||
fn all_crypt_b64(s: &str) -> bool {
|
||||
!s.is_empty() && s.bytes().all(is_crypt_b64)
|
||||
}
|
||||
|
||||
fn is_bcrypt_format(s: &str) -> bool {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() != 60
|
||||
|| !matches!(bytes[2], b'a' | b'b' | b'x' | b'y')
|
||||
|| bytes[3] != b'$'
|
||||
|| !bytes[4].is_ascii_digit()
|
||||
|| !bytes[5].is_ascii_digit()
|
||||
|| bytes[6] != b'$'
|
||||
{
|
||||
false
|
||||
} else {
|
||||
bytes[7..].iter().copied().all(is_crypt_b64)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_md5_crypt(body: &str) -> bool {
|
||||
let Some((salt, hash)) = body.split_once('$') else {
|
||||
return false;
|
||||
};
|
||||
!salt.is_empty()
|
||||
&& salt.len() <= 8
|
||||
&& all_crypt_b64(salt)
|
||||
&& hash.len() == 22
|
||||
&& all_crypt_b64(hash)
|
||||
}
|
||||
|
||||
fn is_sha_crypt(body: &str, hash_len: usize) -> bool {
|
||||
let remainder = if let Some(after) = body.strip_prefix("rounds=") {
|
||||
let Some((rounds, rest)) = after.split_once('$') else {
|
||||
return false;
|
||||
};
|
||||
if rounds.is_empty() || !rounds.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
rest
|
||||
} else {
|
||||
body
|
||||
};
|
||||
let Some((salt, hash)) = remainder.split_once('$') else {
|
||||
return false;
|
||||
};
|
||||
!salt.is_empty()
|
||||
&& salt.len() <= 16
|
||||
&& all_crypt_b64(salt)
|
||||
&& hash.len() == hash_len
|
||||
&& all_crypt_b64(hash)
|
||||
}
|
||||
|
||||
fn is_sha1_crypt(body: &str) -> bool {
|
||||
let mut parts = body.splitn(3, '$');
|
||||
let Some(rounds) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
let Some(salt) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
let Some(hash) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
if rounds.is_empty()
|
||||
|| !rounds.bytes().all(|b| b.is_ascii_digit())
|
||||
|| salt.is_empty()
|
||||
|| salt.len() > 64
|
||||
|| !all_crypt_b64(salt)
|
||||
{
|
||||
false
|
||||
} else {
|
||||
hash.len() == 28 && all_crypt_b64(hash)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ldap_hash(scheme: &str, body: &str) -> bool {
|
||||
match scheme.to_ascii_uppercase().as_str() {
|
||||
"SHA" => b64_decoded_len_eq(body, 20),
|
||||
"SSHA" => b64_decoded_len_ge(body, 21),
|
||||
"SHA256" => b64_decoded_len_eq(body, 32),
|
||||
"SSHA256" => b64_decoded_len_ge(body, 33),
|
||||
"SHA512" => b64_decoded_len_eq(body, 64),
|
||||
"SSHA512" => b64_decoded_len_ge(body, 65),
|
||||
"MD5" => b64_decoded_len_eq(body, 16),
|
||||
"ARGON2" | "ARGON2I" | "ARGON2ID" | "PBKDF2" => is_complete_phc(body),
|
||||
"CRYPT" => is_password_hash(body) || is_unix_des_crypt(body),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unix_des_crypt(s: &str) -> bool {
|
||||
let bytes = s.as_bytes();
|
||||
(bytes.len() == 13 && bytes.iter().copied().all(is_crypt_b64))
|
||||
|| (bytes.len() == 20 && bytes[0] == b'_' && bytes[1..].iter().copied().all(is_crypt_b64))
|
||||
}
|
||||
|
||||
fn b64_decoded_len_eq(body: &str, len: usize) -> bool {
|
||||
b64_decode_loose(body)
|
||||
.map(|d| d.len() == len)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn b64_decoded_len_ge(body: &str, min: usize) -> bool {
|
||||
b64_decode_loose(body)
|
||||
.map(|d| d.len() >= min)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn b64_decode_loose(s: &str) -> Option<Vec<u8>> {
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::engine::general_purpose::STANDARD_NO_PAD;
|
||||
STANDARD
|
||||
.decode(s)
|
||||
.ok()
|
||||
.or_else(|| STANDARD_NO_PAD.decode(s).ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
String::from_utf8(Base64Encoder::new().encode(bytes).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_password_hash_detects_phc_strings() {
|
||||
let argon = Argon2::default()
|
||||
.hash_password(b"hello")
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(is_password_hash(&argon), "argon2 not detected: {argon}");
|
||||
|
||||
let pbkdf = Pbkdf2::default()
|
||||
.hash_password(b"hello")
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(is_password_hash(&pbkdf), "pbkdf2 not detected: {pbkdf}");
|
||||
|
||||
let scr = Scrypt::default()
|
||||
.hash_password(b"hello")
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(is_password_hash(&scr), "scrypt not detected: {scr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_password_hash_detects_crypt_variants() {
|
||||
let bc = bcrypt::hash("hello").unwrap();
|
||||
assert!(is_password_hash(&bc), "bcrypt not detected: {bc}");
|
||||
assert!(bcrypt::verify("hello", &bc));
|
||||
|
||||
let md5 = "$1$5pZSV9va$azfrPr6af3Fc7dLblQXVa0";
|
||||
assert!(is_password_hash(md5));
|
||||
assert!(md5_crypt::verify("password", md5));
|
||||
|
||||
let sha256 = "$5$WH1ABM5sKhxbkgCK$sOnTVjQn1Y3EWibd8gWqqJqjH.KaFrxJE5rijqxcPp7";
|
||||
assert!(is_password_hash(sha256));
|
||||
assert!(sha256_crypt::verify("test", sha256));
|
||||
|
||||
let sha256_rounds =
|
||||
"$5$rounds=11858$WH1ABM5sKhxbkgCK$aTQsjPkz0rBsH3lQlJxw9HDTDXPKBxC0LlVeV69P.t1";
|
||||
assert!(is_password_hash(sha256_rounds));
|
||||
assert!(sha256_crypt::verify("test", sha256_rounds));
|
||||
|
||||
let s512 = sha512_crypt::hash("hello").unwrap();
|
||||
assert!(is_password_hash(&s512), "sha512_crypt not detected: {s512}");
|
||||
assert!(sha512_crypt::verify("hello", &s512));
|
||||
|
||||
let s1 = sha1_crypt::hash("hello").unwrap();
|
||||
assert!(is_password_hash(&s1), "sha1_crypt not detected: {s1}");
|
||||
assert!(sha1_crypt::verify("hello", &s1));
|
||||
|
||||
let bsdi = "_J9..K0AyUubDkQmPLeM";
|
||||
assert!(is_password_hash(bsdi), "bsdi_crypt not detected: {bsdi}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_password_hash_detects_ldap_schemes() {
|
||||
let mut h = Sha1::new();
|
||||
h.update(b"hello");
|
||||
let sha = b64(&h.finalize()[..]);
|
||||
assert!(is_password_hash(&format!("{{SHA}}{sha}")));
|
||||
|
||||
let mut h = Sha1::new();
|
||||
h.update(b"hello");
|
||||
h.update(b"saltbytes");
|
||||
let mut buf = h.finalize().to_vec();
|
||||
buf.extend_from_slice(b"saltbytes");
|
||||
let ssha = b64(&buf);
|
||||
assert!(is_password_hash(&format!("{{SSHA}}{ssha}")));
|
||||
|
||||
let mut h = Sha256::new();
|
||||
h.update(b"hello");
|
||||
let sha256 = b64(&h.finalize()[..]);
|
||||
assert!(is_password_hash(&format!("{{SHA256}}{sha256}")));
|
||||
|
||||
let mut h = Sha256::new();
|
||||
h.update(b"hello");
|
||||
h.update(b"saltbytes");
|
||||
let mut buf = h.finalize().to_vec();
|
||||
buf.extend_from_slice(b"saltbytes");
|
||||
let ssha256 = b64(&buf);
|
||||
assert!(is_password_hash(&format!("{{SSHA256}}{ssha256}")));
|
||||
|
||||
let mut h = Sha512::new();
|
||||
h.update(b"hello");
|
||||
let sha512 = b64(&h.finalize()[..]);
|
||||
assert!(is_password_hash(&format!("{{SHA512}}{sha512}")));
|
||||
|
||||
let mut h = Sha512::new();
|
||||
h.update(b"hello");
|
||||
h.update(b"saltbytes");
|
||||
let mut buf = h.finalize().to_vec();
|
||||
buf.extend_from_slice(b"saltbytes");
|
||||
let ssha512 = b64(&buf);
|
||||
assert!(is_password_hash(&format!("{{SSHA512}}{ssha512}")));
|
||||
|
||||
let digest = md5::compute(b"hello");
|
||||
let md5b = b64(&digest[..]);
|
||||
assert!(is_password_hash(&format!("{{MD5}}{md5b}")));
|
||||
|
||||
let inner = sha512_crypt::hash("hello").unwrap();
|
||||
assert!(is_password_hash(&format!("{{CRYPT}}{inner}")));
|
||||
assert!(is_password_hash(&format!("{{crypt}}{inner}")));
|
||||
|
||||
assert!(is_password_hash(
|
||||
"{CRYPT}$1$5pZSV9va$azfrPr6af3Fc7dLblQXVa0"
|
||||
));
|
||||
assert!(is_password_hash("{CRYPT}abcdefghij012"));
|
||||
assert!(is_password_hash("{CRYPT}_J9..K0AyUubDkQmPLeM"));
|
||||
|
||||
let a = Argon2::default()
|
||||
.hash_password(b"hello")
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(is_password_hash(&format!("{{ARGON2ID}}{a}")));
|
||||
assert!(is_password_hash(&format!("{{ARGON2}}{a}")));
|
||||
assert!(is_password_hash(&format!("{{ARGON2I}}{a}")));
|
||||
|
||||
let p = Pbkdf2::default()
|
||||
.hash_password(b"hello")
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(is_password_hash(&format!("{{PBKDF2}}{p}")));
|
||||
|
||||
let mut h = Sha1::new();
|
||||
h.update(b"hello");
|
||||
let sha_lc = b64(&h.finalize()[..]);
|
||||
assert!(is_password_hash(&format!("{{sha}}{sha_lc}")));
|
||||
|
||||
let mut h = Sha256::new();
|
||||
h.update(b"hello");
|
||||
h.update(b"saltbytes");
|
||||
let mut buf = h.finalize().to_vec();
|
||||
buf.extend_from_slice(b"saltbytes");
|
||||
let ssha256_lc = b64(&buf);
|
||||
assert!(is_password_hash(&format!("{{ssha256}}{ssha256_lc}")));
|
||||
|
||||
let digest = md5::compute(b"hello");
|
||||
let md5_mc = b64(&digest[..]);
|
||||
assert!(is_password_hash(&format!("{{Md5}}{md5_mc}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_password_hash_rejects_passwords() {
|
||||
let not_hashes = [
|
||||
"",
|
||||
"hello",
|
||||
"p@ssw0rd!",
|
||||
"password123",
|
||||
"correct horse battery staple",
|
||||
"$myPassword",
|
||||
"$1incomplete",
|
||||
"$1$",
|
||||
"$1$short",
|
||||
"$1$abc$tooshorthash",
|
||||
"$5$",
|
||||
"$5$nohashpart$",
|
||||
"$5$saltonly$alsotooshort",
|
||||
"$6$",
|
||||
"$$$",
|
||||
"$$argon2$",
|
||||
"$argon2id$broken",
|
||||
"$argon2id$v=19$bad",
|
||||
"$2",
|
||||
"$2y$",
|
||||
"$2y$10$short",
|
||||
"$2z$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
|
||||
"$sha1$",
|
||||
"$sha1$notdigits$salt$hash",
|
||||
"{",
|
||||
"{}",
|
||||
"{}foo",
|
||||
"{SHA}",
|
||||
"{SHA}not!valid!base!64",
|
||||
"{SHA}aGVsbG8=",
|
||||
"{MD5}",
|
||||
"{MD5}aGVsbG8=",
|
||||
"{SHA256}aGVsbG8=",
|
||||
"{SHA512}aGVsbG8=",
|
||||
"{SSHA}aGVsbG8=",
|
||||
"{UNKNOWN}whatever",
|
||||
"{PLAIN}stillplain",
|
||||
"{plain}stillplain",
|
||||
"{CLEAR}stillplain",
|
||||
"{clear}stillplain",
|
||||
"{CRYPT}plainpw",
|
||||
"{CRYPT}",
|
||||
"{CRYPT}toolongtobeunixcryptbutshortbsdi",
|
||||
"{ARGON2ID}notaphcstring",
|
||||
"_short",
|
||||
"_notvalidbsdi",
|
||||
"regular_password",
|
||||
"1234567890123",
|
||||
"abcdefghij012",
|
||||
"$5$rounds=$saltvalue$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJK",
|
||||
];
|
||||
for p in not_hashes {
|
||||
assert!(!is_password_hash(p), "false positive: {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user