use crate::authn::factor::{
EmailOtpConfig, FactorConfig, FactorCredential, FactorKind, HotpConfig, OtpAlgorithm,
PasswordConfig, RecoveryCodesConfig, TotpConfig,
};
use crate::blocking::{KdfError, KdfExecutor};
use crate::validation::{MAX_OTP_CODE_BYTES, MAX_PASSWORD_BYTES};
use axess_rng::SecureRng;
use std::sync::Arc;
pub(crate) const DEFAULT_MAX_OTP_ATTEMPTS: u8 = 5;
pub(crate) fn effective_max_attempts(configured: u8) -> u8 {
if configured == 0 {
DEFAULT_MAX_OTP_ATTEMPTS
} else {
configured
}
}
pub(crate) fn apply_hotp_failure(
prior: &crate::authn::factor::HotpConfig,
) -> crate::authn::factor::HotpConfig {
let mut next = prior.clone();
if prior.attempt_count >= effective_max_attempts(prior.max_attempts) {
next.counter = prior
.counter
.saturating_add(prior.lookahead_window as u64 + 1);
next.attempt_count = 0;
} else {
next.attempt_count = prior.attempt_count.saturating_add(1);
}
next
}
pub(crate) fn apply_email_otp_failure(prior: &EmailOtpConfig) -> EmailOtpConfig {
let mut next = prior.clone();
if prior.attempt_count >= effective_max_attempts(prior.max_attempts) {
next.pending_hash = None;
next.pending_until = None;
next.attempt_count = 0;
} else {
next.attempt_count = prior.attempt_count.saturating_add(1);
}
next
}
pub(crate) enum VerifyOutcome {
Fail,
FailWithUpdate(FactorConfig),
Pass,
PassWithUpdate(FactorConfig),
}
pub(crate) fn verify_credential(
credential: &FactorCredential,
config: &FactorConfig,
kind: &FactorKind,
now: chrono::DateTime<chrono::Utc>,
pepper: Option<&axess_factors::RecoveryCodePepper>,
) -> VerifyOutcome {
match (credential, config, kind) {
(FactorCredential::Password(pwd), FactorConfig::Password(cfg), FactorKind::Password) => {
verify_password_factor(pwd.as_ref(), cfg)
}
(FactorCredential::OtpCode(code), FactorConfig::Totp(cfg), FactorKind::Totp) => {
verify_totp_factor(code.as_ref(), cfg, now)
}
(FactorCredential::OtpCode(code), FactorConfig::Hotp(cfg), FactorKind::Hotp) => {
verify_hotp_factor(code.as_ref(), cfg)
}
(FactorCredential::OtpCode(code), FactorConfig::EmailOtp(cfg), FactorKind::EmailOtp) => {
verify_email_otp_factor(code.as_ref(), cfg, now)
}
(
FactorCredential::RecoveryCode(code),
FactorConfig::RecoveryCodes(cfg),
FactorKind::RecoveryCode,
) => verify_recovery_code_factor(code.as_ref(), cfg, now, pepper),
(FactorCredential::Fido2Assertion(_), FactorConfig::Fido2(_), FactorKind::Fido2) => {
VerifyOutcome::Fail
}
_ => VerifyOutcome::Fail,
}
}
fn verify_password_factor(pwd_str: &str, cfg: &PasswordConfig) -> VerifyOutcome {
if pwd_str.len() > MAX_PASSWORD_BYTES {
return VerifyOutcome::Fail;
}
let hash_str: &str = cfg.hash.as_ref();
if axess_factors::verify_password(pwd_str, hash_str).is_ok() {
VerifyOutcome::Pass
} else {
VerifyOutcome::Fail
}
}
fn verify_totp_factor(
code: &str,
cfg: &TotpConfig,
now: chrono::DateTime<chrono::Utc>,
) -> VerifyOutcome {
if code.len() > MAX_OTP_CODE_BYTES {
return VerifyOutcome::Fail;
}
let algorithm = match cfg.algorithm {
OtpAlgorithm::Sha1 => axess_factors::TotpAlgorithm::SHA1,
OtpAlgorithm::Sha256 => axess_factors::TotpAlgorithm::SHA256,
OtpAlgorithm::Sha512 => axess_factors::TotpAlgorithm::SHA512,
};
let matched = axess_factors::verify_totp(
cfg.secret.as_ref(),
code,
now,
axess_factors::TotpVerifyParams {
length: Some(cfg.digits as usize),
period: Some(cfg.period_secs as u64),
past_window: Some(cfg.past_window as u64),
future_window: Some(cfg.future_window as u64),
algorithm,
},
);
match matched {
None => VerifyOutcome::Fail,
Some(step) => {
if cfg.last_step.is_some_and(|ls| step <= ls) {
return VerifyOutcome::Fail;
}
let mut updated = cfg.clone();
updated.last_step = Some(step);
VerifyOutcome::PassWithUpdate(FactorConfig::Totp(updated))
}
}
}
fn verify_hotp_factor(code: &str, cfg: &HotpConfig) -> VerifyOutcome {
if code.len() > MAX_OTP_CODE_BYTES {
return VerifyOutcome::Fail;
}
if cfg.attempt_count >= effective_max_attempts(cfg.max_attempts) {
let mut burned = cfg.clone();
burned.counter = cfg.counter.saturating_add(cfg.lookahead_window as u64 + 1);
burned.attempt_count = 0;
tracing::warn!("HOTP attempt limit reached; burning lookahead window");
return VerifyOutcome::FailWithUpdate(FactorConfig::Hotp(burned));
}
let hotp_algo = match cfg.algorithm {
OtpAlgorithm::Sha1 => axess_factors::HotpAlgorithm::Sha1,
OtpAlgorithm::Sha256 => axess_factors::HotpAlgorithm::Sha256,
OtpAlgorithm::Sha512 => axess_factors::HotpAlgorithm::Sha512,
};
let matched = axess_factors::verify_hotp(
cfg.secret.as_ref(),
code,
cfg.counter,
cfg.digits as usize,
cfg.lookahead_window as u64,
hotp_algo,
);
match matched {
None => {
let mut failed = cfg.clone();
failed.attempt_count = cfg.attempt_count.saturating_add(1);
VerifyOutcome::FailWithUpdate(FactorConfig::Hotp(failed))
}
Some(counter) => {
let mut updated = cfg.clone();
updated.counter = counter.saturating_add(1);
updated.attempt_count = 0;
VerifyOutcome::PassWithUpdate(FactorConfig::Hotp(updated))
}
}
}
fn verify_email_otp_factor(
code: &str,
cfg: &EmailOtpConfig,
now: chrono::DateTime<chrono::Utc>,
) -> VerifyOutcome {
if code.len() > MAX_OTP_CODE_BYTES {
return VerifyOutcome::Fail;
}
if !code.bytes().all(|b| b.is_ascii_digit()) {
return VerifyOutcome::Fail;
}
let hash = match &cfg.pending_hash {
Some(h) => h,
None => return VerifyOutcome::Fail,
};
match cfg.pending_until {
Some(until) if now <= until => {}
_ => return VerifyOutcome::Fail,
}
if cfg.attempt_count >= effective_max_attempts(cfg.max_attempts) {
tracing::warn!("email OTP attempt limit exceeded, burning code");
let mut burned = cfg.clone();
burned.pending_hash = None;
burned.pending_until = None;
burned.attempt_count = 0;
return VerifyOutcome::FailWithUpdate(FactorConfig::EmailOtp(burned));
}
if axess_factors::verify_password(code, hash.as_ref()).is_err() {
let mut failed = cfg.clone();
failed.attempt_count = cfg.attempt_count.saturating_add(1);
return VerifyOutcome::FailWithUpdate(FactorConfig::EmailOtp(failed));
}
let mut updated = cfg.clone();
updated.pending_hash = None;
updated.pending_until = None;
updated.attempt_count = 0;
VerifyOutcome::PassWithUpdate(FactorConfig::EmailOtp(updated))
}
fn verify_recovery_code_factor(
code: &str,
cfg: &RecoveryCodesConfig,
now: chrono::DateTime<chrono::Utc>,
pepper: Option<&axess_factors::RecoveryCodePepper>,
) -> VerifyOutcome {
if code.len() > MAX_OTP_CODE_BYTES {
return VerifyOutcome::Fail;
}
match axess_factors::verify_recovery_code(cfg, code, now, pepper) {
Some(updated) => VerifyOutcome::PassWithUpdate(FactorConfig::RecoveryCodes(updated)),
None => VerifyOutcome::Fail,
}
}
fn runs_kdf(credential: &FactorCredential, config: &FactorConfig) -> bool {
match (credential, config) {
(FactorCredential::Password(_), FactorConfig::Password(_))
| (FactorCredential::OtpCode(_), FactorConfig::EmailOtp(_)) => true,
(FactorCredential::RecoveryCode(_), FactorConfig::RecoveryCodes(cfg)) => {
cfg.needs_slow_verification()
}
_ => false,
}
}
pub(crate) async fn verify_credential_blocking(
kdf: &KdfExecutor,
credential: &FactorCredential,
config: &FactorConfig,
kind: &FactorKind,
now: chrono::DateTime<chrono::Utc>,
pepper: Option<&Arc<axess_factors::RecoveryCodePepper>>,
) -> Result<VerifyOutcome, KdfError> {
if !runs_kdf(credential, config) {
return Ok(verify_credential(
credential,
config,
kind,
now,
pepper.map(|p| &**p),
));
}
let (credential, config, kind, pepper) = (
credential.clone(),
config.clone(),
kind.clone(),
pepper.cloned(),
);
kdf.run(move || verify_credential(&credential, &config, &kind, now, pepper.as_deref()))
.await
}
pub(crate) fn generate_otp_code(rng: &impl SecureRng, length: usize) -> String {
const CHUNK: usize = 18;
let mut code = String::with_capacity(length);
let mut remaining = length;
while remaining > 0 {
let width = remaining.min(CHUNK);
code.push_str(&random_digits(rng, width));
remaining -= width;
}
code
}
fn random_digits(rng: &impl SecureRng, width: usize) -> String {
let modulus = 10u64.pow(width as u32);
let max_fair = u64::MAX - (u64::MAX % modulus);
loop {
let mut bytes = [0u8; 8];
rng.fill_bytes(&mut bytes);
let value = u64::from_le_bytes(bytes);
if value < max_fair {
return format!("{:0>width$}", value % modulus, width = width);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::authn::factor::{EmailOtpConfig, PasswordConfig, ZeroizedString};
use crate::testing::mock_random::MockRng;
use chrono::Utc;
use std::sync::Arc;
#[test]
fn password_correct() {
let hash = axess_factors::generate_password_hash("Gnomes2+");
let cfg = FactorConfig::Password(PasswordConfig {
hash: ZeroizedString::new(&hash),
rules: Default::default(),
});
let cred = FactorCredential::Password(ZeroizedString::new("Gnomes2+"));
let outcome = verify_credential(&cred, &cfg, &FactorKind::Password, Utc::now(), None);
assert!(matches!(outcome, VerifyOutcome::Pass));
}
#[test]
fn password_wrong() {
let hash = axess_factors::generate_password_hash("Gnomes2+");
let cfg = FactorConfig::Password(PasswordConfig {
hash: ZeroizedString::new(&hash),
rules: Default::default(),
});
let cred = FactorCredential::Password(ZeroizedString::new("wrong"));
let outcome = verify_credential(&cred, &cfg, &FactorKind::Password, Utc::now(), None);
assert!(matches!(outcome, VerifyOutcome::Fail));
}
#[test]
fn password_oversized_rejected() {
let hash = axess_factors::generate_password_hash("x");
let cfg = FactorConfig::Password(PasswordConfig {
hash: ZeroizedString::new(&hash),
rules: Default::default(),
});
let oversized = "x".repeat(crate::validation::MAX_PASSWORD_BYTES + 1);
let cred = FactorCredential::Password(ZeroizedString::new(&oversized));
let outcome = verify_credential(&cred, &cfg, &FactorKind::Password, Utc::now(), None);
assert!(matches!(outcome, VerifyOutcome::Fail));
}
#[test]
fn email_otp_expired_rejected() {
let code = "12345678";
let hash = axess_factors::generate_password_hash(code);
let expired = Utc::now() - chrono::Duration::seconds(10);
let cfg = FactorConfig::EmailOtp(EmailOtpConfig {
email: "test@example.com".into(),
pending_hash: Some(ZeroizedString::new(&hash)),
pending_until: Some(expired),
..Default::default()
});
let cred = FactorCredential::OtpCode(Arc::from(code));
let outcome = verify_credential(&cred, &cfg, &FactorKind::EmailOtp, Utc::now(), None);
assert!(matches!(outcome, VerifyOutcome::Fail));
}
#[test]
fn email_otp_no_pending_hash_rejected() {
let cfg = FactorConfig::EmailOtp(EmailOtpConfig {
email: "test@example.com".into(),
pending_hash: None,
pending_until: None,
..Default::default()
});
let cred = FactorCredential::OtpCode(Arc::from("12345678"));
let outcome = verify_credential(&cred, &cfg, &FactorKind::EmailOtp, Utc::now(), None);
assert!(matches!(outcome, VerifyOutcome::Fail));
}
#[test]
fn email_otp_attempt_limit_burns_code() {
let code = "12345678";
let hash = axess_factors::generate_password_hash(code);
let future = Utc::now() + chrono::Duration::seconds(300);
let cfg = FactorConfig::EmailOtp(EmailOtpConfig {
email: "test@example.com".into(),
pending_hash: Some(ZeroizedString::new(&hash)),
pending_until: Some(future),
attempt_count: 5,
max_attempts: 5,
..Default::default()
});
let cred = FactorCredential::OtpCode(Arc::from(code));
let outcome = verify_credential(&cred, &cfg, &FactorKind::EmailOtp, Utc::now(), None);
match outcome {
VerifyOutcome::FailWithUpdate(FactorConfig::EmailOtp(updated)) => {
assert!(updated.pending_hash.is_none());
assert_eq!(updated.attempt_count, 0);
}
_ => panic!("expected FailWithUpdate with burned code"),
}
}
#[test]
fn apply_email_otp_failure_increments_below_limit() {
let prior = EmailOtpConfig {
email: "test@example.com".into(),
pending_hash: Some(ZeroizedString::new("hash")),
pending_until: Some(Utc::now()),
attempt_count: 2,
max_attempts: 5,
..Default::default()
};
let next = apply_email_otp_failure(&prior);
assert_eq!(next.attempt_count, 3);
assert!(next.pending_hash.is_some());
}
#[test]
fn apply_email_otp_failure_burns_at_limit() {
let prior = EmailOtpConfig {
email: "test@example.com".into(),
pending_hash: Some(ZeroizedString::new("hash")),
pending_until: Some(Utc::now()),
attempt_count: 5,
max_attempts: 5,
..Default::default()
};
let next = apply_email_otp_failure(&prior);
assert!(next.pending_hash.is_none());
assert!(next.pending_until.is_none());
assert_eq!(next.attempt_count, 0);
}
#[test]
fn email_otp_wrong_code_increments_counter() {
let hash = axess_factors::generate_password_hash("12345678");
let future = Utc::now() + chrono::Duration::seconds(300);
let cfg = FactorConfig::EmailOtp(EmailOtpConfig {
email: "test@example.com".into(),
pending_hash: Some(ZeroizedString::new(&hash)),
pending_until: Some(future),
attempt_count: 2,
max_attempts: 5,
..Default::default()
});
let cred = FactorCredential::OtpCode(Arc::from("00000000"));
let outcome = verify_credential(&cred, &cfg, &FactorKind::EmailOtp, Utc::now(), None);
match outcome {
VerifyOutcome::FailWithUpdate(FactorConfig::EmailOtp(updated)) => {
assert_eq!(updated.attempt_count, 3);
assert!(updated.pending_hash.is_some());
}
_ => panic!("expected FailWithUpdate with incremented counter"),
}
}
#[test]
fn mismatched_credential_config_rejected() {
let cfg = FactorConfig::Password(PasswordConfig {
hash: ZeroizedString::new("$argon2id$v=19$m=19456,t=2,p=1$fake$fake"),
rules: Default::default(),
});
let cred = FactorCredential::OtpCode(Arc::from("123456"));
let outcome = verify_credential(&cred, &cfg, &FactorKind::Totp, Utc::now(), None);
assert!(matches!(outcome, VerifyOutcome::Fail));
}
#[test]
fn otp_code_correct_length() {
let rng = MockRng::new(42);
let code = generate_otp_code(&rng, 8);
assert_eq!(code.len(), 8);
assert!(code.chars().all(|c| c.is_ascii_digit()));
}
#[test]
fn otp_code_deterministic_with_same_seed() {
let code1 = generate_otp_code(&MockRng::new(99), 6);
let code2 = generate_otp_code(&MockRng::new(99), 6);
assert_eq!(code1, code2);
}
#[test]
fn otp_code_different_seeds_differ() {
let code1 = generate_otp_code(&MockRng::new(1), 6);
let code2 = generate_otp_code(&MockRng::new(2), 6);
assert_ne!(code1, code2);
}
}
#[cfg(test)]
mod verification_tests;