fix(mcp): stop repeated keychain prompts and validate upgrade identity

This commit is contained in:
ccq
2026-10-04 01:58:32 +08:00
committed by GitHub
parent a9369cdae9
commit c737fde538
12 changed files with 477 additions and 13 deletions
+35 -1
View File
@@ -59,10 +59,25 @@ if (!routes[url]) process.exit(22);
fs.copyFileSync(path.join(root, routes[url]), args[args.indexOf('-o') + 1]);
`, { mode: 0o755 });
writeFileSync(join(mocks, "uname"), `#!/bin/sh\ncase "$1" in -s) echo '${platform.startsWith("darwin") ? "Darwin" : "Linux"}';; -m) echo '${platform.includes("arm64") ? "aarch64" : "x86_64"}';; esac\n`, { mode: 0o755 });
writeFileSync(join(mocks, "codesign"), `#!${process.execPath}
const args = process.argv.slice(2);
const mode = process.env.SIGNATURE_MODE || 'valid';
const requirement = 'identifier "com.dbx.app.mcp" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "TVDM965TDL"';
if (process.env.FIXTURE_PLATFORM.startsWith('linux')) process.exit(99);
if (args[0] === '--verify') {
if (!args.includes('--strict') || !args.includes('--all-architectures') || args[args.indexOf('-R') + 1] !== '=' + requirement) process.exit(98);
process.exit(['unsigned', 'adhoc', 'wrong-team', 'wrong-id', 'tampered'].includes(mode) ? 1 : 0);
}
if (args[0] !== '--display') process.exit(97);
console.error('Identifier=com.dbx.app.mcp');
console.error('TeamIdentifier=TVDM965TDL');
console.error('Timestamp=' + (mode === 'no-timestamp' ? 'none' : 'Oct 3, 2026'));
console.error('designated => ' + (mode === 'hash-bound' ? 'cdhash H"' + 'ab'.repeat(20) + '"' : requirement.replaceAll('exists', '/* exists */')));
`, { mode: 0o755 });
const installedBinary = join(home, ".dbx/bin/dbx-mcp");
const marker = join(home, ".dbx/bin/.dbx-mcp-version");
const run = (extra = {}) => spawnSync("sh", [installer], {
env: { ...process.env, HOME: home, SHELL: "/bin/zsh", ZDOTDIR: home, PATH: `${mocks}:${process.env.PATH}`, FIXTURE: root, ...extra },
env: { ...process.env, HOME: home, SHELL: "/bin/zsh", ZDOTDIR: home, PATH: `${mocks}:${process.env.PATH}`, FIXTURE: root, FIXTURE_PLATFORM: platform, ...extra },
encoding: "utf8", timeout: 30_000,
});
return { root, home, mocks, binary: installedBinary, marker, run };
@@ -162,6 +177,25 @@ for (const source of ["npm", "github"]) {
});
}
for (const platform of ["darwin-arm64", "darwin-x64"]) {
for (const mode of ["unsigned", "adhoc", "wrong-team", "wrong-id", "tampered", "hash-bound", "no-timestamp"]) {
test(`rejects ${mode} macOS signature before replacing an installation: ${platform}`, (context) => {
const setup = fixture(context, platform);
mkdirSync(join(setup.home, ".dbx/bin"), { recursive: true });
const original = "#!/bin/sh\nprintf 'dbx-mcp 0.4.95\\n'\n";
writeFileSync(setup.binary, original, { mode: 0o755 });
writeFileSync(setup.marker, "0.4.95\n");
const result = setup.run({ SIGNATURE_MODE: mode });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /signature verification failed/i);
assert.equal(readFileSync(setup.binary, "utf8"), original);
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.95\n");
assert.ok(!existsSync(join(setup.home, ".zshrc")));
assert.ok(!readdirSync(join(setup.home, ".dbx/bin")).some((name) => name.startsWith(".dbx-mcp-install.")));
});
}
}
test("stale marker without binary reinstalls, and bash users get migration guidance", (context) => {
const setup = fixture(context);
mkdirSync(join(setup.home, ".dbx/bin"), { recursive: true });
Generated
+1
View File
@@ -2538,6 +2538,7 @@ dependencies = [
"rustls 0.23.43",
"rustls-pemfile 2.2.0",
"schemars 1.2.2",
"security-framework 3.7.0",
"serde",
"serde_json",
"sqlparser",
@@ -669,13 +669,22 @@ mod tests {
#[test]
fn denied_or_corrupt_platform_key_never_creates_a_fallback() {
for provider_error in ["KEYRING_ACCESS_FAILED: permission denied", "SECRET_KEY_INVALID"] {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("secret.key");
let expected = provider_error.to_string();
let result = SecretCodec::resolve_platform_default(Some(path.clone()), true, move |_| Err(expected));
assert!(matches!(result, Err(error) if error == provider_error));
assert!(!path.exists());
for allow_create in [false, true] {
for provider_error in [
"KEYRING_ACCESS_FAILED: permission denied",
"KEYRING_ACCESS_FAILED: locked credential store",
"SECRET_KEY_INVALID",
] {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("secret.key");
let expected = provider_error.to_string();
let result = SecretCodec::resolve_platform_default(Some(path.clone()), allow_create, move |create| {
assert_eq!(create, allow_create);
Err(expected)
});
assert!(matches!(result, Err(error) if error == provider_error));
assert!(!path.exists());
}
}
}
+4 -1
View File
@@ -15,7 +15,7 @@ sqlite-bundled = ["dbx-core/sqlite-bundled"]
# keychain key Desktop provisioned during the data security upgrade; the
# Linux zigbuild legs cannot link the dbus Secret Service backend and keep
# falling back to the managed .dbx/secret.key file.
os-keyring = ["dbx-core/os-keyring"]
os-keyring = ["dbx-core/os-keyring", "dep:security-framework"]
transaction-test-hooks = []
[[bin]]
@@ -45,6 +45,9 @@ tower-http = { version = "0.6", features = ["cors"] }
uuid = { version = "1", features = ["v4"] }
url = "2"
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = { version = "3.7", optional = true }
[dev-dependencies]
rmcp = { version = "2.2.0", features = ["transport-streamable-http-client-reqwest"] }
rustls-pemfile = "2.2"
+153
View File
@@ -0,0 +1,153 @@
pub struct BackgroundCredentialGuard {
#[cfg(all(target_os = "macos", feature = "os-keyring"))]
_interaction: security_framework::os::macos::keychain::KeychainUserInteractionLock,
}
pub fn disable_background_interaction() -> Result<BackgroundCredentialGuard, String> {
Ok(BackgroundCredentialGuard {
#[cfg(all(target_os = "macos", feature = "os-keyring"))]
_interaction: security_framework::os::macos::keychain::SecKeychain::disable_user_interaction().map_err(
|error| format!("KEYRING_ACCESS_FAILED: cannot disable background Keychain interaction: {error}"),
)?,
})
}
pub fn startup_error(error: String) -> String {
#[cfg(all(target_os = "macos", feature = "os-keyring"))]
if error.contains("KEYRING_ACCESS_FAILED") {
return format!(
"{error} Unlock the user Keychain, then run the same official dbx-mcp executable with \
--authorize-keychain once in a terminal and restart your MCP client. \
Do not add that command to MCP client arguments."
);
}
error
}
#[cfg(not(all(target_os = "macos", feature = "os-keyring")))]
pub fn authorize_keychain() -> 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 authorize_keychain() -> Result<(), String> {
use security_framework::os::macos::{
code_signing::{Flags, SecCode, SecRequirement},
keychain::{SecKeychain, SecPreferencesDomain},
passwords::find_generic_password,
};
let untrusted = |error| format!("KEYCHAIN_AUTHORIZATION_UNTRUSTED: {error}");
let requirement: SecRequirement = RELEASE_REQUIREMENT.parse().map_err(|error| untrusted(format!("{error}")))?;
// Validate the running code, not just a possibly replaced file at its path.
SecCode::for_self(Flags::NONE)
.and_then(|code| code.check_validity(Flags::STRICT_VALIDATE, &requirement))
.map_err(|error| untrusted(format!("expected the official DBX Developer ID identity: {error}")))?;
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)
.output()
.map_err(|error| untrusted(format!("cannot inspect signing identity: {error}")))?;
if !signature.status.success() {
return Err(untrusted("cannot inspect signing identity".to_string()));
}
let info =
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()))?;
// 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)
.map_err(|error| format!("KEYRING_ACCESS_FAILED: cannot open the user Keychain: {error}"))?;
let (_password, _item) =
find_generic_password(Some(&[keychain]), "com.dbx.app.secret-store.v1", "local-data-encryption-key").map_err(
|error| {
if error.code() == -25300 {
"SECRET_KEY_UNAVAILABLE: no existing DBX Keychain key; use Desktop data-security setup".to_string()
} else {
format!("KEYRING_ACCESS_FAILED: existing DBX Keychain key cannot be authorized: {error}")
}
},
)?;
Ok(())
}
#[cfg(any(all(target_os = "macos", feature = "os-keyring"), test))]
const RELEASE_REQUIREMENT: &str = "identifier \"com.dbx.app.mcp\" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = \"TVDM965TDL\"";
#[cfg(any(all(target_os = "macos", feature = "os-keyring"), test))]
fn validate_signature_info(info: &str) -> Result<(), &'static str> {
let has_line = |expected| info.lines().any(|line| line == expected);
if !has_line("Identifier=com.dbx.app.mcp")
|| !has_line("TeamIdentifier=TVDM965TDL")
|| has_line("Signature=adhoc")
|| !info
.lines()
.any(|line| line.strip_prefix("Timestamp=").is_some_and(|value| !value.is_empty() && value != "none"))
{
return Err("missing official release identity or secure timestamp");
}
let normalize = |value: &str| -> String {
value.replace("/* exists */", "exists").chars().filter(|c| !c.is_whitespace() && *c != '"').collect()
};
let mut designated = info.lines().filter_map(|line| line.strip_prefix("designated => "));
if !designated.next().is_some_and(|value| normalize(value) == normalize(RELEASE_REQUIREMENT))
|| designated.next().is_some()
{
return Err("a stable Developer ID designated requirement is required");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn signature() -> String {
format!(
"Identifier=com.dbx.app.mcp\nTeamIdentifier=TVDM965TDL\nTimestamp=Oct 3, 2026\ndesignated => {}\n",
RELEASE_REQUIREMENT.replace("exists", "/* exists */")
)
}
#[test]
fn accepts_the_stable_release_designated_requirement() {
assert!(validate_signature_info(&signature()).is_ok());
}
#[test]
fn rejects_unstable_or_incomplete_signatures() {
let valid = signature();
for invalid in [
valid.replace("Identifier=com.dbx.app.mcp", "Identifier=com.dbx.other"),
valid.replace("TeamIdentifier=TVDM965TDL", "TeamIdentifier=OTHERTEAM1"),
valid.replace("Timestamp=Oct 3, 2026", "Timestamp=none"),
valid.replace("Timestamp=Oct 3, 2026", "Timestamp="),
valid.replace("Timestamp=Oct 3, 2026\n", ""),
format!("{valid}Signature=adhoc\n"),
valid.replace(&RELEASE_REQUIREMENT.replace("exists", "/* exists */"), "cdhash H\"abcd\""),
valid.replace("designated => ", "missing => "),
format!("{valid}designated => cdhash H\"abcd\"\n"),
] {
assert!(validate_signature_info(&invalid).is_err(), "{invalid}");
}
}
#[test]
fn startup_guidance_preserves_the_provider_error() {
let error = "SECRET_KEY_UNAVAILABLE: KEYRING_ACCESS_FAILED".to_string();
let result = startup_error(error.clone());
assert!(result.starts_with(&error));
assert_eq!(result.contains("--authorize-keychain"), cfg!(all(target_os = "macos", feature = "os-keyring")));
assert_eq!(startup_error("DATA_MIGRATION_REQUIRED".to_string()), "DATA_MIGRATION_REQUIRED");
}
#[cfg(not(all(target_os = "macos", feature = "os-keyring")))]
#[test]
fn unsupported_platform_policy_does_not_attempt_keychain_authorization() {
assert!(authorize_keychain().unwrap_err().starts_with("KEYCHAIN_AUTHORIZATION_UNSUPPORTED"));
assert!(disable_background_interaction().is_ok());
}
}
+66 -3
View File
@@ -6,12 +6,35 @@ use dbx_mcp::{
};
use rmcp::ServiceExt;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
mod credentials;
fn main() -> Result<(), Box<dyn std::error::Error>> {
if std::env::args_os().nth(1).is_some_and(|argument| argument == "--version") {
println!("dbx-mcp {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
if std::env::args_os().nth(1).is_some_and(|argument| argument == "--authorize-keychain") {
if std::env::args_os().len() != 2 {
return Err("Usage: dbx-mcp --authorize-keychain".into());
}
credentials::authorize_keychain()?;
println!("DBX Keychain access authorized. Restart your MCP client.");
return Ok(());
}
run_noninteractive(run())
}
fn run_noninteractive(
future: impl std::future::Future<Output = Result<(), Box<dyn std::error::Error>>>,
) -> Result<(), Box<dyn std::error::Error>> {
// This process-wide policy belongs only to the standalone binary and must
// outlive the runtime, including credential reads during task shutdown.
let _credential_guard = credentials::disable_background_interaction().map_err(std::io::Error::other)?;
let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build()?;
runtime.block_on(future)
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
// Diagnostics (plugin-tool discovery, bridge fallbacks) go to stderr,
// which is safe under the stdio transport. RUST_LOG controls the level;
// warnings are on by default so silent degradation stays visible.
@@ -24,7 +47,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
)
} else {
let db_path = dbx_mcp::paths::storage_db_path().map_err(std::io::Error::other)?;
Arc::new(LocalBackend::open(&db_path).await.map_err(std::io::Error::other)?)
Arc::new(
LocalBackend::open(&db_path)
.await
.map_err(|error| std::io::Error::other(credentials::startup_error(error)))?,
)
};
match runtime.transport {
McpTransport::Stdio => {
@@ -40,3 +67,39 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
}
#[cfg(all(test, target_os = "macos", feature = "os-keyring"))]
mod tests {
use super::*;
use security_framework::os::macos::keychain::SecKeychain;
use std::sync::atomic::{AtomicBool, Ordering};
struct ShutdownProbe(Arc<AtomicBool>);
impl Drop for ShutdownProbe {
fn drop(&mut self) {
self.0.store(matches!(SecKeychain::user_interaction_allowed(), Ok(false)), Ordering::SeqCst);
}
}
#[test]
fn background_interaction_is_disabled_through_runtime_shutdown() {
let original = SecKeychain::user_interaction_allowed().unwrap();
let shutdown_was_noninteractive = Arc::new(AtomicBool::new(false));
let observed = Arc::clone(&shutdown_was_noninteractive);
run_noninteractive(async move {
assert!(!SecKeychain::user_interaction_allowed().unwrap());
let (started, ready) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let _probe = ShutdownProbe(observed);
started.send(()).unwrap();
std::future::pending::<()>().await;
});
ready.await.unwrap();
Ok(())
})
.unwrap();
assert!(shutdown_was_noninteractive.load(Ordering::SeqCst));
assert_eq!(SecKeychain::user_interaction_allowed().unwrap(), original);
}
}
+48
View File
@@ -0,0 +1,48 @@
#include <Security/Security.h>
#include <stdio.h>
#include <stdlib.h>
static void record(const char *operation) {
Boolean allowed = true;
SecKeychainGetUserInteractionAllowed(&allowed);
const char *path = getenv("DBX_TEST_KEYCHAIN_POLICY_LOG");
FILE *log = path ? fopen(path, "a") : NULL;
if (log) {
fprintf(log, "%s interaction=%d\n", operation, (int)allowed);
fclose(log);
}
}
static OSStatus denied_read(CFTypeRef keychain, UInt32 service_len, const char *service,
UInt32 account_len, const char *account, UInt32 *length, void **password, SecKeychainItemRef *item) {
record("read");
return errSecInteractionNotAllowed;
}
static OSStatus denied_lookup(SecPreferencesDomain domain, SecKeychainRef *keychain) {
record("lookup");
return errSecInteractionNotAllowed;
}
static OSStatus denied_add(SecKeychainRef keychain, UInt32 service_len, const char *service,
UInt32 account_len, const char *account, UInt32 length, const void *password, SecKeychainItemRef *item) {
record("write");
return errSecInteractionNotAllowed;
}
static OSStatus denied_modify(SecKeychainItemRef item, const SecKeychainAttributeList *attributes,
UInt32 length, const void *password) {
record("write");
return errSecInteractionNotAllowed;
}
// Interpose only inside the test child; no real credential reads or writes reach Security.framework.
#define INTERPOSE(replacement, original) \
__attribute__((used)) static struct { const void *new_function; const void *old_function; } \
interpose_##original __attribute__((section("__DATA,__interpose"))) = \
{ (const void *)(replacement), (const void *)(original) }
INTERPOSE(denied_read, SecKeychainFindGenericPassword);
INTERPOSE(denied_lookup, SecKeychainCopyDomainDefault);
INTERPOSE(denied_add, SecKeychainAddGenericPassword);
INTERPOSE(denied_modify, SecKeychainItemModifyAttributesAndData);
+93
View File
@@ -14,3 +14,96 @@ fn version_exits_without_initializing_storage_or_transport() {
assert!(output.stderr.is_empty());
assert!(!directory.path().join("must-not-exist").exists());
}
#[test]
fn keychain_authorization_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(["--authorize-keychain", "--transport", "stdio"])
.env("DBX_DATA_DIR", &data_dir)
.env("DBX_WEB_URL", "not a URL")
.env("DBX_MCP_TRANSPORT", "invalid")
.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 --authorize-keychain"));
assert!(!data_dir.exists());
}
#[test]
fn keychain_authorization_rejects_untrusted_or_unsupported_builds_before_storage() {
let directory = tempfile::tempdir().unwrap();
let data_dir = directory.path().join("must-not-exist");
let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_dbx-mcp"));
#[cfg(all(target_os = "macos", feature = "os-keyring"))]
let log = intercept_keychain(&mut command, directory.path());
let output = command
.arg("--authorize-keychain")
.env("DBX_DATA_DIR", &data_dir)
.env("DBX_WEB_URL", "not a URL")
.env("DBX_MCP_TRANSPORT", "invalid")
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
let error = String::from_utf8_lossy(&output.stderr);
if cfg!(all(target_os = "macos", feature = "os-keyring")) {
assert!(error.contains("KEYCHAIN_AUTHORIZATION_UNTRUSTED"), "{error}");
} else {
assert!(error.contains("KEYCHAIN_AUTHORIZATION_UNSUPPORTED"), "{error}");
}
assert!(!data_dir.exists());
#[cfg(all(target_os = "macos", feature = "os-keyring"))]
assert!(!log.exists(), "untrusted recovery must not query the Keychain");
}
#[cfg(all(target_os = "macos", feature = "os-keyring"))]
fn intercept_keychain(command: &mut std::process::Command, directory: &std::path::Path) -> std::path::PathBuf {
let library = directory.join("keychain-policy.dylib");
let log = directory.join("keychain-policy.log");
let build = std::process::Command::new("/usr/bin/clang")
.args(["-dynamiclib", "-Wno-deprecated-declarations", "-framework", "Security", "-o"])
.arg(&library)
.arg(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/macos-keychain-policy.c"))
.output()
.unwrap();
assert!(build.status.success(), "{}", String::from_utf8_lossy(&build.stderr));
command.env("DYLD_INSERT_LIBRARIES", &library).env("DBX_TEST_KEYCHAIN_POLICY_LOG", &log);
log
}
#[cfg(all(target_os = "macos", feature = "os-keyring"))]
#[test]
fn background_keychain_denial_is_noninteractive_and_never_provisions_a_key() {
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
.env("HOME", directory.path())
.env("DBX_DATA_DIR", directory.path().join("data"))
.env_remove("DBX_WEB_URL")
.env_remove("DBX_SECRET_KEY")
.env_remove("DBX_SECRET_KEY_FILE")
.env_remove("DBX_MCP_TRANSPORT")
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert!(!output.status.success());
let reads = std::fs::read_to_string(&log).unwrap_or_else(|error| {
panic!(
"the child must probe the intercepted credential provider: {error}; {}",
String::from_utf8_lossy(&output.stderr)
)
});
assert!(reads.contains("lookup interaction=0"), "{reads}");
assert!(!reads.contains("interaction=1"), "{reads}");
assert!(!reads.contains("write"), "{reads}");
let error = String::from_utf8_lossy(&output.stderr);
assert!(error.contains("KEYRING_ACCESS_FAILED"), "{error}");
assert!(error.contains("--authorize-keychain"), "{error}");
assert!(!directory.path().join(".dbx/secret.key").exists());
}
+14
View File
@@ -43,6 +43,8 @@ Homebrew 安装后,客户端配置使用 `echo "$(brew --prefix)/bin/dbx-mcp"`
npm/npx 通道保持兼容。DBX 桌面端现在默认安装独立原生服务,并为已有 npm 用户显示推荐的原生迁移入口。迁移会保留 npm 包作为兜底,也不会自动改写外部客户端文件:请将启动命令替换为页面生成的原生绝对路径,删除 Node/npx 参数并保留 `env`。仍可选择执行 `npm rm -g @dbx-app/mcp-server` 清理旧包。桌面端可以更新和卸载当前的独立原生或 Homebrew 安装;卸载独立原生版本后,保留的 npm 安装会重新成为兜底。
macOS 上,Shell 安装器还会在替换二进制前验证官方 Developer ID 签名及稳定的 `com.dbx.app.mcp` designated requirement。未签名、临时签名、签名身份不符或绑定单次构建哈希的安装包会被拒绝,已安装的程序和版本标记保持不变。此校验针对 Shell 安装器,Homebrew 和 npm 保持各自的安装流程。
## npm 快速开始
<Steps>
@@ -393,6 +395,18 @@ DBX 在 **设置 → MCP** 中保存一份权威策略,并在每次请求时
## 常见问题
### 升级后的凭据访问
独立 macOS MCP 在后台运行时不弹出钥匙串对话框。已有加密密钥被锁定或拒绝访问时,启动会保留 `KEYRING_ACCESS_FAILED`(可能包含在 `SECRET_KEY_UNAVAILABLE` 中),而不是反复要求输入密码。先解锁登录钥匙串;对于旧版绑定构建哈希的授权,在终端对 **MCP 客户端实际使用的同一个官方程序**执行一次:
```bash
"$HOME/.dbx/bin/dbx-mcp" --authorize-keychain
```
Homebrew 或 npm 安装需要替换为对应的二进制路径。该命令先验证运行程序的官方签名和稳定 designated requirement,再读取已有 DBX 钥匙串条目;不会打开数据库、启动 MCP 传输、创建或替换密钥、导出密钥,也不会自行改写 ACL。macOS 首次迁移时可能要求确认一次,对已验证的 DBX 程序选择 **始终允许**,然后重启 MCP 客户端。已获授权的程序应直接成功。稳定签名使普通正式升级继续沿用授权,但不能绕过签名团队变更或用户撤销权限。
不要将 `--authorize-keychain` 添加到 MCP 客户端的启动参数,它是执行后立即退出的一次性恢复命令。缺失的密钥仍需通过 Desktop 数据安全设置处理,该命令不能恢复丢失的密钥。Windows 和 Linux 不使用此 macOS 命令;应恢复同一系统用户的凭据库访问(或配置的无界面密钥文件),再重启 MCP。凭据库拒绝访问或密钥损坏时,不应自动生成替代加密密钥。
<Accordions>
<Accordion title="npm 没有安装当前平台包">不要使用 `--no-optional`,重新安装 `@dbx-app/mcp-server`,并用 `node -p 'process.platform + "-" + process.arch'` 检查平台。Alpine Linux 默认使用 musl,目前不在 Linux 发布包支持范围内。</Accordion>
<Accordion title="找不到 dbx.db">将 `DBX_DATA_DIR` 设置为包含 `dbx.db` 的目录。Windows 便携版通常是 `DBX.exe` 旁边的 `data` 目录。</Accordion>
+14
View File
@@ -32,6 +32,8 @@ The installer verifies the download, installs `dbx-mcp` into `~/.dbx/bin`, and p
Rerun the same install command to upgrade. A matching version reports `already up to date`. Version probing uses `dbx-mcp --version`, falling back to `~/.dbx/bin/.dbx-mcp-version` for older binaries. Offline installation fails without replacing an existing binary. Download sources are tried in order: npmjs, npmmirror, then GitHub Releases; an integrity mismatch stops installation.
On macOS, the shell installer also verifies the official Developer ID signature and stable `com.dbx.app.mcp` designated requirement before replacing the binary. Unsigned, ad-hoc, unexpected-signer or hash-bound artifacts are rejected, leaving the installed binary and version marker unchanged. This check applies to the shell installer; Homebrew and npm retain their own installation flows.
Homebrew is also available on macOS and Linux:
```bash
@@ -393,6 +395,18 @@ Updated servers do not let `DBX_MCP_ALLOW_WRITES` or `DBX_MCP_ALLOW_DANGEROUS_SQ
## Troubleshooting
### Credential access after an upgrade
Standalone macOS MCP runs without Keychain dialogs. If the existing encryption key is locked or denied, startup fails with `KEYRING_ACCESS_FAILED` (possibly inside `SECRET_KEY_UNAVAILABLE`) instead of repeatedly asking for a password. Unlock your login keychain first. For an older hash-bound authorization, run the **same official executable used by your MCP client** once in a terminal:
```bash
"$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.
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.
<Accordions>
<Accordion title="The optional platform package was not installed">Reinstall without `--no-optional`, then verify the platform with `node -p 'process.platform + "-" + process.arch'`. The current platform must appear in the supported platform table.</Accordion>
<Accordion title="dbx.db cannot be found">Set `DBX_DATA_DIR` to the folder containing `dbx.db`. For Windows portable builds, this is normally the `data` folder beside `DBX.exe`.</Accordion>
+20 -1
View File
@@ -47,6 +47,24 @@ json_quote() {
}
} END { printf "\"" }'
}
verify_macos_signature() {
command -v codesign >/dev/null 2>&1 || fail 'macOS signature verification failed: codesign is unavailable; existing installation unchanged.'
requirement='identifier "com.dbx.app.mcp" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "TVDM965TDL"'
codesign --verify --strict --all-architectures -R "=$requirement" "$1" >/dev/null 2>&1 ||
fail 'macOS signature verification failed: expected the official DBX Developer ID signature; existing installation unchanged.'
signature=$(codesign --display --verbose=4 --requirements - "$1" 2>&1) ||
fail 'macOS signature verification failed: cannot inspect signing identity; existing installation unchanged.'
printf '%s\n' "$signature" | grep -Fxq 'Identifier=com.dbx.app.mcp' &&
printf '%s\n' "$signature" | grep -Fxq 'TeamIdentifier=TVDM965TDL' &&
printf '%s\n' "$signature" | grep -Eq '^Timestamp=.+$' &&
! printf '%s\n' "$signature" | grep -Eq '^Timestamp=none$|^Signature=adhoc$' ||
fail 'macOS signature verification failed: missing release signing identity or timestamp; existing installation unchanged.'
# A valid signature with a hash-bound requirement would lose Keychain consent on the next upgrade.
designated=$(printf '%s\n' "$signature" | sed -n 's/^designated => //p' | sed 's|/\* *exists *\*/|exists|g' | tr -d '[:space:]"')
stable=$(printf '%s\n' "$requirement" | tr -d '[:space:]"')
[ "$designated" = "$stable" ] ||
fail 'macOS signature verification failed: unstable designated requirement; existing installation unchanged.'
}
print_configs() {
quoted_binary=$(printf '%s' "$binary" | json_quote)
printf '\nClaude Code (.mcp.json), Cursor (.cursor/mcp.json), ZCode config, generic JSON:\n'
@@ -73,7 +91,7 @@ configure_path() {
fi
}
for dependency in curl tar openssl awk sed grep sort; do
for dependency in curl tar openssl awk sed grep sort tr; do
command -v "$dependency" >/dev/null 2>&1 || fail "Required command not found: $dependency"
done
operating_system=$(uname -s)
@@ -144,6 +162,7 @@ if [ "$downloaded" = false ]; then
fi
[ -s "$work_dir/dbx-mcp" ] || fail 'Downloaded binary is empty.'
chmod 755 "$work_dir/dbx-mcp"
if [ "$operating_system" = Darwin ]; then verify_macos_signature "$work_dir/dbx-mcp"; fi
printf '%s\n' "$version" > "$work_dir/version"
mv -f "$work_dir/dbx-mcp" "$binary"
mv -f "$work_dir/version" "$marker"
+13
View File
@@ -110,6 +110,19 @@ test(
assert.notEqual(first.hash, second.hash);
assert.equal(first.requirement, second.requirement);
assert.match(execute(binary, [identity.keychain]), /build=2 status=0/);
compile(3);
signDevelopmentBinary(binary, directory);
const third = signature();
assert.notEqual(second.hash, third.hash);
assert.equal(second.requirement, third.requirement);
assert.match(execute(binary, [identity.keychain]), /build=3 status=0/);
execute("/usr/bin/security", ["lock-keychain", identity.keychain]);
const locked = spawnSync(binary, [identity.keychain], { encoding: "utf8", timeout: 5000 });
assert.equal(locked.error, undefined, "a locked keychain must fail without waiting for a dialog");
assert.equal(locked.status, 3);
assert.match(locked.stdout, /status=-\d+/);
execute("/usr/bin/security", ["unlock-keychain", "-p", identity.password, identity.keychain]);
assert.match(execute(binary, [identity.keychain]), /build=3 status=0/);
assert.equal(ensureSigningIdentity(directory).fingerprint, identity.fingerprint);
} finally {
if (identity) removeSigningKeychain(identity.keychain);