# Secure Enclave 和 Touch ID 如何保护 AI 代理的机密

AI 编程代理应该能够请求执行某项操作，却永远拿不到授权该操作的凭据。这才是值得围绕其构建的安全属性。在 Mac 上，Secure Enclave 和 Touch ID 可以强制执行其中一部分，但前提是机密始终留在代理无法读取的边界之后。

Mac 弹出提示说代理可以访问「GitHub」，这不算安全设计。复制出来的 `ghp_...` 令牌、导出的 `AWS_SESSION_TOKEN`，或粘贴到工具调用中的 SSH 私钥，都会赋予代理持久权限，之后再弹出 Touch ID 也无法收回。真正困难的不是加密字符串，而是拒绝把字符串交给会转发任意文本的软件。

我见过这种错误的几种形式：令牌放进 `.env`，凭据助手打印密码，私钥挂载到容器里，然后告诉代理「使用任何可用的凭据」。每个选择看起来都只是临时方案，但每个选择都会让机密进入代理的工作集。

## Secure Enclave 不会让交出承载令牌变得安全

Secure Enclave 可以保护加密操作，但无法让进程读取过的承载令牌在之后变得无害。这个区别决定了 Touch ID 究竟是在保护开发者机密，还是只是在泄露前增加一个仪式性的提示。

Apple 将 Secure Enclave 描述为一种隔离硬件，它可以创建和使用私钥，而不把私钥明文暴露给主处理器。其文档中的限制同样重要：Secure Enclave 私钥在那里生成，不能导入已有的私钥明文，并且只支持特定的 P-256 签名和密钥协商操作。API 令牌不属于这些私钥。它通常只是一个不透明字符串，远程服务会接受任何提交该字符串的人。Apple Developer Documentation 中的「Protecting keys with the Secure Enclave」清楚说明了它的隔离能力和边界。

因此，以下三个对象必须分开看待，而不是混为一谈：

- Secure Enclave 私钥是不可导出的密钥材料，只能用于有限的加密操作。
- Keychain 项目是经过加密的应用数据，macOS 可以限制其访问方式。
- 承载凭据是可复制的值，只要服务接受它，就能授予访问权限。

把它们当成同义词，会产生糟糕的设计。Secure Enclave 密钥可以签署挑战，而不会泄露自身。Keychain 项目可以要求用户在场后，系统才返回其中的数据。承载令牌一旦被进程收到字节内容，就变成普通机密。

所以，「我们把令牌存进 Keychain」并不能完整回答代理场景的问题。它只回答了静态存储的问题，却没有说明谁能请求令牌、哪个进程会收到令牌、该进程能否把令牌传给子进程，或能否将其写入标准输出。

Apple 的 Keychain 指南清楚划出了边界。Keychain Services 可以在返回项目之前要求身份验证，Secure Enclave 只会为生物识别检查提供通过或失败的结果。应用和操作系统都不会获得指纹数据。这对保护生物识别模板非常好，但没有说明经过授权的应用之后会如何处理返回的密码字节。

不要等把机密交给代理之后，再让 Touch ID 解决问题。

更好的模型包含两层。保险库进程在用户通过门禁后取回或使用凭据。代理可以通过指定凭据和目标来请求操作，但不能读取或替换凭据，也不能要求其他工具打印凭据。保险库进程负责执行 HTTP 请求或 SSH 身份验证，并返回经过刻意限制的结果。

这是一种更窄的接口，也是关键所在。

## 边界必须位于标准输出和环境变量之前

开发者机密通常会在攻击者需要破解加密之前，通过普通的数据通道泄露。环境变量、子进程继承、shell 跟踪、调试日志、崩溃报告、工具响应以及复制的终端输出，都会把受保护的机密变成可携带的机密。

我之所以直说，是因为这种失败模式太容易预测：如果代理可以运行 `printenv`、读取 `.env`、调用凭据助手，或从 MCP 工具结果中获得 API 密钥，它就已经拥有凭据。最初的存储方式是 Keychain、密码管理器还是加密文件，已经不再改变威胁。

