Compare commits

..

3 Commits

Author SHA1 Message Date
voson
b0fcb83592 release(secrets-mcp): 0.5.9 — users.key_version 与会话失效;Web 条目解密 API 与列表增强
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m24s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
2026-04-06 17:23:20 +08:00
voson
8942718641 scripts: 添加基于 CSV 的 MCP secrets 重加密修复工具
通过读取 entry_id/secret_name/secret_value 调用 secrets_update 让服务端用当前密钥重加密。附带模板 CSV,.gitignore 忽略 *.pyc。
2026-04-06 16:38:37 +08:00
voson
53d53ff96a 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
2026-04-06 12:04:35 +08:00
11 changed files with 999 additions and 64 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@
tmp/ tmp/
client_secret_*.apps.googleusercontent.com.json client_secret_*.apps.googleusercontent.com.json
node_modules/ node_modules/
*.pyc

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.9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"askama", "askama",

View File

@@ -428,6 +428,9 @@ async fn migrate_schema(pool: &PgPool) -> Result<()> {
-- ── Drop legacy actor columns ───────────────────────────────────────────── -- ── Drop legacy actor columns ─────────────────────────────────────────────
ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor; ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor;
ALTER TABLE audit_log DROP COLUMN IF EXISTS actor; ALTER TABLE audit_log DROP COLUMN IF EXISTS actor;
-- ── key_version: incremented on passphrase change to invalidate other sessions ──
ALTER TABLE users ADD COLUMN IF NOT EXISTS key_version BIGINT NOT NULL DEFAULT 0;
"#, "#,
) )
.execute(pool) .execute(pool)

View File

@@ -200,6 +200,8 @@ pub struct User {
pub key_params: Option<serde_json::Value>, pub key_params: Option<serde_json::Value>,
/// Plaintext API key for MCP Bearer authentication. Auto-created on first login. /// Plaintext API key for MCP Bearer authentication. Auto-created on first login.
pub api_key: Option<String>, pub api_key: Option<String>,
/// Incremented each time the passphrase is changed; used to invalidate sessions on other devices.
pub key_version: i64,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }

View File

