jit 这个名称很容易让人联想到编译原理里的 JIT(Just-In-Time)编译器,但放在 macOS 安全工具领域,它的意思同样直白:secrets 只有在需要的那一刻才被解开,用完立刻丢回黑暗里。借助 Touch ID 做门禁,等于把"谁能解开秘密"的权力交给了当前这台 Mac 上的生物识别硬件,而不是交给一段容易写错的密码逻辑。这篇博客围绕 jit 类工具展开,先讲清楚它要解决什么问题,再深入到 macOS 的 Keychain、LocalAuthentication、Secure Enclave 如何协同工作,最后给出一个可运行的最小 Swift 命令行实现,并整理常见故障和安全实践。整套内容适合对 macOS 开发、CLI 工具和敏感信息管理感兴趣的读者,读完可以自己动手写出一个受 Touch ID 保护的"即时秘密"工具。
1. 先理解 jit 的核心思路:秘密不落盘,需要时才解开
1.1 从"保存密码"到"释放秘密"
很多人在 Mac 上管理 API Key、数据库口令、证书私钥时,习惯把它们放在.env、.zshrc、某个 YAML 配置文件,或者干脆躺在代码仓库里。这样做的代价是:只要文件被读取,秘密就暴露了。哪怕文件权限是 600,也只是拦住了普通使用者,拦不住拿到设备或镜像的攻击者。
jit 的思路是"不持久保存明文秘密"。原始秘密经过加密后可以放在本地磁盘,但加密所用的主密钥被放进系统钥匙串,并且绑定 Touch ID 访问控制。当你需要某个秘密时,先执行命令触发 Touch ID 验证,系统确认是设备主人后,才把主密钥释放给当前进程,再用它解密出真正要用的值。这个值只在内存里短暂存在,命令结束后不写入任何持久化位置。
用一句话概括:密码管理器保存的是密码本身,jit 保存的是"解锁密码的能力"。秘密的明文生命周期被压缩到"读取到输出"之间的几百毫秒内,这就是 just-in-time 的含义。
1.2 为什么不是直接放进系统钥匙串
系统钥匙串本身已经能够保存秘密,很多命令行工具会选择把 API Token 放进钥匙串,读取时也会触发 Touch ID。那为什么还要设计一层"加密文件 + 主密钥"的结构?这里有三个现实原因:
第一,钥匙串是分目录的,不同进程、不同签名、不同访问组能读取的范围不同。把每一个 secret 都塞进钥匙串,会增加管理成本和跨设备同步复杂度。
第二,秘密的类型不一定是短字符串。它可能是多行证书、配置文件、二进制密钥材料。直接塞进钥匙串虽然也能存,但每次读取都要触发一次系统弹窗,对频繁使用的工具很不友好。
第三,just-in-time 带来了一个额外能力:你可以把加密后的 secret 文件放进 Git 仓库,或者随备份分发。只要主密钥安全地待在 Secure Enclave 保护的钥匙串里,密文泄露不等于秘密泄露。这让配置同步和团队分发变得简单。
1.3 jit 的典型工作流
假设你已经初始化了一个秘密存储目录,里面有一个名为stripe_key的条目。使用 jit 的完整链路是:
jit get stripe_key执行后,程序先读取~/.jit/secrets.aes中的加密数据,再通过 Keychain 取出主密钥。取主密钥的动作会触发系统 Touch ID 弹窗,验证通过后,程序用主密钥在内存中完成 AES-GCM 解密,最后把明文打印到标准输出。如果你不希望明文进入终端回滚日志,也可以改成写入一个受控的临时文件,使用后立即删除:
jit get stripe_key --output /tmp/use_me.tmp # 程序使用完毕后 rm /tmp/use_me.tmp从这个流程能看出,jit 不是要替代 Keychain,而是把 Keychain 当作"身份验证黑盒",让真正的敏感数据走自己的加密通道。
2. macOS 上 Touch ID 门控依赖的三项底层能力
2.1 LocalAuthentication:负责确认"是不是这台设备的本人"
macOS 的 LocalAuthentication 框架封装了生物识别验证逻辑。即便命令行程序没有任何 UI,只要正确调用LAContext.evaluatePolicy,系统就会弹出 Touch ID 验证窗口。验证策略通常使用deviceOwnerAuthenticationWithBiometrics,它允许用 Touch ID,也可能在 Touch ID 不可用时回退到设备密码。
在实现时,你总是需要先检查canEvaluatePolicy:
import LocalAuthentication let context = LAContext() var error: NSError? if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { // 可以弹出 Touch ID } else { // 本机不支持或未注册指纹 }这一步很重要。不是每台 Mac 都有 Touch ID,也不是每次系统都会允许调用。canEvaluatePolicy失败时,要先向用户解释原因,而不是直接报错。
2.2 Keychain 的访问控制 ACL:把 Touch ID 和秘密解锁绑死
只弹 Touch ID 还不够。恶意程序可以诱导用户点确认,或者直接崩溃进程来读内存。关键是把"验证通过"和"取出密钥"绑定在同一个原子操作里。macOS Keychain 提供了SecAccessControlCreateWithFlags,可以创建带访问控制列表的钥匙串条目。当使用kSecAccessControlBiometryCurrentSet或kSecAccessControlUserPresence时,系统会在取出密钥前自动执行生物识别验证。
这意味着你把主密钥放进钥匙串时,钥匙串本身知道这条记录必须由 Touch ID 或密码门控。即使其他进程通过 API 读到这条记录的元信息,也无法在没有通过验证的情况下得到主密钥内容。
2.3 二进制签名和 Entitlements:不是形式主义
命令行工具要真正触发 Touch ID,通常需要满足两个额外条件:二进制有有效签名,且签名正确关联到当前登录用户。用 Xcode 构建应用时,会默认做好签名;但在 Swift Package 中构建命令行工具时,需要手动处理。
简单说,签名不只用于分发,它还让钥匙串能够安全地识别"哪个进程可以访问哪个条目"。如果二进制没有签名或签名不一致,Keychain 可能拒绝返回数据,甚至不会弹 Touch ID。
3. 准备开发环境:用 Swift 写一个最小 CLI 骨架
3.1 环境要求和前置依赖
开发机建议满足这些条件:
| 项目 | 要求 | 说明 |
|---|---|---|
| macOS 版本 | 12.0 及以上 | LocalAuthentication 的两个关键 API 在更早版本也可用,但建议较新系统 |
| Xcode | 14.0 及以上 | 提供swift编译器和签名工具 |
| 硬件 | 支持 Touch ID 的 Mac | 真机测试需要;没有 Touch ID 时可用密码回退 |
| Git | 可选 | 用于管理源码 |
如果不清楚本机是否具备 Swift 编译环境,可以执行:
swift --version xcode-select -pxcode-select -p会输出 Xcode 工具链路径。如果提示找不到命令,需要先安装 Command Line Tools:
xcode-select --install注意:安装 Command Line Tools 后能编译命令行程序,但为了触发钥匙串的 Touch ID 访问控制,还是建议用 Xcode 或codesign做一次本地签名。本文后续会给出签名命令。
3.2 创建 Swift Package
直接在终端创建一个新目录,并初始化可执行项目:
mkdir jit-demo cd jit-demo swift package init --type executable生成的Package.swift可以精简成:
// swift-tools-version: 5.9 import PackageDescription let package = Package( name: "jit-demo", targets: [ .executableTarget( name: "jit-demo", path: "Sources/jit-demo" ) ] )这里使用了 Swift 5.9 工具链,如果你的环境版本不一致,可以相应调整。
3.3 设计项目文件结构
最终目录结构可以保持清晰,把钥匙串、加密、命令执行拆开:
jit-demo/ ├── Package.swift ├── Sources/ │ └── jit-demo/ │ ├── main.swift │ ├── KeychainManager.swift │ ├── CryptoBox.swift │ ├── SecretStore.swift │ └── Commands.swiftmain.swift处理参数分发;KeychainManager负责和钥匙串交互;CryptoBox封装 AES-GCM 加密解密;SecretStore管理~/.jit下的密文文件;Commands实现add、get、remove、init等子命令。
3.4 签名和可访问权限准备
在命令行测试之前,可以先编译一次:
swift build swift run jit-demo --help编译通过后,需要为二进制添加一个本地签名。下面演示用临时自签名证书签名,仅用于开发测试。生产发布应考虑 Apple Developer ID 签名:
codesign --force --deep --sign - .build/debug/jit-demo签名完成后,用codesign -dv验证:
codesign -dv .build/debug/jit-demo输出里能看到Signature=adhoc或对应证书信息。如果这一步缺失,后面 Touch ID 弹窗可能不会出现,Keychain 也可能直接返回errSecInteractionNotAllowed。
4. 实现核心逻辑
4.1 秘密数据模型
每条 secret 需要记录名称、密文、算法参数和创建时间。最简化的 JSON 模型如下:
{ "name": "stripe_key", "ciphertext": "base64...", "nonce": "base64...", "createdAt": 1710000000 }nonce是 AES-GCM 加密时使用的随机数,不能重复使用。为了简化,可以选择一次随机生成 12 字节 nonce。
在 Swift 中定义:
struct SecretEntry: Codable { let name: String let ciphertext: Data let nonce: Data let createdAt: Date }整个存储文件是一个[String: SecretEntry]的字典,写回磁盘前用 JSONEncoder 编码。
4.2 初始化主密钥并写入钥匙串
主密钥是加密所有 secret 的根密钥。可以使用SecRandomCopyBytes生成 32 字节随机数据,然后写入 Keychain,并绑定 Touch ID:
func createAndStoreMasterKey() throws { var randomBytes = [UInt8](repeating: 0, count: 32) let status = SecRandomCopyBytes(kSecRandomDefault, randomBytes.count, &randomBytes) guard status == errSecSuccess else { throw KeychainError.randomGenerationFailed } let keyData = Data(randomBytes) try storeMasterKeyInKeychain(keyData) }storeMasterKeyInKeychain才是关键。它使用SecAccessControlCreateWithFlags创建 ACL:
import Security let accessControl = SecAccessControlCreateWithFlags( kCFAllocatorDefault, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, [.biometryCurrentSet], nil )! let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "com.jit.masterkey", kSecAttrAccount as String: "default", kSecValueData as String: keyData, kSecAttrAccessControl as String: accessControl as Any ] SecItemDelete(query as CFDictionary) let status = SecItemAdd(query as CFDictionary, nil)这里使用kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,意思是设备首次解锁后可以访问,且条目不随备份迁移到另一台设备。biometryCurrentSet表示只认可当前已录入的指纹,指纹库变化后条目失效,安全性更高。
4.3 加密秘密写入本地文件
写入 secret 时,先从 Keychain 取主密钥,这一步会触发 Touch ID。为了不在每次添加时都弹窗,可以选择把主密钥在进程生命周期内缓存,但这是安全取舍问题。最小实现里,为保证"每次写入/读取都需要验证",可以直接每次调用 Keychain 读取。
加密逻辑:
import CryptoKit func encrypt(secret: Data, using key: SymmetricKey) throws -> (ciphertext: Data, nonce: Data) { let sealedBox = try AES.GCM.seal(secret, using: key) return (sealedBox.ciphertext, sealedBox.nonce) }CryptoKit的 AES-GCM 实现已经足够安全,不需要手写加密。生成的密文和 nonce 写进 SecretStore 后,再序列化成 JSON 文件。
实际使用 CryptoKit 需要导入Crypto或CryptoKit,在 macOS 命令行中可以直接使用。
4.4 Touch ID 门控读取
读取 secret 的路径分两步:
第一步,从 Keychain 读取主密钥。由于 ACL 已经绑定了biometryCurrentSet,系统会自动弹出 Touch ID 弹窗:
func loadMasterKey() throws -> Data { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "com.jit.masterkey", kSecAttrAccount as String: "default", kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) guard status == errSecSuccess else { throw KeychainError.copyFailed(status) } return item as! Data }这一步的体验是:终端中运行jit get stripe_key后,屏幕中间弹出 Touch ID 提示,手指按一下,主密钥才会释放到进程。
第二步,读取 secret 密文,用主密钥解密:
func decrypt(encrypted: SecretEntry, using key: SymmetricKey) throws -> Data { let sealedBox = try AES.GCM.SealedBox( nonce: AES.GCM.Nonce(data: encrypted.nonce), ciphertext: encrypted.ciphertext ) return try AES.GCM.open(sealedBox, using: key) }4.5 输出后的内存清理
解密后的明文拿到后,直接打印到终端其实是最简单的使用方式,但也会让秘密停留在终端回滚和系统日志里。更安全的方式是支持--output指定输出文件,调用方用完立即删除:
func materialize(secret: Data, to outputPath: String?) throws { if let outputPath { let url = URL(fileURLWithPath: outputPath) try Data(secret).write(to: url, options: [.atomic]) // 权限尽量收紧 try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: outputPath) } else { guard let text = String(data: secret, encoding: .utf8) else { throw SecretStoreError.notUTF8 } print(text) } }这里要注意:String(data:encoding:)和print会在进程内部产生不可控的复制副本。真正生产级工具,应该在输出完成后主动清零缓冲区,避免核心转储或调试器读取。Swift 的内存管理不保证立即清零,更精细的实现需要借助Data和UnsafeMutableRawBufferPointer,或者直接用 C 库的secureZero。
5. CLI 命令设计与使用示例
5.1 命令一览
一个实用的 jit 工具至少应该支持以下命令:
| 命令 | 作用 | 是否触发 Touch ID |
|---|---|---|
jit init | 初始化存储目录,生成主密钥 | 首次写入时触发 |
jit add <name> | 从标准输入读取秘密并加密保存 | 需要读取主密钥,触发 |
jit get <name> | 解密并输出秘密 | 需要读取主密钥,触发 |
jit remove <name> | 删除一条密文记录 | 不触发,直接操作文件 |
jit list | 列出所有 secret 名称 | 不触发 |
jit status | 检查存储和钥匙串状态 | 触发一次验证 |
为了让add不把明文留在 shell 历史里,应该从 stdin 读取,而不是从命令行参数读取:
echo -n "sk_live_xxxx" | jit add stripe_key如果从不支持历史记录的程序调用,也可以使用--file:
jit add stripe_key --file /tmp/secret.txt5.2 演示完整流程
第一步初始化:
jit init终端会弹 Touch ID。验证通过后,~/.jit/目录被创建,主密钥存入对应服务的钥匙串条目。可以查看目录:
ls -la ~/.jit预期能看到一个secrets.json文件。
第二步添加密钥:
printf 'sk_test_xxxxxxxx' | jit add api_key这一步会再次触发 Touch ID,因为添加时加密需要读取主密钥。
第三步读取密钥:
jit get api_key如果希望输出到临时文件供脚本使用:
jit get api_key --output /tmp/api_key.txt wc -c /tmp/api_key.txt rm /tmp/api_key.txt5.3 与 keychain 命令的差异
macOS 自带的security命令也能操作钥匙串,比如security add-generic-password,但 jit 的优势在于:
- 密文文件可以放在自定义目录,便于团队共享。
- 支持非字符串格式的秘密。
- 可以封装更多业务逻辑,比如密钥轮换、临时过期。
- 更符合"即时生成/即时释放"的工作流。
钥匙串适合保存系统级凭证,jit 适合保存应用配置和开发环境里的敏感凭证。
6. 运行验证和日志排查
6.1 验证 Touch ID 真正生效
很多人在小工具里写完 Keychain 代码,发现没有弹 Touch ID,而是直接拿到了数据。原因往往是没有设置 ACL,或者用了kSecAccessibleAlways。验证方式很简单:把 Touch ID 指纹先临时改成另一个手指,或者删除所有指纹,再运行jit get。如果 ACL 生效,操作会因为验证失败而被拒绝。
另一个不触发弹窗的原因是二进制没有签名。此时可以在终端启动一个 root shell 再运行,或者查看 Console 日志里的 Security 相关消息。
可以输出 Keychain 操作结果来辅助判断:
switch status { case errSecSuccess: print("ok") case errSecUserCanceled: print("canceled") case errSecAuthFailed: print("auth failed") case errSecInteractionNotAllowed: print("interaction not allowed") default: print("status: \(status)") }6.2 模拟验证失败
验证失败时,程序应该捕获LAError或 Keychain 状态码,并给出清晰提示。下面是一个排查表:
| 现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| Touch ID 弹窗不出现 | 二进制未签名或 ACL 未设置 | codesign -dv查看签名;检查 Keychain access control 代码 | 签名;改用userPresence测试 |
直接报错errSecInteractionNotAllowed | 进程没有交互权限,常见于 SSH 会话或后台任务 | 确认是否在ssh会话中运行,或者从 launchd 调用 | 改为前台运行,或接入安全二次验证流程 |
| 验证成功但拿到空数据 | Keychain 查询条件错误 | 打印 query 字典,检查 service/account | 确认 service 和 account 一致 |
| 输出后密文仍可被读取 | 没有设置文件权限 | stat -f "%Lp" ~/.jit/secrets.json | 设置 600 权限,或加密存储 |
| 指纹变更后无法访问 | biometryCurrentSet绑定旧指纹 | 删除钥匙串条目重新初始化 | 改用biometryAny,但安全性略低 |
6.3 排查链路
当 jit 工具出现问题时,按这条链路排查:
- 先确认是最小复现:能否用
printf 'test' | jit add x直接触发错误。 - 检查
~/.jit/secrets.json是否存在且内容正确。 - 检查钥匙串条目是否存在:
security find-generic-password -s com.jit.masterkey。 - 检查是否在非交互式终端运行,比如 CI、SSH、远程执行。
- 检查二进制签名:
codesign --verify --verbose .build/debug/jit-demo。 - 打开 Console.app,过滤
securityd和jit-demo,查看钥匙串错误日志。 - 如果确认是 ACL 问题,可以先删除旧钥匙串条目重新初始化,但要注意这会导致旧密文无法解密。
7. 常见坑与安全实践
7.1 至少要注意的三个坑
第一个坑:使用kSecAccessibleAlways或kSecAccessibleAlwaysThisDeviceOnly。这会把主密钥的读取权限放开,Touch ID 形同虚设。正确做法是使用kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,并配合 ACL。
第二个坑:在 CI 或自动化脚本里直接调用jit get。Touch ID 需要真实用户交互,CI 环境中没有 Touch ID,也不会有人按指纹。工具不应该在无人值守场景下静默读取主密钥。这个问题没有完美解决方案,只能通过安全策略告知使用者:jit 的定位是交互式终端工具。
第三个坑:把解密后的秘密写进 shell 历史或脚本日志。直接jit get db_password输出到终端,再被记录到~/.zsh_history,秘密就泄露了。建议使用--output写临时文件,并在使用后立即清理;或者让工具支持jq风格的管道,每次读取完自动零化内存。
7.2 生产环境加固清单
如果是团队使用或发布开源工具,需要额外考虑这些点:
| 项目 | 建议 |
|---|---|
| 主密钥轮换 | 高级版本应支持重新加密所有 secret,实现主密钥轮换 |
| 二次密码 | 在 Touch ID 之外可以提供PASSWORD兜底密码,作为恢复手段 |
| 数据同步 | 密文文件可以进入 Git,但要禁用 Git 对文件的 Hook 和 diff 工具,避免意外输出 |
| 原子写入 | 写入 secrets.json 时必须使用临时文件 + rename,防止中途崩溃损坏文件 |
| 备份 | 建议备份加密后的 secrets.json 和一个恢复用的主密钥备份,但要单独加密 |
| 日志 | 程序中不要 print 任何密文内容,调试信息最多输出名称和长度 |
| 签名 | 发布版本应使用 Developer ID 签名,并测试 Gatekeeper 兼容性 |
7.3 扩展方向
jit 的最小实现已经可以支撑个人使用,但还有很多可以延伸的设计:
- 支持对称密钥来自多个来源:除了 Keychain,可以增加"外部密钥文件 + 密码加密"模式,用于无 Touch ID 的 Linux 环境。
- 引入口令分组:不同环境(dev、prod)使用不同的主密钥,避免一次 Touch ID 解锁所有秘密。
- 接入 nopass 或自定义 shell 集成:比如在
zsh里定义一个函数,按几个键就完成密码注入。 - 支持 TOTP:当秘密是账号密钥时,可以配合临时一次性密码生成器,让"即时"进一步延伸为"动态"。
这些方向不会改变 jit 的基础设计,但能体现出 just-in-time secrets 在真实工程中的价值:它不再是一个简单的密码本,而是一个"由人机交互触发、在内存中短暂释放、用完即焚"的密钥服务。
动手从最小实现开始,先把自己的stripe_key、github_token放进去,再逐步加固。只有真正体验过 Touch ID 解锁瞬间的流畅和安全感,才会理解为什么这类工具值得被反复打磨。