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

123 lines
5.7 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 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)
)
}
}