From b0fcb83592aedb58b1f06c88971181508050a09b Mon Sep 17 00:00:00 2001 From: voson Date: Mon, 6 Apr 2026 16:39:26 +0800 Subject: [PATCH] =?UTF-8?q?release(secrets-mcp):=200.5.9=20=E2=80=94=20use?= =?UTF-8?q?rs.key=5Fversion=20=E4=B8=8E=E4=BC=9A=E8=AF=9D=E5=A4=B1?= =?UTF-8?q?=E6=95=88=EF=BC=9BWeb=20=E6=9D=A1=E7=9B=AE=E8=A7=A3=E5=AF=86=20?= =?UTF-8?q?API=20=E4=B8=8E=E5=88=97=E8=A1=A8=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 2 +- crates/secrets-core/src/db.rs | 3 + crates/secrets-core/src/models.rs | 2 + crates/secrets-core/src/service/user.rs | 9 +- crates/secrets-mcp/Cargo.toml | 2 +- crates/secrets-mcp/src/web.rs | 179 ++++++++++++--- crates/secrets-mcp/templates/entries.html | 262 ++++++++++++++++++++-- 7 files changed, 406 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9b1e4d9..aaeef28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2066,7 +2066,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.5.8" +version = "0.5.9" dependencies = [ "anyhow", "askama", diff --git a/crates/secrets-core/src/db.rs b/crates/secrets-core/src/db.rs index 4000f13..4013fca 100644 --- a/crates/secrets-core/src/db.rs +++ b/crates/secrets-core/src/db.rs @@ -428,6 +428,9 @@ async fn migrate_schema(pool: &PgPool) -> Result<()> { -- ── Drop legacy actor columns ───────────────────────────────────────────── ALTER TABLE secrets_history 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) diff --git a/crates/secrets-core/src/models.rs b/crates/secrets-core/src/models.rs index 16e0b3c..9313f62 100644 --- a/crates/secrets-core/src/models.rs +++ b/crates/secrets-core/src/models.rs @@ -200,6 +200,8 @@ pub struct User { pub key_params: Option, /// Plaintext API key for MCP Bearer authentication. Auto-created on first login. pub api_key: Option, + /// Incremented each time the passphrase is changed; used to invalidate sessions on other devices. + pub key_version: i64, pub created_at: DateTime, pub updated_at: DateTime, } diff --git a/crates/secrets-core/src/service/user.rs b/crates/secrets-core/src/service/user.rs index 69e8319..acf6a56 100644 --- a/crates/secrets-core/src/service/user.rs +++ b/crates/secrets-core/src/service/user.rs @@ -31,7 +31,7 @@ pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result if let Some(oa) = existing { 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", ) .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( "INSERT INTO users (email, name, avatar_url) \ 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(&display_name) @@ -108,7 +108,8 @@ pub async fn change_user_key( } sqlx::query( - "UPDATE users SET key_salt = $1, key_check = $2, key_params = $3, updated_at = NOW() \ + "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) @@ -146,7 +147,7 @@ pub async fn update_user_key_setup( /// Fetch a user by ID. pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result> { 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", ) .bind(user_id) diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml index b3f4b29..9140ab9 100644 --- a/crates/secrets-mcp/Cargo.toml +++ b/crates/secrets-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets-mcp" -version = "0.5.8" +version = "0.5.9" edition.workspace = true [[bin]] diff --git a/crates/secrets-mcp/src/web.rs b/crates/secrets-mcp/src/web.rs index 02b8d3a..460a4e2 100644 --- a/crates/secrets-mcp/src/web.rs +++ b/crates/secrets-mcp/src/web.rs @@ -22,6 +22,7 @@ use secrets_core::service::{ api_key::{ensure_api_key, regenerate_api_key}, audit_log::{count_for_user, list_for_user}, delete::delete_by_id, + get_secret::get_all_secrets_by_id, search::{SearchParams, count_entries, fetch_secret_schemas, ilike_pattern, list_entries}, update::{UpdateEntryFieldsByIdParams, update_fields_by_id}, user::{ @@ -37,6 +38,7 @@ const SESSION_USER_ID: &str = "user_id"; const SESSION_OAUTH_STATE: &str = "oauth_state"; const SESSION_OAUTH_BIND_MODE: &str = "oauth_bind_mode"; const SESSION_LOGIN_PROVIDER: &str = "login_provider"; +const SESSION_KEY_VERSION: &str = "key_version"; // ── Template types ──────────────────────────────────────────────────────────── @@ -175,6 +177,57 @@ async fn current_user_id(session: &Session) -> Option { } } +/// 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 { + 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 = match session.get::(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) -> Option { let trust_proxy = std::env::var("TRUST_PROXY") .as_deref() @@ -267,6 +320,10 @@ pub fn web_router() -> Router { "/api/entries/{entry_id}/secrets/{secret_id}", 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/check-name", get(api_secret_check_name)) } @@ -542,6 +599,9 @@ where ); 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( &state.pool, @@ -571,16 +631,9 @@ async fn dashboard( State(state): State, session: Session, ) -> Result { - let Some(user_id) = current_user_id(&session).await else { - return Ok(Redirect::to("/login").into_response()); - }; - - 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 user = match require_valid_user(&state.pool, &session, "dashboard").await { + Ok(u) => u, + Err(r) => return Ok(r), }; let tmpl = DashboardTemplate { @@ -599,17 +652,11 @@ async fn entries_page( session: Session, Query(q): Query, ) -> Result { - let Some(user_id) = current_user_id(&session).await else { - return Ok(Redirect::to("/login").into_response()); - }; - - 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 = match require_valid_user(&state.pool, &session, "entries_page").await { + Ok(u) => u, + Err(r) => return Ok(r), }; + let user_id = user.id; let folder_filter = q .folder @@ -855,17 +902,11 @@ async fn audit_page( session: Session, Query(aq): Query, ) -> Result { - let Some(user_id) = current_user_id(&session).await else { - return Ok(Redirect::to("/login").into_response()); - }; - - 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 = match require_valid_user(&state.pool, &session, "audit_page").await { + Ok(u) => u, + Err(r) => return Ok(r), }; + let user_id = user.id; let page = aq.page.unwrap_or(1).max(1); @@ -1172,6 +1213,25 @@ async fn api_key_change( 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 })) } @@ -1811,6 +1871,65 @@ async fn oauth_protected_resource_metadata(State(state): State) -> imp ) } +// ── Decrypt entry secrets (Web UI) ─────────────────────────────────────────── + +async fn api_entry_secrets_decrypt( + State(state): State, + session: Session, + headers: HeaderMap, + Path(entry_id): Path, +) -> Result, 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 ──────────────────────────────────────────────────────────────────── fn render_template(tmpl: T) -> Result { diff --git a/crates/secrets-mcp/templates/entries.html b/crates/secrets-mcp/templates/entries.html index c9d48b6..eec3ff9 100644 --- a/crates/secrets-mcp/templates/entries.html +++ b/crates/secrets-mcp/templates/entries.html @@ -128,7 +128,7 @@ border-collapse: separate; 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 { color: var(--text-muted); font-size: 12px; @@ -138,24 +138,28 @@ top: 0; z-index: 2; background: var(--surface); + text-align: center; + vertical-align: middle; } td { font-size: 13px; line-height: 1.45; } tbody tr:nth-child(2n) td { background: rgba(255, 255, 255, 0.01); } .mono { font-family: 'JetBrains Mono', monospace; } - .col-type { min-width: 108px; width: 1%; } - .col-name { min-width: 180px; max-width: 260px; } + .col-type { min-width: 108px; width: 1%; text-align: center; vertical-align: middle; } + .col-name { min-width: 180px; max-width: 260px; text-align: center; vertical-align: middle; } .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-actions { min-width: 132px; width: 1%; } + .col-actions { min-width: 132px; width: 1%; text-align: center; vertical-align: middle; } .cell-name, .cell-tags-val { overflow-wrap: anywhere; word-break: break-word; } .cell-notes { min-width: 260px; max-width: 360px; } .notes-scroll { - max-height: 120px; + height: calc(1.5em * 2 + 16px); + min-height: calc(1.5em * 2 + 16px); overflow: auto; + resize: vertical; white-space: pre-wrap; word-break: break-word; padding: 8px; @@ -170,7 +174,7 @@ max-width: 360px; max-height: 120px; overflow: auto; } .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-chip { display: inline-flex; @@ -312,7 +316,11 @@ color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace; 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-readonly-value { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; @@ -348,6 +356,9 @@ margin-bottom: 4px; text-transform: uppercase; 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; } } .pagination { @@ -368,6 +379,43 @@ .page-info { 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); } @@ -465,7 +513,8 @@
- + +
@@ -498,7 +547,7 @@ + +