Archival commit, newer version exists, will push

This commit is contained in:
nke4 committed 2026-10-27 11:22:24 +01:00
commit b6094834bb
22 files changed
+7369

No files matched your search

+233
View File
@@ -0,0 +1,233 @@
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<String> {
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<u8>)> = 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<u8>>(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<App>) {
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]);
}
}
}