From 53d53ff96a83571fbb3c085e5803f12c784c7d1c Mon Sep 17 00:00:00 2001 From: voson Date: Mon, 6 Apr 2026 11:44:23 +0800 Subject: [PATCH] =?UTF-8?q?release(secrets-mcp):=200.5.8=20=E2=80=94=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9B=B4=E6=8D=A2=E5=AF=86=E7=A0=81=E7=9F=AD?= =?UTF-8?q?=E8=AF=AD=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - secrets-core: change_user_key() 事务内全量解密并重加密 secrets - web: POST /api/key-change;已有密钥时拒绝 POST /api/key-setup(409) - dashboard: 更换密码需当前密码,调用 key-change - 同步 Cargo.lock --- Cargo.lock | 2 +- crates/secrets-core/src/service/user.rs | 46 ++++++++ crates/secrets-mcp/Cargo.toml | 2 +- crates/secrets-mcp/src/web.rs | 114 +++++++++++++++++++- crates/secrets-mcp/templates/dashboard.html | 59 ++++++++-- 5 files changed, 210 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b2127a4..9b1e4d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2066,7 +2066,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.5.7" +version = "0.5.8" dependencies = [ "anyhow", "askama", diff --git a/crates/secrets-core/src/service/user.rs b/crates/secrets-core/src/service/user.rs index 7d5350c..69e8319 100644 --- a/crates/secrets-core/src/service/user.rs +++ b/crates/secrets-core/src/service/user.rs @@ -76,6 +76,52 @@ pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result Ok((user, true)) } +/// Re-encrypt all of a user's secrets from `old_key` to `new_key` and update the key metadata. +/// +/// Runs entirely inside a single database transaction: if any secret fails to re-encrypt +/// the whole operation is rolled back, leaving the database unchanged. +pub async fn change_user_key( + pool: &PgPool, + user_id: Uuid, + old_key: &[u8; 32], + new_key: &[u8; 32], + new_salt: &[u8], + new_key_check: &[u8], + new_key_params: &Value, +) -> Result<()> { + let mut tx = pool.begin().await?; + + let secrets: Vec<(uuid::Uuid, Vec)> = + sqlx::query_as("SELECT id, encrypted FROM secrets WHERE user_id = $1 FOR UPDATE") + .bind(user_id) + .fetch_all(&mut *tx) + .await?; + + for (id, encrypted) in &secrets { + let plaintext = crate::crypto::decrypt(old_key, encrypted)?; + let new_encrypted = crate::crypto::encrypt(new_key, &plaintext)?; + sqlx::query("UPDATE secrets SET encrypted = $1, updated_at = NOW() WHERE id = $2") + .bind(&new_encrypted) + .bind(id) + .execute(&mut *tx) + .await?; + } + + sqlx::query( + "UPDATE users SET key_salt = $1, key_check = $2, key_params = $3, updated_at = NOW() \ + WHERE id = $4", + ) + .bind(new_salt) + .bind(new_key_check) + .bind(new_key_params) + .bind(user_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) +} + /// Store the PBKDF2 salt, key_check, and params for a user's passphrase setup. pub async fn update_user_key_setup( pool: &PgPool, diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml index 3bfdd34..b3f4b29 100644 --- a/crates/secrets-mcp/Cargo.toml +++ b/crates/secrets-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets-mcp" -version = "0.5.7" +version = "0.5.8" edition.workspace = true [[bin]] diff --git a/crates/secrets-mcp/src/web.rs b/crates/secrets-mcp/src/web.rs index bcddb25..02b8d3a 100644 --- a/crates/secrets-mcp/src/web.rs +++ b/crates/secrets-mcp/src/web.rs @@ -25,7 +25,7 @@ use secrets_core::service::{ search::{SearchParams, count_entries, fetch_secret_schemas, ilike_pattern, list_entries}, update::{UpdateEntryFieldsByIdParams, update_fields_by_id}, user::{ - OAuthProfile, bind_oauth_account, find_or_create_user, get_user_by_id, + OAuthProfile, bind_oauth_account, change_user_key, find_or_create_user, get_user_by_id, unbind_oauth_account, update_user_key_setup, }, }; @@ -256,6 +256,7 @@ pub fn web_router() -> Router { .route("/account/unbind/{provider}", post(account_unbind)) .route("/api/key-salt", get(api_key_salt)) .route("/api/key-setup", post(api_key_setup)) + .route("/api/key-change", post(api_key_change)) .route("/api/apikey", get(api_apikey_get)) .route("/api/apikey/regenerate", post(api_apikey_regenerate)) .route( @@ -1040,6 +1041,20 @@ async fn api_key_setup( .await .ok_or(StatusCode::UNAUTHORIZED)?; + // Guard: if a passphrase is already configured, reject and direct to /api/key-change + let user = get_user_by_id(&state.pool, user_id) + .await + .map_err(|e| { + tracing::error!(error = %e, %user_id, "failed to load user for key-setup guard"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::UNAUTHORIZED)?; + + 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 @@ -1064,6 +1079,103 @@ async fn api_key_setup( Ok(Json(KeySetupResponse { ok: true })) } +// ── Change passphrase (re-encrypts all secrets) ─────────────────────────────── + +#[derive(Deserialize)] +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, +} + +async fn api_key_change( + State(state): State, + session: Session, + Json(body): Json, +) -> Result, StatusCode> { + let user_id = current_user_id(&session) + .await + .ok_or(StatusCode::UNAUTHORIZED)?; + + let user = get_user_by_id(&state.pool, user_id) + .await + .map_err(|e| { + tracing::error!(error = %e, %user_id, "failed to load user for key-change"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::UNAUTHORIZED)?; + + // 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 + })?; + + tracing::info!(%user_id, secrets_count = "(see service log)", "passphrase changed and secrets re-encrypted"); + Ok(Json(KeySetupResponse { ok: true })) +} + // ── API Key management ──────────────────────────────────────────────────────── #[derive(Serialize)] diff --git a/crates/secrets-mcp/templates/dashboard.html b/crates/secrets-mcp/templates/dashboard.html index cbdbc9a..4b3b52d 100644 --- a/crates/secrets-mcp/templates/dashboard.html +++ b/crates/secrets-mcp/templates/dashboard.html @@ -305,6 +305,17 @@