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

143 lines
5.2 KiB
Swift

import HubProjects
import SwiftUI
/// 一个「改完才提交」的文本框。
///
/// 不用 `Binding` 直接写回 store:那样每敲一个字符都要走一遍
/// 加密 → 原子写 → 重新物化 `.env`。功能上没错,但会把密钥文件反复重写,
/// 而每次重写都是一次权限回归的窗口(`.atomic` 换 inode)。
///
/// 所以这里在本地攒着,**回车或者失焦**才提交一次,而且值没变就不提交。
struct EditableText: View {
let placeholder: String
let value: String
var monospaced: Bool = false
var onCommit: (String) -> Void
@State private var draft: String = ""
@FocusState private var focused: Bool
var body: some View {
TextField(placeholder, text: $draft)
.textFieldStyle(.roundedBorder)
.font(monospaced ? .system(size: 12, design: .monospaced) : .system(size: 12))
.focused($focused)
.onAppear { draft = value }
// store 那边被别的路径改了(比如撤销、重新加载),要跟上。
.onChange(of: value) { _, new in if !focused { draft = new } }
.onSubmit { commit() }
.onChange(of: focused) { _, isFocused in if !isFocused { commit() } }
}
private func commit() {
guard draft != value else { return }
onCommit(draft)
}
}
/// 带遮蔽开关的值输入框。
///
/// 默认遮蔽。这不是装饰 —— 这台机器上截图工具是可用的,
/// 一个默认全展开的密钥列表等于把所有值挂在屏幕上。
struct SecretValueField: View {
let value: String
var placeholder: String = "值"
@Binding var revealed: Bool
var onCommit: (String) -> Void
var body: some View {
HStack(spacing: 6) {
if revealed {
EditableText(placeholder: placeholder, value: value, monospaced: true, onCommit: onCommit)
} else {
// 遮蔽态用 SecureField,而不是把明文塞进普通 TextField 再画个遮罩 ——
// 后者在辅助功能、拖拽、截屏里都是明文。
SecureFieldProxy(value: value, placeholder: placeholder, onCommit: onCommit)
}
Button {
revealed.toggle()
} label: {
Image(systemName: revealed ? "eye.slash" : "eye")
}
.buttonStyle(.borderless)
.help(revealed ? "遮起来" : "显示")
}
}
}
/// `SecureField` 版本的「改完才提交」。
struct SecureFieldProxy: View {
let value: String
let placeholder: String
var onCommit: (String) -> Void
@State private var draft: String = ""
@FocusState private var focused: Bool
var body: some View {
SecureField(placeholder, text: $draft)
.textFieldStyle(.roundedBorder)
.font(.system(size: 12, design: .monospaced))
.focused($focused)
.onAppear { draft = value }
.onChange(of: value) { _, new in if !focused { draft = new } }
.onSubmit { commit() }
.onChange(of: focused) { _, isFocused in if !isFocused { commit() } }
}
private func commit() {
guard draft != value else { return }
onCommit(draft)
}
}
/// 库打不开时顶部那条横幅。
///
/// **必须显眼,而且必须说清楚是哪一种。** 「钥匙不对」和「文件坏了」
/// 用户要做的事完全不同:前者去找恢复码,后者是文件真没了。
/// 含混的一句「加载失败」只会让用户自己去删文件重来 —— 而那正是最坏的处置。
struct VaultStateBanner: View {
let state: VaultLoadState
var onRecover: (() -> Void)?
var body: some View {
switch state {
case .loaded, .empty:
EmptyView()
case .locked(let reason):
banner(
symbol: "lock.trianglebadge.exclamationmark",
color: IslandTheme.waiting,
title: "打不开,已停止一切写入",
detail: reason + "\n数据还在,没有被改动。"
)
case .broken(let path):
banner(
symbol: "exclamationmark.triangle.fill",
color: IslandTheme.danger,
title: "文件损坏,已改名保住",
detail: "原文件在 \(path)。这里可以重新开始。"
)
}
}
@ViewBuilder
private func banner(symbol: String, color: Color, title: String, detail: String) -> some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: symbol).foregroundStyle(color)
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.system(size: 12, weight: .semibold))
Text(detail).font(.system(size: 11)).foregroundStyle(.secondary)
}
Spacer()
if let onRecover {
Button("用恢复码恢复…", action: onRecover).font(.system(size: 11))
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(color.opacity(0.12), in: .rect(cornerRadius: 10, style: .continuous))
.padding(.horizontal, 20)
}
}