Files
secrets/crates/secrets-mcp/src/web/account.rs
voson d772066210 fix(secrets-mcp 0.5.20): code review plan — export secret types, env map, rollback, API key, MCP tools, web session & validation
- 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
2026-04-11 17:10:28 +08:00

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 }))
}