两个功能来自同一个痛点,但威胁模型正好相反,这是整个设计的支点。 **共用密钥**:平台密钥(飞书、领星、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>
145 lines
5.8 KiB
Swift
145 lines
5.8 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
/// 账号密码库。
|
|
///
|
|
/// ## 和共用密钥库的关系:**没有关系,而且必须没有**
|
|
///
|
|
/// 两个功能长得像(都是「存密码」),但目标相反:
|
|
/// 共用密钥库**故意**要把值写成明文文件给 AI 用;这里的密码**只给人看**。
|
|
///
|
|
/// 所以它们不共用存储、不共用目录,也不共用任何一条写盘路径。
|
|
/// 这里的密码只经过 `EncryptedFile`,没有任何代码路径能把它送进 `EnvRenderer`
|
|
/// 或 `EnvMaterializer` —— `CredentialStoreTests` 里有一条哨兵测试专门钉这件事。
|
|
@Observable
|
|
@MainActor
|
|
public final class CredentialStore {
|
|
|
|
/// 不含密码。密码只能走 `password(for:)`,那条路要过身份验证。
|
|
public private(set) var credentials: [Credential] = []
|
|
public private(set) var state: VaultLoadState = .empty
|
|
|
|
@ObservationIgnored private var secrets: [UUID: String] = [:]
|
|
@ObservationIgnored private let file: EncryptedFile<Payload>
|
|
@ObservationIgnored private let gate: BiometricGate
|
|
|
|
private struct Record: Codable, Sendable {
|
|
var credential: Credential
|
|
var password: String
|
|
}
|
|
|
|
private struct Payload: Codable, Sendable {
|
|
var records: [Record]
|
|
}
|
|
|
|
public nonisolated static var defaultURL: URL {
|
|
FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent("Library/Application Support/claude-hub/credentials.dat")
|
|
}
|
|
|
|
/// - Parameters:
|
|
/// - url: nil = 不落盘(测试用)。
|
|
/// - gate: 查看/复制前的验证。测试传 `.alwaysAllow`,别去弹系统对话框。
|
|
public init(
|
|
url: URL? = CredentialStore.defaultURL,
|
|
keys: VaultKeySource = .keychain,
|
|
gate: BiometricGate = .deviceOwner
|
|
) {
|
|
self.file = EncryptedFile<Payload>(url: url, context: "creds.v1", keys: keys)
|
|
self.gate = gate
|
|
if let payload = file.load() {
|
|
credentials = payload.records.map(\.credential)
|
|
secrets = Dictionary(payload.records.map { ($0.credential.id, $0.password) },
|
|
uniquingKeysWith: { first, _ in first })
|
|
}
|
|
state = file.state
|
|
}
|
|
|
|
// MARK: - 查
|
|
|
|
/// 某个项目下的账号,按环境分组。`projectKey` 传 nil 拿的是不属于任何项目的那些。
|
|
public func grouped(projectKey: String?) -> [(env: CredentialEnv, items: [Credential])] {
|
|
let mine = credentials.filter { $0.projectKey == projectKey }
|
|
return CredentialEnv.allCases.compactMap { env in
|
|
let items = mine.filter { $0.env == env }.sorted { $0.label < $1.label }
|
|
return items.isEmpty ? nil : (env, items)
|
|
}
|
|
}
|
|
|
|
public var usedProjectKeys: [String] {
|
|
Array(Set(credentials.compactMap(\.projectKey))).sorted()
|
|
}
|
|
|
|
public var hasOrphans: Bool { credentials.contains { $0.projectKey == nil } }
|
|
|
|
/// **拿密码的唯一入口,而且一定要过验证。**
|
|
///
|
|
/// 不提供一个「不验证也能拿」的旁路 —— 有了那个口子,某处图省事一用,
|
|
/// 这道门就形同虚设,而且从界面上完全看不出来。
|
|
public func password(for id: UUID, reason: String? = nil) async throws -> String {
|
|
guard let value = secrets[id] else { throw Failure.notFound }
|
|
let label = credentials.first { $0.id == id }?.label ?? "这条记录"
|
|
try await gate.authenticate(reason ?? "查看「\(label)」的密码")
|
|
return value
|
|
}
|
|
|
|
public enum Failure: Error, Equatable { case notFound, locked }
|
|
|
|
// MARK: - 改
|
|
|
|
/// 新增/修改都不需要验证 —— 用户手上已经有那个密码了,再问一遍只是骚扰。
|
|
/// 验证守的是「读出来」,不是「写进去」。
|
|
///
|
|
/// - Parameter password: **nil = 保留原来的密码。**
|
|
/// 编辑界面上密码框是空的(显示原密码要过验证,改个备注还要按指纹太烦),
|
|
/// 所以「没填」必须和「改成空」区分开 —— 不区分的话,用户改一下 URL
|
|
/// 就把密码清空了,而且要等到下次用的时候才发现。
|
|
@discardableResult
|
|
public func upsert(_ credential: Credential, password: String?) -> Bool {
|
|
guard state.canWrite else { return false }
|
|
var updated = credential
|
|
updated.updatedAt = Date()
|
|
if let index = credentials.firstIndex(where: { $0.id == credential.id }) {
|
|
credentials[index] = updated
|
|
} else {
|
|
credentials.append(updated)
|
|
}
|
|
if let password { secrets[credential.id] = password }
|
|
return persist()
|
|
}
|
|
|
|
@discardableResult
|
|
public func remove(id: UUID) -> Bool {
|
|
guard state.canWrite else { return false }
|
|
credentials.removeAll { $0.id == id }
|
|
secrets[id] = nil
|
|
return persist()
|
|
}
|
|
|
|
/// 项目从 projects.yaml 里没了,把它的账号挪成「不属于任何项目」。
|
|
///
|
|
/// **不删。** 项目从清单里消失的常见原因是路径改了或者临时注释掉了,
|
|
/// 而这里存的是用户唯一一份密码记录 —— 跟着删掉是不可逆的。
|
|
public func detachCredentials(ofMissingProjectKeys missing: Set<String>) {
|
|
guard state.canWrite, !missing.isEmpty else { return }
|
|
var touched = false
|
|
for index in credentials.indices {
|
|
if let key = credentials[index].projectKey, missing.contains(key) {
|
|
credentials[index].projectKey = nil
|
|
touched = true
|
|
}
|
|
}
|
|
if touched { _ = persist() }
|
|
}
|
|
|
|
@discardableResult
|
|
private func persist() -> Bool {
|
|
let records = credentials.map {
|
|
Record(credential: $0, password: secrets[$0.id] ?? "")
|
|
}
|
|
let ok = file.save(Payload(records: records))
|
|
state = file.state
|
|
return ok
|
|
}
|
|
}
|