@@ -31,7 +31,7 @@ pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result
if let Some(oa) = existing { if let Some(oa) = existing {
let user: User = sqlx::query_as( let user: User = sqlx::query_as(
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at \ "SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, key_version, created_at, updated_at \
FROM users WHERE id = $1", FROM users WHERE id = $1",
) )
.bind(oa.user_id) .bind(oa.user_id)
@@ -50,7 +50,7 @@ pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result
let user: User = sqlx::query_as( let user: User = sqlx::query_as(
"INSERT INTO users (email, name, avatar_url) \ "INSERT INTO users (email, name, avatar_url) \
VALUES ($1, $2, $3) \ VALUES ($1, $2, $3) \
RETURNING id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at", RETURNING id, email, name, avatar_url, key_salt, key_check, key_params, api_key, key_version, created_at, updated_at",
) )
.bind(&profile.email) .bind(&profile.email)
.bind(&display_name) .bind(&display_name)
@@ -76,6 +76,53 @@ 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, \
key_version = key_version + 1, 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,
@@ -100,7 +147,7 @@ pub async fn update_user_key_setup(
/// Fetch a user by ID. /// Fetch a user by ID.
pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result<Option<User>> { pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result<Option<User>> {
let user = sqlx::query_as( let user = sqlx::query_as(
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at \ "SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, key_version, created_at, updated_at \
FROM users WHERE id = $1", FROM users WHERE id = $1",
) )
.bind(user_id) .bind(user_id)

View File

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

View File

@@ -22,10 +22,11 @@ use secrets_core::service::{
api_key::{ensure_api_key, regenerate_api_key}, api_key::{ensure_api_key, regenerate_api_key},
audit_log::{count_for_user, list_for_user}, audit_log::{count_for_user, list_for_user},
delete::delete_by_id, delete::delete_by_id,
get_secret::get_all_secrets_by_id,
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,
}, },
}; };
@@ -37,6 +38,7 @@ const SESSION_USER_ID: &str = "user_id";
const SESSION_OAUTH_STATE: &str = "oauth_state"; const SESSION_OAUTH_STATE: &str = "oauth_state";
const SESSION_OAUTH_BIND_MODE: &str = "oauth_bind_mode"; const SESSION_OAUTH_BIND_MODE: &str = "oauth_bind_mode";
const SESSION_LOGIN_PROVIDER: &str = "login_provider"; const SESSION_LOGIN_PROVIDER: &str = "login_provider";
const SESSION_KEY_VERSION: &str = "key_version";
// ── Template types ──────────────────────────────────────────────────────────── // ── Template types ────────────────────────────────────────────────────────────
@@ -175,6 +177,57 @@ async fn current_user_id(session: &Session) -> Option<Uuid> {
} }
} }
/// Load and validate the current user from session and DB.
///
/// Returns the user if the session is valid. Flushes the session and returns
/// `Err(Redirect::to("/login"))` when:
/// - the session has no `user_id`,
/// - the user no longer exists in the database, or
/// - the stored `key_version` does not match the DB value (passphrase changed on
/// another device since this session was created).
async fn require_valid_user(
pool: &sqlx::PgPool,
session: &Session,
context: &str,
) -> Result<secrets_core::models::User, Response> {
let Some(user_id) = current_user_id(session).await else {
return Err(Redirect::to("/login").into_response());
};
let user = match secrets_core::service::user::get_user_by_id(pool, user_id).await {
Err(e) => {
tracing::error!(error = %e, %user_id, context, "failed to load user");
return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response());
}
Ok(None) => {
if let Err(e) = session.flush().await {
tracing::warn!(error = %e, "failed to flush stale session");
}
return Err(Redirect::to("/login").into_response());
}
Ok(Some(u)) => u,
};
let session_kv: Option<i64> = match session.get::<i64>(SESSION_KEY_VERSION).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "failed to read key_version from session; treating as missing");
None
}
};
if let Some(kv) = session_kv
&& kv != user.key_version
{
tracing::info!(%user_id, session_kv = kv, db_kv = user.key_version, "key_version mismatch; invalidating session");
if let Err(e) = session.flush().await {
tracing::warn!(error = %e, "failed to flush outdated session");
}
return Err(Redirect::to("/login").into_response());
}
Ok(user)
}
fn request_client_ip(headers: &HeaderMap, connect_info: ConnectInfo<SocketAddr>) -> Option<String> { fn request_client_ip(headers: &HeaderMap, connect_info: ConnectInfo<SocketAddr>) -> Option<String> {
let trust_proxy = std::env::var("TRUST_PROXY") let trust_proxy = std::env::var("TRUST_PROXY")
.as_deref() .as_deref()
@@ -256,6 +309,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(
@@ -266,6 +320,10 @@ pub fn web_router() -> Router<AppState> {
"/api/entries/{entry_id}/secrets/{secret_id}", "/api/entries/{entry_id}/secrets/{secret_id}",
axum::routing::delete(api_entry_secret_unlink), axum::routing::delete(api_entry_secret_unlink),
) )
.route(
"/api/entries/{id}/secrets/decrypt",
get(api_entry_secrets_decrypt),
)
.route("/api/secrets/{secret_id}", patch(api_secret_patch)) .route("/api/secrets/{secret_id}", patch(api_secret_patch))
.route("/api/secrets/check-name", get(api_secret_check_name)) .route("/api/secrets/check-name", get(api_secret_check_name))
} }
@@ -541,6 +599,9 @@ where
); );
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
if let Err(e) = session.insert(SESSION_KEY_VERSION, user.key_version).await {
tracing::warn!(error = %e, user_id = %user.id, "failed to insert key_version into session after OAuth");
}
log_login( log_login(
&state.pool, &state.pool,
@@ -570,16 +631,9 @@ async fn dashboard(
State(state): State<AppState>, State(state): State<AppState>,
session: Session, session: Session,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let Some(user_id) = current_user_id(&session).await else { let user = match require_valid_user(&state.pool, &session, "dashboard").await {
return Ok(Redirect::to("/login").into_response()); Ok(u) => u,
}; Err(r) => return Ok(r),
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
tracing::error!(error = %e, %user_id, "failed to load user for dashboard");
StatusCode::INTERNAL_SERVER_ERROR
})? {
Some(u) => u,
None => return Ok(Redirect::to("/login").into_response()),
}; };
let tmpl = DashboardTemplate { let tmpl = DashboardTemplate {
@@ -598,17 +652,11 @@ async fn entries_page(
session: Session, session: Session,
Query(q): Query<EntriesQuery>, Query(q): Query<EntriesQuery>,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let Some(user_id) = current_user_id(&session).await else { let user = match require_valid_user(&state.pool, &session, "entries_page").await {
return Ok(Redirect::to("/login").into_response()); Ok(u) => u,
}; Err(r) => return Ok(r),
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
tracing::error!(error = %e, %user_id, "failed to load user for entries page");
StatusCode::INTERNAL_SERVER_ERROR
})? {
Some(u) => u,
None => return Ok(Redirect::to("/login").into_response()),
}; };
let user_id = user.id;
let folder_filter = q let folder_filter = q
.folder .folder
@@ -854,17 +902,11 @@ async fn audit_page(
session: Session, session: Session,
Query(aq): Query<AuditQuery>, Query(aq): Query<AuditQuery>,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let Some(user_id) = current_user_id(&session).await else { let user = match require_valid_user(&state.pool, &session, "audit_page").await {
return Ok(Redirect::to("/login").into_response()); Ok(u) => u,
}; Err(r) => return Ok(r),
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
tracing::error!(error = %e, %user_id, "failed to load user for audit page");
StatusCode::INTERNAL_SERVER_ERROR
})? {
Some(u) => u,
None => return Ok(Redirect::to("/login").into_response()),
}; };
let user_id = user.id;
let page = aq.page.unwrap_or(1).max(1); let page = aq.page.unwrap_or(1).max(1);
@@ -1040,6 +1082,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 +1120,122 @@ 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
})?;
// 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 ──────────────────────────────────────────────────────── // ── API Key management ────────────────────────────────────────────────────────
#[derive(Serialize)] #[derive(Serialize)]
@@ -1699,6 +1871,65 @@ async fn oauth_protected_resource_metadata(State(state): State<AppState>) -> imp
) )
} }
// ── Decrypt entry secrets (Web UI) ───────────────────────────────────────────
async fn api_entry_secrets_decrypt(
State(state): State<AppState>,
session: Session,
headers: HeaderMap,
Path(entry_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let enc_key_hex = headers
.get("x-encryption-key")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": tr(lang, "缺少 X-Encryption-Key 请求头", "缺少 X-Encryption-Key 請求標頭", "Missing X-Encryption-Key header") })),
)
})?;
let master_key =
secrets_core::crypto::extract_key_from_hex(enc_key_hex).map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": tr(lang, "X-Encryption-Key 格式无效", "X-Encryption-Key 格式無效", "Invalid X-Encryption-Key format") })),
)
})?;
let secrets =
get_all_secrets_by_id(&state.pool, entry_id, &master_key, Some(user_id))
.await
.map_err(|e| {
let msg = e.to_string();
if msg.contains("DecryptionFailed") || msg.contains("decryption") {
(
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": tr(lang, "解密失败,请确认密码短语正确", "解密失敗,請確認密碼短語正確", "Decryption failed, please verify your passphrase") })),
)
} else if msg.contains("not found") {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": tr(lang, "条目不存在或无权访问", "條目不存在或無權存取", "Entry not found or no access") })),
)
} else {
tracing::error!(error = %e, %entry_id, "decrypt entry secrets failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": tr(lang, "操作失败,请稍后重试", "操作失敗,請稍後重試", "Operation failed, please try again later") })),
)
}
})?;
Ok(Json(json!({ "ok": true, "secrets": secrets })))
}
// ── Helper ──────────────────────────────────────────────────────────────────── // ── Helper ────────────────────────────────────────────────────────────────────
fn render_template<T: Template>(tmpl: T) -> Result<Response, StatusCode> { fn render_template<T: Template>(tmpl: T) -> Result<Response, StatusCode> {

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

View File

@@ -128,7 +128,7 @@
border-collapse: separate; border-collapse: separate;
border-spacing: 0; border-spacing: 0;
} }
th, td { text-align: left; vertical-align: top; padding: 12px 10px; border-top: 1px solid var(--border); } th, td { text-align: left; vertical-align: middle; padding: 12px 10px; border-top: 1px solid var(--border); }
th { th {
color: var(--text-muted); color: var(--text-muted);
font-size: 12px; font-size: 12px;
@@ -138,24 +138,28 @@
top: 0; top: 0;
z-index: 2; z-index: 2;
background: var(--surface); background: var(--surface);
text-align: center;
vertical-align: middle;
} }
td { font-size: 13px; line-height: 1.45; } td { font-size: 13px; line-height: 1.45; }
tbody tr:nth-child(2n) td { background: rgba(255, 255, 255, 0.01); } tbody tr:nth-child(2n) td { background: rgba(255, 255, 255, 0.01); }
.mono { font-family: 'JetBrains Mono', monospace; } .mono { font-family: 'JetBrains Mono', monospace; }
.col-type { min-width: 108px; width: 1%; } .col-type { min-width: 108px; width: 1%; text-align: center; vertical-align: middle; }
.col-name { min-width: 180px; max-width: 260px; } .col-name { min-width: 180px; max-width: 260px; text-align: center; vertical-align: middle; }
.col-tags { min-width: 160px; max-width: 220px; } .col-tags { min-width: 160px; max-width: 220px; }
.col-secrets { min-width: 220px; max-width: 420px; vertical-align: top; } .col-secrets { min-width: 220px; max-width: 420px; vertical-align: middle; }
.col-secrets .secret-list { max-height: 120px; overflow: auto; } .col-secrets .secret-list { max-height: 120px; overflow: auto; }
.col-actions { min-width: 132px; width: 1%; } .col-actions { min-width: 132px; width: 1%; text-align: center; vertical-align: middle; }
.cell-name, .cell-tags-val { .cell-name, .cell-tags-val {
overflow-wrap: anywhere; overflow-wrap: anywhere;
word-break: break-word; word-break: break-word;
} }
.cell-notes { min-width: 260px; max-width: 360px; } .cell-notes { min-width: 260px; max-width: 360px; }
.notes-scroll { .notes-scroll {
max-height: 120px; height: calc(1.5em * 2 + 16px);
min-height: calc(1.5em * 2 + 16px);
overflow: auto; overflow: auto;
resize: vertical;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
padding: 8px; padding: 8px;
@@ -170,7 +174,7 @@
max-width: 360px; max-height: 120px; overflow: auto; max-width: 360px; max-height: 120px; overflow: auto;
} }
.col-actions { white-space: nowrap; } .col-actions { white-space: nowrap; }
.row-actions { display: flex; flex-wrap: wrap; gap: 6px; } .row-actions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; align-items: center; }
.secret-list { display: flex; flex-wrap: wrap; gap: 6px; max-width: 100%; } .secret-list { display: flex; flex-wrap: wrap; gap: 6px; max-width: 100%; }
.secret-chip { .secret-chip {
display: inline-flex; display: inline-flex;
@@ -312,7 +316,11 @@
color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace; color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
outline: none; outline: none;
} }
.modal-field textarea { min-height: 72px; resize: vertical; } .modal-field textarea { resize: vertical; }
#edit-notes {
height: calc(1.5em * 2 + 16px);
min-height: calc(1.5em * 2 + 16px);
}
.modal-field textarea.metadata-edit { min-height: 140px; } .modal-field textarea.metadata-edit { min-height: 140px; }
.modal-readonly-value { .modal-readonly-value {
background: var(--bg); border: 1px solid var(--border); border-radius: 6px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
@@ -348,6 +356,9 @@
margin-bottom: 4px; text-transform: uppercase; margin-bottom: 4px; text-transform: uppercase;
content: attr(data-label); content: attr(data-label);
} }
.col-name, .col-type, .col-actions { text-align: left; }
th, td { vertical-align: top; }
.row-actions { justify-content: flex-start; }
.detail, .notes-scroll, .secret-list { max-width: none; } .detail, .notes-scroll, .secret-list { max-width: none; }
} }
.pagination { .pagination {
@@ -368,6 +379,43 @@
.page-info { .page-info {
color: var(--text-muted); font-size: 13px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); font-size: 13px; font-family: 'JetBrains Mono', monospace;
} }
.view-secret-row {
display: flex; flex-direction: column; gap: 4px; padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.view-secret-row:last-child { border-bottom: none; }
.view-secret-header {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.view-secret-name {
font-family: 'JetBrains Mono', monospace; font-size: 12px;
color: var(--text); font-weight: 600;
}
.view-secret-type {
font-family: 'JetBrains Mono', monospace; font-size: 11px;
color: var(--text-muted); background: var(--surface2);
border: 1px solid var(--border); border-radius: 4px; padding: 1px 6px;
}
.view-secret-actions { margin-left: auto; display: flex; gap: 6px; }
.view-secret-value-wrap { position: relative; }
.view-secret-value {
font-family: 'JetBrains Mono', monospace; font-size: 12px;
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
padding: 7px 10px; word-break: break-all; white-space: pre-wrap;
max-height: 140px; overflow: auto; color: var(--text); line-height: 1.5;
}
.view-secret-value.masked { letter-spacing: 2px; user-select: none; filter: blur(4px); }
.btn-icon {
padding: 3px 8px; border-radius: 5px; font-size: 11px; cursor: pointer;
border: 1px solid var(--border); background: var(--surface2); color: var(--text-muted);
font-family: inherit;
}
.btn-icon:hover { color: var(--text); border-color: var(--text-muted); }
.view-locked-msg {
font-size: 13px; color: var(--text-muted); padding: 16px 0;
line-height: 1.6; text-align: center;
}
.view-locked-msg a { color: var(--accent); }
</style> </style>
</head> </head>
<body> <body>
@@ -465,7 +513,8 @@
</td> </td>
<td class="col-actions" data-label="操作"> <td class="col-actions" data-label="操作">
<div class="row-actions"> <div class="row-actions">
<button type="button" class="btn-row btn-edit" data-i18n="rowEdit">编辑</button> <button type="button" class="btn-row btn-view-secrets" data-i18n="rowView">查看密文</button>
<button type="button" class="btn-row btn-edit" data-i18n="rowEdit">编辑条目</button>
<button type="button" class="btn-row danger btn-del" data-i18n="rowDelete">删除</button> <button type="button" class="btn-row danger btn-del" data-i18n="rowDelete">删除</button>
</div> </div>
</td> </td>
@@ -498,7 +547,7 @@
<div id="edit-overlay" class="modal-overlay" hidden> <div id="edit-overlay" class="modal-overlay" hidden>
<div class="modal modal-wide" role="dialog" aria-modal="true" aria-labelledby="edit-title"> <div class="modal modal-wide" role="dialog" aria-modal="true" aria-labelledby="edit-title">
<div class="modal-title" id="edit-title" data-i18n="modalTitle">编辑条目</div> <div class="modal-title" id="edit-title" data-i18n="modalTitle">编辑条目信息</div>
<div id="edit-error" class="modal-error"></div> <div id="edit-error" class="modal-error"></div>
<div class="modal-field"><label for="edit-name" data-i18n="modalName">名称</label><input id="edit-name" type="text" autocomplete="off"></div> <div class="modal-field"><label for="edit-name" data-i18n="modalName">名称</label><input id="edit-name" type="text" autocomplete="off"></div>
<div class="modal-field"><label for="edit-type" data-i18n="modalType">类型</label><input id="edit-type" type="text" autocomplete="off"></div> <div class="modal-field"><label for="edit-type" data-i18n="modalType">类型</label><input id="edit-type" type="text" autocomplete="off"></div>
@@ -528,6 +577,17 @@
</div> </div>
</div> </div>
</div> </div>
<div id="view-overlay" class="modal-overlay" hidden>
<div class="modal modal-wide" role="dialog" aria-modal="true" aria-labelledby="view-title">
<div class="modal-title" id="view-title" data-i18n="viewTitle">查看条目密文</div>
<div id="view-entry-name" style="font-size:13px;color:var(--text-muted);margin-bottom:14px;font-family:'JetBrains Mono',monospace;"></div>
<div id="view-body"></div>
<div class="modal-footer">
<button type="button" class="btn-modal" id="view-close" data-i18n="modalCancel">关闭</button>
</div>
</div>
</div>
<script src="/static/i18n.js"></script> <script src="/static/i18n.js"></script>
<script id="secret-type-options" type="application/json">{{ secret_type_options_json|safe }}</script> <script id="secret-type-options" type="application/json">{{ secret_type_options_json|safe }}</script>
<script> <script>
@@ -551,9 +611,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
colTags: '标签', colTags: '标签',
colSecrets: '密文', colSecrets: '密文',
colActions: '操作', colActions: '操作',
rowEdit: '编辑', rowEdit: '编辑条目',
rowDelete: '删除', rowDelete: '删除',
modalTitle: '编辑条目', modalTitle: '编辑条目信息',
modalName: '名称', modalName: '名称',
modalType: '类型', modalType: '类型',
modalFolder: '文件夹', modalFolder: '文件夹',
@@ -591,6 +651,16 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
secretTypeInvalid: '类型不能为空', secretTypeInvalid: '类型不能为空',
prevPage: '上一页', prevPage: '上一页',
nextPage: '下一页', nextPage: '下一页',
rowView: '查看密文',
viewTitle: '查看条目密文',
viewNoSecrets: '该条目没有关联的密文字段。',
viewLockedMsg: '请先前往 <a href="/dashboard">MCP 配置页</a> 解锁密码短语,然后再查看密文。',
viewDecryptError: '解密失败,请确认密码短语与加密时一致。',
viewCopy: '复制',
viewCopied: '已复制',
viewShow: '显示',
viewHide: '隐藏',
viewLoading: '解密中…',
}, },
'zh-TW': { 'zh-TW': {
pageTitle: 'Secrets — 條目', pageTitle: 'Secrets — 條目',
@@ -609,9 +679,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
colTags: '標籤', colTags: '標籤',
colSecrets: '密文', colSecrets: '密文',
colActions: '操作', colActions: '操作',
rowEdit: '編輯', rowEdit: '編輯條目',
rowDelete: '刪除', rowDelete: '刪除',
modalTitle: '編輯條目', modalTitle: '編輯條目資訊',
modalName: '名稱', modalName: '名稱',
modalType: '類型', modalType: '類型',
modalFolder: '資料夾', modalFolder: '資料夾',
@@ -649,6 +719,16 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
secretTypeInvalid: '類型不能為空', secretTypeInvalid: '類型不能為空',
prevPage: '上一頁', prevPage: '上一頁',
nextPage: '下一頁', nextPage: '下一頁',
rowView: '查看密文',
viewTitle: '查看條目密文',
viewNoSecrets: '該條目沒有關聯的密文欄位。',
viewLockedMsg: '請先前往 <a href="/dashboard">MCP 設定頁</a> 解鎖密碼短語,再查看密文。',
viewDecryptError: '解密失敗,請確認密碼短語與加密時一致。',
viewCopy: '複製',
viewCopied: '已複製',
viewShow: '顯示',
viewHide: '隱藏',
viewLoading: '解密中…',
}, },
en: { en: {
pageTitle: 'Secrets — Entries', pageTitle: 'Secrets — Entries',
@@ -667,9 +747,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
colTags: 'Tags', colTags: 'Tags',
colSecrets: 'Secrets', colSecrets: 'Secrets',
colActions: 'Actions', colActions: 'Actions',
rowEdit: 'Edit', rowEdit: 'Edit entry',
rowDelete: 'Delete', rowDelete: 'Delete',
modalTitle: 'Edit entry', modalTitle: 'Edit entry details',
modalName: 'Name', modalName: 'Name',
modalType: 'Type', modalType: 'Type',
modalFolder: 'Folder', modalFolder: 'Folder',
@@ -706,7 +786,17 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
secretTypePlaceholder: 'Select type', secretTypePlaceholder: 'Select type',
secretTypeInvalid: 'Type cannot be empty', secretTypeInvalid: 'Type cannot be empty',
prevPage: 'Previous', prevPage: 'Previous',
nextPage: 'Next' nextPage: 'Next',
rowView: 'View secrets',
viewTitle: 'View entry secrets',
viewNoSecrets: 'This entry has no associated secret fields.',
viewLockedMsg: 'Please go to the <a href="/dashboard">MCP config page</a> to unlock your passphrase first.',
viewDecryptError: 'Decryption failed. Please verify your passphrase matches the one used when encrypting.',
viewCopy: 'Copy',
viewCopied: 'Copied',
viewShow: 'Show',
viewHide: 'Hide',
viewLoading: 'Decrypting…',
} }
}; };
@@ -757,6 +847,137 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
var currentEntryId = null; var currentEntryId = null;
var pendingDeleteId = null; var pendingDeleteId = null;
// ── View secrets modal ────────────────────────────────────────────────────
var viewOverlay = document.getElementById('view-overlay');
var viewEntryName = document.getElementById('view-entry-name');
var viewBody = document.getElementById('view-body');
function closeView() {
viewOverlay.hidden = true;
viewBody.innerHTML = '';
viewEntryName.textContent = '';
}
document.getElementById('view-close').addEventListener('click', closeView);
viewOverlay.addEventListener('click', function (e) {
if (e.target === viewOverlay) closeView();
});
function renderViewSecrets(secrets) {
viewBody.innerHTML = '';
var names = Object.keys(secrets);
if (names.length === 0) {
var msg = document.createElement('div');
msg.className = 'view-locked-msg';
msg.textContent = t('viewNoSecrets');
viewBody.appendChild(msg);
return;
}
names.forEach(function (name) {
var raw = secrets[name];
var valueStr = (raw === null || raw === undefined) ? '' :
(typeof raw === 'object') ? JSON.stringify(raw, null, 2) : String(raw);
var isPassword = (name === 'password' || name === 'passwd' || name === 'secret');
var masked = isPassword;
var row = document.createElement('div');
row.className = 'view-secret-row';
var header = document.createElement('div');
header.className = 'view-secret-header';
var nameSpan = document.createElement('span');
nameSpan.className = 'view-secret-name';
nameSpan.textContent = name;
header.appendChild(nameSpan);
var actions = document.createElement('div');
actions.className = 'view-secret-actions';
if (isPassword) {
var toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = 'btn-icon btn-toggle-mask';
toggleBtn.textContent = t('viewShow');
toggleBtn.addEventListener('click', function () {
masked = !masked;
valueEl.classList.toggle('masked', masked);
toggleBtn.textContent = masked ? t('viewShow') : t('viewHide');
});
actions.appendChild(toggleBtn);
}
var copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'btn-icon';
copyBtn.textContent = t('viewCopy');
copyBtn.addEventListener('click', function () {
navigator.clipboard.writeText(valueStr).then(function () {
copyBtn.textContent = t('viewCopied');
setTimeout(function () { copyBtn.textContent = t('viewCopy'); }, 1800);
}).catch(function () {});
});
actions.appendChild(copyBtn);
header.appendChild(actions);
row.appendChild(header);
var valueWrap = document.createElement('div');
valueWrap.className = 'view-secret-value-wrap';
var valueEl = document.createElement('div');
valueEl.className = 'view-secret-value' + (masked ? ' masked' : '');
valueEl.textContent = valueStr;
valueWrap.appendChild(valueEl);
row.appendChild(valueWrap);
viewBody.appendChild(row);
});
}
function openView(tr) {
var entryId = tr.getAttribute('data-entry-id');
var nameEl = tr.querySelector('.cell-name');
var entryName = nameEl ? nameEl.textContent.trim() : '';
var encKey = sessionStorage.getItem('enc_key');
viewEntryName.textContent = entryName;
viewBody.innerHTML = '';
viewOverlay.hidden = false;
if (!encKey) {
var msg = document.createElement('div');
msg.className = 'view-locked-msg';
msg.innerHTML = t('viewLockedMsg');
viewBody.appendChild(msg);
return;
}
var loadingMsg = document.createElement('div');
loadingMsg.className = 'view-locked-msg';
loadingMsg.textContent = t('viewLoading');
viewBody.appendChild(loadingMsg);
fetch('/api/entries/' + encodeURIComponent(entryId) + '/secrets/decrypt', {
credentials: 'same-origin',
headers: { 'X-Encryption-Key': encKey }
}).then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
return data;
});
}).then(function (data) {
renderViewSecrets(data.secrets || {});
}).catch(function (e) {
viewBody.innerHTML = '';
var errMsg = document.createElement('div');
errMsg.className = 'view-locked-msg';
errMsg.style.color = '#f85149';
errMsg.textContent = e.message || t('viewDecryptError');
viewBody.appendChild(errMsg);
});
}
// ─────────────────────────────────────────────────────────────────────────
function showEditErr(msg) { function showEditErr(msg) {
editError.textContent = msg || ''; editError.textContent = msg || '';
editError.classList.toggle('visible', !!msg); editError.classList.toggle('visible', !!msg);
@@ -981,6 +1202,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !editOverlay.hidden) closeEdit(); if (e.key === 'Escape' && !editOverlay.hidden) closeEdit();
if (e.key === 'Escape' && !deleteOverlay.hidden) closeDelete(); if (e.key === 'Escape' && !deleteOverlay.hidden) closeDelete();
if (e.key === 'Escape' && !viewOverlay.hidden) closeView();
}); });
function showDeleteErr(msg) { function showDeleteErr(msg) {
@@ -1295,6 +1517,12 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
}); });
document.querySelectorAll('tr[data-entry-id]').forEach(function (tr) { document.querySelectorAll('tr[data-entry-id]').forEach(function (tr) {
var viewBtn = tr.querySelector('.btn-view-secrets');
if (viewBtn) {
var hasSecrets = tr.querySelectorAll('.secret-chip').length > 0;
if (!hasSecrets) viewBtn.disabled = true;
viewBtn.addEventListener('click', function () { openView(tr); });
}
tr.querySelector('.btn-edit').addEventListener('click', function () { openEdit(tr); }); tr.querySelector('.btn-edit').addEventListener('click', function () { openEdit(tr); });
tr.querySelector('.btn-del').addEventListener('click', function () { tr.querySelector('.btn-del').addEventListener('click', function () {
var id = tr.getAttribute('data-entry-id'); var id = tr.getAttribute('data-entry-id');

View File

@@ -0,0 +1 @@
entry_id,secret_name,secret_value
1 entry_id secret_name secret_value

View File

@@ -0,0 +1,383 @@
#!/usr/bin/env python3
"""
Batch re-encrypt secret fields from a CSV file.
CSV format:
entry_id,secret_name,secret_value
019d...,api_key,sk-xxxx
019d...,password,hunter2
The script groups rows by entry_id, then calls `secrets_update` with `secrets_obj`
so the server re-encrypts the provided plaintext values with the current key.
Warnings:
- Keep the CSV outside version control whenever possible.
- Delete the filled CSV after the repair is complete.
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
import urllib.error
import urllib.request
from collections import OrderedDict
from pathlib import Path
from typing import Any
DEFAULT_USER_AGENT = "Cursor/3.0.12 (darwin arm64)"
REQUIRED_COLUMNS = {"entry_id", "secret_name", "secret_value"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Repair secret ciphertexts by re-submitting plaintext via secrets_update."
)
parser.add_argument(
"--csv",
required=True,
help="Path to CSV file with columns: entry_id,secret_name,secret_value",
)
parser.add_argument(
"--mcp-json",
default=str(Path.home() / ".cursor" / "mcp.json"),
help="Path to mcp.json used to resolve URL and headers",
)
parser.add_argument(
"--server",
default="secrets",
help="MCP server name inside mcp.json (default: secrets)",
)
parser.add_argument("--url", help="Override MCP URL")
parser.add_argument("--auth", help="Override Authorization header value")
parser.add_argument("--encryption-key", help="Override X-Encryption-Key header value")
parser.add_argument(
"--user-agent",
default=DEFAULT_USER_AGENT,
help=f"User-Agent header (default: {DEFAULT_USER_AGENT})",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Parse and print grouped updates without sending requests",
)
return parser.parse_args()
def load_mcp_config(path: str, server_name: str) -> dict[str, Any]:
data = json.loads(Path(path).read_text(encoding="utf-8"))
servers = data.get("mcpServers", {})
if server_name not in servers:
raise KeyError(f"Server '{server_name}' not found in {path}")
return servers[server_name]
def resolve_connection_settings(args: argparse.Namespace) -> tuple[str, str, str]:
server = load_mcp_config(args.mcp_json, args.server)
headers = server.get("headers", {})
url = args.url or server.get("url")
auth = args.auth or headers.get("Authorization")
encryption_key = args.encryption_key or headers.get("X-Encryption-Key")
if not url:
raise ValueError("Missing MCP URL. Pass --url or configure it in mcp.json.")
if not auth:
raise ValueError(
"Missing Authorization header. Pass --auth or configure it in mcp.json."
)
if not encryption_key:
raise ValueError(
"Missing X-Encryption-Key. Pass --encryption-key or configure it in mcp.json."
)
return url, auth, encryption_key
def load_updates(csv_path: str) -> OrderedDict[str, OrderedDict[str, str]]:
grouped: OrderedDict[str, OrderedDict[str, str]] = OrderedDict()
with Path(csv_path).open("r", encoding="utf-8-sig", newline="") as fh:
reader = csv.DictReader(fh)
fieldnames = set(reader.fieldnames or [])
missing = REQUIRED_COLUMNS - fieldnames
if missing:
raise ValueError(
"CSV missing required columns: " + ", ".join(sorted(missing))
)
for line_no, row in enumerate(reader, start=2):
entry_id = (row.get("entry_id") or "").strip()
secret_name = (row.get("secret_name") or "").strip()
secret_value = row.get("secret_value") or ""
if not entry_id and not secret_name and not secret_value:
continue
if not entry_id:
raise ValueError(f"Line {line_no}: entry_id is required")
if not secret_name:
raise ValueError(f"Line {line_no}: secret_name is required")
entry_group = grouped.setdefault(entry_id, OrderedDict())
if secret_name in entry_group:
raise ValueError(
f"Line {line_no}: duplicate secret_name '{secret_name}' for entry_id '{entry_id}'"
)
entry_group[secret_name] = secret_value
if not grouped:
raise ValueError("CSV contains no updates")
return grouped
def post_json(
url: str,
payload: dict[str, Any],
auth: str,
encryption_key: str,
user_agent: str,
session_id: str | None = None,
) -> tuple[int, str | None, str]:
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"Authorization": auth,
"X-Encryption-Key": encryption_key,
"User-Agent": user_agent,
}
if session_id:
headers["mcp-session-id"] = session_id
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return (
resp.status,
resp.headers.get("mcp-session-id") or session_id,
resp.read().decode("utf-8"),
)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
return exc.code, session_id, body
def parse_sse_json(body: str) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
for line in body.splitlines():
if line.startswith("data: {"):
items.append(json.loads(line[6:]))
return items
def initialize_session(
url: str, auth: str, encryption_key: str, user_agent: str
) -> str:
status, session_id, body = post_json(
url,
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "repair-script", "version": "1.0"},
},
},
auth,
encryption_key,
user_agent,
)
if status != 200 or not session_id:
raise RuntimeError(f"initialize failed: status={status}, body={body[:500]}")
status, _, body = post_json(
url,
{"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}},
auth,
encryption_key,
user_agent,
session_id,
)
if status not in (200, 202):
raise RuntimeError(
f"notifications/initialized failed: status={status}, body={body[:500]}"
)
return session_id
def load_entry_index(
url: str, auth: str, encryption_key: str, user_agent: str, session_id: str
) -> dict[str, tuple[str, str]]:
status, _, body = post_json(
url,
{
"jsonrpc": "2.0",
"id": 999_001,
"method": "tools/call",
"params": {
"name": "secrets_find",
"arguments": {
"limit": 1000,
},
},
},
auth,
encryption_key,
user_agent,
session_id,
)
items = parse_sse_json(body)
last = items[-1] if items else {"raw": body[:1000]}
if status != 200:
raise RuntimeError(
f"secrets_find failed: status={status}, body={body[:500]}"
)
if "error" in last:
raise RuntimeError(f"secrets_find returned error: {last}")
content = last.get("result", {}).get("content", [])
if not content:
raise RuntimeError("secrets_find returned no content")
payload = json.loads(content[0]["text"])
index: dict[str, tuple[str, str]] = {}
for entry in payload.get("entries", []):
entry_id = entry.get("id")
name = entry.get("name")
folder = entry.get("folder", "")
if entry_id and name is not None:
index[entry_id] = (name, folder)
return index
def call_secrets_update(
url: str,
auth: str,
encryption_key: str,
user_agent: str,
session_id: str,
request_id: int,
entry_id: str,
entry_name: str,
entry_folder: str,
secrets_obj: dict[str, str],
) -> dict[str, Any]:
payload = {
"jsonrpc": "2.0",
"id": request_id,
"method": "tools/call",
"params": {
"name": "secrets_update",
"arguments": {
"id": entry_id,
"name": entry_name,
"folder": entry_folder,
"secrets_obj": secrets_obj,
# Pass the key as an argument too, so repair can still work
# even when a client/proxy mishandles custom headers.
"encryption_key": encryption_key,
},
},
}
status, _, body = post_json(
url, payload, auth, encryption_key, user_agent, session_id
)
items = parse_sse_json(body)
last = items[-1] if items else {"raw": body[:1000]}
if status != 200:
raise RuntimeError(
f"secrets_update failed for {entry_id}: status={status}, body={body[:500]}"
)
return last
def main() -> int:
args = parse_args()
try:
url, auth, encryption_key = resolve_connection_settings(args)
updates = load_updates(args.csv)
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(f"Loaded {len(updates)} entries from {args.csv}")
if args.dry_run:
for entry_id, secrets_obj in updates.items():
print(
json.dumps(
{"id": entry_id, "secrets_obj": secrets_obj},
ensure_ascii=False,
indent=2,
)
)
return 0
try:
session_id = initialize_session(url, auth, encryption_key, args.user_agent)
entry_index = load_entry_index(
url, auth, encryption_key, args.user_agent, session_id
)
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
success = 0
failures = 0
for request_id, (entry_id, secrets_obj) in enumerate(updates.items(), start=2):
try:
if entry_id not in entry_index:
raise RuntimeError(
f"entry id not found in secrets_find results: {entry_id}"
)
entry_name, entry_folder = entry_index[entry_id]
result = call_secrets_update(
url,
auth,
encryption_key,
args.user_agent,
session_id,
request_id,
entry_id,
entry_name,
entry_folder,
secrets_obj,
)
if "error" in result:
failures += 1
print(
json.dumps(
{"id": entry_id, "status": "error", "result": result},
ensure_ascii=False,
),
file=sys.stderr,
)
else:
success += 1
print(
json.dumps(
{"id": entry_id, "status": "ok", "result": result},
ensure_ascii=False,
)
)
except Exception as exc:
failures += 1
print(f"{entry_id}: ERROR: {exc}", file=sys.stderr)
print(f"Done. success={success} failure={failures}")
return 0 if failures == 0 else 2
if __name__ == "__main__":
raise SystemExit(main())