- Export/import: optional secret_types map; AddResult includes entry_id - env_map: dot→__ segment encoding; collision errors - rollback: FOR UPDATE + txn-consistent snapshot; restore name from history - regenerate_api_key: rows_affected guard - MCP: find count propagates errors; add uses entry_id for relations; rollback no encryption key - Web: load_session_user_strict + JSON handlers key_version; PATCH length limits - Tests: ExportEntry serde, env segment
298 lines
9.9 KiB
Rust
298 lines
9.9 KiB
Rust
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<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
key_check: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
params: Option<serde_json::Value>,
|
|
}
|
|
|
|
#[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<AppState>,
|
|
session: Session,
|
|
) -> Result<Response, StatusCode> {
|
|
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<AppState>,
|
|
session: Session,
|
|
) -> Result<Json<KeySaltResponse>, 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<AppState>,
|
|
session: Session,
|
|
Json(body): Json<KeySetupRequest>,
|
|
) -> Result<Json<KeySetupResponse>, 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<AppState>,
|
|
session: Session,
|
|
Json(body): Json<KeyChangeRequest>,
|
|
) -> Result<Json<KeySetupResponse>, 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<AppState>,
|
|
session: Session,
|
|
) -> Result<Json<ApiKeyResponse>, 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<AppState>,
|
|
session: Session,
|
|
) -> Result<Json<ApiKeyResponse>, 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 }))
|
|
}
|