给 AI 编程助手造一个本地凭据库:MCP 工具设计、密钥注入和输出打码的工程细节

0 阅读12分钟

这篇是工程向的复盘。我们在做一个本地密码管理器(小密盾),其中一块是让 Claude Code、Codex 这类 AI 编程助手能用密钥,但尽量看不到明文。下面按「需求 → 架构 → 关键实现 → 踩过的坑 → 怎么测 → 局限」来讲,代码片段都做了简化。

一、需求:AI 要用密钥,但明文最好别进上下文

AI 编程助手干活时,经常需要生产库密码、云厂商 AccessKey、部署令牌。最常见的交法是贴进对话,问题在于明文会被复制到很多你管不到的地方:服务商侧的请求、本地会话记录、命令行参数、命令输出、截图。

我们给自己定了几条要求:

  1. 本地解密:密钥只在用户自己的设备上解开,服务端不持有密码库。
  2. 按条授权:AI 默认什么都看不到,用户一条条开放。
  3. 可用不可见:开放了的条目,AI 能拿去跑命令,但读到的是打码,命令输出里出现也打码。
  4. 人在回路:电脑锁着时 AI 要用密钥,推到手机上确认。
  5. 开放接入:不绑某一家 AI,用 MCP + 命令行。
  6. 可审计:每次读取都有记录,能一键收回。

二、整体架构

整体架构

核心思路是「所有判断都在桌面 App 里做」:

  • 桌面 App(Flutter 界面 + Rust 核心)解锁后持有会话,负责 AI 权限判定、调用方识别、打码和审计。加密核心是独立的 Rust crate,开源。
  • AI 只有两个入口,都是同一个签名过的命令行程序 rootkey:
    • rootkey mcp:stdio 的 MCP server,本身不碰密钥,只把工具调用转给 App;
    • rootkey run:AI 在 shell 里执行,把密钥注入子进程。
  • 命令行和 App 之间走本机 Unix socket(权限 0600)。macOS 上 App 会用对端的 audit token 校验代码签名。
  • 浏览器扩展是瘦客户端:不做加密、不缓存,按页面来源向 App 要单条凭据。
  • 网络另一端只有三样东西:用户自己的网盘(只收密文)、授权服务器(只管激活码,令牌用 Ed25519 签名、本机离线验)、锁屏放行用的中转服务器(只转加密信封)。

密钥层级简单交代一下:主密码 → Argon2id(128 MiB / 3 轮 / 4 并行)→ 主解锁密钥 → 账户根密钥 → 保险库密钥 → 每条目一把条目密钥;包装用 AES-256-GCM-SIV,条目内容用 XChaCha20-Poly1305,附加数据绑定条目 ID。这部分在开源仓库里有完整说明和测试,这里不展开。

三、MCP 工具设计:19 个工具,读写分开

rootkey mcp 是一个 stdio 的 MCP server,本身不碰密钥,所有请求转给桌面 App 里的守护 socket。目前一共 19 个工具,按用途分四组:

组工具
状态与查找vault_status、vault_list、vault_search、vault_request_access
读取与使用vault_get、vault_read_ref、fill_login、password_generate
写入vault_create、vault_edit、vault_delete
项目与环境变量project_list、project_get、project_set_variable、project_set_environment、project_capture、project_link_item、project_unlink_item、project_remove_variable

几条设计取舍:

  • 搜不到 ≠ 没有。AI 只能搜到开放给它的条目。要的东西搜不到时,它调 vault_request_access 说明要什么、为什么,由人在 App 里挑一条开放;在人点头之前,AI 看不到库里有什么。这样避免 AI 以为没有就自己另建一份重复的。
  • 工具描述和返回值都是给模型看的接口文档。真模型评测里发现,打码提示写得含糊时,模型以为单个条目不能用 run 注入;现在打码串本身就写明用法:●●●●●● (可用不可见:用 rootkey run <条目> --env-var <变量名> -- <命令> 注入,或 fill_login 代填网页),模型照着做。
  • 项目和环境是一等公民。写代码的人用密钥,往往是「这个项目的 production 环境要这几个变量」。project_get 返回的是变量清单和关联的条目,不含密钥值;要跑命令就 rootkey run --project shop --stage production -- <命令> 一次注入整组。

