Files
wuguanganandClaude Opus 5 a9363e43c9 feat: 共用密钥中心 + 项目账号密码库
两个功能来自同一个痛点,但威胁模型正好相反,这是整个设计的支点。

**共用密钥**:平台密钥(飞书、领星、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>
2026-08-17 12:45:20 +08:00

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
}()
}