use askama::Template; use axum::{Json, extract::State, http::StatusCode, response::Response}; use serde::{Deserialize, Serialize}; use tower_sessions::Session; use secrets_core::crypto::hex; use secrets_core::service::{ api_key::{ensure_api_key, regenerate_api_key}, user::{change_user_key, get_user_by_id, update_user_key_setup}, }; use crate::AppState; use super::{SESSION_KEY_VERSION, load_session_user_strict, render_template, require_valid_user}; #[derive(Template)] #[template(path = "dashboard.html")] struct DashboardTemplate { user_name: String, user_email: String, has_passphrase: bool, base_url: String, version: &'static str, } #[derive(Serialize)] pub(super) struct KeySaltResponse { has_passphrase: bool, #[serde(skip_serializing_if = "Option::is_none")] salt: Option, #[serde(skip_serializing_if = "Option::is_none")] key_check: Option, #[serde(skip_serializing_if = "Option::is_none")] params: Option, } #[derive(Deserialize)] pub(super) struct KeySetupRequest { /// Hex-encoded 32-byte random salt salt: String, /// Hex-encoded AES-256-GCM encryption of "secrets-mcp-key-check" with the derived key key_check: String, /// Key derivation parameters, e.g. {"alg":"pbkdf2-sha256","iterations":600000} params: serde_json::Value, } #[derive(Serialize)] pub(super) struct KeySetupResponse { ok: bool, } #[derive(Deserialize)] pub(super) struct KeyChangeRequest { /// Old derived key as 64-char hex — used to decrypt existing secrets old_key: String, /// New derived key as 64-char hex — used to re-encrypt secrets new_key: String, /// New 32-byte hex salt salt: String, /// New key_check: AES-256-GCM of KEY_CHECK_PLAINTEXT with the new key (hex) key_check: String, /// New key derivation parameters params: serde_json::Value, } #[derive(Serialize)] pub(super) struct ApiKeyResponse { api_key: String, } pub(super) async fn dashboard( State(state): State, session: Session, ) -> Result { let user = match require_valid_user(&state.pool, &session, "dashboard").await { Ok(u) => u, Err(r) => return Ok(r), }; let tmpl = DashboardTemplate { user_name: user.name.clone(), user_email: user.email.clone().unwrap_or_default(), has_passphrase: user.key_salt.is_some(), base_url: state.base_url.clone(), version: env!("CARGO_PKG_VERSION"), }; render_template(tmpl) } pub(super) async fn api_key_salt( State(state): State, session: Session, ) -> Result, StatusCode> { let user = match load_session_user_strict(&state.pool, &session).await { Ok(Some(u)) => u, Ok(None) => return Err(StatusCode::UNAUTHORIZED), Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; if user.key_salt.is_none() { return Ok(Json(KeySaltResponse { has_passphrase: false, salt: None, key_check: None, params: None, })); } Ok(Json(KeySaltResponse { has_passphrase: true, salt: user.key_salt.as_deref().map(hex::encode_hex), key_check: user.key_check.as_deref().map(hex::encode_hex), params: user.key_params, })) } pub(super) async fn api_key_setup( State(state): State, session: Session, Json(body): Json, ) -> Result, StatusCode> { let user = match load_session_user_strict(&state.pool, &session).await { Ok(Some(u)) => u, Ok(None) => return Err(StatusCode::UNAUTHORIZED), Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; let user_id = user.id; // Guard: if a passphrase is already configured, reject and direct to /api/key-change if user.key_salt.is_some() { tracing::warn!(%user_id, "key-setup called but passphrase already configured; use /api/key-change"); return Err(StatusCode::CONFLICT); } let salt = hex::decode_hex(&body.salt).map_err(|e| { tracing::warn!(error = %e, "invalid hex in key-setup salt"); StatusCode::BAD_REQUEST })?; let key_check = hex::decode_hex(&body.key_check).map_err(|e| { tracing::warn!(error = %e, "invalid hex in key-setup key_check"); StatusCode::BAD_REQUEST })?; if salt.len() != 32 { tracing::warn!(salt_len = salt.len(), "key-setup salt must be 32 bytes"); return Err(StatusCode::BAD_REQUEST); } update_user_key_setup(&state.pool, user_id, &salt, &key_check, &body.params) .await .map_err(|e| { tracing::error!(error = %e, "failed to update key setup"); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(KeySetupResponse { ok: true })) } // ── Change passphrase (re-encrypts all secrets) ─────────────────────────────── pub(super) async fn api_key_change( State(state): State, session: Session, Json(body): Json, ) -> Result, StatusCode> { let user = match load_session_user_strict(&state.pool, &session).await { Ok(Some(u)) => u, Ok(None) => return Err(StatusCode::UNAUTHORIZED), Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; let user_id = user.id; // Must have an existing passphrase to change let existing_key_check = user.key_check.ok_or_else(|| { tracing::warn!(%user_id, "key-change called but no passphrase configured; use /api/key-setup"); StatusCode::BAD_REQUEST })?; // Validate and decode old key let old_key_bytes = secrets_core::crypto::extract_key_from_hex(&body.old_key).map_err(|e| { tracing::warn!(error = %e, "invalid old_key hex in key-change"); StatusCode::BAD_REQUEST })?; // Verify old_key against the stored key_check let plaintext = secrets_core::crypto::decrypt(&old_key_bytes, &existing_key_check).map_err(|_| { tracing::warn!(%user_id, "key-change rejected: old_key does not match stored key_check"); StatusCode::UNAUTHORIZED })?; if plaintext != b"secrets-mcp-key-check" { tracing::warn!(%user_id, "key-change rejected: decrypted key_check content mismatch"); return Err(StatusCode::UNAUTHORIZED); } // Validate and decode new key let new_key_bytes = secrets_core::crypto::extract_key_from_hex(&body.new_key).map_err(|e| { tracing::warn!(error = %e, "invalid new_key hex in key-change"); StatusCode::BAD_REQUEST })?; // Decode new salt and key_check let new_salt = hex::decode_hex(&body.salt).map_err(|e| { tracing::warn!(error = %e, "invalid hex in key-change salt"); StatusCode::BAD_REQUEST })?; if new_salt.len() != 32 { tracing::warn!( salt_len = new_salt.len(), "key-change salt must be 32 bytes" ); return Err(StatusCode::BAD_REQUEST); } let new_key_check = hex::decode_hex(&body.key_check).map_err(|e| { tracing::warn!(error = %e, "invalid hex in key-change key_check"); StatusCode::BAD_REQUEST })?; change_user_key( &state.pool, user_id, &old_key_bytes, &new_key_bytes, &new_salt, &new_key_check, &body.params, ) .await .map_err(|e| { tracing::error!(error = %e, %user_id, "failed to change user key"); StatusCode::INTERNAL_SERVER_ERROR })?; // Refresh the session's key_version so the current session is not immediately // invalidated by require_valid_user on the next page load. match get_user_by_id(&state.pool, user_id).await { Ok(Some(updated_user)) => { if let Err(e) = session .insert(SESSION_KEY_VERSION, updated_user.key_version) .await { tracing::warn!(error = %e, %user_id, "failed to update key_version in session after key change"); } } Ok(None) => { tracing::warn!(%user_id, "user not found after key change; session not updated"); } Err(e) => { tracing::warn!(error = %e, %user_id, "failed to reload user after key change; session not updated"); } } tracing::info!(%user_id, secrets_count = "(see service log)", "passphrase changed and secrets re-encrypted"); Ok(Json(KeySetupResponse { ok: true })) } // ── API Key management ──────────────────────────────────────────────────────── pub(super) async fn api_apikey_get( State(state): State, session: Session, ) -> Result, StatusCode> { let user = match load_session_user_strict(&state.pool, &session).await { Ok(Some(u)) => u, Ok(None) => return Err(StatusCode::UNAUTHORIZED), Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; let user_id = user.id; let api_key = ensure_api_key(&state.pool, user_id).await.map_err(|e| { tracing::error!(error = %e, %user_id, "ensure_api_key failed"); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(ApiKeyResponse { api_key })) } pub(super) async fn api_apikey_regenerate( State(state): State, session: Session, ) -> Result, StatusCode> { let user = match load_session_user_strict(&state.pool, &session).await { Ok(Some(u)) => u, Ok(None) => return Err(StatusCode::UNAUTHORIZED), Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; let user_id = user.id; let api_key = regenerate_api_key(&state.pool, user_id) .await .map_err(|e| { tracing::error!(error = %e, %user_id, "regenerate_api_key failed"); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(ApiKeyResponse { api_key })) }