234 lines
7.3 KiB
Rust
234 lines
7.3 KiB
Rust
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]);
|
|
}
|
|
}
|
|
}
|