use std::fmt;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::auth::{PasswordHashError, PasswordHashString, PasswordHasher, verify_password};
const ABSENT_USER_PLAINTEXT: &[u8] = b"arcature/absent-user/not-a-password";
pub const CREDENTIAL_REJECTION: &str = "These credentials do not match our records.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CredentialOutcome {
Verified,
Rejected,
}
impl CredentialOutcome {
#[must_use]
pub fn is_verified(self) -> bool {
matches!(self, Self::Verified)
}
}
#[derive(Clone)]
#[non_exhaustive]
pub struct CredentialChecker {
hasher: PasswordHasher,
absent: PasswordHashString,
verifications: Arc<AtomicU64>,
}
impl CredentialChecker {
pub fn new(hasher: PasswordHasher) -> Result<Self, PasswordHashError> {
let absent = hasher.hash(ABSENT_USER_PLAINTEXT)?;
Ok(Self {
hasher,
absent,
verifications: Arc::new(AtomicU64::new(0)),
})
}
#[must_use]
pub fn check(
&self,
stored: Option<&PasswordHashString>,
presented: &[u8],
) -> CredentialOutcome {
let (hash, account_exists) = match stored {
Some(hash) => (hash, true),
None => (&self.absent, false),
};
self.verifications.fetch_add(1, Ordering::Relaxed);
let password_matched = verify_password(&self.hasher, presented, hash).is_ok();
if account_exists && password_matched {
CredentialOutcome::Verified
} else {
CredentialOutcome::Rejected
}
}
#[must_use]
pub fn verifications(&self) -> u64 {
self.verifications.load(Ordering::Relaxed)
}
}
impl fmt::Debug for CredentialChecker {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CredentialChecker")
.field("absent", &"<precomputed>")
.field("verifications", &self.verifications())
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::{CREDENTIAL_REJECTION, CredentialChecker, CredentialOutcome};
use crate::auth::{PasswordConfig, PasswordHasher};
fn hasher() -> PasswordHasher {
PasswordHasher::new(PasswordConfig::new(8, 1, 1)).expect("valid params")
}
fn checker() -> CredentialChecker {
CredentialChecker::new(hasher()).expect("absent-user hash")
}
#[test]
fn the_right_password_for_a_real_account_verifies() {
let stored = hasher().hash(b"correct horse").expect("hash");
assert_eq!(
checker().check(Some(&stored), b"correct horse"),
CredentialOutcome::Verified
);
}
#[test]
fn a_wrong_password_and_an_absent_account_give_the_same_answer() {
let checker = checker();
let stored = hasher().hash(b"correct horse").expect("hash");
assert_eq!(
checker.check(Some(&stored), b"wrong"),
checker.check(None, b"wrong"),
"the two failure paths must be one outcome"
);
assert_eq!(checker.check(None, b"wrong"), CredentialOutcome::Rejected);
}
#[test]
fn the_absent_account_branch_still_runs_the_hash() {
let checker = checker();
assert_eq!(checker.verifications(), 0);
let _ = checker.check(None, b"nobody home");
assert_eq!(
checker.verifications(),
1,
"an unknown address must still pay for one Argon2id verification"
);
let stored = hasher().hash(b"correct horse").expect("hash");
let _ = checker.check(Some(&stored), b"correct horse");
assert_eq!(checker.verifications(), 2);
}
#[test]
fn every_absent_account_attempt_pays_the_same_price() {
let checker = checker();
for attempt in 0..10 {
let _ = checker.check(None, format!("guess-{attempt}").as_bytes());
}
assert_eq!(checker.verifications(), 10);
}
#[test]
fn guessing_the_dummy_plaintext_is_not_a_login() {
let checker = checker();
assert_eq!(
checker.check(None, super::ABSENT_USER_PLAINTEXT),
CredentialOutcome::Rejected
);
}
#[test]
fn a_clone_shares_the_counter() {
let checker = checker();
let clone = checker.clone();
let _ = clone.check(None, b"x");
assert_eq!(checker.verifications(), 1);
}
#[test]
fn the_rejection_message_names_neither_half_of_the_form() {
let message = CREDENTIAL_REJECTION.to_lowercase();
assert!(!message.contains("email"), "{CREDENTIAL_REJECTION}");
assert!(!message.contains("password"), "{CREDENTIAL_REJECTION}");
assert!(!message.contains("account"), "{CREDENTIAL_REJECTION}");
}
#[test]
fn debug_does_not_print_the_absent_hash() {
let rendered = format!("{:?}", checker());
assert!(rendered.contains("<precomputed>"), "{rendered}");
assert!(!rendered.contains("$argon2id$"), "{rendered}");
}
}