
jit 这个名称很容易让人联想到编译原理里的 JITJust-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 不可用时回退到设备密码。在实现时你总是需要先检查canEvaluatePolicyimport 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 在更早版本也可用但建议较新系统Xcode14.0 及以上提供swift编译器和签名工具硬件支持 Touch ID 的 Mac真机测试需要没有 Touch ID 时可用密码回退Git可选用于管理源码如果不清楚本机是否具备 Swift 编译环境可以执行swift --version xcode-select -pxcode-select -p会输出 Xcode 工具链路径。如果提示找不到命令需要先安装 Command Line Toolsxcode-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输出里能看到Signatureadhoc或对应证书信息。如果这一步缺失后面 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 IDfunc 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创建 ACLimport 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 IDjit 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如果从不支持历史记录的程序调用也可以使用--filejit 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 解锁瞬间的流畅和安全感才会理解为什么这类工具值得被反复打磨。