use crate::{App, db, vault}; use axum::http::{HeaderMap, header}; use std::sync::Arc; use std::time::Duration; pub const COOKIE: &str = "labdeck_session"; const SESSION_TTL: i64 = 12 * 3600; const TICKET_TTL: i64 = 30; #[derive(Default)] pub struct LoginGuard { fails: u32, locked_until: i64, } pub fn random_token() -> String { use base64::Engine; let mut b = [0u8; 32]; rand::fill(&mut b); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b) } pub fn is_initialized(app: &App) -> bool { let c = app.db.lock().unwrap(); db::meta_get(&c, "salt").is_some() && db::meta_get(&c, "verifier").is_some() } pub fn session_from_headers(headers: &HeaderMap) -> Option { let raw = headers.get(header::COOKIE)?.to_str().ok()?; for part in raw.split(';') { let part = part.trim(); if let Some(v) = part.strip_prefix(&format!("{COOKIE}=")) { return Some(v.to_string()); } } None } pub fn session_valid(app: &App, headers: &HeaderMap) -> bool { let Some(tok) = session_from_headers(headers) else { return false; }; let sessions = app.sessions.lock().unwrap(); sessions.get(&tok).map(|exp| *exp > db::now()).unwrap_or(false) } pub fn session_create(app: &App) -> String { let tok = random_token(); app.sessions .lock() .unwrap() .insert(tok.clone(), db::now() + SESSION_TTL); tok } pub fn session_drop(app: &App, tok: &str) { let empty = { let mut s = app.sessions.lock().unwrap(); s.remove(tok); s.is_empty() }; if empty { *app.vault_key.write().unwrap() = None; } } pub fn cookie_header(token: &str, secure: bool, max_age: i64) -> String { format!( "{COOKIE}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={max_age}{}", if secure { "; Secure" } else { "" } ) } pub fn session_ttl() -> i64 { SESSION_TTL } pub fn ticket_create(app: &App) -> String { let t = random_token(); app.tickets .lock() .unwrap() .insert(t.clone(), db::now() + TICKET_TTL); t } /// Single use. pub fn ticket_redeem(app: &App, ticket: &str) -> bool { let mut t = app.tickets.lock().unwrap(); matches!(t.remove(ticket), Some(exp) if exp > db::now()) } // ---------------------------------------------------------------- setup / login pub fn setup(app: &App, password: &str) -> Result<(), String> { if password.chars().count() < 10 { return Err("master password must be at least 10 characters".into()); } if is_initialized(app) { return Err("already initialized".into()); } let salt = vault::new_salt(); let key = vault::derive(password, &salt)?; let verifier = vault::make_verifier(&key); let c = app.db.lock().unwrap(); db::meta_set(&c, "salt", &salt).map_err(|e| e.to_string())?; db::meta_set(&c, "verifier", &verifier).map_err(|e| e.to_string())?; db::event_add(&c, "system", "vault initialized", ""); drop(c); *app.vault_key.write().unwrap() = Some(key); Ok(()) } pub enum LoginErr { Locked(i64), Bad, NotInit, Internal(String), } /// Checks the master password; on success the vault key is held in memory. pub fn login(app: &App, password: &str) -> Result<(), LoginErr> { { let g = app.login_guard.lock().unwrap(); let now = db::now(); if g.locked_until > now { return Err(LoginErr::Locked(g.locked_until - now)); } } let (salt, verifier) = { let c = app.db.lock().unwrap(); (db::meta_get(&c, "salt"), db::meta_get(&c, "verifier")) }; let (Some(salt), Some(verifier)) = (salt, verifier) else { return Err(LoginErr::NotInit); }; let key = vault::derive(password, &salt).map_err(LoginErr::Internal)?; if vault::check_verifier(&key, &verifier) { app.login_guard.lock().unwrap().fails = 0; *app.vault_key.write().unwrap() = Some(key); let c = app.db.lock().unwrap(); db::event_add(&c, "login", "login", ""); Ok(()) } else { let mut g = app.login_guard.lock().unwrap(); g.fails += 1; // 0,0,0,2s,4s,8s ... capped at 5 minutes if g.fails > 3 { let wait = (2i64 << (g.fails - 4).min(7)).min(300); g.locked_until = db::now() + wait; } let c = app.db.lock().unwrap(); db::event_add(&c, "login_fail", "failed login", &format!("attempt {}", g.fails)); Err(LoginErr::Bad) } } pub fn change_password(app: &App, old: &str, new: &str) -> Result<(), String> { if new.chars().count() < 10 { return Err("new password must be at least 10 characters".into()); } let (salt, verifier) = { let c = app.db.lock().unwrap(); (db::meta_get(&c, "salt"), db::meta_get(&c, "verifier")) }; let (Some(salt), Some(verifier)) = (salt, verifier) else { return Err("not initialized".into()); }; let old_key = vault::derive(old, &salt)?; if !vault::check_verifier(&old_key, &verifier) { return Err("current password is wrong".into()); } let new_salt = vault::new_salt(); let new_key = vault::derive(new, &new_salt)?; let mut c = app.db.lock().unwrap(); let tx = c.transaction().map_err(|e| e.to_string())?; { let mut rows: Vec<(i64, Vec)> = Vec::new(); let mut st = tx .prepare("SELECT id, secret FROM hosts WHERE secret IS NOT NULL") .map_err(|e| e.to_string())?; let it = st .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec>(1)?))) .map_err(|e| e.to_string())?; for r in it { rows.push(r.map_err(|e| e.to_string())?); } drop(st); for (id, blob) in rows { let plain = vault::decrypt(&old_key, &blob) .ok_or_else(|| format!("could not decrypt secret of host {id}"))?; let nb = vault::encrypt(&new_key, &plain); tx.execute( "UPDATE hosts SET secret=?1 WHERE id=?2", rusqlite::params![nb, id], ) .map_err(|e| e.to_string())?; } } db::meta_set(&tx, "salt", &new_salt).map_err(|e| e.to_string())?; db::meta_set(&tx, "verifier", &vault::make_verifier(&new_key)).map_err(|e| e.to_string())?; tx.commit().map_err(|e| e.to_string())?; db::event_add(&c, "system", "master password changed", ""); drop(c); *app.vault_key.write().unwrap() = Some(new_key); Ok(()) } /// Periodic cleanup: expired sessions/tickets, old history; locks the vault when nobody is logged in. pub async fn janitor(app: Arc) { let mut tick = tokio::time::interval(Duration::from_secs(60)); let mut n: u64 = 0; loop { tick.tick().await; n += 1; let now = db::now(); let none_left = { let mut s = app.sessions.lock().unwrap(); s.retain(|_, exp| *exp > now); s.is_empty() }; app.tickets.lock().unwrap().retain(|_, exp| *exp > now); if none_left { *app.vault_key.write().unwrap() = None; } if n % 10 == 1 { let c = app.db.lock().unwrap(); let _ = c.execute("DELETE FROM checks WHERE ts < ?1", [now - 14 * 86_400]); let _ = c.execute("DELETE FROM events WHERE ts < ?1", [now - 180 * 86_400]); } } }