use super::seal::Sealer;
use crate::ctx::{BoxFuture, Identity};
use crate::platform::Secret;
use crate::security::RateLimit;
use crate::time::DateTime;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub const RECOVERY_CODES: usize = 10;
pub(crate) const PROOF: &str = "zeyn_mfa";
pub(crate) const ENROLL: &str = "zeyn_totp_enroll";
pub(crate) const ENROLL_TTL: i64 = 600;
#[derive(Clone, Default)]
pub(crate) struct InFlight(Arc<Mutex<std::collections::HashSet<String>>>);
pub(crate) struct Turn(InFlight, String);
impl Drop for Turn {
fn drop(&mut self) {
if let Ok(mut users) = self.0.0.lock() {
users.remove(&self.1);
}
}
}
impl InFlight {
pub(crate) fn enter(&self, user: &str) -> Option<Turn> {
let mut users = self.0.lock().ok()?;
users.insert(user.to_string()).then(|| Turn(self.clone(), user.to_string()))
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TotpRecord {
pub secret: String,
pub recovery: Vec<String>,
pub last_step: u64,
}
pub trait TotpStore: Send + Sync + 'static {
fn load(&self, user: &str) -> BoxFuture<crate::Result<Option<TotpRecord>>>;
fn save(&self, user: &str, record: TotpRecord) -> BoxFuture<crate::Result<()>>;
}
#[derive(Clone, Default)]
pub struct MemoryTotpStore(Arc<Mutex<HashMap<String, TotpRecord>>>);
impl TotpStore for MemoryTotpStore {
fn load(&self, user: &str) -> BoxFuture<crate::Result<Option<TotpRecord>>> {
let found = self.0.lock().ok().and_then(|m| m.get(user).cloned());
Box::pin(async move { Ok(found) })
}
fn save(&self, user: &str, record: TotpRecord) -> BoxFuture<crate::Result<()>> {
if let Ok(mut m) = self.0.lock() {
m.insert(user.to_string(), record);
}
Box::pin(async { Ok(()) })
}
}
#[derive(Serialize, Deserialize)]
pub(crate) struct Proof {
#[serde(rename = "u")]
pub user: String,
#[serde(rename = "a")]
pub at: i64,
#[serde(rename = "e")]
pub expires: i64,
}
#[derive(Serialize, Deserialize)]
pub(crate) struct Pending {
#[serde(rename = "u")]
pub user: String,
#[serde(rename = "s")]
pub secret: String,
#[serde(rename = "e")]
pub expires: i64,
}
pub struct Totp {
pub(crate) store: Arc<dyn TotpStore>,
pub(crate) sealer: Option<Sealer>,
pub(crate) issuer: String,
pub(crate) lifetime: Duration,
pub(crate) attempts: RateLimit,
pub(crate) in_flight: InFlight,
}
impl Totp {
pub fn new(store: impl TotpStore, key: Secret<String>) -> Self {
Totp {
store: Arc::new(store),
sealer: Sealer::new(&key),
issuer: "Zeyn".into(),
lifetime: Duration::from_secs(12 * 3600),
attempts: RateLimit::per_user(5, Duration::from_secs(300)),
in_flight: InFlight::default(),
}
}
pub fn issuer(mut self, name: &str) -> Self {
self.issuer = name.replace(':', " ");
self
}
pub fn lifetime(mut self, lifetime: Duration) -> Self {
self.lifetime = lifetime;
self
}
pub fn attempts(mut self, attempts: u32, window: Duration) -> Self {
self.attempts = RateLimit::per_user(attempts, window);
self
}
pub(crate) fn problems(&self) -> Vec<String> {
if self.sealer.is_none() {
vec![format!("Totp: the key must be at least {} bytes", super::seal::MIN_SECRET)]
} else {
Vec::new()
}
}
pub(crate) fn apply(&self, proof: Option<&str>, identity: &mut Identity) {
let (Some(sealer), Some(value), Some(user)) = (&self.sealer, proof, identity.user.as_deref()) else { return };
let Some(p) = sealer.open::<Proof>(PROOF, value) else { return };
if p.user != user || p.expires <= super::now_secs() {
return;
}
identity.mfa = true;
let at = DateTime::from_unix_ms(p.at.saturating_mul(1000));
if identity.authenticated_at.is_none_or(|old| old.unix_ms() < at.unix_ms()) {
identity.authenticated_at = Some(at);
}
}
pub(crate) fn generator(&self, secret: Vec<u8>, user: &str) -> Option<totp_rs::Totp> {
totp_rs::Builder::new()
.with_secret(secret)
.with_account_name(user.replace(':', " "))
.with_issuer(Some(self.issuer.clone()))
.build()
.ok()
}
pub(crate) fn check(&self, secret: Vec<u8>, user: &str, code: &str) -> Option<u64> {
let code: String = code.chars().filter(|c| !c.is_whitespace()).collect();
if code.len() != 6 || !code.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
self.generator(secret, user)?.check(&code, super::now_secs().max(0) as u64)
}
pub(crate) fn recovery_codes(&self) -> Option<(Vec<String>, Vec<String>)> {
let sealer = self.sealer.as_ref()?;
let codes: Vec<String> = (0..RECOVERY_CODES)
.map(|_| {
let b = base32(&super::random::<7>()).to_lowercase();
format!("{}-{}", &b[..5], &b[5..10])
})
.collect();
let hashes = codes.iter().map(|c| sealer.hash(c)).collect();
Some((codes, hashes))
}
pub(crate) fn recovery_hash(&self, code: &str) -> Option<String> {
let code: String = code.trim().to_lowercase().chars().filter(|c| !c.is_whitespace()).collect();
(code.len() == 11).then(|| self.sealer.as_ref().map(|s| s.hash(&code))).flatten()
}
}
pub(crate) fn base32(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let mut out = String::with_capacity(bytes.len().div_ceil(5) * 8);
let (mut buffer, mut bits) = (0u32, 0u32);
for &b in bytes {
buffer = (buffer << 8) | b as u32;
bits += 8;
while bits >= 5 {
bits -= 5;
out.push(ALPHABET[((buffer >> bits) & 31) as usize] as char);
}
}
if bits > 0 {
out.push(ALPHABET[((buffer << (5 - bits)) & 31) as usize] as char);
}
out
}
pub(crate) fn qr_svg(text: &str, label: &str) -> Option<String> {
let qr = qrcodegen::QrCode::encode_text(text, qrcodegen::QrCodeEcc::Medium).ok()?;
let size = qr.size();
let mut path = String::with_capacity((size * size) as usize * 4);
for y in 0..size {
for x in 0..size {
if qr.get_module(x, y) {
path.push_str(&format!("M{},{}h1v1h-1z", x + 4, y + 4));
}
}
}
let full = size + 8;
Some(format!(
r##"<svg class="z-totp-qr" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {full} {full}" width="220" height="220" role="img" aria-label="{}" shape-rendering="crispEdges"><rect width="{full}" height="{full}" fill="#fff"/><path d="{path}" fill="#000"/></svg>"##,
crate::render::escape(label)
))
}