zero-knowledge

作为架构性失明的 Zero-Knowledge (2/5)

大多数密钥管理器都暴露了 getSecret(name),明文最终会落入 AI 的对话记录中。lodos 的 schema 让这种交换在结构上变得无法实现,AI 无法看到密钥的值,而构建流程会强制执行这一点。

2 分钟阅读

你的 AI 助手刚刚请求查看你的 Stripe live key。大多数密钥管理器会答应,API 暴露了 getSecret(name),agent 调用它,明文落入对话记录,然后再流入 agent 所对接的任何管道。真正有意思的问题是:什么样的 schema 能让这种交换在结构上变得无法实现。

在设计 lodos 时,我选了一个说起来容易、做起来难的立场:AI 无法看到 vault 中任何密钥的值。不是"不会",是不能。没有任何代码路径会把明文密钥返回给发起调用的 AI。Prisma schema 里没有一个 AI 能读到的列;IPC 层没有一个会返回解密字段的方法;MCP 工具注册表里没有一个工具的契约是把值给我。如果未来的我试图添加这样一个工具,构建脚本会在合并前抓住它。

这篇文章是 schema 的详细解读,设计架构性失明而非策略性失明,实际上是什么样子,以及你为此付出了什么代价。vault 是我最引以为傲的一层,部分原因在于它对自己"不做什么"这件事非常诚实。

双存储模型

最朴素的设计是单列方案:encryptedJson 保存整个 section 的 payload,读取时解密,再从解密后的 blob 中提供字段。这样能跑起来,但会带来一类 bug,任何读取路径最终都会触发解密。原本只想读取 account_id(公开字段)的查询,也会顺带把 api_key(敏感字段)解密出来。审计日志或许能捕捉到这次多余的解密,但泄漏到 LLM 对话记录的部分很可能捕捉不到。

所以 vault 的 section 有两个并列的存储:

model SecretSection {
  id            String  @id @default(cuid())
  name          String  // "stripe", "aws", "github"
  encryptedJson Bytes   // libsodium secretbox(sensitive-fields, sectionKey)
  nonSensitive  Json    // { account_id, region, username, ... } - plain
  fieldSchema   Json    // [{key, type, sensitive, required}, ...]
  keyWrapped    Bytes   // sectionKey, wrapped by masterDEK
  // ...
}

双存储模型:写入时每个字段会根据其 sensitive 标志被分流,敏感字段经由 libsodium 进入 encryptedJson,非敏感字段进入明文 JSON 列,因此读取一个公开字段绝不会顺带解密出一个密钥。

每个字段都明确知道自己是 sensitive: true 还是 sensitive: false。这个路由只在写入时发生一次。敏感字段走 libsodium;非敏感字段落入一个明文 JSON 列。读取非敏感字段只是一次 SQLite 读取,没有解密、没有 DEK 访问、没有审计写入。读取敏感字段则需要主密码。

你失去的是:对"哪些字段是哪种类型"这件事自欺欺人的能力。你无法在读取时才做决定。schema 强制你提前做出这个判断。

你得到的是:删除了一整类 bug。chat 中的 MCP 工具 secrets_field_metadata 会为非敏感字段返回真实长度,为敏感字段返回 0,这不是因为我们隐藏了长度,而是因为这条元数据路径根本不会触碰 encryptedJson。这种纪律是在类型层面成立的,而不是靠注释维持的。

加密流水线

敏感的一侧运行在 Argon2id 和 libsodium 之上。没有什么花哨的东西:

// On unlock
const masterDEK = await argon2id(masterPassword, {
  memLimit: 64 * 1024 * 1024,  // 64MB
  opsLimit: 3,
  salt: vault.argon2Salt,
});
// HMAC check against vault.masterKeyCheck - fail-fast on wrong password

