#![allow(clippy::disallowed_methods)]
#[derive(Debug, thiserror::Error)]
pub enum PasswordError {
#[error("hashing failed: {0}")]
Hash(String),
#[error("verification error: {0}")]
Verify(String),
#[error("password hashing is busy")]
Busy,
}
pub fn hash(password: &str) -> Result<String, PasswordError> {
use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
use argon2::Argon2;
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| PasswordError::Hash(e.to_string()))
}
pub fn verify(password: &str, stored_hash: &str) -> Result<bool, PasswordError> {
use argon2::password_hash::{PasswordHash, PasswordVerifier};
use argon2::Argon2;
let parsed =
PasswordHash::new(stored_hash).map_err(|e| PasswordError::Verify(e.to_string()))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok())
}
fn dummy_hash() -> &'static str {
use std::sync::OnceLock;
static DUMMY: OnceLock<String> = OnceLock::new();
DUMMY
.get_or_init(|| {
hash("rustango-timing-equalization-dummy")
.expect("argon2id hashing of a fixed dummy input cannot fail")
})
.as_str()
}
pub fn verify_dummy(password: &str) {
let _ = verify(password, dummy_hash());
}
pub async fn hash_async(password: &str) -> Result<String, PasswordError> {
let password = password.to_owned();
off_runtime(move || hash(&password)).await?
}
pub async fn verify_async(password: &str, stored_hash: &str) -> Result<bool, PasswordError> {
let (password, stored_hash) = (password.to_owned(), stored_hash.to_owned());
off_runtime(move || verify(&password, &stored_hash)).await?
}
pub async fn verify_dummy_async(password: &str) -> Result<(), PasswordError> {
let password = password.to_owned();
off_runtime(move || verify_dummy(&password)).await
}
pub const DEFAULT_HASH_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
static HASH_WAIT: crate::boot_slot::BootSlot<std::time::Duration> =
crate::boot_slot::BootSlot::new();
pub fn configure_hash_wait(wait: std::time::Duration) -> bool {
HASH_WAIT.set_explicit(wait)
}
#[cfg(feature = "config")]
pub(crate) fn configure_hash_wait_from_settings(wait: std::time::Duration) -> bool {
HASH_WAIT.set_from_settings(wait)
}
#[must_use]
pub fn hash_wait() -> std::time::Duration {
*HASH_WAIT.get(|| DEFAULT_HASH_WAIT)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HashLane {
Login,
#[cfg_attr(not(feature = "tenancy"), allow(dead_code))]
Credential,
}
pub(crate) struct HashQueue {
slots: std::sync::Arc<tokio::sync::Semaphore>,
credential: std::sync::Arc<tokio::sync::Semaphore>,
}
impl HashQueue {
pub(crate) fn new(slots: usize) -> Self {
let slots = slots.max(1);
Self {
slots: std::sync::Arc::new(tokio::sync::Semaphore::new(slots)),
credential: std::sync::Arc::new(tokio::sync::Semaphore::new((slots / 2).max(1))),
}
}
pub(crate) async fn run<T, F>(
&self,
lane: HashLane,
wait: std::time::Duration,
f: F,
) -> Result<T, PasswordError>
where
T: Send + 'static,
F: FnOnce() -> T + Send + 'static,
{
let deadline = tokio::time::Instant::now() + wait;
let acquire = |s: &std::sync::Arc<tokio::sync::Semaphore>| {
tokio::time::timeout_at(deadline, std::sync::Arc::clone(s).acquire_owned())
};
let lane_permit = match lane {
HashLane::Login => None,
HashLane::Credential => Some(
acquire(&self.credential)
.await
.map_err(|_| PasswordError::Busy)?
.expect("password semaphore is never closed"),
),
};
let permit = acquire(&self.slots)
.await
.map_err(|_| PasswordError::Busy)?
.expect("password semaphore is never closed");
Ok(tokio::task::spawn_blocking(move || {
let _permits = (permit, lane_permit);
f()
})
.await
.unwrap_or_else(|e| std::panic::resume_unwind(e.into_panic())))
}
}
pub(crate) async fn off_runtime<T, F>(f: F) -> Result<T, PasswordError>
where
T: Send + 'static,
F: FnOnce() -> T + Send + 'static,
{
off_runtime_in(HashLane::Login, f).await
}
pub(crate) async fn off_runtime_in<T, F>(lane: HashLane, f: F) -> Result<T, PasswordError>
where
T: Send + 'static,
F: FnOnce() -> T + Send + 'static,
{
static QUEUE: std::sync::OnceLock<HashQueue> = std::sync::OnceLock::new();
QUEUE
.get_or_init(|| {
HashQueue::new(
std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get),
)
})
.run(lane, hash_wait(), f)
.await
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StrengthIssue {
TooShort,
NoDigitsOrSymbols,
NoVariety,
KnownWeak,
}
#[must_use]
pub fn strength_score(password: &str) -> Vec<StrengthIssue> {
let mut issues = Vec::new();
if password.chars().count() < 12 {
issues.push(StrengthIssue::TooShort);
}
let has_digit = password.chars().any(|c| c.is_ascii_digit());
let has_symbol = password
.chars()
.any(|c| !c.is_alphanumeric() && !c.is_whitespace());
let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
if !has_digit && !has_symbol {
issues.push(StrengthIssue::NoDigitsOrSymbols);
}
if !has_digit && !has_symbol && !has_upper && has_lower {
issues.push(StrengthIssue::NoVariety);
}
let lower = password.to_ascii_lowercase();
if KNOWN_WEAK.iter().any(|&w| w == lower) {
issues.push(StrengthIssue::KnownWeak);
}
issues
}
const KNOWN_WEAK: &[&str] = &[
"password",
"password1",
"password123",
"12345678",
"123456789",
"qwerty",
"qwerty123",
"letmein",
"admin",
"admin123",
"welcome",
"welcome1",
"iloveyou",
"monkey",
"abc123",
"111111",
"000000",
"passw0rd",
];
#[cfg(test)]
pub(crate) async fn ticks_while<F: std::future::Future>(fut: F) -> (F::Output, usize) {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let ticks = Arc::new(AtomicUsize::new(0));
let t = Arc::clone(&ticks);
let ticker = tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
t.fetch_add(1, Ordering::Relaxed);
}
});
tokio::task::yield_now().await;
let before = ticks.load(Ordering::Relaxed);
let out = fut.await;
let n = ticks.load(Ordering::Relaxed) - before;
ticker.abort();
(out, n)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_and_verify_match() {
let h = hash("CorrectHorseBatteryStaple!42").unwrap();
assert!(verify("CorrectHorseBatteryStaple!42", &h).unwrap());
}
#[test]
fn verify_rejects_wrong_password() {
let h = hash("real-password").unwrap();
assert!(!verify("wrong-password", &h).unwrap());
}
#[test]
fn verify_invalid_hash_errors() {
let r = verify("anything", "not-a-valid-hash");
assert!(r.is_err());
}
#[test]
fn dummy_hash_is_valid_and_verify_dummy_does_real_work() {
assert!(!verify("whatever-an-attacker-types", dummy_hash()).unwrap());
verify_dummy("whatever-an-attacker-types");
}
#[tokio::test(flavor = "current_thread")]
async fn async_variants_do_not_block_the_runtime() {
let (h, n) = ticks_while(hash_async("correct horse battery staple")).await;
assert!(n >= 2, "hash_async stalled the runtime ({n} ticks)");
let h = h.unwrap();
let (ok, n) = ticks_while(verify_async("correct horse battery staple", &h)).await;
assert!(ok.unwrap());
assert!(n >= 2, "verify_async stalled the runtime ({n} ticks)");
let (r, n) = ticks_while(verify_dummy_async("nobody")).await;
assert!(r.is_ok());
assert!(n >= 2, "verify_dummy_async stalled the runtime ({n} ticks)");
}
#[tokio::test]
async fn off_runtime_resumes_the_original_panic() {
struct Marker;
let err = tokio::spawn(off_runtime(|| std::panic::panic_any(Marker)))
.await
.unwrap_err();
assert!(err.into_panic().downcast::<Marker>().is_ok());
}
#[tokio::test]
async fn a_full_queue_times_out_then_recovers() {
let wait = std::time::Duration::from_millis(20);
let q = HashQueue::new(1);
let (tx, rx) = std::sync::mpsc::channel::<()>();
let hold = tokio::spawn({
let slots = std::sync::Arc::clone(&q.slots);
async move {
let _p = slots.acquire_owned().await.unwrap();
tokio::task::spawn_blocking(move || rx.recv())
.await
.unwrap()
}
});
while q.slots.available_permits() > 0 {
tokio::task::yield_now().await;
}
let started = std::time::Instant::now();
let r = q.run(HashLane::Login, wait, || ()).await;
assert!(matches!(r, Err(PasswordError::Busy)));
assert!(started.elapsed() >= wait);
tx.send(()).unwrap();
hold.await.unwrap().unwrap();
assert!(q.run(HashLane::Login, wait, || 7).await.is_ok());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn credential_jobs_cannot_take_every_slot() {
let wait = std::time::Duration::from_millis(50);
let q = std::sync::Arc::new(HashQueue::new(4));
let (tx, rx) = std::sync::mpsc::channel::<()>();
let rx = std::sync::Arc::new(std::sync::Mutex::new(rx));
let hogs: Vec<_> = (0..6)
.map(|_| {
let (q, rx) = (std::sync::Arc::clone(&q), std::sync::Arc::clone(&rx));
tokio::spawn(async move {
q.run(
HashLane::Credential,
std::time::Duration::from_secs(5),
move || {
let _ = rx.lock().unwrap().recv();
},
)
.await
})
})
.collect();
while q.credential.available_permits() > 0 {
tokio::task::yield_now().await;
}
let r = q.run(HashLane::Credential, wait, || ()).await;
assert!(matches!(r, Err(PasswordError::Busy)), "share is capped");
assert!(q.run(HashLane::Login, wait, || 1).await.is_ok());
for _ in 0..6 {
tx.send(()).unwrap();
}
for h in hogs {
assert!(h.await.unwrap().is_ok());
}
}
#[test]
fn strong_password_has_no_issues() {
let issues = strength_score("Tr0ub4dor&3-CorrectBattery");
assert!(issues.is_empty(), "got issues: {:?}", issues);
}
#[test]
fn short_password_flagged() {
let issues = strength_score("aB3!");
assert!(issues.contains(&StrengthIssue::TooShort));
}
#[test]
fn all_letter_password_flagged() {
let issues = strength_score("abcdefghijklmnop");
assert!(issues.contains(&StrengthIssue::NoDigitsOrSymbols));
assert!(issues.contains(&StrengthIssue::NoVariety));
}
#[test]
fn mixed_case_no_digits_only_flags_no_digits() {
let issues = strength_score("ABCDEFGHIJKLMnop");
assert!(issues.contains(&StrengthIssue::NoDigitsOrSymbols));
assert!(!issues.contains(&StrengthIssue::NoVariety));
}
#[test]
fn known_weak_password_flagged() {
let issues = strength_score("password123");
assert!(issues.contains(&StrengthIssue::KnownWeak));
}
#[test]
fn known_weak_check_is_case_insensitive() {
let issues = strength_score("PASSWORD123");
assert!(issues.contains(&StrengthIssue::KnownWeak));
}
#[test]
fn long_password_with_digit_passes_length_and_variety_check() {
let issues = strength_score("ThisIsLongEnough1");
assert!(!issues.contains(&StrengthIssue::TooShort));
assert!(!issues.contains(&StrengthIssue::NoDigitsOrSymbols));
}
}