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