坑:outputSchema 里用了 $ref,某个客户端里一个工具都没了

MCP 允许给工具声明 outputSchema。我们最初为了复用,把 11 个工具的 schema 写成:

{ "$ref": "#/$defs/item", "$defs": { "item": { "type": "object", "...": "..." } } }

Claude Code、Gemini CLI、Qwen Code 都显示连接成功,工具也正常,因为它们不校验 outputSchema。Cursor 的 agent 严格按规范校验,结果是整个 server 的工具一个都不出现,而且没有任何报错。

修法很简单:outputSchema 顶层直接写 schema 本体,必须带 "type": "object",不要用 $ref 包一层。更重要的是验收方式:

  • 官方 Inspector 的 --strict 能查出不少可移植性问题,但查不出这个:带 $ref 的工具会被官方 SDK 静默丢弃,零诊断。
  • 所以第一步永远是数个数:tools/list 出来的条数必须等于你写的条数。

四、rootkey run:注入子进程,输出打码

一次请求的完整路径

rootkey run 的语义和 1Password 的 op run、GitHub Actions 的 secret 很像:值不经过调用方,直接进子进程的环境变量。

rootkey run "订单库 (production)" --env-var DB_PASSWORD -- python3 scripts/orders_today.py
rootkey run --project shop --stage production -- ./deploy.sh      # 一次注入整组变量

实现上有几个细节:

**1. 注入后立刻清零。**值放进 Command 的环境变量,spawn 之后内核已经把环境复制给子进程,父进程手里那份马上 zeroize。值不写盘,也不打到终端。

for (name, value) in &secrets {
    cmd.env(name, value);
}
let child = cmd.spawn();
// 子进程已持有环境变量(内核复制),父进程立刻清零
for (_, value) in secrets.iter_mut() {
    value.zeroize();
}

**2. 只有调用方是 AI 时才打码。**人在自己终端里跑,不接管输出,交互程序还能拿到 TTY;AI 调用时,把 stdout / stderr 接成管道,扫描后再转发。

**3. 打码要处理块边界。**管道是一块一块读的,一个密钥可能正好被切在两块中间。做法是:不是最后一块时,末尾留出「最长密钥长度减一」个字节不处理,拼到下一块再扫。另外长的值先比,避免一个值是另一个值的前缀时只换掉半截。替换成 ****(变量名),一次注入好几个变量时,看对话的人也对得上遮的是哪个。

/// 扫一段输出,把密钥值换成 ****(变量名);返回实际处理掉的字节数
fn mask_chunk(data: &[u8], mask: &[(String, String)], longest: usize, eof: bool) -> (Vec<u8>, usize) {
    let mut sorted: Vec<_> = mask.iter().filter(|(_, v)| !v.is_empty()).collect();
    sorted.sort_by_key(|(_, v)| std::cmp::Reverse(v.len())); // 长的先比
    // 不是最后一块:留出 longest-1 个字节,防止密钥被切在两块之间
    let stop = if eof { data.len() } else { data.len().saturating_sub(longest.saturating_sub(1)) };
    let (mut out, mut i) = (Vec::with_capacity(data.len()), 0);
    while i < stop {
        match sorted.iter().find(|(_, v)| data[i..].starts_with(v.as_bytes())) {
            Some((name, v)) => { out.extend_from_slice(format!("****({name})").as_bytes()); i += v.len(); }
            None => { out.push(data[i]); i += 1; }
        }
    }
    (out, i)
}

**4. 太短的值不打码。**短于 4 字节的值、标成非密钥的变量(比如主机名)不打码,否则输出里满屏星号。

