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

174 lines
7.5 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CryptoKit
import XCTest
@testable import HubProjects
/// 加密落盘的状态机。
///
/// 这一组几乎每一条都在守同一件事:**「打不开」的时候不许动用户的数据。**
/// `AcceptanceStore.swift:514-518` 记的那次事故(解码失败 → 内存空 → 下次写入覆盖)
/// 在这里的代价是丢线上密码,所以每一条失败路径都要单独钉一遍。
@MainActor
final class EncryptedFileTests: XCTestCase {
private struct Payload: Codable, Sendable, Equatable {
var secret: String
}
private var dir: URL!
private var url: URL { dir.appendingPathComponent("vault.dat") }
private let keyA = SymmetricKey(data: Data(repeating: 0x11, count: 32))
private let keyB = SymmetricKey(data: Data(repeating: 0x22, count: 32))
override func setUpWithError() throws {
dir = FileManager.default.temporaryDirectory
.appendingPathComponent("vf-vault-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: dir)
}
private func file(_ key: SymmetricKey = SymmetricKey(data: Data(repeating: 0x11, count: 32)),
context: String = "creds.v1") -> EncryptedFile<Payload> {
EncryptedFile<Payload>(url: url, context: context, keys: .fixed(key))
}
// MARK: - 正常路径
func testRoundTrip() throws {
let a = file()
XCTAssertNil(a.load())
XCTAssertEqual(a.state, .empty)
XCTAssertTrue(a.save(Payload(secret: "hunter2")))
let b = file()
XCTAssertEqual(b.load(), Payload(secret: "hunter2"))
XCTAssertEqual(b.state, .loaded)
}
/// 落盘的必须是密文。这条挡的是「某次重构把加密路径短路了」。
func testFileOnDiskIsCiphertextAndIs600() throws {
let f = file()
f.save(Payload(secret: "SENTINEL-DO-NOT-LEAK"))
let raw = try Data(contentsOf: url)
XCTAssertNil(raw.range(of: Data("SENTINEL-DO-NOT-LEAK".utf8)), "明文直接落盘了")
let mode = try FileManager.default.attributesOfItem(atPath: url.path)[.posixPermissions]
XCTAssertEqual((mode as? NSNumber)?.intValue, 0o600)
}
/// 备份留的也是密文,权限一样收紧。
func testBackupIsCiphertextAndIs600() throws {
let f = file()
// 哨兵要够长:两三个字节的串在随机密文里本来就会撞上,那样测的是运气不是加密。
f.save(Payload(secret: "SENTINEL-VERSION-ONE-DO-NOT-LEAK"))
f.save(Payload(secret: "SENTINEL-VERSION-TWO-DO-NOT-LEAK"))
let backup = url.appendingPathExtension("hubbak")
let raw = try Data(contentsOf: backup)
XCTAssertNil(raw.range(of: Data("SENTINEL-VERSION-ONE-DO-NOT-LEAK".utf8)))
let mode = try FileManager.default.attributesOfItem(atPath: backup.path)[.posixPermissions]
XCTAssertEqual((mode as? NSNumber)?.intValue, 0o600)
}
/// nil url = 不落盘(测试用)。此时一切照常可写,磁盘上什么都不该出现。
func testNilURLNeverTouchesDisk() {
let f = EncryptedFile<Payload>(url: nil, context: "creds.v1", keys: .fixed(keyA))
XCTAssertNil(f.load())
XCTAssertTrue(f.save(Payload(secret: "x")))
XCTAssertEqual((try? FileManager.default.contentsOfDirectory(atPath: dir.path))?.count, 0)
}
// MARK: - 钥匙不对:锁住,绝不动文件
/// **换了一把钥匙 → 锁住,文件原封不动。**
///
/// 这是换机、重装、钥匙串出问题之后最可能出现的状态。
/// 这里要是走成「当空库」或者「改名」,用户的密码就真的没了 ——
/// 而他手上的恢复码本来是能救回来的。
func testWrongKeyLocksAndLeavesFileUntouched() throws {
file(keyA).save(Payload(secret: "原始数据"))
let before = try Data(contentsOf: url)
let f = file(keyB)
XCTAssertNil(f.load())
guard case .locked = f.state else { return XCTFail("拿错钥匙没有锁住,state=\(f.state)") }
XCTAssertEqual(try Data(contentsOf: url), before, "锁住了却改了文件")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path), "文件被改名或删了")
}
/// 锁住之后**任何写入都必须被挡掉**。这一行 guard 少了,上面那些区分全部白搭。
func testSaveIsRefusedWhileLocked() throws {
file(keyA).save(Payload(secret: "原始数据"))
let before = try Data(contentsOf: url)
let f = file(keyB)
_ = f.load()
XCTAssertFalse(f.save(Payload(secret: "覆盖!")))
XCTAssertEqual(try Data(contentsOf: url), before, "锁死状态下还是把文件覆盖了")
}
/// 钥匙串里根本没有钥匙(换了台机器)—— 同样是锁住,不是「第一次使用」。
func testMissingKeyLocksInsteadOfStartingFresh() throws {
file(keyA).save(Payload(secret: "原始数据"))
let missing = VaultKeySource(
load: { throw VaultKeychain.Failure.notFound },
create: { throw VaultKeychain.Failure.notFound }
)
let f = EncryptedFile<Payload>(url: url, context: "creds.v1", keys: missing)
XCTAssertNil(f.load())
guard case .locked = f.state else { return XCTFail("钥匙没了却没锁住,state=\(f.state)") }
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
}
/// 文件属于另一个库(复制错了)。它是别处的好数据,**不能改名**。
func testWrongContextLocksWithoutRenaming() throws {
EncryptedFile<Payload>(url: url, context: "shared.v1", keys: .fixed(keyA))
.save(Payload(secret: "共用密钥库的数据"))
let f = file(keyA, context: "creds.v1")
XCTAssertNil(f.load())
guard case .locked = f.state else { return XCTFail("跨库没锁住,state=\(f.state)") }
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path), "别的库的文件被改名了")
}
// MARK: - 文件真坏了:改名保住,不是删掉
func testCorruptCiphertextIsRescued() throws {
file(keyA).save(Payload(secret: "原始数据"))
var raw = try Data(contentsOf: url)
raw[raw.count - 1] ^= 0xFF
try raw.write(to: url)
let f = file(keyA)
XCTAssertNil(f.load())
guard case .broken(let rescued) = f.state else {
return XCTFail("坏文件没进 broken,state=\(f.state)")
}
XCTAssertTrue(FileManager.default.fileExists(atPath: rescued), "坏文件被删了而不是改名保住")
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
}
/// 完全不是 vault 的文件也走改名,不能直接覆盖 —— 用户可能只是放错了地方。
func testForeignFileIsRescuedNotOverwritten() throws {
try Data("这是别的东西".utf8).write(to: url)
let f = file(keyA)
XCTAssertNil(f.load())
guard case .broken(let rescued) = f.state else { return XCTFail("state=\(f.state)") }
XCTAssertEqual(try String(contentsOfFile: rescued, encoding: .utf8), "这是别的东西")
}
/// broken 之后也不许自动重建 —— 得由界面明确问过用户。
func testSaveIsRefusedWhileBroken() throws {
try Data("坏的".utf8).write(to: url)
let f = file(keyA)
_ = f.load()
XCTAssertFalse(f.save(Payload(secret: "新的")))
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
}
}