看看这种常见流程：

```text
Agent -> runs a shell command -> credential helper reads Keychain
      -> helper prints token -> shell captures stdout
      -> agent receives token -> token appears in context or logs
```

Keychain 提示可能完全按照设计工作。系统验证了 Mac 用户身份，随后助手却把受保护项目转换成文本，代理通过与接收编译器错误和测试输出相同的通道收到了这段文本。

设计就在这一刻失败了。

安全的凭据路径应该明显不同：

```text
Agent -> requests "POST api.example.com/releases" using credential "release-bot"
      -> gateway asks for authorization if required
      -> gateway obtains or uses credential internally
      -> gateway sends HTTPS request with Authorization header
      -> agent receives status, selected headers, and response body
```

代理收到的是经过身份验证的操作结果，而不是授权标头。这看似只是一个小小的 API 选择，却是委托执行和秘密分发之间的分界线。

SSH 也遵循同一规则。不要把 `~/.ssh/id_ed25519` 的路径传给代理，不要给它一个可以在没有明显边界的情况下签署任意挑战的 `SSH_AUTH_SOCK`，也不要提供一个能从安全存储中导出密钥的命令。让范围严格受限的助手建立 SSH 连接并运行请求的命令，然后返回标准输出、标准错误、退出状态和主机身份信息。私钥必须留在代理的进程树之外。

这需要付出代价。一些开发者工具默认可以直接读取凭据，而网关方案意味着需要适配器、更窄的工具 API，以及处理特殊身份验证流程时偶尔会有摩擦。但我宁愿一次性承担这部分工程成本，也不愿等生产令牌出现在代理记录中后再去轮换它。

不要把加密磁盘和受控执行路径混为一谈。

## Touch ID 证明用户在场，不证明用户的意图

Touch ID 可以证明某个人在某个时刻批准了一个门禁，但无法证明这个人理解代理接下来的命令，无法证明命令符合代码仓库的意图，也无法证明目标是安全的。

Apple 的 LocalAuthentication 框架有意只向应用提供有限结果：框架与 Secure Enclave 协作，并返回成功或失败。调用应用提供提示文字并选择身份验证策略。这种分离是正确的。系统不应假装自己能够从一次生物识别事件中理解应用意图。

在代理工作中，应把 Touch ID 看成某项能力的门禁，而不是对一段文字的批准。写着「允许此代理使用生产凭据」的卡片给人的信息太少。卡片应列出签名进程、目标主机、凭据标签，以及批准持续一次调用还是一次运行，让人能够作出具体判断。

我倾向于设置三个不同的授权时刻：

1. 解锁保险库。保险库锁定时，所有依赖机密的操作都失败。不能有「使用未受保护的备用方式」这一分支。
2. 在一次运行期间批准新的代理进程。审批应标明代码签名者，而不是只显示 `node` 或 `python` 这类可变的进程名称。
3. 对具有不可逆影响的凭据，每次使用都要求用户在场，例如生产部署令牌、云平台所有者角色，或能够修改整组服务器的 SSH 密钥。

对于编程代理，按会话审批应该是默认选择。新进程是有意义的边界：全新的启动可能使用不同的二进制文件、不同的工作区、不同的继承环境变量或不同的 MCP 配置。永久审批会让后续操作变得安静，而这恰恰发生在操作来源更难检查的时候。

按调用审批应该比较少见，但对于可能造成广泛损害的凭据，审批必须严格执行。只能打开只读问题跟踪器的令牌，不值得每次 `GET` 都要求指纹。能够对生产环境运行 `kubectl apply` 的 SSH 凭据则值得。这里会产生中断，而中断本来就是有意设计的。

常见替代方案是一个大型策略文件：批准匹配某个正则表达式的命令，允许名单中的域名，拒绝包含某些词的参数。这种方法看起来容易扩展，因为它用自动化取代了提示。但它也创造了第二种编程语言，必须理解 shell 引号、重定向、包装器、符号链接、`curl --config`、编码后的载荷、远程命令展开，以及代理安装的每个新工具。