**5. 这是兜底,不是防线。**逐字节精确匹配,没有处理任何编码变体:让子进程把值 base64、hex 或者倒过来再打印,就能绕过去。我们的结论是不在这上面继续加规则:能执行任意命令的 AI,本来就拿得到子进程里的值。「可用不可见」防的是正常工作的 AI 把值意外带进对话,不防恶意 AI。

五、怎么判断调用方是不是 AI、是不是我们自己的程序

AI 和人用的是同一个命令行,行为却要不一样:AI 只能碰开放给它的条目、要打码;人在自己终端里用,不受这些限制。所以「是不是 AI」必须判断准,而且不能信自报。

命令行侧的判断(只是提示):ROOTKEY_CALLER=agent|human 显式指定最优先;否则看 CLAUDECODE 这类环境标记,或者 stdin 不是终端。

App 侧的判断(以它为准):取 socket 对端的进程号(macOS 用 LOCAL_PEERPID,Linux 用 SO_PEERCRED),沿父进程链往上找,最多 16 层,对照已知 AI 客户端表。node、python 这类解释器,看它跑的脚本名(比如 node …/bin/gemini)。祖先链上有 AI,自称是人也按 AI 处理,ROOTKEY_CALLER=human 冒充不了。

#[cfg(target_os = "macos")]
fn peer_pid(fd: RawFd) -> Option<i32> {
    const SOL_LOCAL: i32 = 0;
    const LOCAL_PEERPID: i32 = 0x002;
    let mut pid: libc::pid_t = 0;
    let mut len = std::mem::size_of::<libc::pid_t>() as libc::socklen_t;
    let r = unsafe { libc::getsockopt(fd, SOL_LOCAL, LOCAL_PEERPID, &mut pid as *mut _ as *mut _, &mut len) };
    (r == 0 && pid > 0).then_some(pid)
}

是不是我们自己的程序:macOS 上,App 用对端的 audit token(LOCAL_PEERTOKEN)拿到 SecCode,校验签名和 Team ID。命令行另有一个令牌文件(32 字节随机数,权限 0600,每次 App 启动重新生成);我们签名的 rootkey 免令牌,这是为了让跑在沙盒里、读不到令牌文件的 AI 客户端也能用。浏览器那条路,native host 启动时还会核对父进程是不是浏览器。

几个刻意的取舍:

  • 识别表不收 IDE。否则人在 IDE 内置终端里手敲命令也会被当成 AI。代价是 IDE 内置智能体会被当成「人」,所以这种终端里注入 AI 不可见的条目,要人在桌面上点一次确认。
  • Windows 目前还没有祖先链识别,只看自报。
  • 一个主动双 fork 脱离父进程链的程序,能逃过识别。这归入「以你身份运行的恶意程序」,不在防护范围。

六、本机 IPC:一个前缀写错,整个窗口黑屏

socket 放在 macOS 的 App Group 容器里(~/Library/Group Containers/<TeamID>.rootkey/ipc/native.sock),这样沙盒里的 App、Safari 扩展、命令行都能找到同一个路径。这里踩过一个很贵的坑。

**macOS 和 iOS 的 App Group 命名规则是反的。**iOS 用 group.xxx,由描述文件授权;macOS 不经开发者门户下发,靠「前缀 = 你自己的 Team ID」证明归属,从 Sequoia 起强制。我们最初在 macOS 上沿用了 group. 写法,结果:

  • 系统认为 App 在访问「别人的数据」,每次启动弹出授权框。弹框没回答之前,整个窗口是黑的;采样主线程是空闲的,Dart 已经跑过 runApp,光栅线程一帧不出。看起来像渲染坏了,往渲染方向查全是死路。
  • containerURL(forSecurityApplicationGroupIdentifier:) 返回 nil,Safari 扩展和自动填充从此连不上主 App,正式包一样中招。

