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