我不会把生产权限交给一种没人会在周五之后审查的语法。

改用一条简单的判断阶梯：已锁定还是已解锁，本次运行是否批准，这个凭据是否需要重新审批。发生事故后开始复盘时，这些控制点的含义清晰可见。

## Keychain 访问控制有锋利的边缘

只有在你有意选择访问限制、理解备用行为，并防止已授权进程变成秘密分发器时，生物识别保护的 Keychain 项目才有用。

Apple 记录了 `SecAccessControlCreateWithFlags` 的用法，可以为 Keychain 项目附加可访问性和授权要求。对于不应通过备份或 iCloud Keychain 迁移的本地开发者机密，`kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly` 通常是合理的存储类别。它要求设备设置密码，移除密码后项目将不可用；`ThisDeviceOnly` 后缀还会阻止项目转移到另一台设备。

如果机密必须要求生物识别检查，应使用访问控制标志，而不是围绕普通 Keychain 查询额外添加一个提示。下面这段简化的 Swift 代码会存储一个通用密码，只有当前登记的生物识别集合才能释放它：

```swift
import Security

let access = SecAccessControlCreateWithFlags(
    kCFAllocatorDefault,
    kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
    .biometryCurrentSet,
    nil
)!

let item: [CFString: Any] = [
    kSecClass: kSecClassGenericPassword,
    kSecAttrService: "com.example.agent-gateway",
    kSecAttrAccount: "release-bot",
    kSecAttrAccessControl: access,
    kSecValueData: Data(token.utf8)
]

let status = SecItemAdd(item as CFDictionary, nil)
precondition(status == errSecSuccess)
```

`biometryCurrentSet` 比 `biometryAny` 更严格。它把访问绑定到当前登记的指纹或面容数据，因此改变登记的生物识别集合会使受保护项目失效。`biometryAny` 接受任何已登记的生物识别信息，不会提供同样的登记变化触发机制。Apple 在 `SecAccessControlCreateFlags` 中列出了这两个标志；如果新增指纹应该强制重新配置，就选择前者。

取回项目时需要身份验证上下文，以及用人能够理解的方式描述操作的提示：

```swift
import LocalAuthentication
import Security

let context = LAContext()
context.localizedReason = "Use release-bot for the requested deployment action"

let query: [CFString: Any] = [
    kSecClass: kSecClassGenericPassword,
    kSecAttrService: "com.example.agent-gateway",
    kSecAttrAccount: "release-bot",
    kSecReturnData: true,
    kSecMatchLimit: kSecMatchLimitOne,
    kSecUseAuthenticationContext: context,
    kSecUseOperationPrompt: "Authorize credential use"
]

var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
```

这段代码有意留下了一个未完成之处：它没有告诉你该如何处理 `result`。普通应用可能会把它转换成 `Data`，创建 `Authorization` 标头，然后继续执行。代理网关必须保证这段数据始终留在执行身份验证请求的进程中。不要通过 MCP 响应返回它，不要放入临时文件，请求失败时也不要记录它。

要小心重复使用窗口。Apple 的示例显示，Touch ID 的满足状态可以在配置的时间内复用最近一次设备解锁事件，最长可达五分钟。这种便利能帮助普通应用避免重复提示，但对于代理控制点，它可能抹掉你原本想强制要求的明确批准时刻。除非你已经有意识地记录并接受这一点，否则对于每次使用的凭据不要设置宽限窗口。

还有一个限制也值得说明。生物识别可能失败、不可用，或在多次失败后锁定。Apple 提供了允许设备密码备用方式的策略，也提供了要求生物识别的策略。要根据你的安全声明决定使用哪一种。如果你说「每次生产部署都使用 Touch ID」，却默默接受了不相关的备用路径，那么实际声明已经改变，界面文字也应该随之改变。

## 沿着一条恶意指令走到它获胜的地方

代理不需要戏剧性的漏洞来滥用凭据。它只需要一条看似合理的指令、一项范围过大的能力，以及一个返回多于用户本意的输出通道。

