# 安全说明（UniTerminal 凭据存储）

> 这一份是**凭据存储**的对外说明：威胁模型、边界、密码存在哪、怎么清、已知限制。
> 设计与取舍的完整记录见 `docs/superpowers/plans/ledger-m0.md`。

## 一、威胁模型

先说清楚**我们在防谁**，再说我们做了什么。分界线是"能碰到这台机器的用户账户"。

### 1.1 本地攻击者（拿到了磁盘 / 用户账户）

| 攻击者能做的 | 我们的处境 |
|---|---|
| 读 `~/.dsh/uniterminal/` 下的文件 | `profiles.json` 里**没有密码**（只有主机信息与 `remember` 布尔）。凭据在**系统密码箱**里（Windows 凭据管理器 / macOS 钥匙串 / Linux Secret Service），它的访问控制由操作系统负责：同一台机器上的其他用户、离线拷贝的磁盘镜像都拿不到里面的内容。 |
| 读 `credentials.json`（**只在没有系统密码箱的机器上存在**） | 那台机器上密码是**明文**的。文件的保护只有两条：POSIX `0600`、Windows 上的 NTFS ACL（`%USERPROFILE%` 只有本人可读）。**同一台机器上的其他账户、拿到管理员权限的人、离线拷贝的磁盘镜像都能读到。** |
| 以你的账户身份运行程序 | 那他就是"你"：系统密码箱会以你的身份解锁，凭据能被读走。**这一层不是插件能挡的**——操作系统把"你的账户"就定义为"你"。 |
| 读 `diag.log` | 诊断上报里**没有凭据字段**（见第二节）。 |

### 1.2 远程攻击者（只能通过网络碰到插件）

| 攻击者能做的 | 我们的处境 |
|---|---|
| 从别的机器连插件的 HTTP 端点 | 所有 `/api/uniterminal/*` 端点都要求 **loopback 来源**，非回环地址一律 403，连接**不读请求体**。 |
| 通过插件去连攻击者控制的服务器 | 插件不代理任意 TCP：隧道只允许 SSH 的 `-L` / `-R` / `-D`，SFTP 与 exec 只能作用在**用户已经建立的会话**上。 |
| 在 SSH 会话里看密码 | 密码只在**宿主 Node 进程**里。会话建立后密码被交给 ssh2 客户端，之后不再持有。 |

### 1.3 我们**不**防的

- 已经被攻陷的操作系统账户（等于"你就是攻击者"）。
- 键盘记录器、屏幕录制这类**运行期**窃取——插件层面无解。
- 共享机器上的**其他人**：系统密码箱通常按**登录用户**隔离，所以共用同一个 Windows / Linux 账户的人仍然能读到凭据。

## 二、永远不发生的事

这四条是硬约束，任何改动都不许破坏（对应的测试在 `test/store/security.test.ts` 与 `test/routes.test.ts`）：

1. **密码永远不进浏览器。**
   `GET /api/uniterminal/security/refs` **只回 ref 名字**（`profile:<id>` / `key:<id>` / `key-passphrase:<id>`），
   永远不回密钥。前端**不回填**密码框、不把密码放进任何变量 / DOM / 诊断。
2. **密码永远不进 `profiles.json`。**
   档案里只有主机信息、`remember` 布尔与认证方式；请求体里带 `password` / `privateKey` / `passphrase` /
   `secret` 的字段一律被路由层拒绝（`CREDENTIAL_FIELDS` 白名单）。
3. **密码永远不进日志与诊断。**
   `diag.log` 与 `reportClient()` 上报里只有 id、状态、类别；写入失败时错误消息里的密码会被 `scrub` 抹掉。
4. **插件不自己实现加密。**
   密码只有两个去处：**操作系统密码箱**，或**明文文件**（并在界面如实说明当前是哪种）。
   不存在"插件自建加密、由插件保管密钥"这条路。

## 三、代码里的"私钥标记"是怎么回事

自动扫描器经常把下面这几处报成"泄露了私钥"。它们都不是凭据，看到时可以对一下：

| 位置 | 是什么 |
|---|---|
| `src/agent-tools.ts` 里的正则 | **用来清除**私钥的：AI 执行的命令在写进 `diag.log` 前会先用它把私钥段落抹掉。它存在正是为了不留密钥 |
| `test/**` 的 `FAKE_PRIVATE_KEY` 之类 | 假密钥，正文是字面量 `FAKE-FOR-TESTS` 或标记常量；用来验证"私钥不会被写进日志/DOM" |
| 私钥输入框的占位提示 | 只是告诉用户粘什么，**没有值、没有正文、没有 END 行** |

仓库里从来没有任何真实私钥。判断依据很简单：真实的 OpenSSH 私钥在 BEGIN 与 END 之间有一大段 base64，
上面这些位置都没有；`diag.log` 里也不会有（那是被 scrub 掉的东西）。

