release(secrets-mcp): 0.5.8 — 修复更换密码短语流程
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m17s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s

- 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:
voson
2026-04-06 11:44:23 +08:00
parent cab234cfcb
commit 53d53ff96a
5 changed files with 210 additions and 13 deletions

2
Cargo.lock generated
View File

@@ -2066,7 +2066,7 @@ dependencies = [
[[package]] [[package]]
name = "secrets-mcp" name = "secrets-mcp"
version = "0.5.7" version = "0.5.8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"askama", "askama",

View File

@@ -76,6 +76,52 @@ pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result
Ok((user, true)) 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<u8>)> =
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. /// Store the PBKDF2 salt, key_check, and params for a user's passphrase setup.
pub async fn update_user_key_setup( pub async fn update_user_key_setup(
pool: &PgPool, pool: &PgPool,

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "secrets-mcp" name = "secrets-mcp"
version = "0.5.7" version = "0.5.8"
edition.workspace = true edition.workspace = true
[[bin]] [[bin]]

View File

@@ -25,7 +25,7 @@ use secrets_core::service::{
search::{SearchParams, count_entries, fetch_secret_schemas, ilike_pattern, list_entries}, search::{SearchParams, count_entries, fetch_secret_schemas, ilike_pattern, list_entries},
update::{UpdateEntryFieldsByIdParams, update_fields_by_id}, update::{UpdateEntryFieldsByIdParams, update_fields_by_id},
user::{ 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, 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("/account/unbind/{provider}", post(account_unbind))
.route("/api/key-salt", get(api_key_salt)) .route("/api/key-salt", get(api_key_salt))
.route("/api/key-setup", post(api_key_setup)) .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", get(api_apikey_get))
.route("/api/apikey/regenerate", post(api_apikey_regenerate)) .route("/api/apikey/regenerate", post(api_apikey_regenerate))
.route( .route(
@@ -1040,6 +1041,20 @@ async fn api_key_setup(
.await .await
.ok_or(StatusCode::UNAUTHORIZED)?; .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| { let salt = hex::decode_hex(&body.salt).map_err(|e| {
tracing::warn!(error = %e, "invalid hex in key-setup salt"); tracing::warn!(error = %e, "invalid hex in key-setup salt");
StatusCode::BAD_REQUEST StatusCode::BAD_REQUEST
@@ -1064,6 +1079,103 @@ async fn api_key_setup(
Ok(Json(KeySetupResponse { ok: true })) 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 ──────────────────────────────────────────────────────── // ── API Key management ────────────────────────────────────────────────────────
#[derive(Serialize)] #[derive(Serialize)]

View File

@@ -305,6 +305,17 @@
<div class="modal-bd" id="change-modal"> <div class="modal-bd" id="change-modal">
<div class="modal"> <div class="modal">
<h3 data-i18n="changeTitle">更换密码</h3> <h3 data-i18n="changeTitle">更换密码</h3>
<div class="field">
<label data-i18n="labelCurrent">当前密码</label>
<div class="pw-field">
<input type="password" id="change-pass-old" data-i18n-ph="phCurrent" autocomplete="current-password">
<button type="button" class="pw-toggle" data-target="change-pass-old" aria-pressed="false"
onclick="togglePwVisibility(this)" aria-label="">
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
</button>
</div>
</div>
<div class="field"> <div class="field">
<label data-i18n="labelNew">新密码</label> <label data-i18n="labelNew">新密码</label>
<div class="pw-field"> <div class="pw-field">
@@ -345,8 +356,10 @@ const T = {
labelPassphrase: '加密密码', labelPassphrase: '加密密码',
labelConfirm: '确认密码', labelConfirm: '确认密码',
labelNew: '新密码', labelNew: '新密码',
labelCurrent: '当前密码',
phPassphrase: '输入密码…', phPassphrase: '输入密码…',
phConfirm: '再次输入…', phConfirm: '再次输入…',
phCurrent: '输入当前密码…',
btnSetup: '设置并获取配置', btnSetup: '设置并获取配置',
btnUnlock: '解锁并获取配置', btnUnlock: '解锁并获取配置',
setupNote: '密码不会上传服务器。遗忘后数据将无法恢复。', setupNote: '密码不会上传服务器。遗忘后数据将无法恢复。',
@@ -354,6 +367,7 @@ const T = {
errShort: '密码至少需要 8 个字符。', errShort: '密码至少需要 8 个字符。',
errMismatch: '两次输入不一致。', errMismatch: '两次输入不一致。',
errWrong: '密码错误,请重试。', errWrong: '密码错误,请重试。',
errWrongOld: '当前密码错误,请重试。',
unlockedTitle: 'MCP 配置', unlockedTitle: 'MCP 配置',
tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI', tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI',
tabOpencode: 'OpenCode', tabOpencode: 'OpenCode',
@@ -379,8 +393,10 @@ const T = {
labelPassphrase: '加密密碼', labelPassphrase: '加密密碼',
labelConfirm: '確認密碼', labelConfirm: '確認密碼',
labelNew: '新密碼', labelNew: '新密碼',
labelCurrent: '目前密碼',
phPassphrase: '輸入密碼…', phPassphrase: '輸入密碼…',
phConfirm: '再次輸入…', phConfirm: '再次輸入…',
phCurrent: '輸入目前密碼…',
btnSetup: '設定並取得設定', btnSetup: '設定並取得設定',
btnUnlock: '解鎖並取得設定', btnUnlock: '解鎖並取得設定',
setupNote: '密碼不會上傳伺服器。遺忘後資料將無法復原。', setupNote: '密碼不會上傳伺服器。遺忘後資料將無法復原。',
@@ -388,6 +404,7 @@ const T = {
errShort: '密碼至少需要 8 個字元。', errShort: '密碼至少需要 8 個字元。',
errMismatch: '兩次輸入不一致。', errMismatch: '兩次輸入不一致。',
errWrong: '密碼錯誤,請重試。', errWrong: '密碼錯誤,請重試。',
errWrongOld: '目前密碼錯誤,請重試。',
unlockedTitle: 'MCP 設定', unlockedTitle: 'MCP 設定',
tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI', tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI',
tabOpencode: 'OpenCode', tabOpencode: 'OpenCode',
@@ -413,8 +430,10 @@ const T = {
labelPassphrase: 'Encryption password', labelPassphrase: 'Encryption password',
labelConfirm: 'Confirm password', labelConfirm: 'Confirm password',
labelNew: 'New password', labelNew: 'New password',
labelCurrent: 'Current password',
phPassphrase: 'Enter password…', phPassphrase: 'Enter password…',
phConfirm: 'Repeat password…', phConfirm: 'Repeat password…',
phCurrent: 'Enter current password…',
btnSetup: 'Set up & get config', btnSetup: 'Set up & get config',
btnUnlock: 'Unlock & get config', btnUnlock: 'Unlock & get config',
setupNote: 'Your password never leaves this device. If forgotten, encrypted data cannot be recovered.', setupNote: 'Your password never leaves this device. If forgotten, encrypted data cannot be recovered.',
@@ -422,6 +441,7 @@ const T = {
errShort: 'Password must be at least 8 characters.', errShort: 'Password must be at least 8 characters.',
errMismatch: 'Passwords do not match.', errMismatch: 'Passwords do not match.',
errWrong: 'Incorrect password, please try again.', errWrong: 'Incorrect password, please try again.',
errWrongOld: 'Current password is incorrect, please try again.',
unlockedTitle: 'MCP Config', unlockedTitle: 'MCP Config',
tabMcp: 'Cursor, Claude Code, Codex, Gemini CLI', tabMcp: 'Cursor, Claude Code, Codex, Gemini CLI',
tabOpencode: 'OpenCode', tabOpencode: 'OpenCode',
@@ -832,14 +852,16 @@ async function confirmRegenerate() {
// ── Change passphrase modal ──────────────────────────────────────────────────── // ── Change passphrase modal ────────────────────────────────────────────────────
function openChangeModal() { function openChangeModal() {
document.getElementById('change-pass-old').value = '';
document.getElementById('change-pass1').value = ''; document.getElementById('change-pass1').value = '';
document.getElementById('change-pass2').value = ''; document.getElementById('change-pass2').value = '';
document.getElementById('change-pass-old').type = 'password';
document.getElementById('change-pass1').type = 'password'; document.getElementById('change-pass1').type = 'password';
document.getElementById('change-pass2').type = 'password'; document.getElementById('change-pass2').type = 'password';
document.getElementById('change-error').style.display = 'none'; document.getElementById('change-error').style.display = 'none';
document.getElementById('change-modal').classList.add('open'); document.getElementById('change-modal').classList.add('open');
syncPwToggleI18n(); syncPwToggleI18n();
setTimeout(() => document.getElementById('change-pass1').focus(), 50); setTimeout(() => document.getElementById('change-pass-old').focus(), 50);
} }
function closeChangeModal() { function closeChangeModal() {
@@ -847,11 +869,13 @@ function closeChangeModal() {
} }
async function doChange() { async function doChange() {
const passOld = document.getElementById('change-pass-old').value;
const pass1 = document.getElementById('change-pass1').value; const pass1 = document.getElementById('change-pass1').value;
const pass2 = document.getElementById('change-pass2').value; const pass2 = document.getElementById('change-pass2').value;
const errEl = document.getElementById('change-error'); const errEl = document.getElementById('change-error');
errEl.style.display = 'none'; errEl.style.display = 'none';
if (!passOld) { showErr(errEl, t('errEmpty')); return; }
if (!pass1) { showErr(errEl, t('errEmpty')); return; } if (!pass1) { showErr(errEl, t('errEmpty')); return; }
if (pass1.length < 8) { showErr(errEl, t('errShort')); return; } if (pass1.length < 8) { showErr(errEl, t('errShort')); return; }
if (pass1 !== pass2) { showErr(errEl, t('errMismatch')); return; } if (pass1 !== pass2) { showErr(errEl, t('errMismatch')); return; }
@@ -860,24 +884,39 @@ async function doChange() {
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<span class="spinner" style="border-top-color:#0d1117"></span>'; btn.innerHTML = '<span class="spinner" style="border-top-color:#0d1117"></span>';
try { try {
const salt = crypto.getRandomValues(new Uint8Array(32)); // Fetch current salt to derive old key for verification
const cryptoKey = await deriveKey(pass1, salt, true); const saltResp = await fetchAuth('/api/key-salt');
const keyCheckHex = await encryptKeyCheck(cryptoKey); if (!saltResp.ok) throw new Error('HTTP ' + saltResp.status);
const hexKey = await exportKeyHex(cryptoKey); const saltData = await saltResp.json();
if (!saltData.has_passphrase) throw new Error('No passphrase configured');
const resp = await fetchAuth('/api/key-setup', { // Derive old key and verify it
const oldCryptoKey = await deriveKey(passOld, hexToBytes(saltData.salt), true);
const validOld = await verifyKeyCheck(oldCryptoKey, saltData.key_check);
if (!validOld) { showErr(errEl, t('errWrongOld')); return; }
const oldHexKey = await exportKeyHex(oldCryptoKey);
// Derive new key
const newSalt = crypto.getRandomValues(new Uint8Array(32));
const newCryptoKey = await deriveKey(pass1, newSalt, true);
const newKeyCheckHex = await encryptKeyCheck(newCryptoKey);
const newHexKey = await exportKeyHex(newCryptoKey);
const resp = await fetchAuth('/api/key-change', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
salt: bytesToHex(salt), old_key: oldHexKey,
key_check: keyCheckHex, new_key: newHexKey,
salt: bytesToHex(newSalt),
key_check: newKeyCheckHex,
params: { alg: 'pbkdf2-sha256', iterations: PBKDF2_ITERATIONS } params: { alg: 'pbkdf2-sha256', iterations: PBKDF2_ITERATIONS }
}) })
}); });
if (!resp.ok) throw new Error('HTTP ' + resp.status); if (!resp.ok) throw new Error('HTTP ' + resp.status);
currentEncKey = hexKey; currentEncKey = newHexKey;
sessionStorage.setItem('enc_key', hexKey); sessionStorage.setItem('enc_key', newHexKey);
renderRealConfig(); renderRealConfig();
closeChangeModal(); closeChangeModal();
} catch (e) { } catch (e) {