//! Per-bot login vault. Humans store credentials; the model only sees ids. use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Nonce}; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::routing::{get, patch}; use axum::{Json, Router}; use chrono::{DateTime, Utc}; use rand::RngCore; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use sqlx::FromRow; use uuid::Uuid; use crate::db::Actor; use crate::state::AppState; #[derive(Debug, Clone, Serialize, FromRow)] #[serde(rename_all = "camelCase")] pub struct VaultAccount { pub id: String, pub bot_id: String, pub site: String, pub host: String, pub username: String, pub notes: String, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpsertAccount { pub site: String, #[serde(default)] pub host: String, pub username: String, #[serde(default)] pub password: String, #[serde(default)] pub notes: String, } pub fn router() -> Router { Router::new() .route( "/api/bots/{bot_id}/accounts", get(list_accounts).post(create_account), ) .route( "/api/bots/{bot_id}/accounts/{account_id}", patch(update_account).delete(delete_account), ) } /// An id in a URL is not permission. Anything addressed by `bot_id` checks /// that the agent lives in the caller's own workspace first, so guessing an id /// cannot reach another person's memory, vault, or schedule. async fn assert_bot_is_mine( state: &AppState, actor: &Actor, bot_id: &str, ) -> Result<(), StatusCode> { state .db .get_bot(actor, bot_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; Ok(()) } async fn list_accounts( State(state): State, actor: Actor, Path(bot_id): Path, ) -> Result>, StatusCode> { assert_bot_is_mine(&state, &actor, &bot_id).await?; list(&state, &actor, &bot_id) .await .map(Json) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } async fn create_account( State(state): State, actor: Actor, Path(bot_id): Path, Json(input): Json, ) -> Result, (StatusCode, Json)> { assert_bot_is_mine(&state, &actor, &bot_id) .await .map_err(|status| (status, Json(json!({"message":"bot not found"}))))?; insert(&state, &actor, &bot_id, input) .await .map(Json) .map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error})))) } async fn update_account( State(state): State, actor: Actor, Path((bot_id, account_id)): Path<(String, String)>, Json(input): Json, ) -> Result, (StatusCode, Json)> { assert_bot_is_mine(&state, &actor, &bot_id) .await .map_err(|status| (status, Json(json!({"message":"bot not found"}))))?; update_row(&state, &actor, &bot_id, &account_id, input) .await .map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))? .map(Json) .ok_or(( StatusCode::NOT_FOUND, Json(json!({"message":"account not found"})), )) } async fn delete_account( State(state): State, actor: Actor, Path((bot_id, account_id)): Path<(String, String)>, ) -> Result { assert_bot_is_mine(&state, &actor, &bot_id).await?; let deleted = sqlx::query( "DELETE FROM vault_accounts WHERE id=$1 AND bot_id=$2 AND space_id=$3 AND user_id=$4", ) .bind(&account_id) .bind(&bot_id) .bind(&actor.space_id) .bind(&actor.user_id) .execute(state.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; if deleted.rows_affected() == 0 { return Err(StatusCode::NOT_FOUND); } Ok(StatusCode::NO_CONTENT) } pub async fn list( state: &AppState, actor: &Actor, bot_id: &str, ) -> Result, String> { list_on(state.pool(), actor, bot_id).await } pub async fn list_on( pool: &sqlx::PgPool, actor: &Actor, bot_id: &str, ) -> Result, String> { sqlx::query_as( "SELECT id, bot_id, site, host, username, notes, created_at, updated_at FROM vault_accounts WHERE bot_id=$1 AND space_id=$2 AND user_id=$3 ORDER BY updated_at DESC", ) .bind(bot_id) .bind(&actor.space_id) .bind(&actor.user_id) .fetch_all(pool) .await .map_err(|error| error.to_string()) } /// `get_secret_on` projection: vault columns plus the encrypted password. type SecretRow = ( String, String, String, String, String, String, DateTime, DateTime, String, ); pub async fn get_secret_on( pool: &sqlx::PgPool, actor: &Actor, bot_id: &str, account_id: &str, ) -> Result, String> { let row: Option = sqlx::query_as( "SELECT id, bot_id, site, host, username, notes, created_at, updated_at, password_ciphertext FROM vault_accounts WHERE id=$1 AND bot_id=$2 AND space_id=$3 AND user_id=$4", ) .bind(account_id) .bind(bot_id) .bind(&actor.space_id) .bind(&actor.user_id) .fetch_optional(pool) .await .map_err(|error| error.to_string())?; let Some(row) = row else { return Ok(None); }; let password = decrypt(&row.8)?; Ok(Some(( VaultAccount { id: row.0, bot_id: row.1, site: row.2, host: row.3, username: row.4.clone(), notes: row.5, created_at: row.6, updated_at: row.7, }, row.4, password, ))) } async fn insert( state: &AppState, actor: &Actor, bot_id: &str, input: UpsertAccount, ) -> Result { let site = clean_site(&input.site)?; let username = clean_username(&input.username)?; if input.password.is_empty() { return Err("password is required".into()); } let host = normalize_host(&input.host, &site); let id = Uuid::new_v4().to_string(); let ciphertext = encrypt(&input.password)?; sqlx::query_as( "INSERT INTO vault_accounts (id, space_id, user_id, bot_id, site, host, username, password_ciphertext, notes) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id, bot_id, site, host, username, notes, created_at, updated_at", ) .bind(&id) .bind(&actor.space_id) .bind(&actor.user_id) .bind(bot_id) .bind(site) .bind(host) .bind(username) .bind(ciphertext) .bind(input.notes.trim()) .fetch_one(state.pool()) .await .map_err(|error| error.to_string()) } async fn update_row( state: &AppState, actor: &Actor, bot_id: &str, account_id: &str, input: UpsertAccount, ) -> Result, String> { let site = clean_site(&input.site)?; let username = clean_username(&input.username)?; let host = normalize_host(&input.host, &site); if input.password.is_empty() { sqlx::query_as( "UPDATE vault_accounts SET site=$1, host=$2, username=$3, notes=$4, updated_at=now() WHERE id=$5 AND bot_id=$6 AND space_id=$7 AND user_id=$8 RETURNING id, bot_id, site, host, username, notes, created_at, updated_at", ) .bind(site) .bind(host) .bind(username) .bind(input.notes.trim()) .bind(account_id) .bind(bot_id) .bind(&actor.space_id) .bind(&actor.user_id) .fetch_optional(state.pool()) .await .map_err(|error| error.to_string()) } else { let ciphertext = encrypt(&input.password)?; sqlx::query_as( "UPDATE vault_accounts SET site=$1, host=$2, username=$3, notes=$4, password_ciphertext=$5, updated_at=now() WHERE id=$6 AND bot_id=$7 AND space_id=$8 AND user_id=$9 RETURNING id, bot_id, site, host, username, notes, created_at, updated_at", ) .bind(site) .bind(host) .bind(username) .bind(input.notes.trim()) .bind(ciphertext) .bind(account_id) .bind(bot_id) .bind(&actor.space_id) .bind(&actor.user_id) .fetch_optional(state.pool()) .await .map_err(|error| error.to_string()) } } fn clean_site(site: &str) -> Result { let value = site.trim(); if value.is_empty() || value.chars().count() > 80 { return Err("site name must be 1–80 characters".into()); } Ok(value.to_string()) } fn clean_username(username: &str) -> Result { let value = username.trim(); if value.is_empty() || value.chars().count() > 200 { return Err("username must be 1–200 characters".into()); } Ok(value.to_string()) } fn normalize_host(host: &str, site: &str) -> String { let raw = host.trim(); if raw.is_empty() { return site.to_lowercase(); } raw.trim_start_matches("https://") .trim_start_matches("http://") .split('/') .next() .unwrap_or(raw) .trim() .to_lowercase() } fn vault_key() -> Result<[u8; 32], String> { // The vault key is its own secret. It used to fall back to the old shared // login token, which quietly mixed "who can sign in" with "what can be // decrypted"; saved passwords stay locked until it is set on purpose. let material = std::env::var("LAZYBOY_VAULT_KEY") .ok() .filter(|value| !value.is_empty()) .ok_or_else(|| "set LAZYBOY_VAULT_KEY to encrypt saved passwords".to_string())?; let digest = Sha256::digest(material.as_bytes()); let mut key = [0u8; 32]; key.copy_from_slice(&digest); Ok(key) } fn encrypt(plaintext: &str) -> Result { let cipher = Aes256Gcm::new_from_slice(&vault_key()?).map_err(|error| error.to_string())?; let mut nonce_bytes = [0u8; 12]; rand::thread_rng().fill_bytes(&mut nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes); let encrypted = cipher .encrypt(nonce, plaintext.as_bytes()) .map_err(|error| error.to_string())?; let mut packed = Vec::with_capacity(12 + encrypted.len()); packed.extend_from_slice(&nonce_bytes); packed.extend_from_slice(&encrypted); Ok(hex::encode(packed)) } fn decrypt(packed: &str) -> Result { let bytes = hex::decode(packed).map_err(|error| error.to_string())?; if bytes.len() < 13 { return Err("corrupt vault entry".into()); } let cipher = Aes256Gcm::new_from_slice(&vault_key()?).map_err(|error| error.to_string())?; let nonce = Nonce::from_slice(&bytes[..12]); let plain = cipher .decrypt(nonce, &bytes[12..]) .map_err(|_| "could not decrypt vault entry".to_string())?; String::from_utf8(plain).map_err(|error| error.to_string()) } #[cfg(test)] mod tests { use super::{decrypt, encrypt, normalize_host}; #[test] // Rust 2024 marks `set_var` unsafe; this only seeds a test-only key and no // other test in this binary reads it. #[allow(unsafe_code)] fn round_trips_a_password() { unsafe { std::env::set_var("LAZYBOY_VAULT_KEY", "test-vault-key-for-unit-tests") }; let packed = encrypt("s3cret!").unwrap(); assert!(!packed.contains("s3cret")); assert_eq!(decrypt(&packed).unwrap(), "s3cret!"); } #[test] fn host_strips_urls() { assert_eq!( normalize_host("https://mail.google.com/inbox", "Gmail"), "mail.google.com" ); assert_eq!(normalize_host("", "Gmail"), "gmail"); } }