fix(mcp): verify keychain authorization persists for background use

This commit is contained in:
t8y2
2026-10-16 16:08:54 +08:00
parent 49f3ded4dc
commit f35d2a9997
5 changed files with 90 additions and 8 deletions
+44 -6
View File
@@ -1,3 +1,6 @@
/// Hidden child entry point of `--authorize-keychain`; not for MCP client arguments.
pub const VERIFY_ARGUMENT: &str = "--verify-keychain-access";
pub struct BackgroundCredentialGuard {
#[cfg(all(target_os = "macos", feature = "os-keyring"))]
_interaction: security_framework::os::macos::keychain::KeychainUserInteractionLock,
@@ -32,11 +35,7 @@ pub fn authorize_keychain() -> Result<(), String> {
#[cfg(all(target_os = "macos", feature = "os-keyring"))]
pub fn authorize_keychain() -> Result<(), String> {
use security_framework::os::macos::{
code_signing::{Flags, SecCode, SecRequirement},
keychain::{SecKeychain, SecPreferencesDomain},
passwords::find_generic_password,
};
use security_framework::os::macos::code_signing::{Flags, SecCode, SecRequirement};
let untrusted = |error| format!("KEYCHAIN_AUTHORIZATION_UNTRUSTED: {error}");
let requirement: SecRequirement = RELEASE_REQUIREMENT.parse().map_err(|error| untrusted(format!("{error}")))?;
@@ -47,7 +46,7 @@ pub fn authorize_keychain() -> Result<(), String> {
let executable = std::env::current_exe().map_err(|error| untrusted(format!("{error}")))?;
let signature = std::process::Command::new("/usr/bin/codesign")
.args(["--display", "--verbose=4", "--requirements", "-"])
.arg(executable)
.arg(&executable)
.output()
.map_err(|error| untrusted(format!("cannot inspect signing identity: {error}")))?;
if !signature.status.success() {
@@ -57,6 +56,45 @@ pub fn authorize_keychain() -> Result<(), String> {
format!("{}\n{}", String::from_utf8_lossy(&signature.stdout), String::from_utf8_lossy(&signature.stderr));
validate_signature_info(&info).map_err(|error| untrusted(error.to_string()))?;
read_existing_key()?;
// "Allow" (once) satisfies the read above without persisting an ACL entry,
// and a granted request may be reused inside this process. Only a fresh
// background process proves what the MCP client will actually experience.
let verification = std::process::Command::new(executable)
.arg(VERIFY_ARGUMENT)
.output()
.map_err(|error| format!("KEYRING_ACCESS_FAILED: cannot verify background Keychain access: {error}"))?;
if !verification.status.success() {
return Err(format!(
"KEYRING_ACCESS_FAILED: authorization was not retained for background use ({}). \
Run --authorize-keychain again and choose Always Allow when macOS asks.",
String::from_utf8_lossy(&verification.stderr).trim()
));
}
Ok(())
}
/// Internal child of `--authorize-keychain`: one non-interactive read, exactly
/// what an MCP client's background launch performs.
#[cfg(not(all(target_os = "macos", feature = "os-keyring")))]
pub fn verify_background_access() -> Result<(), String> {
Err("KEYCHAIN_AUTHORIZATION_UNSUPPORTED: this command requires an official macOS build with OS keyring support"
.to_string())
}
#[cfg(all(target_os = "macos", feature = "os-keyring"))]
pub fn verify_background_access() -> Result<(), String> {
let _guard = disable_background_interaction()?;
read_existing_key()
}
#[cfg(all(target_os = "macos", feature = "os-keyring"))]
fn read_existing_key() -> Result<(), String> {
use security_framework::os::macos::{
keychain::{SecKeychain, SecPreferencesDomain},
passwords::find_generic_password,
};
// Match keyring's User-domain lookup exactly; never authorize a same-name
// item from another keychain, provision a new key, or rewrite an ACL.
let keychain = SecKeychain::default_for_domain(SecPreferencesDomain::User)
+7
View File
@@ -21,6 +21,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("DBX Keychain access authorized. Restart your MCP client.");
return Ok(());
}
if std::env::args_os().nth(1).is_some_and(|argument| argument == credentials::VERIFY_ARGUMENT) {
if std::env::args_os().len() != 2 {
return Err(format!("Usage: dbx-mcp {}", credentials::VERIFY_ARGUMENT).into());
}
credentials::verify_background_access()?;
return Ok(());
}
run_noninteractive(run())
}
+37
View File
@@ -107,3 +107,40 @@ fn background_keychain_denial_is_noninteractive_and_never_provisions_a_key() {
assert!(error.contains("--authorize-keychain"), "{error}");
assert!(!directory.path().join(".dbx/secret.key").exists());
}
#[test]
fn keychain_verification_child_rejects_extra_arguments_without_opening_storage() {
let directory = tempfile::tempdir().unwrap();
let data_dir = directory.path().join("must-not-exist");
let output = std::process::Command::new(env!("CARGO_BIN_EXE_dbx-mcp"))
.args(["--verify-keychain-access", "--transport", "stdio"])
.env("DBX_DATA_DIR", &data_dir)
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr).contains("Usage: dbx-mcp --verify-keychain-access"));
assert!(!data_dir.exists());
}
#[cfg(all(target_os = "macos", feature = "os-keyring"))]
#[test]
fn keychain_verification_child_reads_noninteractively_and_never_writes() {
let directory = tempfile::tempdir().unwrap();
let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_dbx-mcp"));
let log = intercept_keychain(&mut command, directory.path());
let output = command
.arg("--verify-keychain-access")
.env("DBX_DATA_DIR", directory.path().join("data"))
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert!(!output.status.success());
let reads = std::fs::read_to_string(&log).expect("the child must probe the intercepted credential provider");
assert!(reads.contains("lookup interaction=0"), "{reads}");
assert!(!reads.contains("interaction=1"), "{reads}");
assert!(!reads.contains("write"), "{reads}");
assert!(String::from_utf8_lossy(&output.stderr).contains("KEYRING_ACCESS_FAILED"));
assert!(!directory.path().join("data").exists());
}
+1 -1
View File
@@ -416,7 +416,7 @@ DBX 在 **设置 → MCP** 中保存一份权威策略,并在每次请求时
"$HOME/.dbx/bin/dbx-mcp" --authorize-keychain
```
Homebrew 或 npm 安装需要替换为对应的二进制路径。该命令先验证运行程序的官方签名和稳定 designated requirement,再读取已有 DBX 钥匙串条目;不会打开数据库、启动 MCP 传输、创建或替换密钥、导出密钥,也不会自行改写 ACL。macOS 首次迁移时可能要求确认一次,对已验证的 DBX 程序选择 **始终允许**,然后重启 MCP 客户端。已获授权的程序应直接成功。稳定签名使普通正式升级继续沿用授权,但不能绕过签名团队变更或用户撤销权限。
Homebrew 或 npm 安装需要替换为对应的二进制路径。该命令先验证运行程序的官方签名和稳定 designated requirement,再读取已有 DBX 钥匙串条目;不会打开数据库、启动 MCP 传输、创建或替换密钥、导出密钥,也不会自行改写 ACL。macOS 首次迁移时可能要求确认一次,对已验证的 DBX 程序选择 **始终允许**(不要选 **允许**)。随后命令会用一个全新的非交互进程再读取一次密钥;若授权未被保留,会以 `KEYRING_ACCESS_FAILED` 报错,因此看到成功提示即表示后台启动可用。最后重启 MCP 客户端。已获授权的程序应直接成功。稳定签名使普通正式升级继续沿用授权,但不能绕过签名团队变更或用户撤销权限。
不要将 `--authorize-keychain` 添加到 MCP 客户端的启动参数,它是执行后立即退出的一次性恢复命令。缺失的密钥仍需通过 Desktop 数据安全设置处理,该命令不能恢复丢失的密钥。Windows 和 Linux 不使用此 macOS 命令;应恢复同一系统用户的凭据库访问(或配置的无界面密钥文件),再重启 MCP。凭据库拒绝访问或密钥损坏时,不应自动生成替代加密密钥。
+1 -1
View File
@@ -416,7 +416,7 @@ Standalone macOS MCP runs without Keychain dialogs. If the existing encryption k
"$HOME/.dbx/bin/dbx-mcp" --authorize-keychain
```
Replace that path for a Homebrew or npm installation. The command verifies the running program's official signature and stable designated requirement before reading the existing DBX Keychain item. It does not open the database, start an MCP transport, create or replace a key, export key material, or rewrite an ACL itself. macOS may ask for confirmation once; choose **Always Allow** for the verified DBX executable, then restart your MCP client. An already-authorized executable should not prompt. Stable signing keeps that authorization valid across normal signed upgrades, not across a changed signing team or a revoked permission.
Replace that path for a Homebrew or npm installation. The command verifies the running program's official signature and stable designated requirement before reading the existing DBX Keychain item. It does not open the database, start an MCP transport, create or replace a key, export key material, or rewrite an ACL itself. macOS may ask for confirmation once; choose **Always Allow** (not **Allow**) for the verified DBX executable. The command then re-reads the key from a fresh non-interactive process and fails with `KEYRING_ACCESS_FAILED` if the authorization was not retained, so a success message means background launches will work. Restart your MCP client afterwards. An already-authorized executable should not prompt. Stable signing keeps that authorization valid across normal signed upgrades, not across a changed signing team or a revoked permission.
Do not put `--authorize-keychain` in your MCP client's arguments: it is a one-time recovery command and exits immediately. Missing keys still require the Desktop data-security setup; this command cannot recreate a lost key. Windows and Linux do not use this macOS command. On those platforms, restore access to the credential store for the same OS user (or the configured headless key file) and restart MCP; denied or corrupt providers must not cause a replacement encryption key to be generated.