两个功能来自同一个痛点,但威胁模型正好相反,这是整个设计的支点。 **共用密钥**:平台密钥(飞书、领星、OpenAI)在十几个项目里要各配一遍, 而最省事的做法——直接粘进聊天框告诉 AI——是最糟的那个。现在按平台建组、 勾给项目,物化成 ~/.vibe-foreman/env/by-project/<项目>-<哈希>.env (目录 0700、文件 0600),复制路径给 AI。这份数据**故意是明文、故意给 AI 用**。 **账号密码**:本地/测试/线上分开记,只给人看。AES-GCM 加密, 查看和复制过 Touch ID。这份数据**绝不能被任何自动化读到**。 两者不共用存储、不共用目录、不共用界面,并用一条哨兵测试钉死 「密码的值不会出现在给 AI 的那个目录里」。 三个决定了做法的发现: 1. 「让 AI Read 那个文件」会把密钥写进两个明文文件—— ~/.claude/projects/*.jsonl(0644、永久、每轮重发给 API), 以及 Hub 自己的验收清单(Evidence.ran 会存命令和输出尾巴,同样不过期)。 所以「复制给 AI」给的不是裸路径,是带 `set -a; source` 用法和 「不要用 Read/cat/grep」禁令的三句话。换成 source 之后, transcript 里只剩路径和变量名,值一次都不出现。 2. 按项目分文件**是分装不是隔离**——同 uid 下 cat ../别的.env 照样拿得到。 README 和代码注释都按分装写,不吹成边界。 3. Keychain 的授权弹窗**就是那道防线**。SecItemAdd 不传 kSecAttrAccess 时, 默认 ACL 只允许创建它的 app 无提示读取,而在「攻击者 = 同 uid 的自动化」 这个模型下,能区分「Hub 在读」和「security 命令在读」的只有它—— 文件权限做不到(同 uid),Touch ID 也做不到(LAContext 不参与取密钥)。 为了消弹窗放宽 ACL,等于一条 Bash 就能取走主密钥。 几个不写下来一定会再踩的坑(已进 NOTES.md): - .atomic 写换 inode,chmod 必须每次都做,只在创建时设一次从第二次写起就是 0644 - GCM 的 AAD 必须用文件里存下来的那段字节,重新序列化会让历史文件永久打不开 - 「钥匙不对」和「文件坏了」处置完全相反,靠 header 里的 keyID 分辨; EncryptedFile.save() 的 guard state.canWrite 是这套区分唯一的落地点 - LAContext 复用才有 reuse duration,每次新建等于每条密码按一次指纹 - 剪贴板定时清空必须比对 changeCount,否则用户复制的别的东西会被莫名清掉 - .env 的引号:shell / node dotenv / python-dotenv 三家的交集只有单引号; 多行值用字面换行不能用 \n;含单引号的值没有共同解,改写成 _B64 显式降级 617 个测试全过(原 530)。真 bash `set -a; source` 验过含空格、$、反引号、 #、多行 PEM 和含单引号的值。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
208 lines
8.5 KiB
Swift
208 lines
8.5 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
import HubCore
|
|
import os
|
|
|
|
/// 加密落盘的状态机。共用密钥库和账号密码库都挂在它上面。
|
|
public enum VaultLoadState: Equatable, Sendable {
|
|
/// 文件还不存在。第一次用就是这个,可以正常写。
|
|
case empty
|
|
case loaded
|
|
/// **打不开,但数据很可能是好的。** 钥匙串里的主密钥没了、拿到的是另一把、
|
|
/// 或者文件根本不属于这个库。此时禁止一切写入 —— 用户拿恢复码回来还能救。
|
|
case locked(String)
|
|
/// 文件真的坏了,已经改名保住。原位置可以重建。
|
|
case broken(rescuedPath: String)
|
|
|
|
public var canWrite: Bool {
|
|
switch self {
|
|
case .empty, .loaded: return true
|
|
case .locked, .broken: return false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 主密钥从哪来。做成可注入的,单测才不会去碰真的钥匙串 ——
|
|
/// `swift test` 的二进制是 ad-hoc 签名的,碰一下就会弹系统授权框,
|
|
/// 那样测试就没法在 CI 或者后台跑了。
|
|
public struct VaultKeySource: Sendable {
|
|
public var load: @Sendable () throws -> SymmetricKey
|
|
public var create: @Sendable () throws -> SymmetricKey
|
|
|
|
public init(
|
|
load: @escaping @Sendable () throws -> SymmetricKey,
|
|
create: @escaping @Sendable () throws -> SymmetricKey
|
|
) {
|
|
self.load = load
|
|
self.create = create
|
|
}
|
|
|
|
public static let keychain = VaultKeySource(
|
|
load: { try VaultKeychain.loadMasterKey() },
|
|
create: { try VaultKeychain.createMasterKey() }
|
|
)
|
|
|
|
public static func fixed(_ key: SymmetricKey) -> VaultKeySource {
|
|
VaultKeySource(load: { key }, create: { key })
|
|
}
|
|
}
|
|
|
|
/// 一个加密的 JSON 文件。
|
|
///
|
|
/// ## 为什么要有 `state`,而不是「解不出来就当空的」
|
|
///
|
|
/// `AcceptanceStore.swift:514-518` 记着一次真实事故:解码失败 → 内存里是空的 →
|
|
/// 下一次写入把几周的数据整个覆盖掉。
|
|
///
|
|
/// 加密之后,「解不出来」的触发面从「JSON 写坏了」扩大到四种:
|
|
/// 写坏了、钥匙串里的钥匙没了、拿到的是另一把钥匙、GCM 认证失败。
|
|
/// **大了三倍,而后果从丢验收记录变成丢线上密码。**
|
|
///
|
|
/// 所以这里把「打不开」拆成两类,处置完全相反:
|
|
/// - `locked`:数据大概率是好的 → **一个字节都不动**,等用户拿恢复码回来
|
|
/// - `broken`:文件确实坏了 → 改名 `.broken-<时间>` 保住,原位置才允许重建
|
|
///
|
|
/// 分不出这两类,就必然会在某一次「钥匙不见了」时把好数据当垃圾清掉。
|
|
@MainActor
|
|
public final class EncryptedFile<Payload: Codable & Sendable> {
|
|
|
|
public private(set) var state: VaultLoadState = .empty
|
|
|
|
private let url: URL?
|
|
private let context: String
|
|
private let keys: VaultKeySource
|
|
|
|
/// - Parameter url: nil = 不落盘。理由同仓库里其它 store:
|
|
/// 路径写死在类型里,测试就会直接读写用户的真实数据。
|
|
public init(url: URL?, context: String, keys: VaultKeySource = .keychain) {
|
|
self.url = url
|
|
self.context = context
|
|
self.keys = keys
|
|
if url == nil { state = .loaded }
|
|
}
|
|
|
|
// MARK: - 读
|
|
|
|
public func load() -> Payload? {
|
|
guard let url else { state = .loaded; return nil }
|
|
guard let raw = try? Data(contentsOf: url) else { state = .empty; return nil }
|
|
|
|
let parsed: VaultCrypto.Parsed
|
|
do {
|
|
parsed = try VaultCrypto.parse(raw)
|
|
} catch {
|
|
// 连文件头都不成形。这不是钥匙的问题。
|
|
state = rescue(url, reason: "\(error)")
|
|
return nil
|
|
}
|
|
|
|
let key: SymmetricKey
|
|
do {
|
|
key = try keys.load()
|
|
} catch {
|
|
state = .locked("钥匙串里取不到主密钥(\(error))。换过机器或重装过系统的话,用恢复码恢复。")
|
|
return nil
|
|
}
|
|
|
|
do {
|
|
let plain = try VaultCrypto.open(parsed, using: key, context: context)
|
|
guard let payload = try? JSONDecoder().decode(Payload.self, from: plain) else {
|
|
// 解密成功但内容不认识 —— 版本不兼容或者内容被改过。
|
|
// 密文是好的,所以这是「坏文件」那一档。
|
|
state = rescue(url, reason: "解密成功但内容解析不了")
|
|
return nil
|
|
}
|
|
state = .loaded
|
|
return payload
|
|
} catch VaultCrypto.Failure.wrongKey(let expected, let found) {
|
|
state = .locked("这份数据是另一把钥匙封的(文件 \(expected),当前 \(found))。用恢复码换回原来那把。")
|
|
return nil
|
|
} catch VaultCrypto.Failure.wrongContext(let expected, let found) {
|
|
// 不是这个库的文件。**不改名** —— 它是别处的好数据,动它才是事故。
|
|
state = .locked("这个文件属于 \(found),不是 \(expected)。检查一下是不是复制错了。")
|
|
return nil
|
|
} catch {
|
|
state = rescue(url, reason: "密文认证失败")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - 写
|
|
|
|
@discardableResult
|
|
public func save(_ payload: Payload) -> Bool {
|
|
// **这一行是整个类型的重点。** 少了它,上面那套状态区分全部白搭。
|
|
guard state.canWrite else { return false }
|
|
guard let url else { return true }
|
|
|
|
let key: SymmetricKey
|
|
do {
|
|
// 已经加载过的库必须用原来那把;只有全新的库才允许现造一把。
|
|
// 反过来(load 失败就造新的)会让「钥匙丢了」变成「静默换新钥匙」,
|
|
// 旧密文从此永远打不开,而界面上看起来一切正常。
|
|
if state == .empty {
|
|
key = try (try? keys.load()) ?? keys.create()
|
|
} else {
|
|
key = try keys.load()
|
|
}
|
|
} catch {
|
|
state = .locked("拿不到主密钥(\(error)),已停止写入以免覆盖旧数据。")
|
|
return false
|
|
}
|
|
|
|
do {
|
|
let plain = try JSONEncoder().encode(payload)
|
|
let sealed = try VaultCrypto.seal(plain, using: key, context: context)
|
|
|
|
let fm = FileManager.default
|
|
try fm.createDirectory(at: url.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
|
|
// 覆盖前留一份上一版的密文。密文备份本身是安全的,可以放心留着。
|
|
// 只留一份、不带时间戳 —— 同 `HookInstaller` 的理由:每次写攒一个
|
|
// 带时间戳的备份,一个月后目录就没法看了。
|
|
if let previous = try? Data(contentsOf: url) {
|
|
let backup = url.appendingPathExtension("hubbak")
|
|
try? previous.write(to: backup, options: .atomic)
|
|
try? fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: backup.path)
|
|
}
|
|
|
|
try sealed.write(to: url, options: .atomic)
|
|
// `.atomic` 换 inode,所以每次写完都要重设一遍,不能只在创建时设。
|
|
try fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
|
state = .loaded
|
|
return true
|
|
} catch {
|
|
state = .locked("写入失败:\(error)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - 救援
|
|
|
|
/// 把坏文件改名保住,不是删掉。原位置腾出来才能重建。
|
|
private func rescue(_ url: URL, reason: String) -> VaultLoadState {
|
|
let stamp = BrokenFileStamp.formatter.string(from: Date())
|
|
let rescued = url.appendingPathExtension("broken-\(stamp)")
|
|
try? FileManager.default.moveItem(at: url, to: rescued)
|
|
// 只记文件名和原因,绝不记内容。
|
|
HubLog.app.warning(
|
|
"密钥库文件打不开(\(reason, privacy: .public)),已改名保住:\(rescued.lastPathComponent, privacy: .public)"
|
|
)
|
|
return .broken(rescuedPath: rescued.path)
|
|
}
|
|
|
|
}
|
|
|
|
/// 泛型类型里不能放 static 存储属性,所以搬出来单独放。
|
|
private enum BrokenFileStamp {
|
|
static let formatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
// 固定 POSIX locale:农历/阿拉伯数字之类的本地化会让文件名变得没法预测。
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.timeZone = .current
|
|
f.dateFormat = "yyyyMMdd-HHmmss"
|
|
return f
|
|
}()
|
|
}
|