两个功能来自同一个痛点,但威胁模型正好相反,这是整个设计的支点。 **共用密钥**:平台密钥(飞书、领星、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>
174 lines
7.5 KiB
Swift
174 lines
7.5 KiB
Swift
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))
|
||
}
|
||
}
|