两个功能来自同一个痛点,但威胁模型正好相反,这是整个设计的支点。 **共用密钥**:平台密钥(飞书、领星、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>
123 lines
5.7 KiB
Swift
123 lines
5.7 KiB
Swift
import XCTest
|
||
@testable import HubProjects
|
||
|
||
/// 项目身份。这一组里第一条是安全测试,不是整洁性测试。
|
||
final class ProjectKeyTests: XCTestCase {
|
||
|
||
private func project(_ name: String, _ path: String) -> Project {
|
||
Project(name: name, path: path)
|
||
}
|
||
|
||
/// **同名不同路径的项目必须写进不同文件。**
|
||
///
|
||
/// 只用项目名做文件名的话,`~/code/a/api` 和 `~/code/b/api` 共用一个 `.env`,
|
||
/// 于是项目 A 的会话直接拿到项目 B 的密钥。这是安全事故,不是显示问题。
|
||
/// 而 `api` / `server` / `web` / `admin` 这种重名在真实机器上到处都是。
|
||
func testSameNameDifferentPathGetDifferentFiles() {
|
||
let a = project("api", "~/code/a/api")
|
||
let b = project("api", "~/code/b/api")
|
||
XCTAssertNotEqual(ProjectKey.envFileName(for: a), ProjectKey.envFileName(for: b))
|
||
}
|
||
|
||
/// yaml 里写 `~/x` 和写 `/Users/xxx/x` 是同一个项目,绑定必须命中同一条。
|
||
/// 不成立的话,用户在 yaml 里把 `~` 展开一下,全部密钥绑定就静默失效了。
|
||
func testTildeAndExpandedPathAreTheSameProject() {
|
||
let home = NSString(string: "~").expandingTildeInPath
|
||
XCTAssertEqual(
|
||
ProjectKey.key(forPath: "~/Documents/code/hj-admin"),
|
||
ProjectKey.key(forPath: "\(home)/Documents/code/hj-admin")
|
||
)
|
||
}
|
||
|
||
/// 结尾多一个斜杠不该变成另一个项目。
|
||
func testTrailingSlashIsNormalized() {
|
||
XCTAssertEqual(
|
||
ProjectKey.key(forPath: "~/code/x/"),
|
||
ProjectKey.key(forPath: "~/code/x")
|
||
)
|
||
// 但根目录的那个 `/` 不能被削掉。
|
||
XCTAssertEqual(ProjectKey.key(forPath: "/"), "/")
|
||
}
|
||
|
||
/// 同一个项目每次必须算出同一个文件名。变一次就留下一个孤儿文件,
|
||
/// 而孤儿文件里装的是真密钥,`reconcile` 又认不出它该不该删。
|
||
func testFileNameIsStable() {
|
||
let p = project("hj-admin", "~/Documents/code/hj-admin")
|
||
XCTAssertEqual(ProjectKey.envFileName(for: p), ProjectKey.envFileName(for: p))
|
||
}
|
||
|
||
/// 中文名会被削成空,靠哈希区分。**不能回落成路径** ——
|
||
/// 那会把用户的绝对路径写进文件名。
|
||
func testNonASCIINameFallsBackWithoutLeakingPath() {
|
||
let p = project("后台管理", "~/Documents/code/houtai")
|
||
let name = ProjectKey.envFileName(for: p)
|
||
XCTAssertTrue(name.hasPrefix("project-"), name)
|
||
XCTAssertFalse(name.contains("Users"), "文件名里出现了路径:\(name)")
|
||
XCTAssertTrue(ProjectKey.looksLikeGeneratedEnvFile(name))
|
||
}
|
||
|
||
func testSlugSanitizesSeparatorsAndCase() {
|
||
XCTAssertEqual(ProjectKey.slug("My Project v2"), "my-project-v2")
|
||
XCTAssertEqual(ProjectKey.slug("a///b"), "a-b")
|
||
XCTAssertEqual(ProjectKey.slug("--x--"), "x")
|
||
XCTAssertEqual(ProjectKey.slug("_keep_"), "_keep_")
|
||
}
|
||
|
||
/// `reconcile` 只敢删这个形状的文件。判宽了会删掉用户自己放进去的东西,
|
||
/// 判窄了会让自己生成的孤儿文件永远清不掉(里面装着真密钥)。
|
||
func testGeneratedFileRecognition() {
|
||
XCTAssertTrue(ProjectKey.looksLikeGeneratedEnvFile("hj-admin-9f3a1c8d.env"))
|
||
XCTAssertTrue(ProjectKey.looksLikeGeneratedEnvFile("project-00000000.env"))
|
||
XCTAssertTrue(ProjectKey.looksLikeGeneratedEnvFile("_x_-abcdef01.env"))
|
||
|
||
XCTAssertFalse(ProjectKey.looksLikeGeneratedEnvFile("mine.env"), "用户自己的文件被认成了我们的")
|
||
XCTAssertFalse(ProjectKey.looksLikeGeneratedEnvFile("hj-admin.env"))
|
||
XCTAssertFalse(ProjectKey.looksLikeGeneratedEnvFile("hj-admin-9f3a1c8.env")) // 7 位
|
||
XCTAssertFalse(ProjectKey.looksLikeGeneratedEnvFile("hj-admin-9f3a1c8dz.env")) // 9 位
|
||
XCTAssertFalse(ProjectKey.looksLikeGeneratedEnvFile("HJ-ADMIN-9F3A1C8D.ENV"))
|
||
XCTAssertFalse(ProjectKey.looksLikeGeneratedEnvFile("hj-admin-9f3a1c8d.env.bak"))
|
||
}
|
||
}
|
||
|
||
/// 恢复码。抄错一个字符就永久打不开,所以容错是功能的一部分。
|
||
final class Base32Tests: XCTestCase {
|
||
|
||
func testRoundTripFor32Bytes() {
|
||
let key = Data((0..<32).map { UInt8($0 &* 7 &+ 3) })
|
||
let code = Base32.encode(key)
|
||
XCTAssertEqual(Base32.decode(code), key)
|
||
}
|
||
|
||
/// 用户是从纸上抄回来的:分组的 `-` 会被带进来,大小写会写错,
|
||
/// 中间可能夹空格或换行。这些都必须能吃下。
|
||
func testDecodeToleratesHumanTranscription() {
|
||
let key = Data(repeating: 0x5A, count: 32)
|
||
let code = Base32.encode(key)
|
||
XCTAssertEqual(Base32.decode(code.lowercased()), key)
|
||
XCTAssertEqual(Base32.decode(code.replacingOccurrences(of: "-", with: " ")), key)
|
||
XCTAssertEqual(Base32.decode(code.replacingOccurrences(of: "-", with: "\n")), key)
|
||
}
|
||
|
||
/// `0` 和 `1` 不是合法的 base32 字符,出现在这里只可能是把 `O`/`I` 抄错了。
|
||
/// 纠正它没有歧义,而报「恢复码无效」会让用户完全无从下手。
|
||
func testDecodeFixesZeroAndOne() {
|
||
let withO = "OIOI-OIOI"
|
||
XCTAssertEqual(Base32.decode("0101-0101"), Base32.decode(withO))
|
||
}
|
||
|
||
func testRejectsGarbage() {
|
||
XCTAssertNil(Base32.decode("这不是恢复码"))
|
||
XCTAssertNil(Base32.decode("ABCD-EF89")) // 8/9 不在字母表里
|
||
}
|
||
|
||
/// 分组只是为了好抄,不该改变内容。
|
||
func testGroupingIsCosmetic() {
|
||
let key = Data(repeating: 0xC3, count: 32)
|
||
XCTAssertEqual(Base32.decode(Base32.encode(key, grouped: false)), key)
|
||
XCTAssertEqual(
|
||
Base32.encode(key, grouped: true).replacingOccurrences(of: "-", with: ""),
|
||
Base32.encode(key, grouped: false)
|
||
)
|
||
}
|
||
}
|