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

65 lines
2.6 KiB
Swift

import Foundation
/// RFC 4648 Base32,无填充。只服务于「恢复码」这一个用途。
///
/// 为什么不用 Base64:恢复码是要**人抄写**的。Base64 里 `l/I/1`、`O/0` 长得一样,
/// 大小写还有意义 —— 抄错一个字符,密码库就永久打不开,而用户不会知道是哪一个字符错了。
/// Base32 的字母表(A-Z2-7)刻意去掉了 `0/1/8/9`,且大小写不敏感。
public enum Base32 {
private static let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567")
/// 编码后每 4 个字符插一个 `-`。纯粹为了抄写时不串行,解码会忽略它。
public static func encode(_ data: Data, grouped: Bool = true) -> String {
var out = ""
var buffer = 0
var bits = 0
for byte in data {
buffer = (buffer << 8) | Int(byte)
bits += 8
while bits >= 5 {
bits -= 5
out.append(alphabet[(buffer >> bits) & 0x1F])
}
}
if bits > 0 {
out.append(alphabet[(buffer << (5 - bits)) & 0x1F])
}
guard grouped else { return out }
return stride(from: 0, to: out.count, by: 4).map {
let start = out.index(out.startIndex, offsetBy: $0)
let end = out.index(start, offsetBy: min(4, out.count - $0))
return String(out[start..<end])
}.joined(separator: "-")
}
/// 解码。忽略分隔符和大小写,并且**把常见的手抄错误纠正回来** ——
/// 用户从纸上抄回来时把 `O` 写成 `0`、`I`/`l` 写成 `1` 是必然会发生的,
/// 而这些字符本来就不在字母表里,不纠正的话只能报「恢复码无效」,
/// 用户完全无从下手。
public static func decode(_ text: String) -> Data? {
var buffer = 0
var bits = 0
var out = Data()
for raw in text.uppercased() {
let ch: Character
switch raw {
case "-", " ", "\n", "\t", "\r": continue
// `0` 和 `1` 不是合法的 base32 字符,所以出现在这里只可能是手抄
// `O` 和 `I` 时写错了 —— 纠正它是安全的,不存在歧义。
case "0": ch = "O"
case "1": ch = "I"
default: ch = raw
}
guard let index = alphabet.firstIndex(of: ch) else { return nil }
buffer = (buffer << 5) | index
bits += 5
if bits >= 8 {
bits -= 8
out.append(UInt8((buffer >> bits) & 0xFF))
}
}
return out
}
}