// On read of a sensitive field
const sectionKey = unwrap(section.keyWrapped, masterDEK);
const plaintext = sodium.crypto_secretbox_open(
  section.encryptedJson, section.nonce, sectionKey,
);
const value = JSON.parse(plaintext)[field];
sectionKey.fill(0);  // zero-fill before GC

加密流水线:master DEK 只存在于主进程内存中;渲染进程和 Agent SDK 子进程都没有 vault 访问权限,也没有解密能力,因此任何明文密钥都不会跨越 IPC 边界抵达 AI。

有三个属性对"失明"这一论断至关重要。第一,master DEK 只存在于主进程的内存中,从不进入渲染进程,从不进入 chat-engine 子进程,从不出现在 IPC payload 里。第二,单个 section 被攻破不等于整个 vault 被攻破:每个 section 都有自己的随机 sectionKey,由 master DEK 进行 wrap。第三,解锁状态有五分钟的空闲超时和三十分钟的绝对上限;超过之后,每个 section 都需要重新输入密码才能读取。

这也是我预期安全领域读者会说"是的,但是"并开始追问内存转储、交换文件、GPU 驻留的地方。这些都是合理的问题,但大多超出本文范围。这里真正相关的一点是:AI 无法触及这条流水线。Agent SDK 运行在一个没有 vault 状态访问权限的子进程中;它通过 IPC 与主进程通信,而 IPC 表面上没有任何方法会返回明文。

模式 A、B、D:绝不是 C

在这个系统中,密钥被使用的方式恰好只有四种:

  • A:永不离开 vault。 密钥在 workflow 中以名称被引用,在主进程侧解密,注入到子进程的环境变量中,从不返回给 AI。AI 在 YAML 中看到的是 {{ secrets.stripe.live_key }},在响应中看到的是 HTTP 200。
  • B:窄口径的出站过滤。postgres://user:pass@host/db 这样的值在主进程侧解密,过滤器只提取出 host,这个值成为 allowedEgress 的值。密钥本身作为一个值从不跨越 IPC 边界;跨越边界的是一个派生出来的投影值。
  • C:解密并返回给 AI。 ❌ 不存在。没有注册的 MCP 工具。没有 IPC 方法。没有代码路径。如果你在代码库里 grep 任何匹配 _value_get$ 的工具名,结果按结构上就必然为空。构建脚本会强制执行这一点。
  • D:注入并运行。 生成一个子进程,密钥存在于它的环境变量中。密钥与任何 AI 表面之间隔着六层:仅进环境变量(不进 argv)、对 stdout/stderr 的编码脱敏器、不拼接 shell 字符串、对输出的内容防火墙、带不透明路径的 HMAC 审计日志条目、每次调用都有硬编码的出站白名单。

模式 A、B、D 各自能让密钥完成有用的工作,而绝不把其值返回给 AI;模式 C,解密并返回给 AI,没有工具、没有 IPC 方法、也没有代码路径,构建脚本强制其始终不存在。

把这些写成模式的意义在于:C 恰恰是大多数产品出货时选择的那一种。他们的密钥管理器工具把明文返回给 agent 循环,然后称之为集成。A 到 D 这几种模式同样是集成,只是它们不会把密钥放进对话记录里。

这个权衡是坦诚的。有些工作流用模式 C 会更简单。但没有哪个工作流是必须用模式 C 才能完成的。所以我们只出货 A、B、D,并用构建时的 grep 把 C 挡在外面。

不透明的审计链

审计日志是我在设计时最感到意外的部分。

一条 vault 审计记录会说"section X 的字段 Y 在时间 T 被 actor Z 注入,签名 S,前序签名 P"。自然而然的 schema 会把 fieldPath 存成明文。但这是一个泄漏点:任何对审计表有读权限的人都能看到 AI agent 注入的是 stripe.live_key,而不只是它注入了某个东西。对于正在做合规审查的受监管买家来说,这张审计表本身就变成了需要保密的材料。

所以 schema 不存路径。它存哈希:

fieldPathHash: sha256(sectionName + '.' + fieldKey).slice(0, 16)

一个十六字符的不透明 token。就连错误响应也用它:当注入失败时,错误是 { pathHash: "a3f2…", sectionExists: true },而不是 { field: "stripe.live_key", error: "not found" }。运行在主进程中、处于用户自己上下文里的审计读取器,才会做反向查找。AI 永远看不到明文路径,而一张泄漏出去的审计表也不会透露任何关于存了哪些密钥的信息。

审计链通过 HMAC 相连,每一行都用一个从 master DEK 派生出的子密钥对 (payload + prev-signature) 签名。verifyAuditChain() 在启动时运行;被篡改或删除的行会打断链条,并在 UI 中以取证警报的形式呈现出来。硬删除被禁止;归档是一种就地保留该行的软删除。

你可以把审计表截图给 SOC2 审计员看。审计员看到的是一个 actor 标识符、一个 section、一个不透明的字段哈希、一个时间戳,以及一条 HMAC 链。真实的密钥名称不会泄漏。正是合规型买家愿意为之付费的东西。

当用户分不清架构和 bug 的时候

在第一次真正的 dogfood 过程中,有个用户通过 secret_inject_and_run 运行了 echo $API_KEY。这次调用以 MCP_LETHAL_TRIFECTA_GATE 失败告终。用户把这个问题拿去问 AI 助手做诊断;AI 花了大约 3000 个 token 解释说这条命令之所以被拒绝是因为它是"静态文本",并建议改用 curl 调用。这个诊断是错的。真实的门控条件是 allowedEgress.length === 0 && riskTier === 'novel',这次调用没有出站白名单,所以它被当作可能在没有声明目的地的情况下发生数据外泄而遭到拒绝。

这张 bug 工单最终被重新归类为护城河的胜利。架构本身的运作是正确的,而 AI 的困惑本身就是证据:系统的拒绝依据是出站请求的形状,而不是命令的内容,AI 给出的错误诊断源头是一段不够清晰的工具描述,而不是一个失灵的拒绝机制。我们修改了这段描述;门控逻辑原封不动地保留了下来。

我反复回到的那个教训是:一个 AI 自己都不理解的拒绝,仍然是一次有效的拒绝。架构性失明并不需要 LLM 的配合与认同。

这在商业上意味着什么

我向那些在思考这类工作的创始人推销时,说法很直白。SOC2 和 ISO 的买家不会接受"我们承诺不会"作为一项策略。他们会接受"我们无法做到"。一个看到你的 live key 却承诺不看的供应商,是那种日志系统里偶尔会冒出 CVE 的供应商。而一个 schema 层面上根本无法看到密钥值的供应商,则属于完全不同的风险类别,而这个类别的定价方式也完全不同。

我从第一天起就为 Phase-3 留下的路径是按接收方分别 wrap。section key 本身已经是一层间接性;今天它只被 master DEK wrap 一次。在团队场景下,它会被 wrap N 次,每个接收方公钥一次,而服务端依然无法解密,因为它并不持有任何私钥。这个 wrap 是增量式的,而不是一次重写,因为这种按 section 划分的间接层从第一次提交起就被有意保持得足够通用。这扇门始终敞开着,即便现在还没有为它出货任何功能。

下一层

schema 设计保护的是静态存放和注入过程中的密钥。但 schema 也是代码,代码是可以被修改的。下一篇文章,32 个一旦我倒退就会让构建失败的构建期不变量,会带你过一遍那个脚本,它如何防止 Prisma schema 倒退、防止 workflow 引擎长出一个 eval、防止 MCP 工具注册表冒出一个 secret_value_get。拒绝变成了一次 grep,而这次 grep 自带一个伪造的违规样本,用来证明这次 grep 依然有牙齿。

zero-knowledge安全architectureencryption

在你自己的设备上运营你的公司。

加入候补名单,lodos 开放时第一时间使用。