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:
2026-09-18 10:21:56 -07:00
commit 7dae9b29fd
1650 changed files with 485521 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
[package]
name = "directory"
version = "0.16.22"
edition = "2024"
[dependencies]
utils = { path = "../utils" }
store = { path = "../store" }
trc = { path = "../trc" }
registry = { path = "../registry" }
mail-parser = { version = "0.11" }
mail-builder = { version = "1.0" }
tokio = { version = "1.53", features = ["net"] }
ldap3 = { version = "0.12", default-features = false, features = ["tls-rustls-aws-lc-rs"] }
deadpool = { version = "0.13", features = ["managed", "rt_tokio_1"] }
ahash = { version = "0.8" }
pwhash = "1"
argon2 = "0.6.0"
pbkdf2 = { version = "0.13.0", features = ["phc"] }
scrypt = { version = "0.12.0", features = ["phc"] }
sha1 = "0.11"
sha2 = "0.11"
md5 = "0.8.1"
serde = { version = "1.0", features = ["derive"]}
totp-rs = { version = "6.0.0", features = ["otpauth"] }
reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"] }
serde_json = "1.0"
base64 = "0.23"
nohash-hasher = "0.2.0"
jsonwebtoken = { version = "11.0.0", features = ["aws_lc_rs"] }
[dev-dependencies]
tokio = { version = "1.53", features = ["full"] }
[features]
test_mode = []
enterprise = []
mysql = []
postgres = []
sqlite = []
[lints]
workspace = true
+186
View File
@@ -0,0 +1,186 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{Bind, LdapConnectionManager, LdapDirectory, LdapFilter, LdapFilterItem, LdapMappings};
use crate::Directory;
use deadpool::{Runtime, managed::Pool};
use ldap3::LdapConnSettings;
use registry::schema::structs;
impl LdapDirectory {
pub async fn open(config: structs::LdapDirectory) -> Result<Directory, String> {
let bind_dn = if let Some(dn) = config.bind_dn {
Bind::new(
dn,
config
.bind_secret
.secret()
.await?
.map(|v| v.into_owned())
.ok_or_else(|| {
"LDAP bind password is required when bind DN is set".to_string()
})?,
)
.into()
} else {
None
};
let manager = LdapConnectionManager::new(
config.url,
LdapConnSettings::new()
.set_conn_timeout(config.timeout.into_inner())
.set_starttls(config.use_tls)
.set_no_tls_verify(config.allow_invalid_certs),
bind_dn,
);
let mut mappings = LdapMappings {
base_dn: config.base_dn,
filter_login: LdapFilter::new(&config.filter_login)?,
filter_mailbox: LdapFilter::new(&config.filter_mailbox)?,
filter_member_of: if let Some(filter) = config.filter_member_of {
Some(LdapFilter::new(&filter)?)
} else {
None
},
attr_class: config
.attr_class
.into_inner()
.into_iter()
.map(|a| a.to_lowercase())
.collect(),
attr_groups: config
.attr_member_of
.into_inner()
.into_iter()
.map(|a| a.to_lowercase())
.collect(),
attr_description: config
.attr_description
.into_inner()
.into_iter()
.map(|a| a.to_lowercase())
.collect(),
attr_secret: config
.attr_secret
.into_inner()
.into_iter()
.map(|a| a.to_lowercase())
.collect(),
attr_secret_changed: config
.attr_secret_changed
.into_inner()
.into_iter()
.map(|a| a.to_lowercase())
.collect(),
attr_email: config
.attr_email
.into_inner()
.into_iter()
.map(|a| a.to_lowercase())
.collect(),
attr_email_alias: config
.attr_email_alias
.into_inner()
.into_iter()
.map(|a| a.to_lowercase())
.collect(),
group_class: config.group_class,
attrs_principal: vec![],
};
let mut attrs_principal: Vec<String> = Vec::new();
for attrs in [
&mappings.attr_description,
&mappings.attr_secret,
&mappings.attr_secret_changed,
&mappings.attr_groups,
&mappings.attr_email_alias,
&mappings.attr_email,
&mappings.attr_class,
] {
for attr in attrs.iter().filter(|a| !a.is_empty()) {
if !attrs_principal.contains(attr) {
attrs_principal.push(attr.clone());
}
}
}
mappings.attrs_principal = attrs_principal;
let pool = Pool::builder(manager)
.runtime(Runtime::Tokio1)
.max_size(config.pool_max_connections as usize)
.create_timeout(config.pool_timeout_create.into_inner().into())
.wait_timeout(config.pool_timeout_wait.into_inner().into())
.recycle_timeout(config.pool_timeout_recycle.into_inner().into())
.build()
.map_err(|err| format!("Failed to build LDAP pool: {err}"))?;
Ok(Directory::Ldap(LdapDirectory {
mappings,
pool,
auth_bind: config.bind_authentication,
}))
}
}
impl LdapFilter {
pub(super) fn new(value: &str) -> Result<Self, String> {
let mut filter = Vec::new();
let mut token = String::new();
let mut value = value.chars();
while let Some(ch) = value.next() {
match ch {
'?' => {
// For backwards compatibility, we treat '?' as a placeholder for the full value.
if !token.is_empty() {
filter.push(LdapFilterItem::Static(token));
token = String::new();
}
filter.push(LdapFilterItem::Full);
}
'{' => {
if !token.is_empty() {
filter.push(LdapFilterItem::Static(token));
token = String::new();
}
for ch in value.by_ref() {
if ch == '}' {
break;
} else {
token.push(ch);
}
}
match token.as_str() {
"user" | "username" | "email" => filter.push(LdapFilterItem::Full),
"local" => filter.push(LdapFilterItem::LocalPart),
"domain" => filter.push(LdapFilterItem::DomainPart),
_ => {
return Err(format!("Unknown LDAP filter placeholder: {}", token));
}
}
token.clear();
}
_ => token.push(ch),
}
}
if !token.is_empty() {
filter.push(LdapFilterItem::Static(token));
}
if filter.len() >= 2 {
Ok(LdapFilter { filter })
} else {
Err(format!(
"Missing parameter placeholders in value {:?}",
value
))
}
}
}
+307
View File
@@ -0,0 +1,307 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{LdapDirectory, LdapMappings};
use crate::{Account, Credentials, Group, IntoError, Recipient, core::secret::verify_secret_hash};
use ldap3::{Ldap, LdapConnAsync, ResultEntry, Scope, SearchEntry};
use store::xxhash_rust;
use utils::sanitize_email;
impl LdapDirectory {
pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result<Account> {
let (username, secret) = match credentials {
Credentials::Basic {
username, secret, ..
} => (username, secret),
Credentials::Bearer { token, .. } => (token, token),
};
if secret.is_empty() {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Empty secret rejected"));
}
let mut conn = self.pool.get().await.map_err(|err| err.into_error())?;
let mut result = if self.auth_bind {
let filter = self.mappings.filter_login.build(username);
if let Some(mut result) = self.find_object(&mut conn, &filter).await? {
// Perform bind auth using the found dn
let (auth_bind_conn, mut ldap) = LdapConnAsync::with_settings(
self.pool.manager().settings.clone(),
&self.pool.manager().address,
)
.await
.map_err(|err| err.into_error().caused_by(trc::location!()))?;
ldap3::drive!(auth_bind_conn);
if ldap
.simple_bind(&result.dn, secret)
.await
.map_err(|err| err.into_error().caused_by(trc::location!()))?
.success()
.is_ok()
{
if result.account.email.is_empty() {
result.account.email =
sanitize_email(username).unwrap_or_else(|| username.to_lowercase());
}
result
} else {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Secret rejected during auth bind using lookup filter")
.details(vec![result.dn, filter]));
}
} else {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Auth bind lookup filter yielded no results")
.details(vec![filter]));
}
} else {
let filter = self.mappings.filter_login.build(username);
if let Some(mut result) = self.find_object(&mut conn, &filter).await? {
if let Some(account_secret) = &result.account.secret {
if !verify_secret_hash(account_secret, secret.as_bytes()).await? {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Invalid credentials")
.details(vec![filter]));
}
} else {
return Err(trc::AuthEvent::Error
.into_err()
.details("Account does not have a secret")
.details(vec![filter]));
}
if result.account.email.is_empty() {
result.account.email =
sanitize_email(username).unwrap_or_else(|| username.to_lowercase());
}
result
} else {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Authentication filter yielded no results")
.details(vec![filter]));
}
};
self.add_group_membership(&mut conn, &mut result).await?;
Ok(result.account)
}
pub async fn recipient(&self, address: &str) -> trc::Result<Recipient> {
let mut conn = self.pool.get().await.map_err(|err| err.into_error())?;
let filter = self.mappings.filter_mailbox.build(address);
if let Some(mut result) = self.find_object(&mut conn, &filter).await? {
if !result.is_group {
self.add_group_membership(&mut conn, &mut result).await?;
Ok(Recipient::Account(result.account))
} else {
Ok(Recipient::Group(Group {
email: result.account.email,
email_aliases: result.account.email_aliases,
description: result.account.description,
}))
}
} else {
trc::event!(
Store(trc::StoreEvent::LdapWarning),
Reason = "Mailbox filter yielded no results",
Details = filter
);
Ok(Recipient::Invalid)
}
}
async fn add_group_membership(
&self,
conn: &mut Ldap,
result: &mut LdapResult,
) -> trc::Result<()> {
if let Some(group_dns) = result.account.groups.take() {
let mut groups = Vec::new();
for name in group_dns.into_iter().filter(|name| name.contains('=')) {
let (rs, _res) = conn
.search(
&name,
Scope::Base,
"objectClass=*",
&self.mappings.attr_email,
)
.await
.map_err(|err| err.into_error().caused_by(trc::location!()))?
.success()
.map_err(|err| err.into_error().caused_by(trc::location!()))?;
for entry in rs {
'outer: for (attr, value) in SearchEntry::construct(entry).attrs {
if self.mappings.attr_email.contains(&attr.to_lowercase())
&& let Some(email) =
value.first().map(|s| s.as_str()).and_then(sanitize_email)
{
groups.push(email);
break 'outer;
}
}
}
}
result.account.groups = if groups.is_empty() {
None
} else {
Some(groups)
};
} else if let Some(filter) = &self.mappings.filter_member_of {
let filter = filter.build(&result.dn);
let rs = conn
.search(
&self.mappings.base_dn,
Scope::Subtree,
&filter,
&self.mappings.attr_email,
)
.await
.map_err(|err| err.into_error().caused_by(trc::location!()))?
.success()
.map_err(|err| err.into_error().caused_by(trc::location!()))?
.0;
let had_entries = !rs.is_empty();
let mut groups = Vec::new();
for entry in rs {
for (attr, value) in SearchEntry::construct(entry).attrs {
if self.mappings.attr_email.contains(&attr.to_lowercase()) {
groups.extend(value.into_iter().filter_map(|v| {
sanitize_email(&v).or_else(|| {
trc::event!(
Store(trc::StoreEvent::LdapWarning),
Reason = "Group entry missing valid email attribute",
Details = v
);
None
})
}));
}
}
}
result.account.groups = if had_entries && groups.is_empty() {
None
} else {
Some(groups)
};
} else {
result.account.groups = (!self.mappings.attr_groups.is_empty()).then(Vec::new);
}
Ok(())
}
}
impl LdapDirectory {
async fn find_object(&self, conn: &mut Ldap, filter: &str) -> trc::Result<Option<LdapResult>> {
conn.search(
&self.mappings.base_dn,
Scope::Subtree,
filter,
&self.mappings.attrs_principal,
)
.await
.map_err(|err| err.into_error().caused_by(trc::location!()))?
.success()
.map(|(rs, _)| {
trc::event!(
Store(trc::StoreEvent::LdapQuery),
Details = filter.to_string(),
Result = rs.first().map(result_to_trace).unwrap_or_default()
);
rs.into_iter()
.next()
.map(|entry| self.mappings.map_entry(SearchEntry::construct(entry)))
})
.map_err(|err| err.into_error().caused_by(trc::location!()))
}
}
struct LdapResult {
dn: String,
account: Account,
is_group: bool,
}
impl LdapMappings {
fn map_entry(&self, entry: SearchEntry) -> LdapResult {
let mut account = Account::default();
let mut is_group = false;
for (attr, value) in entry.attrs {
let attr = attr.to_lowercase();
let is_email = self.attr_email.contains(&attr);
let is_email_alias = self.attr_email_alias.contains(&attr);
if is_email || is_email_alias {
let mut values = value.into_iter().filter_map(|v| sanitize_email(&v));
if is_email
&& account.email.is_empty()
&& let Some(email) = values.next()
{
account.email = email;
}
if is_email_alias {
account.email_aliases.extend(values);
}
} else if self.attr_secret.contains(&attr) {
account.secret = value.into_iter().find(|secret| !secret.is_empty());
} else if self.attr_secret_changed.contains(&attr) {
// Create a disabled AppPassword, used to indicate that the password has been changed
// but cannot be used for authentication.
if account.secret.is_none() {
account.secret = value.into_iter().find(|item| !item.is_empty()).map(|item| {
format!("$app${}$", xxhash_rust::xxh3::xxh3_64(item.as_bytes()))
});
}
} else if let Some(idx) = self.attr_description.iter().position(|a| a == &attr) {
if (account.description.is_none() || idx == 0)
&& let Some(desc) = value.into_iter().find(|desc| !desc.is_empty())
{
account.description = Some(desc);
}
} else if self.attr_groups.contains(&attr) {
account.groups.get_or_insert_default().extend(value);
} else if self.attr_class.contains(&attr) {
for value in value {
is_group |= value.eq_ignore_ascii_case(&self.group_class);
}
}
}
if !account.email_aliases.is_empty() {
let mut aliases = Vec::with_capacity(account.email_aliases.len());
for alias in std::mem::take(&mut account.email_aliases) {
if alias != account.email && !aliases.contains(&alias) {
aliases.push(alias);
}
}
account.email_aliases = aliases;
}
LdapResult {
dn: entry.dn,
account,
is_group,
}
}
}
fn result_to_trace(rs: &ResultEntry) -> trc::Value {
let se = SearchEntry::construct(rs.clone());
se.attrs
.into_iter()
.map(|(k, v)| trc::Value::Array(vec![trc::Value::from(k), trc::Value::from(v.join(", "))]))
.chain([trc::Value::from(se.dn)])
.collect::<Vec<_>>()
.into()
}
+125
View File
@@ -0,0 +1,125 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use deadpool::managed::Pool;
use ldap3::{LdapConnSettings, ldap_escape};
pub mod config;
pub mod lookup;
pub mod pool;
pub struct LdapDirectory {
pool: Pool<LdapConnectionManager>,
mappings: LdapMappings,
auth_bind: bool,
}
#[derive(Debug, Default)]
pub struct LdapMappings {
base_dn: String,
filter_login: LdapFilter,
filter_mailbox: LdapFilter,
filter_member_of: Option<LdapFilter>,
attr_class: Vec<String>,
attr_groups: Vec<String>,
attr_description: Vec<String>,
attr_secret: Vec<String>,
attr_secret_changed: Vec<String>,
attr_email: Vec<String>,
attr_email_alias: Vec<String>,
attrs_principal: Vec<String>,
group_class: String,
}
#[derive(Debug, Default)]
pub(crate) struct LdapFilter {
filter: Vec<LdapFilterItem>,
}
#[derive(Debug)]
enum LdapFilterItem {
Static(String),
Full,
LocalPart,
DomainPart,
}
impl LdapFilter {
pub fn build(&self, value: &str) -> String {
let mut result = String::with_capacity(value.len() + 16);
for item in &self.filter {
match item {
LdapFilterItem::Static(s) => result.push_str(s),
LdapFilterItem::Full => result.push_str(ldap_escape(value).as_ref()),
LdapFilterItem::LocalPart => {
result.push_str(
ldap_escape(
value
.rsplit_once('@')
.map(|(local, _)| local)
.unwrap_or(value),
)
.as_ref(),
);
}
LdapFilterItem::DomainPart => {
if let Some((_, domain)) = value.rsplit_once('@') {
result.push_str(ldap_escape(domain).as_ref());
}
}
}
}
result
}
}
pub(crate) struct LdapConnectionManager {
address: String,
settings: LdapConnSettings,
bind_dn: Option<Bind>,
}
pub(crate) struct Bind {
dn: String,
password: String,
}
impl LdapConnectionManager {
pub fn new(address: String, settings: LdapConnSettings, bind_dn: Option<Bind>) -> Self {
Self {
address,
settings,
bind_dn,
}
}
}
impl Bind {
pub fn new(dn: String, password: String) -> Self {
Self { dn, password }
}
}
#[cfg(test)]
mod tests {
use super::LdapFilter;
#[test]
fn filter_placeholders_are_escaped() {
let filter = LdapFilter::new("(&(uid={local})(dc={domain})(mail=?))").unwrap();
assert_eq!(
filter.build("*)(uid=*@ex)(dc=*"),
"(&(uid=\\2a\\29\\28uid=\\2a)(dc=ex\\29\\28dc=\\2a)(mail=\\2a\\29\\28uid=\\2a@ex\\29\\28dc=\\2a))"
);
assert_eq!(
filter.build("[email protected]"),
"(&(uid=john)(dc=example.com)([email protected]))"
);
}
}
+40
View File
@@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::LdapConnectionManager;
use deadpool::managed;
use ldap3::{Ldap, LdapConnAsync, LdapError, exop::WhoAmI};
impl managed::Manager for LdapConnectionManager {
type Type = Ldap;
type Error = LdapError;
async fn create(&self) -> Result<Ldap, LdapError> {
let (conn, mut ldap) =
LdapConnAsync::with_settings(self.settings.clone(), &self.address).await?;
ldap3::drive!(conn);
if let Some(bind) = &self.bind_dn {
ldap.simple_bind(&bind.dn, &bind.password)
.await?
.success()?;
}
Ok(ldap)
}
async fn recycle(
&self,
conn: &mut Ldap,
_: &managed::Metrics,
) -> managed::RecycleResult<LdapError> {
conn.extended(WhoAmI)
.await
.map(|_| ())
.map_err(managed::RecycleError::Backend)
}
}
+9
View File
@@ -0,0 +1,9 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod ldap;
pub mod oidc;
pub mod sql;
+177
View File
@@ -0,0 +1,177 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::Directory;
use crate::backend::oidc::lookup::fetch_jwks_keys;
use crate::backend::oidc::{
CachedKey, DiscoveryDocument, JwksCache, OidcConfig, OidcDiscovery, OidcError, OpenIdDirectory,
};
use ahash::AHashMap;
use registry::schema::structs;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use trc::AuthEvent;
use utils::Client;
const DISCOVERY_RETRY_FOR: Duration = Duration::from_secs(30);
const DISCOVERY_RETRY_INTERVAL: Duration = Duration::from_secs(3);
impl OpenIdDirectory {
pub async fn open(config: structs::OidcDirectory) -> Result<Directory, String> {
Self::new(OidcConfig {
issue_url: config.issuer_url,
require_aud: config.require_audience,
require_scopes: config.require_scopes.into_inner(),
claim_email: config.claim_username,
claim_name: config.claim_name,
claim_groups: config.claim_groups,
default_domain: config.username_domain,
})
.await
.map(Directory::OpenId)
.map_err(|err| err.to_string())
}
pub async fn new(config: OidcConfig) -> Result<Self, OidcError> {
let http = utils::http::http_client_builder(false)
.user_agent("Stalwart/1.0")
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| OidcError::Network(format!("HTTP client build failed: {e}")))?;
let started_at = Instant::now();
let (document, keys) = loop {
match Self::discover(&http, &config).await {
Ok(discovery) => break discovery,
Err(err) if err.is_transient() && started_at.elapsed() < DISCOVERY_RETRY_FOR => {
trc::event!(
Auth(AuthEvent::Warning),
Url = config.issue_url.to_string(),
Reason = format!(
"{err}, retrying in {} seconds",
DISCOVERY_RETRY_INTERVAL.as_secs()
)
);
tokio::time::sleep(DISCOVERY_RETRY_INTERVAL).await;
}
Err(err) => return Err(err),
}
};
Ok(Self {
discovery: OidcDiscovery {
url: config.issue_url.clone(),
document,
},
config,
http,
cache: RwLock::new(JwksCache {
keys,
last_updated: Instant::now(),
}),
})
}
async fn discover(
http: &Client,
config: &OidcConfig,
) -> Result<(DiscoveryDocument, AHashMap<String, Arc<CachedKey>>), OidcError> {
let discovery_url = format!(
"{}/.well-known/openid-configuration",
config.issue_url.trim_end_matches('/')
);
let discovery_bytes = http
.get(&discovery_url)
.send()
.await
.map_err(|e| OidcError::Network(format!("Discovery fetch failed: {e}")))?
.error_for_status()
.map_err(|e| OidcError::Provider(format!("Discovery HTTP error: {e}")))?
.bytes()
.await
.map_err(|e| OidcError::Provider(format!("Discovery HTTP error: {e}")))?;
let discovery: DiscoveryDocument = serde_json::from_slice(&discovery_bytes)
.map_err(|e| OidcError::Provider(format!("Discovery JSON parse error: {e}")))?;
let normalised_issue = config.issue_url.trim_end_matches('/');
let normalised_issuer = discovery.issuer.trim_end_matches('/');
if normalised_issuer != normalised_issue {
return Err(OidcError::Config(format!(
"Issuer mismatch: discovery document says '{}' but configured issue_url is '{}'",
discovery.issuer, config.issue_url,
)));
}
if let Some(supported) = &discovery.scopes_supported {
for scope in &config.require_scopes {
if !supported.contains(scope) {
trc::event!(
Auth(AuthEvent::Warning),
Url = config.issue_url.to_string(),
Reason = format!(
"Required scope '{}' is not in scopes_supported from the IdP",
scope
)
);
}
}
}
if let Some(supported) = &discovery.claims_supported {
let check = |name: &str, label: &str| {
if !supported.iter().any(|c| c == name) {
trc::event!(
Auth(AuthEvent::Warning),
Url = config.issue_url.to_string(),
Reason = format!(
"Configured {} claim '{}' is not in claims_supported from the IdP",
label, name
)
);
}
};
check(&config.claim_email, "claim_email");
if let Some(n) = &config.claim_name {
check(n, "claim_name");
}
if let Some(g) = &config.claim_groups {
check(g, "claim_groups");
}
}
/*{
let cache = Arc::clone(&cache);
let http = http.clone();
let jwks_uri = discovery.jwks_uri.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(24 * 3600));
interval.tick().await;
loop {
interval.tick().await;
match fetch_jwks_keys(&http, &jwks_uri).await {
Ok(new_keys) => {
let mut guard = cache.write().await;
guard.keys = new_keys;
guard.last_updated = Instant::now();
}
Err(e) => {
trc::event!(
Auth(AuthEvent::Warning),
Url = jwks_uri.to_string(),
Reason = format!("Background JWKS refresh failed: {e}")
);
}
}
}
});
}*/
let keys = fetch_jwks_keys(http, &discovery.jwks_uri).await?;
Ok((discovery, keys))
}
}
+464
View File
@@ -0,0 +1,464 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
Account, Credentials,
backend::oidc::{CachedKey, OidcError, OpenIdDirectory},
};
use ahash::AHashMap;
use jsonwebtoken::{
Algorithm, DecodingKey, Header, Validation, decode, decode_header,
jwk::{self, JwkSet},
};
use reqwest::Client;
use serde_json::Value;
use std::time::Instant;
use std::{sync::Arc, time::Duration};
use trc::AuthEvent;
impl OpenIdDirectory {
pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result<Account> {
match credentials {
Credentials::Bearer { token, .. } if token.is_empty() => {
Err(AuthEvent::Failed.into_err().reason("Empty token rejected"))
}
Credentials::Bearer { token, .. } => if let Ok(header) = decode_header(token) {
self.authenticate_jwt(token, header).await
} else {
#[cfg(feature = "test_mode")]
let token = token.strip_prefix(".").unwrap_or(token);
self.authenticate_opaque(token).await
}
.map_err(|err| match err {
OidcError::AuthorizationFailed(reason) => {
AuthEvent::Failed.into_err().reason(reason)
}
err => AuthEvent::Error.into_err().reason(err),
}),
_ => Err(AuthEvent::Error
.into_err()
.reason("Unsupported credentials type for OIDC backend")),
}
}
async fn authenticate_jwt(&self, token: &str, header: Header) -> Result<Account, OidcError> {
if matches!(
header.alg,
Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512
) {
return Err(OidcError::TokenValidation(
"Unsupported algorithm".to_string(),
));
}
let candidates = self.get_key(header.kid.as_deref()).await?;
let mut last_err = None;
for cached in &candidates {
let dk = &cached.decoding_key;
let alg = cached.algorithm;
let mut validation = Validation::new(alg);
if let Some(aud) = &self.config.require_aud {
validation.set_audience(&[aud]);
} else {
validation.validate_aud = false;
}
validation.set_issuer(&[&self.discovery.document.issuer]);
validation.leeway = 60;
match decode::<serde_json::Value>(token, dk, &validation) {
Ok(token_data) => {
if self.config.require_aud.is_some() && token_data.claims.get("aud").is_none() {
last_err = Some(jsonwebtoken::errors::Error::from(
jsonwebtoken::errors::ErrorKind::InvalidAudience,
));
continue;
}
self.validate_scopes(&token_data.claims)?;
let mut claims = token_data.claims;
let jwt_email = self.resolve_email(&claims).ok();
let missing_profile =
is_claim_missing(&claims, self.config.claim_name.as_ref())
|| is_claim_missing(&claims, self.config.claim_groups.as_ref());
if jwt_email.is_none() || missing_profile {
match self.fetch_userinfo(token).await {
Ok(userinfo) => {
if let (Some(base), Value::Object(extra)) =
(claims.as_object_mut(), userinfo)
{
for (key, value) in extra {
if base.get(&key).is_none_or(Value::is_null) {
base.insert(key, value);
}
}
}
}
Err(err) if jwt_email.is_none() => return Err(err),
Err(_) => {}
}
}
let email = match jwt_email {
Some(email) => email,
None => self.resolve_email(&claims)?,
};
return self.build_account(email, &claims);
}
Err(e) => {
last_err = Some(e);
}
}
}
Err(OidcError::TokenValidation(format!(
"JWT validation failed: {}",
last_err.map(|e| e.to_string()).unwrap_or_default()
)))
}
async fn authenticate_opaque(&self, token: &str) -> Result<Account, OidcError> {
let claims = self.fetch_userinfo(token).await?;
self.build_account(self.resolve_email(&claims)?, &claims)
}
async fn get_key(&self, kid: Option<&str>) -> Result<Vec<Arc<CachedKey>>, OidcError> {
{
let guard = self.cache.read().await;
if let Some(kid) = kid {
if let Some(cached) = guard.keys.get(kid) {
return Ok(vec![cached.clone()]);
}
if guard.last_updated.elapsed() < Duration::from_secs(300) {
return Err(OidcError::TokenValidation("Unknown key id".to_string()));
}
} else {
let all: Vec<_> = guard.keys.values().cloned().collect();
if !all.is_empty() {
return Ok(all);
}
}
}
let new_keys = fetch_jwks_keys(&self.http, &self.discovery.document.jwks_uri).await?;
{
let mut guard = self.cache.write().await;
guard.keys = new_keys;
guard.last_updated = Instant::now();
}
let guard = self.cache.read().await;
if let Some(kid) = kid {
if let Some(cached) = guard.keys.get(kid) {
Ok(vec![cached.clone()])
} else {
Err(OidcError::TokenValidation(
"Unknown key id after refresh".to_string(),
))
}
} else {
let all: Vec<_> = guard.keys.values().cloned().collect();
if all.is_empty() {
Err(OidcError::Provider(
"JWKS contains no usable keys".to_string(),
))
} else {
Ok(all)
}
}
}
async fn fetch_userinfo(&self, token: &str) -> Result<serde_json::Value, OidcError> {
let resp = self
.http
.get(&self.discovery.document.userinfo_endpoint)
.bearer_auth(token)
.send()
.await
.map_err(|e| OidcError::Network(format!("UserInfo request failed: {e}")))?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
let reason = resp.text().await.unwrap_or_default();
return Err(OidcError::AuthorizationFailed(format!(
"Token rejected by UserInfo endpoint with status {status}: {reason}"
)));
}
if !status.is_success() {
return Err(OidcError::Provider(format!(
"UserInfo returned HTTP {status}"
)));
}
let bytes = resp
.bytes()
.await
.map_err(|e| OidcError::Provider(format!("UserInfo HTTP error: {e}")))?;
serde_json::from_slice::<serde_json::Value>(&bytes)
.map_err(|e| OidcError::Provider(format!("UserInfo JSON parse error: {e}")))
}
fn validate_scopes(&self, claims: &serde_json::Value) -> Result<(), OidcError> {
if !self.config.require_scopes.is_empty() {
let token_scopes = extract_scopes(claims);
for required in &self.config.require_scopes {
if !token_scopes.iter().any(|s| s == required) {
return Err(OidcError::AuthorizationFailed(format!(
"Missing required scope '{required}', present scopes: {token_scopes:?}"
)));
}
}
}
Ok(())
}
fn build_account(
&self,
email: String,
claims: &serde_json::Value,
) -> Result<Account, OidcError> {
Ok(Account {
email,
email_aliases: Vec::new(),
secret: None,
groups: self
.config
.claim_groups
.as_ref()
.and_then(|groups_claim| claims.get(groups_claim))
.and_then(extract_string_list)
.map(|groups| {
groups
.into_iter()
.map(|group| match &self.config.default_domain {
Some(domain) if !group.contains('@') => format!("{group}@{domain}"),
_ => group,
})
.collect()
}),
description: self
.config
.claim_name
.as_ref()
.and_then(|name_claim| claims.get(name_claim))
.and_then(|v| v.as_str())
.filter(|name| !name.is_empty())
.map(|s| s.to_string()),
})
}
fn resolve_email(&self, claims: &serde_json::Value) -> Result<String, OidcError> {
if let Some(val) = claims
.get(&self.config.claim_email)
.and_then(|v| v.as_str())
{
if val.contains('@') {
return Ok(val.to_string());
}
if let Some(domain) = &self.config.default_domain {
return Ok(format!("{val}@{domain}"));
}
}
if self.config.claim_email != "email"
&& let Some(val) = claims.get("email").and_then(|v| v.as_str())
&& val.contains('@')
{
return Ok(val.to_string());
}
Err(OidcError::AuthorizationFailed(
"Could not determine a valid email address for account".to_string(),
))
}
}
pub(super) async fn fetch_jwks_keys(
http: &Client,
jwks_uri: &str,
) -> Result<AHashMap<String, Arc<CachedKey>>, OidcError> {
let jwks_bytes = http
.get(jwks_uri)
.send()
.await
.map_err(|e| OidcError::Network(format!("JWKS fetch failed: {e}")))?
.error_for_status()
.map_err(|e| OidcError::Provider(format!("JWKS HTTP error: {e}")))?
.bytes()
.await
.map_err(|e| OidcError::Provider(format!("JWKS HTTP error: {e}")))?;
let jwks: JwkSet = serde_json::from_slice(&jwks_bytes)
.map_err(|e| OidcError::Provider(format!("JWKS JSON parse error: {e}")))?;
let mut map = AHashMap::new();
let mut synthetic_id: u64 = 0;
for key in &jwks.keys {
if let Some(pk_use) = &key.common.public_key_use
&& pk_use != &jwk::PublicKeyUse::Signature
{
continue;
}
let algorithm = match &key.algorithm {
jwk::AlgorithmParameters::RSA(_) => match key.common.key_algorithm {
Some(jwk::KeyAlgorithm::RS256) => Algorithm::RS256,
Some(jwk::KeyAlgorithm::RS384) => Algorithm::RS384,
Some(jwk::KeyAlgorithm::RS512) => Algorithm::RS512,
Some(jwk::KeyAlgorithm::PS256) => Algorithm::PS256,
Some(jwk::KeyAlgorithm::PS384) => Algorithm::PS384,
Some(jwk::KeyAlgorithm::PS512) => Algorithm::PS512,
None => Algorithm::RS256,
Some(other) => {
trc::event!(
Auth(AuthEvent::Warning),
Url = jwks_uri.to_string(),
Reason = format!("Unsupported RSA key algorithm {:?}", other)
);
continue;
}
},
jwk::AlgorithmParameters::EllipticCurve(ec) => match ec.curve {
jwk::EllipticCurve::P256 => Algorithm::ES256,
jwk::EllipticCurve::P384 => Algorithm::ES384,
_ => {
trc::event!(
Auth(AuthEvent::Warning),
Url = jwks_uri.to_string(),
Reason = format!("Unsupported EC curve {:?}", ec.curve)
);
continue;
}
},
jwk::AlgorithmParameters::OctetKeyPair(_) => Algorithm::EdDSA,
jwk::AlgorithmParameters::OctetKey(_) => {
trc::event!(
Auth(AuthEvent::Warning),
Url = jwks_uri.to_string(),
Reason = format!(
"Symmetric (HMAC) key found in JWKS (kid={:?}), skipping — HMAC is not accepted",
key.common.key_id
)
);
continue;
}
_ => {
trc::event!(
Auth(AuthEvent::Warning),
Url = jwks_uri.to_string(),
Reason = format!(
"Unrecognised key type in JWKS (kid={:?}), skipping",
key.common.key_id
)
);
continue;
}
};
let decoding_key = match DecodingKey::from_jwk(key) {
Ok(decoding_key) => decoding_key,
Err(e) => {
trc::event!(
Auth(AuthEvent::Warning),
Url = jwks_uri.to_string(),
Reason = format!(
"Failed to build DecodingKey from JWK (kid={:?}): {e}",
key.common.key_id
)
);
continue;
}
};
map.insert(
match &key.common.key_id {
Some(id) => id.clone(),
None => {
let id = format!("_synthetic_{synthetic_id}");
synthetic_id += 1;
id
}
},
CachedKey {
decoding_key,
algorithm,
}
.into(),
);
}
Ok(map)
}
fn extract_scopes(claims: &serde_json::Value) -> Vec<String> {
match claims.get("scope") {
Some(serde_json::Value::String(s)) => s.split_whitespace().map(|s| s.to_string()).collect(),
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
_ => Vec::new(),
}
}
fn extract_string_list(value: &serde_json::Value) -> Option<Vec<String>> {
match value {
serde_json::Value::Array(arr) => Some(
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
),
serde_json::Value::String(s) => Some(s.split_whitespace().map(|s| s.to_string()).collect()),
_ => None,
}
}
#[inline(always)]
fn is_claim_missing(claims: &serde_json::Value, claim: Option<&String>) -> bool {
claim.is_some_and(|claim| claims.get(claim).is_none_or(serde_json::Value::is_null))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn extract_string_list_distinguishes_absent_from_empty() {
assert_eq!(
extract_string_list(&json!(["sales", "support"])),
Some(vec!["sales".to_string(), "support".to_string()])
);
assert_eq!(
extract_string_list(&json!("sales support")),
Some(vec!["sales".to_string(), "support".to_string()])
);
assert_eq!(extract_string_list(&json!([])), Some(vec![]));
assert_eq!(extract_string_list(&json!("")), Some(vec![]));
assert_eq!(extract_string_list(&json!(null)), None);
assert_eq!(extract_string_list(&json!({"groups": []})), None);
assert_eq!(extract_string_list(&json!(42)), None);
}
#[test]
fn is_claim_missing_treats_null_as_absent() {
let claims = json!({"groups": null, "name": "John Doe", "roles": []});
assert!(is_claim_missing(&claims, Some(&"groups".to_string())));
assert!(is_claim_missing(&claims, Some(&"unknown".to_string())));
assert!(!is_claim_missing(&claims, Some(&"name".to_string())));
assert!(!is_claim_missing(&claims, Some(&"roles".to_string())));
assert!(!is_claim_missing(&claims, None));
}
}
+93
View File
@@ -0,0 +1,93 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use ahash::AHashMap;
use jsonwebtoken::{Algorithm, DecodingKey};
use serde::{Deserialize, Serialize};
use std::{fmt, sync::Arc, time::Instant};
use tokio::sync::RwLock;
use utils::Client;
pub mod config;
pub mod lookup;
pub struct OidcConfig {
pub issue_url: String,
pub require_aud: Option<String>,
pub require_scopes: Vec<String>,
pub claim_email: String,
pub claim_name: Option<String>,
pub claim_groups: Option<String>,
pub default_domain: Option<String>,
}
pub struct OidcDiscovery {
pub url: String,
pub document: DiscoveryDocument,
}
#[derive(Deserialize, Serialize)]
pub struct DiscoveryDocument {
pub issuer: String,
pub jwks_uri: String,
pub userinfo_endpoint: String,
pub token_endpoint: String,
pub authorization_endpoint: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_session_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scopes_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub claims_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_challenge_methods_supported: Option<Vec<String>>,
}
struct CachedKey {
decoding_key: DecodingKey,
algorithm: Algorithm,
}
struct JwksCache {
keys: AHashMap<String, Arc<CachedKey>>,
last_updated: Instant,
}
pub struct OpenIdDirectory {
config: OidcConfig,
pub discovery: OidcDiscovery,
http: Client,
cache: RwLock<JwksCache>,
}
#[derive(Debug)]
pub enum OidcError {
TokenValidation(String),
AuthorizationFailed(String),
Network(String),
Provider(String),
Config(String),
}
impl OidcError {
pub fn is_transient(&self) -> bool {
matches!(self, OidcError::Network(_) | OidcError::Provider(_))
}
}
impl fmt::Display for OidcError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OidcError::TokenValidation(msg) => write!(f, "Token validation error: {msg}"),
OidcError::AuthorizationFailed(msg) => write!(f, "Authorization failed: {msg}"),
OidcError::Network(msg) => write!(f, "Network error: {msg}"),
OidcError::Provider(msg) => write!(f, "Provider error: {msg}"),
OidcError::Config(msg) => write!(f, "Configuration error: {msg}"),
}
}
}
impl std::error::Error for OidcError {}
@@ -0,0 +1,68 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{SqlDirectory, SqlMappings};
use crate::Directory;
use registry::schema::structs;
use store::Store;
#[allow(unreachable_patterns)]
impl SqlDirectory {
pub async fn open(
config: structs::SqlDirectory,
data_store: &Store,
) -> Result<Directory, String> {
let sql_store = match config.store {
#[cfg(feature = "postgres")]
structs::SqlAuthStore::PostgreSql(store) => {
store::backend::postgres::PostgresStore::open(store).await?
}
#[cfg(feature = "mysql")]
structs::SqlAuthStore::MySql(store) => {
store::backend::mysql::MysqlStore::open(store).await?
}
#[cfg(feature = "sqlite")]
structs::SqlAuthStore::Sqlite(store) => {
store::backend::sqlite::SqliteStore::open(store)?
}
structs::SqlAuthStore::Default => {
if data_store.is_sql() {
data_store.clone()
} else {
return Err(concat!(
"This directory is set to store accounts in the main data store, ",
"but the configured data store is not an SQL database. ",
"Either select an SQL data store or configure a separate SQL ",
"database for this directory."
)
.to_string());
}
}
_ => {
return Err(
"Binary not compiled with support for the selected SQL directory backend."
.to_string(),
);
}
};
let mappings = SqlMappings {
query_login: config.query_login,
query_recipient: config.query_recipient,
query_member_of: config.query_member_of,
query_email_aliases: config.query_email_aliases,
column_email: config.column_email,
column_secret: config.column_secret,
column_type: config.column_class,
column_description: config.column_description,
};
Ok(Directory::Sql(SqlDirectory {
sql_store,
mappings,
}))
}
}
+201
View File
@@ -0,0 +1,201 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{SqlDirectory, SqlMappings};
use crate::{Account, Credentials, Recipient, core::secret::verify_secret_hash};
use store::{NamedRows, Rows, Value};
use trc::AddContext;
use utils::sanitize_email;
impl SqlDirectory {
pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result<Account> {
let (username, secret) = match credentials {
Credentials::Basic {
username, secret, ..
} => (username, secret),
Credentials::Bearer { .. } => {
return Err(trc::AuthEvent::Error
.into_err()
.details("Unsupported credentials type for SQL authentication"));
}
};
if secret.is_empty() {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Empty secret rejected")
.ctx(trc::Key::AccountName, username.to_string()));
}
let Recipient::Account(mut account) = self.mappings.row_to_account(
self.sql_store
.sql_query::<NamedRows>(&self.mappings.query_login, vec![username.into()])
.await
.caused_by(trc::location!())?,
) else {
return Err(trc::AuthEvent::Failed
.into_err()
.details("SQL login query did not return an account")
.ctx(trc::Key::AccountName, username.to_string()));
};
// Validate secret
if let Some(account_secret) = &account.secret {
if !verify_secret_hash(account_secret, secret.as_bytes()).await? {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Invalid credentials")
.ctx(trc::Key::AccountName, username.to_string()));
}
} else {
return Err(trc::AuthEvent::Error
.into_err()
.details("Account does not have a secret")
.ctx(trc::Key::AccountName, username.to_string()));
}
// Obtain members
if let Some(query) = &self.mappings.query_member_of {
let members = account.groups.get_or_insert_default();
for row in self
.sql_store
.sql_query::<Rows>(query, vec![username.into()])
.await
.caused_by(trc::location!())?
.rows
{
if let Some(Value::Text(address)) = row.values.first()
&& let Some(email) = sanitize_email(address)
{
members.push(email);
}
}
}
// Obtain emails
if let Some(query) = &self.mappings.query_email_aliases {
account.email_aliases.extend(
self.sql_store
.sql_query::<Rows>(query, vec![username.into()])
.await
.caused_by(trc::location!())?
.rows
.into_iter()
.flat_map(|v| {
v.values
.into_iter()
.filter_map(|v| sanitize_email(v.to_str().as_ref()))
}),
);
}
if account.email.is_empty() {
account.email = sanitize_email(username).unwrap_or_else(|| username.to_lowercase());
}
Ok(account)
}
pub async fn recipient(&self, address: &str) -> trc::Result<Recipient> {
let recipient = self.mappings.row_to_account(
self.sql_store
.sql_query::<NamedRows>(&self.mappings.query_recipient, vec![address.into()])
.await
.caused_by(trc::location!())?,
);
match recipient {
Recipient::Account(mut account) => {
// Obtain members
if let Some(query) = &self.mappings.query_member_of {
let members = account.groups.get_or_insert_default();
for row in self
.sql_store
.sql_query::<Rows>(query, vec![account.email.as_str().into()])
.await
.caused_by(trc::location!())?
.rows
{
if let Some(Value::Text(address)) = row.values.first()
&& let Some(email) = sanitize_email(address)
{
members.push(email);
}
}
}
// Obtain emails
if let Some(query) = &self.mappings.query_email_aliases {
account.email_aliases.extend(
self.sql_store
.sql_query::<Rows>(query, vec![account.email.as_str().into()])
.await
.caused_by(trc::location!())?
.rows
.into_iter()
.flat_map(|v| {
v.values
.into_iter()
.filter_map(|v| sanitize_email(v.to_str().as_ref()))
}),
);
}
Ok(Recipient::Account(account))
}
Recipient::Group(group) => Ok(Recipient::Group(group)),
Recipient::Invalid => Ok(Recipient::Invalid),
}
}
}
impl SqlMappings {
pub fn row_to_account(&self, rows: NamedRows) -> Recipient {
if rows.rows.is_empty() {
return Recipient::Invalid;
}
let mut account = Account::default();
let mut is_group = false;
if let Some(row) = rows.rows.into_iter().next() {
for (name, value) in rows.names.into_iter().zip(row.values) {
if name.eq_ignore_ascii_case(&self.column_email) {
if let Value::Text(text) = value
&& let Some(email) = sanitize_email(&text)
{
account.email = email;
}
} else if name.eq_ignore_ascii_case(&self.column_secret) {
if let Value::Text(text) = value
&& !text.is_empty()
{
account.secret = Some(text.into_owned());
}
} else if let Some(column_type) = &self.column_type
&& name.eq_ignore_ascii_case(column_type)
{
is_group = value.to_str().eq_ignore_ascii_case("group");
} else if let Some(column_description) = &self.column_description
&& name.eq_ignore_ascii_case(column_description)
&& let Value::Text(text) = value
&& !text.is_empty()
{
account.description = Some(text.into_owned());
}
}
}
if !is_group {
Recipient::Account(account)
} else {
Recipient::Group(crate::Group {
email: account.email,
email_aliases: account.email_aliases,
description: account.description,
})
}
}
}
+27
View File
@@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use store::Store;
pub mod config;
pub mod lookup;
pub struct SqlDirectory {
sql_store: Store,
mappings: SqlMappings,
}
#[derive(Debug, Default)]
pub(crate) struct SqlMappings {
query_login: String,
query_recipient: String,
query_member_of: Option<String>,
query_email_aliases: Option<String>,
column_email: String,
column_secret: String,
column_type: Option<String>,
column_description: Option<String>,
}
+64
View File
@@ -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,
}
}
}
+54
View File
@@ -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,
}
}
}
+10
View File
@@ -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;
+179
View File
@@ -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())
))
);
}
}
+647
View File
@@ -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:?}");
}
}
}
+126
View File
@@ -0,0 +1,126 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
#![warn(clippy::large_futures)]
use crate::backend::oidc::OpenIdDirectory;
use backend::{ldap::LdapDirectory, sql::SqlDirectory};
use deadpool::managed::PoolError;
use ldap3::LdapError;
use registry::schema::enums::DirectoryType;
use std::{collections::HashMap, fmt::Debug, sync::Arc};
pub mod backend;
pub mod core;
#[derive(Debug, PartialEq, Eq, Clone, Hash)]
pub enum Credentials {
Basic {
username: String,
secret: String,
mfa_token: Option<String>,
},
Bearer {
username: Option<String>,
token: String,
},
}
#[allow(clippy::large_enum_variant)]
pub enum Directory {
Ldap(LdapDirectory),
Sql(SqlDirectory),
OpenId(OpenIdDirectory),
Unavailable(UnavailableDirectory),
}
pub struct UnavailableDirectory {
directory_type: DirectoryType,
error: String,
}
impl UnavailableDirectory {
pub fn new(directory_type: DirectoryType, error: impl Into<String>) -> Self {
Self {
directory_type,
error: error.into(),
}
}
pub fn directory_type(&self) -> DirectoryType {
self.directory_type
}
pub fn error(&self) -> trc::Error {
trc::StoreEvent::NotConfigured
.into_err()
.details("Directory is unavailable because it failed to initialize")
.reason(&self.error)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Recipient {
Account(Account),
Group(Group),
Invalid,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Account {
pub email: String,
pub email_aliases: Vec<String>,
pub secret: Option<String>,
pub groups: Option<Vec<String>>,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Group {
pub email: String,
pub email_aliases: Vec<String>,
pub description: Option<String>,
}
#[derive(Clone, Debug)]
pub struct Directories {
pub default_directory: Option<Arc<Directory>>,
pub directories: HashMap<u32, Arc<Directory>, nohash_hasher::BuildNoHashHasher<u32>>,
}
impl Debug for Directory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Directory").finish()
}
}
trait IntoError {
fn into_error(self) -> trc::Error;
}
impl IntoError for PoolError<LdapError> {
fn into_error(self) -> trc::Error {
match self {
PoolError::Backend(error) => error.into_error(),
PoolError::Timeout(_) => trc::StoreEvent::PoolError
.into_err()
.details("Connection timed out"),
err => trc::StoreEvent::PoolError.reason(err),
}
}
}
impl IntoError for LdapError {
fn into_error(self) -> trc::Error {
if let LdapError::LdapResult { result } = &self {
trc::StoreEvent::LdapError
.ctx(trc::Key::Code, result.rc)
.reason(self)
} else {
trc::StoreEvent::LdapError.reason(self)
}
}
}