use hmac::{Hmac, Mac}; use sha1::Sha1; use super::crypto::{constant_time_eq, now_secs, random_bytes}; use crate::errors::{AuthError, AuthResult}; pub const TOTP_ISSUER: &str = "Auth service"; pub const TOTP_PERIOD_SECS: u64 = 30; pub const TOTP_DIGITS: u32 = 6; pub const TOTP_SECRET_BYTES: usize = 20; pub const TOTP_WINDOW: [i64; 3] = [0, -1, 1]; /// MFA 注册入口由外部账户服务负责(本服务仅验证/兑换, /// `generate_secret`/`otpauth_uri`/`encrypt_secret` 供其复用)。 const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; pub fn base32_encode(data: &[u8]) -> String { let mut output = String::with_capacity(data.len() * 8 / 5 + 1); let mut buffer = 0u32; let mut bits = 0u32; for &byte in data { buffer = (buffer << 8) | byte as u32; bits += 8; while bits >= 5 { bits -= 5; output.push(BASE32_ALPHABET[((buffer >> bits) & 0x1f) as usize] as char); } } if bits > 0 { output.push(BASE32_ALPHABET[((buffer << (5 - bits)) & 0x1f) as usize] as char); } output } pub fn base32_decode(input: &str) -> Option> { let mut output = Vec::with_capacity(input.len() * 5 / 8); let mut buffer = 0u32; let mut bits = 0u32; for raw in input.chars() { let c = raw.to_ascii_uppercase(); let value = match c { 'A'..='Z' => c as u32 - b'A' as u32, '2'..='7' => c as u32 - b'2' as u32 + 26, '=' | ' ' | '-' => continue, _ => return None, }; buffer = (buffer << 5) | value; bits += 5; if bits >= 8 { bits -= 8; output.push(((buffer >> bits) & 0xff) as u8); } } Some(output) } pub fn generate_secret() -> String { base32_encode(&random_bytes(TOTP_SECRET_BYTES)) } pub fn hotp(secret: &[u8], counter: u64) -> AuthResult { let mut mac = Hmac::::new_from_slice(secret) .map_err(|err| AuthError::internal(format!("totp secret is unusable: {err}")))?; mac.update(&counter.to_be_bytes()); let digest = mac.finalize().into_bytes(); let offset = (digest[19] & 0x0f) as usize; Ok(((digest[offset] as u32 & 0x7f) << 24) | ((digest[offset + 1] as u32) << 16) | ((digest[offset + 2] as u32) << 8) | (digest[offset + 3] as u32)) } pub fn format_code(code: u32) -> String { let truncated = code % 10u32.pow(TOTP_DIGITS); format!("{truncated:0width$}", width = TOTP_DIGITS as usize) } pub fn current_step() -> u64 { now_secs() / TOTP_PERIOD_SECS } pub fn code_at(secret: &[u8], step: u64) -> AuthResult { Ok(format_code(hotp(secret, step)?)) } pub fn verify_totp_code( secret: &str, code: &str, last_step: Option, ) -> AuthResult<(u64, i64)> { let code = code.trim(); if code.len() as u32 != TOTP_DIGITS || !code.bytes().all(|byte| byte.is_ascii_digit()) { return Err(AuthError::TotpCodeInvalid); } let secret_bytes = base32_decode(secret).ok_or_else(|| AuthError::internal("totp secret is not base32"))?; if secret_bytes.is_empty() { return Err(AuthError::internal("totp secret is empty")); } let step = current_step(); let mut reused = false; for offset in TOTP_WINDOW { let candidate = (step as i64 + offset).max(0) as u64; let expected = code_at(&secret_bytes, candidate)?; if constant_time_eq(expected.as_bytes(), code.as_bytes()) { if last_step.is_some_and(|last| candidate as i64 <= last) { reused = true; continue; } return Ok((candidate, candidate as i64)); } } if reused { return Err(AuthError::TotpStepReused); } Err(AuthError::TotpCodeInvalid) } pub fn otpauth_uri(secret: &str, account: &str) -> String { let encoded = account .replace('%', "%25") .replace(':', "%3A") .replace('/', "%2F") .replace('?', "%3F") .replace('&', "%26") .replace('=', "%3D"); format!( "otpauth://totp/{issuer}:{account}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits={digits}&period={period}", issuer = TOTP_ISSUER, account = encoded, digits = TOTP_DIGITS, period = TOTP_PERIOD_SECS, ) } pub const SECRET_CIPHERTEXT_PREFIX: &str = "v1:"; fn encryption_key(material: Option<&str>) -> AuthResult> { let Some(material) = material else { return Err(AuthError::internal( "auth.totp_encryption_key is required to handle totp secrets", )); }; super::crypto::decode_base64_key(material) } pub fn decrypt_secret(material: Option<&str>, stored: &str) -> AuthResult { let key = encryption_key(material)?; let sealed = stored .trim() .strip_prefix(SECRET_CIPHERTEXT_PREFIX) .ok_or_else(|| { AuthError::internal("totp secret is stored without encryption and must be re-enrolled") })?; super::crypto::aes_decrypt(&key, "mfa.totp.secret.v1", sealed) } pub fn encrypt_secret(material: Option<&str>, secret: &str) -> AuthResult { let key = encryption_key(material)?; Ok(format!( "{SECRET_CIPHERTEXT_PREFIX}{}", super::crypto::aes_encrypt(&key, "mfa.totp.secret.v1", secret)? )) }