## 四、密码存在哪

启动时插件做一次**只读探测**（列举一次系统密码箱，不为了探测去写测试凭据），
把结果登记进 `~/.dsh/uniterminal/security.json` 的 `storage` 字段：

| 后端 | `storage` | 密码存在哪 | 界面上的话 |
|---|---|---|---|
| Windows 凭据管理器 | `keychain` | Windows 凭据管理器（service `dsh-uniterminal`） | 密码保存在 Windows 凭据管理器 |
| macOS 钥匙串 | `keychain` | macOS 钥匙串（service `dsh-uniterminal`） | 密码保存在 macOS 钥匙串 |
| Linux Secret Service | `keychain` | 系统钥匙环（Secret Service） | 密码保存在系统钥匙环（Secret Service） |
| 没有系统密码箱 | `file` | 本机明文文件 `credentials.json`（0600 / NTFS ACL） | 这台机器没有可用的系统密码箱，密码明文保存在本机凭据文件（仅当前账户可读） |
| 曾用密码箱、现在临时降级 | `file` + `degraded: true` | 本机明文文件（**新写的**） | 系统密码箱暂时不可用，已临时改用本机文件保存 |

界面常驻小字与上表最后两行的关系见 `docs/acceptance-m2a.md` 判据 3 的注记：
**文件后端下常驻小字是验收单那句原文**（"密码以明文保存在本机凭据文件，受 Windows 账户权限保护"），
**密码箱后端下换成上表对应平台那一句**。

### 3.1 为什么 Linux 必须显式 pin `secret-service`

Linux 上不显式指定后端时，底层库会"Secret Service → 内核 keyring"自动回落。
而内核 keyring **不跨重启、也不是加密存储**——静默用它会让用户以为"密码存了"，
重启后却发现没了。插件因此显式 pin `{ linux: { store: "secret-service" } }`：
**该后端不可用时直接抛错**（然后降级到文件后端并如实告诉用户），而不是悄悄换一个更弱的存储。

### 3.2 怎么清

- **取消某台主机的"记住密码"**：界面里取消勾选并保存，或调 `POST /api/uniterminal/profiles/forget-password`。
  那条凭据（系统密码箱里的条目 / 文件里的一行）会被删掉。
- **删掉粘贴的私钥**：`POST /api/uniterminal/profiles/key/delete`（连同它的口令短语）。
- **删掉系统密码箱里的全部条目**：用系统自己的工具清（Windows 凭据管理器 / 钥匙串访问 / Secret Service 的客户端）。
  插件不提供"一键清空系统密码箱"——那会动到别的应用写在同一个 service 下的东西。
- **删掉本机明文库**：删 `~/.dsh/uniterminal/credentials.json`（只在没有系统密码箱的机器上存在）。

### 3.3 怎么迁移

从"明文文件"升级到"系统密码箱"是**自动**的：

1. 启动时探测到系统密码箱可用；
2. 把 `credentials.json` 里的条目**逐条**写进密码箱；
3. **全部写成功之后**才删掉 `credentials.json`（搬完不留明文副本）；
4. 中途失败 → **保留原文件、继续用文件后端**，并把这件事告诉界面（`degraded`）。

绝不会出现"文件删了但密码没搬进去"：删文件是最后一步，且只在全部写成功之后。

## 五、已知限制

1. **没有系统密码箱的机器上，密码是明文落盘的。**
   防护只有文件权限（POSIX `0600` / Windows NTFS ACL）。
   **不要在多用户共用的机器上勾选"记住密码"。**
2. **共用同一台机器、同一个系统账户的人，能读到你的凭据。**
   系统密码箱按登录用户隔离，但"同一个账户"就等于"同一把钥匙"。
3. **系统密码箱被锁 / 服务没起时**，插件会**降级到本机文件**并如实说明。
   此时：
   - **新记住**的密码会写进本机文件；
   - **此前存在密码箱里**的那些条目**取不到**（界面会给"系统密码箱不可用，请稍后重试"，
     而不是"请输入密码"——后者会骗你以为密码没记住）；
   - 密码箱恢复后，下次启动会重新探测并继续用密码箱。
4. **降级期间写进文件的条目，在下次启动成功迁移时会一并搬进密码箱**；
   但**密码箱里已有而文件里没有的条目不会自动倒灌到文件**（降级时不做全量回写：
   那需要把密码箱里的每一条都读出来，风险大于收益）。
5. **Linux 上没有 Secret Service**（无 GNOME Keyring / KWallet 的精简桌面）时，
   插件会走文件后端并明说——**不会**退到内核 keyring（不跨重启）。
6. **没有终端锁屏**：离开座位时已建立的会话没有任何保护，需要时请自行锁屏或断开连接。
7. **插件不提供自研加密**：没有"插件保管密钥"这条路。
   需要更强的保护时，请用操作系统的全盘加密与账户隔离。

