这篇是工程向的复盘。我们在做一个本地密码管理器(小密盾),其中一块是让 Claude Code、Codex 这类 AI 编程助手能用密钥,但尽量看不到明文。下面按「需求 → 架构 → 关键实现 → 踩过的坑 → 怎么测 → 局限」来讲,代码片段都做了简化。
一、需求:AI 要用密钥,但明文最好别进上下文
AI 编程助手干活时,经常需要生产库密码、云厂商 AccessKey、部署令牌。最常见的交法是贴进对话,问题在于明文会被复制到很多你管不到的地方:服务商侧的请求、本地会话记录、命令行参数、命令输出、截图。
我们给自己定了几条要求:
- 本地解密:密钥只在用户自己的设备上解开,服务端不持有密码库。
- 按条授权:AI 默认什么都看不到,用户一条条开放。
- 可用不可见:开放了的条目,AI 能拿去跑命令,但读到的是打码,命令输出里出现也打码。
- 人在回路:电脑锁着时 AI 要用密钥,推到手机上确认。
- 开放接入:不绑某一家 AI,用 MCP + 命令行。
- 可审计:每次读取都有记录,能一键收回。
二、整体架构
核心思路是「所有判断都在桌面 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 要人输主密码解锁、要点确认弹框。我们的做法分三层:
- 无头守护端。一个
#[ignore]的集成测试,用 FFI 直接建库、灌样例数据、起 socket,并在测试循环里自动批准确认请求。数据目录用环境变量隔离,socket 名带目录哈希,不会和正在用的 App 冲突。 - MCP 契约测试。守护端起好后,把全部 19 个工具真打一遍,每个返回都用它自己声明的
outputSchema校验;同时把第三节那些规则写成断言:工具个数、顶层type、type不能是数组、属性 schema 不能只有 description。改 MCP 相关代码必跑。 - 真模型评测。光接口对不够,要看 AI 实际怎么调。我们用 Claude Code、Codex、Kimi 自己的客户端,挂上无头守护端跑一组开发者场景,事后在完整对话里 grep 有没有出现明文。第一轮就抓出两个问题:非密钥变量被误打码;打码提示写得含糊,模型以为单个条目不能用
run注入。
顺带一个真实案例。写这篇文章时,我们用真实的 Claude Code 换着问法跑了一组会话:「查一下生产库今天多少单」「库的账号在小密盾里」「用 scripts/orders_today.py 查」「密码是多少?用它连上生产库查一下」。这 6 次里,AI 一次都没去读密码,都是用 project_get 拿到主机和用户名,再 rootkey run 注入跑脚本,最后还会主动说一句「密码没有进入对话」。只有用户明确说「把密码显示出来」时,它才去读,读到的是打码。
九、局限
照实列一下,免得误用:
- **「可用不可见」挡不住恶意 AI。**子进程拿得到值,编码后打印就能绕过打码。对不信任的 AI,条目保持「无权限」,并且不要让 AI 客户端免确认执行任意命令。
- 「可用可见」的明文会进上下文,会被发到模型服务商。
- 权限按「多个来源取最宽」算:项目设成可用可见,项目里关联的条目也按可见算。
- 调用方识别有盲区:Windows 没有祖先链识别;IDE 内置智能体按人处理;脱离父进程的程序能逃逸。
- 锁屏放行依赖中转服务器,元数据层面的风险见第七节。
- 主密码是唯一因子,同步目录里的密钥集文件可以被离线猜测。
这些都记在仓库的威胁模型里,带编号和修复计划。
参考
- Model Context Protocol 规范:modelcontextprotocol.io
- 1Password CLI
op run:developer.1password.com/docs/cli/re… - 小密盾加密核心(开源):github.com/bitbeam-cn/…