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