想象一个帮助处理拉取请求的编程代理。它读取了攻击者加入仓库的文档。文档称发布检查需要运行某个辅助脚本。辅助脚本使用开发者现有的云 CLI 会话，列出部署凭据，并向外部端点发送编码后的请求。代理有权运行 shell 命令，还继承了 `AWS_PROFILE`、`GH_TOKEN`，或获得了 SSH 代理的访问权限。

第一次失败发生在脚本运行之前：开发者把环境凭据交给了代理。第二次失败发生在工具运行器允许代理选择任意网络目标时。第三次失败发生在日志和命令输出把身份验证材料或会话详情返回到代理上下文时。

登录时使用 Touch ID 并不能挽救这种设计。用户可能在一小时前完成身份验证，之后已经离开。仅由「设备已解锁」保护的 Keychain 项目，可能会对用户从未打算授权其执行本次任务的进程开放。Apple 自己也警告过，对于某些使用场景，设备解锁状态提供的访问限制可能不够严格。

现在改变架构。代理使用以下字段请求操作：

```json
{
  "channel": "http",
  "credential": "release-bot",
  "method": "POST",
  "url": "https://api.example.com/releases",
  "body": {"branch": "feature/fix-ci"}
}
```

网关在内部解析 `release-bot`。它会将请求目标与准备执行的操作进行比较，在凭据需要时请求授权，自行注入标头，然后记录操作。代理得到 HTTP 状态和经过删减的响应，却永远看不到标头值。

仓库文档仍然可以说服代理请求一次有问题的部署，所以目标和操作审查仍然重要。但文档无法要求代理泄露它从未持有的令牌，也无法在批准的调用结束后把令牌用于另一个服务。

这确实减少了爆炸半径，但不是魔法。一个获准部署的遭入侵代理仍然可能部署有害内容。系统限制了凭据窃取并让操作可以追溯，但没有解决恶意代码审查，也没有取代人的判断。

我检查代理设置时，会寻找指令最早能够变成秘密字符串的地方。修复通常就应该放在那里。

## 能力句柄比秘密字符串更安全

代理应该使用结构化的操作参数请求一个命名能力，再由受信任的本地进程把这个能力解析为凭据并执行副作用操作。

「能力」这个词经常被宽泛使用。这里指的是只有网关内部才有用的引用，例如 `release-bot`、`staging-ssh` 或 `billing-read`。它不是代理可以拿来交换令牌的别名，不是会展开成环境变量值的模板变量，而是传给一个独占凭据访问权的进程的选择器。

这种选择会约束工具接口。HTTP 工具应该接受方法、URL、代理可以安全提供的标头和请求正文。凭据注入必须在验证之后、网关内部完成。SSH 工具应该接受主机、用户、命令和选定的凭据身份，然后调用拥有身份验证路径的助手。它们不应返回 `IdentityFile` 路径，也不应提供通用的「读取机密」操作。

这正是无聊的接口胜出的地方。继承开发者所有凭据的通用 shell 第一天支持的工具更多，但也几乎无法回答哪个代理使用哪个账户发出了哪个出站请求。受限的 HTTP 和 SSH 接口起步范围较小，却能保留作出安全判断所需的事实。

Sallyport 采用了这种形式：代理使用随附的 `sp mcp` stdio shim 请求 HTTP 或 SSH 操作，而应用把 API 和 SSH 凭据保存在加密保险库中并自行执行操作。代理收到结果，而不是凭据明文。

这个限制是诚实的。只了解 HTTP 和 SSH 的工具，不会自动覆盖开发者环境中的每个桌面应用、数据库客户端、软件包注册表或本地二进制程序。新增通道时，应设计它的操作模型、删减行为、授权语义和审计字段。一个宽泛的「使用我的登录信息运行任何东西」开关更容易发布，却更难防守。

使用能够显示预期范围的明确凭据标签。`prod-deployer` 比 `token-4` 好，`github-readonly-org` 比 `github` 好。标签会成为人的决策和审计记录的一部分，所以含糊不清最终会变成运营问题，而不只是命名偏好。

