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

98 lines
4.3 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 Foundation
/// 项目在密钥功能里的身份。**只此一处**,别的地方不许再算一遍。
///
/// ## 为什么要单独一个类型
///
/// 仓库里「项目的 key」有两个互不兼容的取法,而且两种都在用:
/// `ProjectStore.pinned` 用 `Project.id`(yaml 里的原文路径,含 `~`),
/// `AcceptanceStore.ledgers` 用 `expandedPath`(展开后的绝对路径)。
/// 各自都有道理,但这次的功能**两头都要碰**(绑定表按项目存,文件名按项目生成),
/// 混用一次就是一次静默事故:绑定表用一种、物化器用另一种,
/// 结果是项目怎么勾都拿不到密钥,而界面上一切正常。
///
/// 这里统一取 `expandedPath`,理由是文件名必须锚在**真实目录**上 ——
/// 见下面 `envFileName` 那段。
///
/// ## 文件名为什么要带哈希后缀
///
/// 这不是装饰。只用项目名的话,`~/code/a/api` 和 `~/code/b/api` 会写进同一个文件,
/// 于是**项目 A 拿到项目 B 的密钥** —— 这是安全事故,不是显示问题。
/// 而项目重名在真实机器上一点都不罕见(`api`、`server`、`web`、`admin`)。
///
/// 后缀取路径哈希的前 8 位(32 bit)。两个不同路径撞上的概率在几十个项目的量级下
/// 可以忽略,而路径仍然短到能一眼看懂、能复制粘贴 ——
/// 「能被复制粘贴」正是这些文件放在 `~/.vibe-foreman/` 而不是
/// `~/Library/Application Support/` 的唯一理由。
public enum ProjectKey {
/// 绑定表、查表、一切按项目索引的地方都用它。
public static func key(for project: Project) -> String {
key(forPath: project.path)
}
/// 展开 `~`,去掉结尾的 `/`。
///
/// 不用 `NSString.standardizingPath`:它在 macOS 上对 `/tmp` 有
/// 「悄悄换成 `/private/tmp`」的特例,而且对不存在的路径行为不好预测 ——
/// 项目目录被删掉之后仍然要能查到它的绑定,所以这里不碰文件系统。
public static func key(forPath raw: String) -> String {
let expanded = raw.hasPrefix("~") ? NSString(string: raw).expandingTildeInPath : raw
var trimmed = expanded
while trimmed.count > 1, trimmed.hasSuffix("/") { trimmed.removeLast() }
return trimmed
}
/// 物化出来的 `.env` 文件名。
public static func envFileName(for project: Project) -> String {
envFileName(name: project.name, key: key(for: project))
}
public static func envFileName(name: String, key: String) -> String {
"\(slug(name))-\(fingerprint(key)).env"
}
/// 只有这个形状的文件才是我们生成的,`reconcile` 也只敢删这个形状的。
/// 用户自己往目录里放的东西一个都不能碰。
public static func looksLikeGeneratedEnvFile(_ fileName: String) -> Bool {
fileName.range(
of: "^[a-z0-9_][a-z0-9_-]*-[0-9a-f]{8}\\.env$",
options: .regularExpression
) != nil
}
// MARK: - 零件
static func fingerprint(_ key: String) -> String {
SHA256.hash(data: Data(key.utf8)).prefix(4)
.map { String(format: "%02x", $0) }.joined()
}
/// 项目名 → 文件名安全的一段。中文项目名会被整段替换掉,
/// 这没关系 —— 唯一性由后面的哈希保证,slug 只负责「人能认出来」。
static func slug(_ name: String) -> String {
var out = ""
var lastWasDash = false
for ch in name.lowercased() {
if ch.isASCII, ch.isLetter || ch.isNumber {
out.append(ch)
lastWasDash = false
} else if ch == "_" {
out.append(ch)
lastWasDash = false
} else if !lastWasDash {
out.append("-")
lastWasDash = true
}
}
while out.hasSuffix("-") { out.removeLast() }
while out.hasPrefix("-") { out.removeFirst() }
if out.count > 32 { out = String(out.prefix(32)) }
while out.hasSuffix("-") { out.removeLast() }
// 全是非 ASCII 的名字(比如「后台」)会被削成空串。
// 回落成一个固定词,靠哈希区分,不要回落成路径 —— 那会把绝对路径写进文件名。
return out.isEmpty ? "project" : out
}
}