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:
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "secrets-mcp"
|
||||
version = "0.5.7"
|
||||
version = "0.5.8"
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -305,6 +305,17 @@
|
||||
<div class="modal-bd" id="change-modal">
|
||||
<div class="modal">
|
||||
<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">
|
||||
<label data-i18n="labelNew">新密码</label>
|
||||
<div class="pw-field">
|
||||
@@ -345,8 +356,10 @@ const T = {
|
||||
labelPassphrase: '加密密码',
|
||||
labelConfirm: '确认密码',
|
||||
labelNew: '新密码',
|
||||
labelCurrent: '当前密码',
|
||||
phPassphrase: '输入密码…',
|
||||
phConfirm: '再次输入…',
|
||||
phCurrent: '输入当前密码…',
|
||||
btnSetup: '设置并获取配置',
|
||||
btnUnlock: '解锁并获取配置',
|
||||
setupNote: '密码不会上传服务器。遗忘后数据将无法恢复。',
|
||||
@@ -354,6 +367,7 @@ const T = {
|
||||
errShort: '密码至少需要 8 个字符。',
|
||||
errMismatch: '两次输入不一致。',
|
||||
errWrong: '密码错误,请重试。',
|
||||
errWrongOld: '当前密码错误,请重试。',
|
||||
unlockedTitle: 'MCP 配置',
|
||||
tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI',
|
||||
tabOpencode: 'OpenCode',
|
||||
@@ -379,8 +393,10 @@ const T = {
|
||||
labelPassphrase: '加密密碼',
|
||||
labelConfirm: '確認密碼',
|
||||
labelNew: '新密碼',
|
||||
labelCurrent: '目前密碼',
|
||||
phPassphrase: '輸入密碼…',
|
||||
phConfirm: '再次輸入…',
|
||||
phCurrent: '輸入目前密碼…',
|
||||
btnSetup: '設定並取得設定',
|
||||
btnUnlock: '解鎖並取得設定',
|
||||
setupNote: '密碼不會上傳伺服器。遺忘後資料將無法復原。',
|
||||
@@ -388,6 +404,7 @@ const T = {
|
||||
errShort: '密碼至少需要 8 個字元。',
|
||||
errMismatch: '兩次輸入不一致。',
|
||||
errWrong: '密碼錯誤,請重試。',
|
||||
errWrongOld: '目前密碼錯誤,請重試。',
|
||||
unlockedTitle: 'MCP 設定',
|
||||
tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI',
|
||||
tabOpencode: 'OpenCode',
|
||||
@@ -413,8 +430,10 @@ const T = {
|
||||
labelPassphrase: 'Encryption password',
|
||||
labelConfirm: 'Confirm password',
|
||||
labelNew: 'New password',
|
||||
labelCurrent: 'Current password',
|
||||
phPassphrase: 'Enter password…',
|
||||
phConfirm: 'Repeat password…',
|
||||
phCurrent: 'Enter current password…',
|
||||
btnSetup: 'Set up & get config',
|
||||
btnUnlock: 'Unlock & get config',
|
||||
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.',
|
||||
errMismatch: 'Passwords do not match.',
|
||||
errWrong: 'Incorrect password, please try again.',
|
||||
errWrongOld: 'Current password is incorrect, please try again.',
|
||||
unlockedTitle: 'MCP Config',
|
||||
tabMcp: 'Cursor, Claude Code, Codex, Gemini CLI',
|
||||
tabOpencode: 'OpenCode',
|
||||
@@ -832,14 +852,16 @@ async function confirmRegenerate() {
|
||||
// ── Change passphrase modal ────────────────────────────────────────────────────
|
||||
|
||||
function openChangeModal() {
|
||||
document.getElementById('change-pass-old').value = '';
|
||||
document.getElementById('change-pass1').value = '';
|
||||
document.getElementById('change-pass2').value = '';
|
||||
document.getElementById('change-pass-old').type = 'password';
|
||||
document.getElementById('change-pass1').type = 'password';
|
||||
document.getElementById('change-pass2').type = 'password';
|
||||
document.getElementById('change-error').style.display = 'none';
|
||||
document.getElementById('change-modal').classList.add('open');
|
||||
syncPwToggleI18n();
|
||||
setTimeout(() => document.getElementById('change-pass1').focus(), 50);
|
||||
setTimeout(() => document.getElementById('change-pass-old').focus(), 50);
|
||||
}
|
||||
|
||||
function closeChangeModal() {
|
||||
@@ -847,11 +869,13 @@ function closeChangeModal() {
|
||||
}
|
||||
|
||||
async function doChange() {
|
||||
const passOld = document.getElementById('change-pass-old').value;
|
||||
const pass1 = document.getElementById('change-pass1').value;
|
||||
const pass2 = document.getElementById('change-pass2').value;
|
||||
const errEl = document.getElementById('change-error');
|
||||
errEl.style.display = 'none';
|
||||
|
||||
if (!passOld) { showErr(errEl, t('errEmpty')); return; }
|
||||
if (!pass1) { showErr(errEl, t('errEmpty')); return; }
|
||||
if (pass1.length < 8) { showErr(errEl, t('errShort')); return; }
|
||||
if (pass1 !== pass2) { showErr(errEl, t('errMismatch')); return; }
|
||||
@@ -860,24 +884,39 @@ async function doChange() {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner" style="border-top-color:#0d1117"></span>';
|
||||
try {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(32));
|
||||
const cryptoKey = await deriveKey(pass1, salt, true);
|
||||
const keyCheckHex = await encryptKeyCheck(cryptoKey);
|
||||
const hexKey = await exportKeyHex(cryptoKey);
|
||||
// Fetch current salt to derive old key for verification
|
||||
const saltResp = await fetchAuth('/api/key-salt');
|
||||
if (!saltResp.ok) throw new Error('HTTP ' + saltResp.status);
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
salt: bytesToHex(salt),
|
||||
key_check: keyCheckHex,
|
||||
old_key: oldHexKey,
|
||||
new_key: newHexKey,
|
||||
salt: bytesToHex(newSalt),
|
||||
key_check: newKeyCheckHex,
|
||||
params: { alg: 'pbkdf2-sha256', iterations: PBKDF2_ITERATIONS }
|
||||
})
|
||||
});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
|
||||
currentEncKey = hexKey;
|
||||
sessionStorage.setItem('enc_key', hexKey);
|
||||
currentEncKey = newHexKey;
|
||||
sessionStorage.setItem('enc_key', newHexKey);
|
||||
renderRealConfig();
|
||||
closeChangeModal();
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user