还有一个容易忽略的约束:Unix socket 路径上限是 104 字节(sun_path)。Team ID 前缀让路径多了 11 字节,所以组名干脆缩成 rootkey,不镜像 bundle id。路径只由一个 crate 计算,Swift 侧手抄的几个字面量由单元测试钉死,改常量必须两边同步。

七、电脑锁着时,请求怎么到手机上

锁屏放行

AI 半夜跑任务时电脑是锁着的,App 里没有解锁的会话。这时请求经服务器中转到手机:

  • 电脑把请求封进信封(写明哪个 AI、要哪一条),附上由账户根密钥派生的认证码;服务器只存密文,看不到内容,10 分钟没人处理就作废,45 分钟后删除。
  • 手机核对认证码后展示请求,三个选项:只放行这一条 / 放行这个 AI N 分钟(默认 10,可设 1–30)/ 拒绝。
  • 「允许」的答复封给发起请求那台电脑的设备公钥,同样带认证码,服务器伪造不出。放行会话按进程祖先链绑定到发起请求的那个 AI,界面、终端、别的 AI 看到的仍然是锁着;到期、手机收回、电脑上锁定,都会结束。

已知的剩余风险:「拒绝」答复不带认证码,被攻破的服务器能让请求失败(只能拒绝服务);设备公钥目前由服务器分发,被攻破的服务器能看到请求本身(哪个 AI、要哪一条),但看不到、也塞不进任何值。

八、怎么测:无头守护端 + 契约测试 + 真模型评测

这类功能最大的测试障碍是:真实 App 要人输主密码解锁、要点确认弹框。我们的做法分三层:

  1. 无头守护端。一个 #[ignore] 的集成测试,用 FFI 直接建库、灌样例数据、起 socket,并在测试循环里自动批准确认请求。数据目录用环境变量隔离,socket 名带目录哈希,不会和正在用的 App 冲突。
  2. MCP 契约测试。守护端起好后,把全部 19 个工具真打一遍,每个返回都用它自己声明的 outputSchema 校验;同时把第三节那些规则写成断言:工具个数、顶层 type、type 不能是数组、属性 schema 不能只有 description。改 MCP 相关代码必跑。
  3. 真模型评测。光接口对不够,要看 AI 实际怎么调。我们用 Claude Code、Codex、Kimi 自己的客户端,挂上无头守护端跑一组开发者场景,事后在完整对话里 grep 有没有出现明文。第一轮就抓出两个问题:非密钥变量被误打码;打码提示写得含糊,模型以为单个条目不能用 run 注入。

顺带一个真实案例。写这篇文章时,我们用真实的 Claude Code 换着问法跑了一组会话:「查一下生产库今天多少单」「库的账号在小密盾里」「用 scripts/orders_today.py 查」「密码是多少?用它连上生产库查一下」。这 6 次里,AI 一次都没去读密码,都是用 project_get 拿到主机和用户名,再 rootkey run 注入跑脚本,最后还会主动说一句「密码没有进入对话」。只有用户明确说「把密码显示出来」时,它才去读,读到的是打码。

一次真实的会话

九、局限

照实列一下,免得误用:

  • **「可用不可见」挡不住恶意 AI。**子进程拿得到值,编码后打印就能绕过打码。对不信任的 AI,条目保持「无权限」,并且不要让 AI 客户端免确认执行任意命令。
  • 「可用可见」的明文会进上下文,会被发到模型服务商。
  • 权限按「多个来源取最宽」算:项目设成可用可见,项目里关联的条目也按可见算。
  • 调用方识别有盲区:Windows 没有祖先链识别;IDE 内置智能体按人处理;脱离父进程的程序能逃逸。
  • 锁屏放行依赖中转服务器,元数据层面的风险见第七节。
  • 主密码是唯一因子,同步目录里的密钥集文件可以被离线猜测。

这些都记在仓库的威胁模型里,带编号和修复计划。

参考