Compare commits
No commits in common. '24eb878b299fd9f5bbd39b214350f3f4526558cb' and 'e92bbdb62cf0437b6a930a50c4070f25dddf9f09' have entirely different histories.
24eb878b29
...
e92bbdb62c
@ -1,305 +0,0 @@
|
||||
//! Authentication — secret generation, credential storage, request verification.
|
||||
//!
|
||||
//! Secret is generated once and stored in a file outside the modules directory.
|
||||
//! Credentials (login/password) are XOR-encrypted with the secret and stored in
|
||||
//! a separate `auth` DB table that modules cannot access through the kv API.
|
||||
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use rand::Rng;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
|
||||
use crate::logging;
|
||||
|
||||
struct AuthState {
|
||||
secret: String,
|
||||
credentials: Option<(String, String)>,
|
||||
secret_paths: Vec<String>,
|
||||
}
|
||||
|
||||
static STATE: OnceLock<Mutex<AuthState>> = OnceLock::new();
|
||||
|
||||
fn state() -> &'static Mutex<AuthState> {
|
||||
STATE.get_or_init(|| Mutex::new(AuthState {
|
||||
secret: String::new(),
|
||||
credentials: None,
|
||||
secret_paths: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn lock_state() -> std::sync::MutexGuard<'static, AuthState> {
|
||||
state().lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Generate a 32-byte hex secret.
|
||||
fn generate_secret() -> String {
|
||||
let bytes: [u8; 32] = rand::rng().random();
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// XOR encrypt/decrypt data with the secret (symmetric).
|
||||
fn xor_with_secret(data: &str, secret: &str) -> String {
|
||||
let secret_bytes = secret.as_bytes();
|
||||
let encrypted: Vec<u8> = data.as_bytes().iter().enumerate()
|
||||
.map(|(i, b)| b ^ secret_bytes[i % secret_bytes.len()])
|
||||
.collect();
|
||||
BASE64.encode(&encrypted)
|
||||
}
|
||||
|
||||
/// XOR decrypt base64 data with the secret.
|
||||
fn decrypt_with_secret(encoded: &str, secret: &str) -> Option<String> {
|
||||
let encrypted = BASE64.decode(encoded).ok()?;
|
||||
let secret_bytes = secret.as_bytes();
|
||||
let decrypted: Vec<u8> = encrypted.iter().enumerate()
|
||||
.map(|(i, b)| b ^ secret_bytes[i % secret_bytes.len()])
|
||||
.collect();
|
||||
String::from_utf8(decrypted).ok()
|
||||
}
|
||||
|
||||
/// Initialize auth: load or generate secret, load credentials from DB.
|
||||
pub async fn init(secret_paths: &[String], db_conn: &tokio_rusqlite::Connection) {
|
||||
// Create auth table
|
||||
if let Err(e) = db_conn.call(|conn| {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS auth (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
|
||||
)?;
|
||||
Ok::<_, tokio_rusqlite::rusqlite::Error>(())
|
||||
}).await {
|
||||
logging::log("ERROR", "AUTH", &format!("failed to create auth table: {e}"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Load or generate secret
|
||||
let secret = load_or_generate_secret(secret_paths).await;
|
||||
logging::log("INFO", "AUTH", "secret ready");
|
||||
|
||||
// Load credentials from DB
|
||||
let creds = db_conn.call(|conn| {
|
||||
let login: Option<String> = conn.query_row(
|
||||
"SELECT value FROM auth WHERE key = 'login'", [], |r| r.get(0),
|
||||
).ok();
|
||||
let password: Option<String> = conn.query_row(
|
||||
"SELECT value FROM auth WHERE key = 'password'", [], |r| r.get(0),
|
||||
).ok();
|
||||
Ok::<_, tokio_rusqlite::rusqlite::Error>((login, password))
|
||||
}).await.unwrap_or((None, None));
|
||||
|
||||
let mut s = lock_state();
|
||||
s.secret_paths = secret_paths.to_vec();
|
||||
|
||||
if let (Some(enc_login), Some(enc_pass)) = creds {
|
||||
if let (Some(login), Some(password)) = (
|
||||
decrypt_with_secret(&enc_login, &secret),
|
||||
decrypt_with_secret(&enc_pass, &secret),
|
||||
) {
|
||||
logging::log("INFO", "AUTH", &format!("credentials loaded for user '{login}'"));
|
||||
s.credentials = Some((login, password));
|
||||
} else {
|
||||
logging::log("WARN", "AUTH", "credentials in DB couldn't be decrypted (secret changed?), auth disabled");
|
||||
}
|
||||
}
|
||||
|
||||
s.secret = secret;
|
||||
}
|
||||
|
||||
async fn load_or_generate_secret(paths: &[String]) -> String {
|
||||
for path in paths {
|
||||
if let Ok(content) = tokio::fs::read_to_string(path).await {
|
||||
let trimmed = content.trim().to_string();
|
||||
if trimmed.len() == 64 {
|
||||
logging::log("DEBUG", "AUTH", &format!("secret loaded from {path}"));
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let secret = generate_secret();
|
||||
for path in paths {
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
if tokio::fs::write(path, &secret).await.is_ok() {
|
||||
logging::log("INFO", "AUTH", &format!("new secret written to {path}"));
|
||||
return secret;
|
||||
}
|
||||
}
|
||||
|
||||
logging::log("WARN", "AUTH", "couldn't persist secret to any path, using ephemeral");
|
||||
secret
|
||||
}
|
||||
|
||||
/// Set credentials. Encrypts and stores in DB.
|
||||
pub fn set_credentials(login: &str, password: &str) {
|
||||
let (enc_login, enc_pass) = {
|
||||
let mut s = lock_state();
|
||||
if s.secret.is_empty() { return; }
|
||||
let enc_login = xor_with_secret(login, &s.secret);
|
||||
let enc_pass = xor_with_secret(password, &s.secret);
|
||||
s.credentials = Some((login.to_string(), password.to_string()));
|
||||
(enc_login, enc_pass)
|
||||
};
|
||||
|
||||
if let Some(handle) = crate::server::runtime_handle() {
|
||||
handle.spawn(async move {
|
||||
if let Some(conn) = crate::db::get_connection() {
|
||||
let _ = conn.call(move |conn| {
|
||||
conn.execute("INSERT OR REPLACE INTO auth (key, value) VALUES ('login', ?1)", [&enc_login])?;
|
||||
conn.execute("INSERT OR REPLACE INTO auth (key, value) VALUES ('password', ?1)", [&enc_pass])?;
|
||||
Ok::<_, tokio_rusqlite::rusqlite::Error>(())
|
||||
}).await;
|
||||
logging::log("INFO", "AUTH", "credentials saved");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear credentials (disable auth, keep secret).
|
||||
pub fn clear_credentials() {
|
||||
lock_state().credentials = None;
|
||||
|
||||
if let Some(handle) = crate::server::runtime_handle() {
|
||||
handle.spawn(async move {
|
||||
if let Some(conn) = crate::db::get_connection() {
|
||||
let _ = conn.call(|conn| {
|
||||
conn.execute("DELETE FROM auth WHERE key IN ('login', 'password')", [])?;
|
||||
Ok::<_, tokio_rusqlite::rusqlite::Error>(())
|
||||
}).await;
|
||||
logging::log("INFO", "AUTH", "credentials cleared");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Full reset: clear credentials + regenerate secret.
|
||||
pub fn reset() {
|
||||
let paths = {
|
||||
let mut s = lock_state();
|
||||
s.credentials = None;
|
||||
s.secret = generate_secret();
|
||||
let new_secret = s.secret.clone();
|
||||
let paths = s.secret_paths.clone();
|
||||
drop(s);
|
||||
|
||||
// Write new secret to file and clear DB
|
||||
if let Some(handle) = crate::server::runtime_handle() {
|
||||
let paths_clone = paths.clone();
|
||||
handle.spawn(async move {
|
||||
// Write new secret
|
||||
for path in &paths_clone {
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
if tokio::fs::write(path, &new_secret).await.is_ok() {
|
||||
logging::log("INFO", "AUTH", &format!("new secret written to {path}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Clear credentials from DB
|
||||
if let Some(conn) = crate::db::get_connection() {
|
||||
let _ = conn.call(|conn| {
|
||||
conn.execute("DELETE FROM auth WHERE key IN ('login', 'password')", [])?;
|
||||
Ok::<_, tokio_rusqlite::rusqlite::Error>(())
|
||||
}).await;
|
||||
}
|
||||
logging::log("INFO", "AUTH", "auth fully reset with new secret");
|
||||
});
|
||||
}
|
||||
paths
|
||||
};
|
||||
let _ = paths; // suppress unused warning
|
||||
}
|
||||
|
||||
/// Check if auth is enabled (credentials are set).
|
||||
pub fn has_auth() -> bool {
|
||||
lock_state().credentials.is_some()
|
||||
}
|
||||
|
||||
/// Check an HTTP request's authorization.
|
||||
pub fn check_auth(auth_header: Option<&str>) -> bool {
|
||||
let s = lock_state();
|
||||
let Some((ref login, ref password)) = s.credentials else {
|
||||
return true; // no auth configured
|
||||
};
|
||||
|
||||
let Some(header) = auth_header else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Bearer token (secret)
|
||||
if let Some(token) = header.strip_prefix("Bearer ") {
|
||||
return token == s.secret;
|
||||
}
|
||||
|
||||
// Basic auth
|
||||
if let Some(encoded) = header.strip_prefix("Basic ") {
|
||||
if let Ok(decoded) = BASE64.decode(encoded) {
|
||||
if let Ok(pair) = String::from_utf8(decoded) {
|
||||
if let Some((u, p)) = pair.split_once(':') {
|
||||
return u == login && p == password;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Get the secret token (for external integrations).
|
||||
pub fn get_secret() -> String {
|
||||
lock_state().secret.clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_xor_roundtrip() {
|
||||
let secret = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||
let data = "hello world";
|
||||
let encrypted = xor_with_secret(data, secret);
|
||||
let decrypted = decrypt_with_secret(&encrypted, secret).unwrap();
|
||||
assert_eq!(decrypted, data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_secret_length() {
|
||||
let secret = generate_secret();
|
||||
assert_eq!(secret.len(), 64);
|
||||
assert!(secret.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_flow() {
|
||||
// Setup
|
||||
{
|
||||
let mut s = lock_state();
|
||||
s.secret = "a".repeat(64);
|
||||
s.credentials = Some(("admin".to_string(), "pass123".to_string()));
|
||||
}
|
||||
|
||||
// Valid basic auth
|
||||
let encoded = BASE64.encode("admin:pass123");
|
||||
assert!(check_auth(Some(&format!("Basic {encoded}"))));
|
||||
|
||||
// Wrong password
|
||||
let wrong = BASE64.encode("admin:wrong");
|
||||
assert!(!check_auth(Some(&format!("Basic {wrong}"))));
|
||||
|
||||
// No header
|
||||
assert!(!check_auth(None));
|
||||
|
||||
// Bearer with secret
|
||||
let secret = get_secret();
|
||||
assert!(check_auth(Some(&format!("Bearer {secret}"))));
|
||||
|
||||
// Clean up
|
||||
lock_state().credentials = None;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_auth_allows_all() {
|
||||
lock_state().credentials = None;
|
||||
assert!(check_auth(None));
|
||||
assert!(check_auth(Some("garbage")));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue