TOTP 2FA, App passwords and account disable support (closes #436 closes #479)

This commit is contained in:
mdecimus 2024-06-28 12:12:51 +02:00
parent 0693253dff
commit d8a73cd0e4
30 changed files with 250 additions and 85 deletions

114
Cargo.lock generated
View file

@ -480,6 +480,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base32"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa"
[[package]]
name = "base64"
version = "0.11.0"
@ -632,7 +638,7 @@ dependencies = [
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
"constant_time_eq 0.3.0",
]
[[package]]
@ -1116,6 +1122,12 @@ version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6051f239ecec86fde3410901ab7860d458d160371533842974fc61f96d15879b"
[[package]]
name = "constant_time_eq"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a53c0a4d288377e7415b53dcfc3c04da5cdc2cc95c8d5ac178b58f0b861ad6"
[[package]]
name = "constant_time_eq"
version = "0.3.0"
@ -1401,11 +1413,12 @@ dependencies = [
[[package]]
name = "dashmap"
version = "5.5.3"
version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
checksum = "804c8821570c3f8b70230c2ba75ffa5c0f9a4189b9a432b6656c536712acae28"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
@ -1427,18 +1440,6 @@ dependencies = [
"generic-array 0.14.7",
]
[[package]]
name = "deadpool"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb84100978c1c7b37f09ed3ce3e5f843af02c2a2c431bae5b19230dad2c1b490"
dependencies = [
"async-trait",
"deadpool-runtime",
"num_cpus",
"tokio",
]
[[package]]
name = "deadpool"
version = "0.12.1"
@ -1457,7 +1458,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ab8a4ea925ce79678034870834602a2980f4b88c09e97feb266496dbb4493d2"
dependencies = [
"async-trait",
"deadpool 0.12.1",
"deadpool",
"getrandom",
"tokio",
"tokio-postgres",
@ -1617,8 +1618,7 @@ version = "0.8.2"
dependencies = [
"ahash 0.8.11",
"argon2",
"async-trait",
"deadpool 0.10.0",
"deadpool",
"futures",
"jmap_proto",
"ldap3",
@ -1642,6 +1642,7 @@ dependencies = [
"store",
"tokio",
"tokio-rustls 0.25.0",
"totp-rs",
"tracing",
"utils",
]
@ -3158,7 +3159,7 @@ dependencies = [
"nlp",
"p256",
"pkcs8",
"quick-xml 0.31.0",
"quick-xml 0.34.0",
"rand",
"rasn",
"rasn-cms",
@ -3176,9 +3177,9 @@ dependencies = [
"smtp-proto",
"store",
"tokio",
"tokio-tungstenite",
"tokio-tungstenite 0.23.1",
"tracing",
"tungstenite",
"tungstenite 0.23.0",
"utils",
"x509-parser 0.16.0",
]
@ -3202,7 +3203,7 @@ dependencies = [
"serde",
"serde_json",
"tokio",
"tokio-tungstenite",
"tokio-tungstenite 0.21.0",
]
[[package]]
@ -4645,15 +4646,6 @@ version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-xml"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.32.0"
@ -4664,6 +4656,15 @@ dependencies = [
"serde",
]
[[package]]
name = "quick-xml"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f24d770aeca0eacb81ac29dfbc55ebcc09312fdd1f8bbecdc7e4a84e000e3b4"
dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.2"
@ -6131,7 +6132,7 @@ dependencies = [
"bitpacking",
"blake3",
"bytes",
"deadpool 0.12.1",
"deadpool",
"deadpool-postgres",
"elasticsearch",
"farmhash",
@ -6613,10 +6614,22 @@ dependencies = [
"rustls-pki-types",
"tokio",
"tokio-rustls 0.25.0",
"tungstenite",
"tungstenite 0.21.0",
"webpki-roots 0.26.3",
]
[[package]]
name = "tokio-tungstenite"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite 0.23.0",
]
[[package]]
name = "tokio-util"
version = "0.7.11"
@ -6674,6 +6687,21 @@ dependencies = [
"tracing",
]
[[package]]
name = "totp-rs"
version = "5.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c4ae9724c5888c0417d2396037ed3b60665925624766416e3e342b6ba5dbd3f"
dependencies = [
"base32",
"constant_time_eq 0.2.6",
"hmac 0.12.1",
"sha1",
"sha2 0.10.8",
"url",
"urlencoding",
]
[[package]]
name = "tower"
version = "0.4.13"
@ -6847,6 +6875,24 @@ dependencies = [
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http 1.1.0",
"httparse",
"log",
"rand",
"sha1",
"thiserror",
"utf-8",
]
[[package]]
name = "twofish"
version = "0.7.1"
@ -7700,7 +7746,7 @@ dependencies = [
"aes",
"arbitrary",
"bzip2",
"constant_time_eq",
"constant_time_eq 0.3.0",
"crc32fast",
"crossbeam-utils",
"deflate64",

View file

@ -119,7 +119,7 @@ All documentation is available at [stalw.art/docs/get-started](https://stalw.art
If you are having problems running Stalwart Mail Server, you found a bug or just have a question,
do not hesitate to reach us on [Github Discussions](https://github.com/stalwartlabs/mail-server/discussions),
[Reddit](https://www.reddit.com/r/stalwartlabs), [Discord](https://discord.gg/aVQr3jF8jd) or [Matrix](https://matrix.to/#/#stalwart:matrix.org).
Additionally you may become a sponsor to obtain priority support from Stalwart Labs Ltd.
Additionally you may purchase a subscription to obtain priority support from Stalwart Labs Ltd.
## Roadmap

View file

@ -2,7 +2,7 @@
name = "stalwart-cli"
description = "Stalwart Mail Server CLI"
authors = ["Stalwart Labs Ltd. <hello@stalw.art>"]
license = "AGPL-3.0-only"
license = "AGPL-3.0-only OR LicenseRef-SEL"
repository = "https://github.com/stalwartlabs/cli"
homepage = "https://github.com/stalwartlabs/cli"
version = "0.8.2"

View file

@ -66,9 +66,9 @@ impl AccountCommands {
));
}
if let Some(password) = password {
changes.push(PrincipalUpdate::set(
changes.push(PrincipalUpdate::add_item(
PrincipalField::Secrets,
PrincipalValue::StringList(vec![sha512_crypt::hash(password).unwrap()]),
PrincipalValue::String(sha512_crypt::hash(password).unwrap()),
));
}
if let Some(description) = description {

View file

@ -20,7 +20,9 @@ use config::{
storage::Storage,
tracers::{OtelTracer, Tracer, Tracers},
};
use directory::{core::secret::verify_secret_hash, Directory, Principal, QueryBy, Type};
use directory::{
core::secret::verify_secret_hash, Directory, DirectoryError, Principal, QueryBy, Type,
};
use expr::if_block::IfBlock;
use listener::{
blocked::{AllowedIps, BlockedIps},
@ -94,6 +96,7 @@ pub enum AuthResult<T> {
Success(T),
Failure,
Banned,
MissingTotp,
}
#[derive(Debug)]
@ -267,6 +270,7 @@ impl Core {
return Ok(AuthResult::Success(principal));
}
Ok(None) => Ok(()),
Err(DirectoryError::MissingTotpCode) => return Ok(AuthResult::MissingTotp),
Err(err) => Err(err),
};

View file

@ -17,9 +17,8 @@ tokio-rustls = { version = "0.25.0"}
rustls = "0.22"
rustls-pki-types = { version = "1" }
ldap3 = { version = "0.11.1", default-features = false, features = ["tls-rustls"] }
deadpool = { version = "0.10.0", features = ["managed", "rt_tokio_1"] }
deadpool = { version = "0.12", features = ["managed", "rt_tokio_1"] }
parking_lot = "0.12"
async-trait = "0.1.68"
ahash = { version = "0.8" }
tracing = "0.1"
lru-cache = "0.1.2"
@ -34,6 +33,7 @@ md5 = "0.7.0"
futures = "0.3"
regex = "1.7.0"
serde = { version = "1.0", features = ["derive"]}
totp-rs = { version = "5.5.1", features = ["otpauth"] }
[dev-dependencies]
tokio = { version = "1.23", features = ["full"] }

View file

@ -6,14 +6,12 @@
use std::sync::atomic::Ordering;
use async_trait::async_trait;
use deadpool::managed;
use tokio::net::TcpStream;
use tokio_rustls::client::TlsStream;
use super::{ImapClient, ImapConnectionManager, ImapError};
#[async_trait]
impl managed::Manager for ImapConnectionManager {
type Type = ImapClient<TlsStream<TcpStream>>;
type Error = ImapError;

View file

@ -59,7 +59,7 @@ impl DirectoryStore for Store {
.await?,
secret,
) {
(Some(mut principal), Some(secret)) if principal.verify_secret(secret).await => {
(Some(mut principal), Some(secret)) if principal.verify_secret(secret).await? => {
if return_member_of {
principal.member_of = self.get_member_of(principal.id).await?;
}

View file

@ -414,6 +414,35 @@ impl ManageDirectory for Store {
) => {
principal.inner.secrets = secrets;
}
(
PrincipalAction::AddItem,
PrincipalField::Secrets,
PrincipalValue::String(secret),
) => {
let mut do_add = true;
let mut new_secrets = Vec::with_capacity(principal.inner.secrets.len() + 1);
for prev_secret in principal.inner.secrets {
if prev_secret == secret {
do_add = false;
} else if prev_secret.starts_with("otpauth://")
|| prev_secret == "$disabled$"
|| prev_secret.starts_with("$app$")
{
new_secrets.push(prev_secret);
}
}
if do_add {
new_secrets.push(secret);
}
principal.inner.secrets = new_secrets;
}
(
PrincipalAction::RemoveItem,
PrincipalField::Secrets,
PrincipalValue::String(secret),
) => {
principal.inner.secrets.retain(|v| *v != secret);
}
(
PrincipalAction::Set,
PrincipalField::Description,

View file

@ -87,7 +87,7 @@ impl LdapDirectory {
.find_principal(&mut conn, &self.mappings.filter_name.build(username))
.await?
{
if principal.verify_secret(secret).await {
if principal.verify_secret(secret).await? {
principal
} else {
tracing::debug!(

View file

@ -4,13 +4,11 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use async_trait::async_trait;
use deadpool::managed;
use ldap3::{exop::WhoAmI, Ldap, LdapConnAsync, LdapError};
use super::LdapConnectionManager;
#[async_trait]
impl managed::Manager for LdapConnectionManager {
type Type = Ldap;
type Error = LdapError;

View file

@ -36,7 +36,7 @@ impl MemoryDirectory {
for principal in &self.principals {
if &principal.name == username {
return if principal.verify_secret(secret).await {
return if principal.verify_secret(secret).await? {
Ok(Some(principal.clone()))
} else {
Ok(None)

View file

@ -4,13 +4,11 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use async_trait::async_trait;
use deadpool::managed;
use mail_send::{smtp::AssertReply, Error};
use super::{SmtpClient, SmtpConnectionManager};
#[async_trait]
impl managed::Manager for SmtpConnectionManager {
type Type = SmtpClient;
type Error = Error;
@ -45,8 +43,10 @@ impl managed::Manager for SmtpConnectionManager {
.map(|_| ())
.map_err(managed::RecycleError::Backend)
} else {
Err(managed::RecycleError::StaticMessage(
"No longer valid: Too many authentication failures",
Err(managed::RecycleError::Message(
"No longer valid: Too many authentication failures"
.to_string()
.into(),
))
}
}

View file

@ -68,7 +68,7 @@ impl SqlDirectory {
// Validate password
if let Some(secret) = secret {
if !principal.verify_secret(secret).await {
if !principal.verify_secret(secret).await? {
tracing::debug!(
context = "directory",
event = "invalid_password",

View file

@ -16,17 +16,51 @@ use sha1::Sha1;
use sha2::Sha256;
use sha2::Sha512;
use tokio::sync::oneshot;
use totp_rs::TOTP;
use crate::DirectoryError;
use crate::Principal;
impl<T: serde::Serialize + serde::de::DeserializeOwned> Principal<T> {
pub async fn verify_secret(&self, secret: &str) -> bool {
for hashed_secret in &self.secrets {
if verify_secret_hash(hashed_secret, secret).await {
return true;
pub async fn verify_secret(&self, mut code: &str) -> crate::Result<bool> {
let mut totp_token = None;
for secret in &self.secrets {
let mut secret = secret.as_str();
if secret == "$disabled$" {
return Ok(false);
} else if secret.starts_with("otpauth://") && totp_token.is_none() {
let totp_token = if let Some(totp_token) = totp_token {
totp_token
} else {
let (_code, _totp_token) = code
.rsplit_once('$')
.filter(|(c, t)| !c.is_empty() && !t.is_empty())
.ok_or(DirectoryError::MissingTotpCode)?;
totp_token = Some(_totp_token);
code = _code;
_totp_token
};
if !TOTP::from_url(secret)
.map_err(DirectoryError::InvalidTotpUrl)?
.check_current(totp_token)
.unwrap_or(false)
{
return Ok(false);
}
} else if let Some((_, app_secret)) =
secret.strip_prefix("$app$").and_then(|s| s.split_once('$'))
{
secret = app_secret;
}
if verify_secret_hash(secret, code).await {
return Ok(true);
}
}
false
Ok(false)
}
}

View file

@ -23,6 +23,7 @@ use deadpool::managed::PoolError;
use ldap3::LdapError;
use mail_send::Credentials;
use store::Store;
use totp_rs::TotpUrlError;
pub mod backend;
pub mod core;
@ -81,6 +82,8 @@ pub enum DirectoryError {
Management(ManagementError),
TimedOut,
Unsupported,
InvalidTotpUrl(TotpUrlError),
MissingTotpCode,
}
#[derive(Debug, PartialEq, Eq)]
@ -309,6 +312,8 @@ impl Display for DirectoryError {
Self::Management(error) => write!(f, "Management error: {:?}", error),
Self::TimedOut => write!(f, "Directory timed out"),
Self::Unsupported => write!(f, "Method not supported by directory"),
Self::InvalidTotpUrl(error) => write!(f, "Invalid TOTP URL: {}", error),
Self::MissingTotpCode => write!(f, "Missing TOTP code"),
}
}
}

View file

@ -23,7 +23,7 @@ parking_lot = "0.12"
tracing = "0.1"
ahash = { version = "0.8" }
md5 = "0.7.0"
dashmap = "5.4"
dashmap = "6.0"
rand = "0.8.5"
[features]

View file

@ -101,6 +101,7 @@ impl<T: SessionStream> Session<T> {
}
// Authenticate
let mut is_totp_error = false;
let access_token = match credentials {
Credentials::Plain { username, secret } | Credentials::XOauth2 { username, secret } => {
match self
@ -110,6 +111,10 @@ impl<T: SessionStream> Session<T> {
{
AuthResult::Success(token) => Some(token),
AuthResult::Failure => None,
AuthResult::MissingTotp => {
is_totp_error = true;
None
}
AuthResult::Banned => return Err(()),
}
}
@ -174,10 +179,14 @@ impl<T: SessionStream> Session<T> {
Ok(())
} else {
self.write_bytes(
StatusResponse::no("Authentication failed")
.with_tag(tag)
.with_code(ResponseCode::AuthenticationFailed)
.into_bytes(),
StatusResponse::no(if is_totp_error {
"Missing TOTP code, try with 'secret$totp_code'."
} else {
"Authentication failed."
})
.with_tag(tag)
.with_code(ResponseCode::AuthenticationFailed)
.into_bytes(),
)
.await?;

View file

@ -39,10 +39,10 @@ hkdf = "0.12.3"
sha1 = "0.10"
sha2 = "0.10"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "http2"]}
tokio-tungstenite = "0.21"
tungstenite = "0.21"
tokio-tungstenite = "0.23"
tungstenite = "0.23"
chrono = "0.4"
dashmap = "5.4"
dashmap = "6.0"
aes = "0.8.3"
cbc = { version = "0.1.2", features = ["alloc"] }
sequoia-openpgp = { version = "1.16", default-features = false, features = ["crypto-rust", "allow-experimental-crypto", "allow-variable-time-crypto"] }
@ -56,7 +56,7 @@ async-trait = "0.1.68"
lz4_flex = { version = "0.11", default-features = false }
rev_lines = "0.3.0"
x509-parser = "0.16.0"
quick-xml = "0.31"
quick-xml = "0.34"
[features]
test_mode = []

View file

@ -221,7 +221,7 @@ fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
}
let mut reader = Reader::from_reader(bytes);
reader.trim_text(true);
reader.config_mut().trim_text(true);
let mut buf = Vec::with_capacity(128);
'outer: for tag_name in ["Autodiscover", "Request", "EMailAddress"] {

View file

@ -331,9 +331,9 @@ impl JMAP {
.data
.update_account(
QueryBy::Id(access_token.primary_id()),
vec![PrincipalUpdate::set(
vec![PrincipalUpdate::add_item(
PrincipalField::Secrets,
PrincipalValue::StringList(vec![new_password]),
PrincipalValue::String(new_password),
)],
)
.await

View file

@ -46,13 +46,22 @@ impl JMAP {
})
})
{
if let AuthResult::Success(access_token) = self
match self
.authenticate_plain(&account, &secret, remote_ip, ServerProtocol::Http)
.await
{
Some(access_token)
} else {
None
AuthResult::Success(access_token) => Some(access_token),
AuthResult::MissingTotp => {
return Err(RequestError::blank(
401,
"TOTP code required",
concat!(
"A TOTP code is required to authenticate this account. ",
"Try authenticating again using 'secret$totp_token'."
),
));
}
_ => None,
}
} else {
tracing::debug!(
@ -161,6 +170,7 @@ impl JMAP {
AuthResult::Failure
}
Ok(AuthResult::Banned) => AuthResult::Banned,
Ok(AuthResult::MissingTotp) => AuthResult::MissingTotp,
Err(_) => AuthResult::Failure,
}
}

View file

@ -6,7 +6,7 @@ repository = "https://github.com/stalwartlabs/jmap-server"
homepage = "https://stalw.art"
keywords = ["imap", "jmap", "smtp", "email", "mail", "server"]
categories = ["email"]
license = "AGPL-3.0-only"
license = "AGPL-3.0-only OR LicenseRef-SEL"
version = "0.8.2"
edition = "2021"
resolver = "2"

View file

@ -79,6 +79,7 @@ impl<T: SessionStream> Session<T> {
}
// Authenticate
let mut is_totp_error = false;
let access_token = match credentials {
Credentials::Plain { username, secret } | Credentials::XOauth2 { username, secret } => {
match self
@ -93,6 +94,10 @@ impl<T: SessionStream> Session<T> {
{
AuthResult::Success(token) => Some(token),
AuthResult::Failure => None,
AuthResult::MissingTotp => {
is_totp_error = true;
None
}
AuthResult::Banned => {
return Err(StatusResponse::bye(
"Too many authentication requests from this IP address.",
@ -156,7 +161,12 @@ impl<T: SessionStream> Session<T> {
self.state = State::NotAuthenticated {
auth_failures: auth_failures + 1,
};
Ok(StatusResponse::no("Authentication failed").into_bytes())
Ok(StatusResponse::no(if is_totp_error {
"Missing TOTP code, try with 'secret$totp_code'."
} else {
"Authentication failed."
})
.into_bytes())
}
_ => {
tracing::debug!(

View file

@ -83,6 +83,7 @@ impl<T: SessionStream> Session<T> {
}
// Authenticate
let mut is_totp_error = false;
let access_token = match credentials {
Credentials::Plain { username, secret } | Credentials::XOauth2 { username, secret } => {
match self
@ -92,6 +93,10 @@ impl<T: SessionStream> Session<T> {
{
AuthResult::Success(token) => Some(token),
AuthResult::Failure => None,
AuthResult::MissingTotp => {
is_totp_error = true;
None
}
AuthResult::Banned => {
self.write_err("Too many authentication requests from this IP address.")
.await?;
@ -164,7 +169,12 @@ impl<T: SessionStream> Session<T> {
auth_failures: auth_failures + 1,
username: username.clone(),
};
self.write_err("Authentication failed").await
self.write_err(if is_totp_error {
"Missing TOTP code, try with 'secret$totp_code'."
} else {
"Authentication failed."
})
.await
}
_ => {
tracing::debug!(

View file

@ -90,10 +90,8 @@ impl<T: SessionStream> Session<T> {
}
}
} else {
self.write_ok(format!(
"Stalwart POP3 bids you farewell (no messages deleted)."
))
.await?;
self.write_ok("Stalwart POP3 bids you farewell (no messages deleted).")
.await?;
}
} else {
self.write_ok("Stalwart POP3 bids you farewell.").await?;

View file

@ -6,7 +6,7 @@ repository = "https://github.com/stalwartlabs/smtp-server"
homepage = "https://stalw.art/smtp"
keywords = ["smtp", "email", "mail", "server"]
categories = ["email"]
license = "AGPL-3.0-only"
license = "AGPL-3.0-only OR LicenseRef-SEL"
version = "0.8.2"
edition = "2021"
resolver = "2"
@ -41,7 +41,7 @@ rayon = "1.5"
tracing = "0.1"
parking_lot = "0.12"
regex = "1.7.0"
dashmap = "5.4"
dashmap = "6.0"
blake3 = "1.3"
lru-cache = "0.1.2"
rand = "0.8.5"

View file

@ -217,6 +217,20 @@ impl<T: SessionStream> Session<T> {
return Err(());
}
Ok(AuthResult::MissingTotp) => {
tracing::debug!(
parent: &self.span,
context = "auth",
event = "authenticate",
result = "missing-totp"
);
return self
.auth_error(
b"334 5.7.8 Missing TOTP token, try with 'secret$totp_code'.\r\n",
)
.await;
}
_ => (),
}
} else {

View file

@ -15,7 +15,7 @@ tracing = "0.1"
mail-auth = { version = "0.4" }
smtp-proto = { version = "0.1" }
mail-send = { version = "0.4", default-features = false, features = ["cram-md5"] }
dashmap = "5.4"
dashmap = "6.0"
ahash = { version = "0.8" }
chrono = "0.4"
rand = "0.8.5"

View file

@ -55,7 +55,7 @@ hyper = { version = "1.0.1", features = ["server", "http1", "http2"] }
hyper-util = { version = "0.1.1", features = ["tokio"] }
http-body-util = "0.1.0"
base64 = "0.22"
dashmap = "5.4"
dashmap = "6.0"
ahash = { version = "0.8" }
serial_test = "3.0.0"
num_cpus = "1.15.0"