Skip to content
Clone
main
164 lines 5.3 KB Rust
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<Vec<u8>> {
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<u32> {
let mut mac = Hmac::<Sha1>::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<String> {
Ok(format_code(hotp(secret, step)?))
}
pub fn verify_totp_code(
secret: &str,
code: &str,
last_step: Option<i64>,
) -> 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<Vec<u8>> {
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<String> {
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<String> {
let key = encryption_key(material)?;
Ok(format!(
"{SECRET_CIPHERTEXT_PREFIX}{}",
super::crypto::aes_encrypt(&key, "mfa.totp.secret.v1", secret)?
))
}