让请求格式足够窄，使网关无需解释就能显示它。审查者可以理解 `POST https://api.example.com/releases`，却无法可靠推断通过 shell 包装器传递的 Base64 数据会产生什么效果。

## 审计记录必须描述操作，不能复制机密

凭据网关需要两类记录：获得权限的代理运行记录，以及它尝试的每个副作用操作。单一的终端日志流无法同时提供两者，除非丢失上下文或泄露敏感材料。

新的代理进程请求访问时，记录这次运行。记录网关可获得的进程身份、代码签名者、启动时间、审批决定和撤销状态。如果用户撤销了这次运行，即使进程仍然存活，之后的请求也必须失败。

每个操作单独记录。对于 HTTP，保留凭据标签、方法、目标、状态、耗时，以及经过谨慎选择的正文摘要。对于 SSH，保留凭据标签、主机、远程用户、命令、退出代码和耗时。不要记录 `Authorization` 标头、承载值、私钥字节、包含客户数据的完整请求正文，或不受限制的命令输出。

包含机密的日志会变成另一个访问控制更糟糕的保险库。

篡改证据很重要，因为代理事故往往从一个有争议的时间线开始：「代理调用过这个端点吗？」「会话获得批准了吗？」「有人编辑过本地历史吗？」哈希链事件日志为后续验证提供了可以检查的具体对象。验证者应该无需解密每个事件就能完成工作，否则检查完整性的人必须先获得日志原本要保护的敏感数据。

Sallyport 从一个写入不可篡改、加密并采用哈希链的审计日志生成 Sessions 和 Activity 日志，`sp audit verify` 可以在离线状态下检查链条，无需保险库密钥。这个特性很有用，因为完整性审查不应要求访问开发者机密。

哈希链不能阻止遭入侵的机器尝试错误操作。它会让悄悄改写记录顺序变得更困难，并为调查提供一致的事件序列。不要把它夸大成预防措施。

Apple Platform Security 和 Apple Developer Security 文档很适合用来理解这一点，因为它们区分了硬件保护、系统控制和应用责任。这种区分正是代理安全所需要的。安全硬件可以控制访问，但应用仍然决定向网络发送什么、向磁盘写入什么。

## 生产访问需要更少的路径，而不是更聪明的猜测

让 AI 编程代理安全落地的方式，是从少量命名凭据、已知目标、可见的会话身份，以及一个需要重新审批的不可逆操作类别开始。广泛的环境凭据访问，只会让你在故障期间发现自己的威胁模型。

允许代理接触凭据前，先完成以下检查：

1. 确认代理无法通过环境变量、文件、凭据助手、工具输出或子进程读取机密。
2. 确认受信任的进程负责执行 HTTP 请求或 SSH 身份验证，并在代理提交结构化参数后注入凭据。
3. 将保险库锁定设置为拒绝所有依赖机密的操作。在应用锁定时测试，而不只是锁定 Mac 屏幕时测试。
4. 新代理进程启动时要求新的会话审批，并让审批标明其签名者。
5. 对具有部署、管理、破坏性操作或广泛 SSH 访问范围的凭据，设置每次使用都需授权。

测试难看的情况。向非生产凭据中加入类似 `canary-agent-secret-9f31` 的假机密。让代理检查仓库、运行测试并报告工具输出。随后在它的记录、终端历史、临时目录、日志、子进程环境和审计记录中搜索这个准确字符串。如果它出现在保险库进程之外的任何地方，说明设计已经把秘密路径交给了代理。

撤销也要进行同样的测试。批准一次运行，发出一个无害请求，在进程仍保持打开时撤销这次运行，然后再次尝试同一个请求。第二次请求必须在到达远程服务之前失败。只有重启后才生效的撤销控制只是文书工作，不是遏制措施。

不要用生物识别提示装饰凭据导出。把提示放在用户授权特定进程或特定操作的边界上，并让凭据留在受保护的一侧。这样，Secure Enclave 和 Touch ID 才能成为 AI 编程代理的有用控制措施，而不是一段让人安心的存储故事。
