Archival commit, newer version exists, will push
This commit is contained in:
commit
b6094834bb
22 files changed
+7369
No files matched your search
+233
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user