release(secrets-mcp): 0.5.8 — 修复更换密码短语流程
- secrets-core: change_user_key() 事务内全量解密并重加密 secrets - web: POST /api/key-change;已有密钥时拒绝 POST /api/key-setup(409) - dashboard: 更换密码需当前密码,调用 key-change - 同步 Cargo.lock
This commit is contained in:
@@ -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<AppState> {
|
||||
.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<AppState>,
|
||||
session: Session,
|
||||
Json(body): Json<KeyChangeRequest>,
|
||||
) -> Result<Json<KeySetupResponse>, 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)]
|
||||
|
||||
Reference in New Issue
Block a user