# 安全模型（dsh-ui-auth）

> **面向：**安全审计员 / 安全决策者。本文说明**信任边界、加密与密钥、权限模型、失效姿态与残余风险**；
> 逐项验证证据在 [SECURITY-VERIFICATION](SECURITY-VERIFICATION.md)，多用户密钥隔离的设计推导在 [RBAC 设计](RBAC-MODEL-PROFILES.md)。

## 1. 一句话定位

本插件在 DSH Web 面板**外层**加一道认证闸门：未登录者拿不到面板（fail-closed），登录者按角色被授权，
每个用户拥有**只属于自己的**模型与 API Key，管理员可**分享**自己的模型但**在任何情况下都读不到别人的私有 Key**。

## 2. 威胁模型

| 角色 | 能力假设 | 我们要防住的 |
|---|---|---|
| **未认证网络攻击者** | 可任意构造 HTTP/WS 请求、扫描端点 | 拿到任何面板能力；枚举用户；暴力破解（限流/锁定） |
| **普通登录用户** | 拥有合法会话 | 读到他人的会话/工作区/文件；读到他人私有 Key；调用未被授权的端点；越权写配置 |
| **被授权使用分享的用户** | 被管理员授予某条分享 | 看到分享者的 API Key；撤销后继续使用；查看与分享无关的配置 |
| **其他管理员** | 拥有管理员角色 | 读取**任何**用户的私有模型配置（含其他管理员的） |
| **本地主机攻击者** | 可读进程工作目录与 `DSH_HOME` | 从落盘文件中得到明文口令、会话 token 之外的东西（见 §5 落盘清单） |
| **XSS / 恶意前端** | 能在页面执行脚本 | 直接拿到明文 Key（私钥只在会话内存且仅在调用瞬间解密） |

**不在威胁模型内**：宿主 DSH 自身的实现漏洞；具备 root/同权限的本地攻击者；已被攻陷的浏览器扩展。

## 3. 信任边界（谁信任谁）

```
浏览器 ──(① 本插件会话 Cookie)──▶ 本插件网关 ──(② 现铸载体 Cookie)──▶ DSH 宿主
                                        │
                                        └─(③ 加密存储：宿主凭据文档)─▶ 磁盘
```

1. **浏览器 ↔ 插件**：唯一凭据是**本插件的会话 Cookie**（HttpOnly）。它的名字**按实例唯一**
   （`DSH_HOME` 短哈希后缀），因为浏览器 Cookie 不区分端口——同机多实例共用同名会导致互相踢下线。
2. **插件 ↔ 宿主**：宿主不知道本插件的用户概念。插件为每个被放行的请求**现铸**宿主载体 Cookie 并注入请求头，
   因此**宿主侧看不到任何用户的私有 Key**（Key 只在插件内存中解密后用于上游调用）。
3. **插件 ↔ 磁盘**：用宿主的凭据文档保存记录，键名必须是宿主语法 `<scope>/<id>`（**恰好两段**）。
   写成三段会让宿主凭据服务在**下次启动时拒绝整份文档**（曾发生过，见 §7）。

## 4. 认证与会话

- **口令**：PBKDF2-HMAC-SHA256 + 每用户随机盐，**从不保存明文**；登录失败按 IP/账号限流并可锁定
  （`DSH_AUTH_MAX_FAILS` / `DSH_AUTH_LOCK_MS`，反代后须开 `DSH_AUTH_TRUST_PROXY` 才能按真实 IP 计数）。
- **会话**：随机 token，HttpOnly Cookie，可跨重启恢复（未过期）；登出/改密/删除用户/重置密码
  **立即失效**相关会话与内存密钥。
- **两步验证**：TOTP（RFC 6238 向量在回归中校验）与 **通行密钥（WebAuthn）**；私钥永不出设备，
  服务端只存公钥。改动登录因子前要求**二次验证**（防会话被借用后改因子）。
- **注册**：需要**有效邀请码**；被删除的用户名写入**永久墓碑**，同名不可重建（避免继承旧归属）。

## 5. 密钥与加密（本插件的核心不变量）

| 场景 | 密钥来源 | 谁能解密 |
|---|---|---|
| **用户私有配置** | `PBKDF2(登录口令, 每用户盐, ≥600k) → userKek` → 每条配置随机 DEK → **AES-256-GCM**，AAD 绑定 `uid/profileId` | **仅该用户**，且**仅在其会话解锁后**（内存中只保留 32 字节 userKek，**不保留口令**） |
| **管理员分享的配置** | 服务端**主密钥**托管 | 服务端（因此被授权者与所有者都能用；Key 仍不下发给任何人） |
| **余额查询** | 同上（需要能解密） | 私有配置**未解锁时无法查余额**，界面明确提示「需先解锁」——这是**有意**的取舍（见 §6） |

**不变量（审计可验证）**：

- **INV-1** 任何用户（含管理员）**不能**读取他人的私有配置：跨用户读取在代码层被拒绝。
- **INV-2** 私有 Key **不写入**宿主明文凭据存储；宿主与磁盘上都只有密文（+ 掩码 `前4...后4`）。
- **INV-3** 未配置任何模型（也未被分享）的用户**被阻断**，**不回退**到部署级配置。
- **INV-4** 撤销分享**立即** fail-closed（撤销后该用户无法再使用）。
- **INV-5** 闸门失效时**拒绝服务**而非放行：缺少现代传输能力（`connection.authorizeIndex`）时，
  插件装上"明确拒绝"监听器对（HTTP 503 + WS 直接销毁）。

**落盘清单**（供本地审计）：宿主凭据文档（密文与掩码、会话与邀请码记录）、会话文件、
首次启动的**初始管理员口令文件** `dsh-ui-auth-bootstrap.txt`（**部署后应立即删除**）、审计记录。

## 6. 权限矩阵（普通用户 vs 管理员）

| 能力 | 普通用户 | 管理员 |
|---|---|---|
| 面板访问 | ✅（仅自己的会话/工作区） | ✅ |
| 用户管理（增删用户、重置口令、清通行密钥） | ❌ | ✅ |
| 邀请码管理 | ❌ | ✅ |
| 自己的模型与 API Key | ✅（私有、E2E） | ✅ |
| 读取他人私有模型配置 | ❌ | **❌（任何情况）** |
| 分享自己的模型给指定用户 | ❌ | ✅ |
| 查看被分享配置的余额 | ✅（仅数字） | ✅ |
| 查看被分享配置的 Key | ❌ | ❌（分享者自己也不从界面读 Key） |
| 原生账户页（部署者账号/充值） | ❌（界面已替换为说明，服务端 `account/*` 全拒绝） | ✅ |
| 插件安装/卸载/启停 | ❌（服务端 403；界面呈只读） | ✅ |
| 部署级设置（`settings/*`、凭据、工作区创建等） | ❌（deny-by-default） | ✅ |

**有意为之的取舍**：
1. **免解锁查余额不做**：那要求服务端能解密私有 Key（放弃 E2E）。所有者明确选择保持现状（[ROADMAP](ROADMAP.md)）。
2. **mux 跨用户泄漏（Issue #6）—— 已在 v0.7.1 修复（两个方向）**：0.2.0 客户端把 `/api/remote.mux`（WebSocket）当作主连接通道，
   普通用户也需要它，而**宿主把这条通道当"单用户"用**。真实浏览器双身份抓包（`npm run test:mux:leak`）确认了两件独立的事：
   - **下行广播**：宿主把**全部**工作区与会话推给每个连接，形态为 `baseline.value.items[].workspaceId`（初始全量，**没有顶层 `sessionId`**，最易漏）、
     `upsert.workspace.workspaceId`、`order.workspaceIds[]`；
   - **上行订阅**：宿主对任何 `sessionId` 的 `open` 都照单服务，因此只过滤下行**不够**——普通用户可以直接订阅他人会话的流。
   现状：`gateUp` 把升级交给网关唯一入口 `handleUpgrade`，`/api/remote.mux` 由 `proxyMuxUpgrade` 透明代理；
   **上行**每个 `open` 先按归属授权（未授权不转发），**下行**逐帧调用策略层 `policy.frame()`（丢弃/裁剪/投影，未知流 fail-closed 丢弃），
   被隐藏的 waterfall 接收方由代理代其释放投递。代理不解析 mux 语义，因此仍与 0.2.0 客户端兼容。
   实测 A/B（**同一构建**，只改 `DSH_AUTH_MUX_PROXY`）：直通时普通用户可见 **20** 个管理员工作区、且对他人会话的 `open` 到达宿主；
   代理下为 **0**，`open` 被我方以 `auth/forbidden` 拒绝（管理员 20 个不受限、`open` 正常到达宿主）。
   **验证边界（诚实标注）**：本环境里宿主运行时对 remote 建出的会话回 `gateway/internal: session not found`，
   所以"他人会话内容"这一层**无法被实证**；上表证明的是"未授权的 open 不再被转发"，不是"内容曾经泄漏过又被堵上"。
   残留：排障用退避开关 `DSH_AUTH_MUX_PROXY=0` 会重现上述两条。
3. **`$events/result`（审批/提问应答）不做内容级授权**：策略层直接 `return false`，改由网关**关联**放行——
   只有本登录**确实被投递过**该事件帧（`correlation.events`，由 mux 代理在转发 waterfall 时填充）才允许提交应答，
   因此不能用 payload 自证属主、也无法应答他人会话。**v0.7.1 删除了此前的"形状非空即放行"退档**
   （那是一处跨会话应答注入面：不需要任何被投递的事件，填两个字符串即可）。
   语义级校验（应答是否合理）仍不在本插件职责内。
   **验证边界**：`WS-ISO` 用行为用例锁定了"未被投递的 `eventId` → 403"，但"已投递的 `eventId` → 放行"
   在本环境**无法端到端验证**——宿主从不投递 `waterfall`/`emit` 帧（实测连管理员也是 0），
   该侧只有 `policy.frame()` 记录事件（单测）与读写同一 `correlation.events` 的源码级证据。
   若未来真实的审批往返出现 403，请优先核对客户端回传的 `eventId` 是否与宿主投递的一致。

## 7. 验收阶段发现并修复的真实缺陷（审计关注点）

这些是"单测测不出、跑真实宿主才暴露"的问题，均已修复并留下回归：

| 缺陷 | 影响 | 现状 |
|---|---|---|
| 凭据键写成三段 | 宿主凭据服务**下次启动拒绝整份文档** → 面板起不来 | 键改两段 + **写入前守卫** + 回归测试 |
| `ctx.llm` 未注入 | 按用户模型的路由注册失败 | 改用 `ctx.inject(['llm'], …)` 作用域注入 |
| 宿主管包解析路径 | 从插件自身路径解析不到宿主包 | 以**宿主进程入口**为锚点 `createRequire` |
| 余额 fetcher 未注入 | 查余额永远 `unavailable` | 注入 + 批量接口 `balanceQueryAll` |
| 升级被自研 mux 接管 | 设置页无限"重新连接中"（含管理员） | 升级改由本插件**透明代理**（见 §6.2） |
| mux 把**全部**工作区/会话推给每个连接 | 普通用户实时看到管理员的工作区与会话（Issue #6 的下行半边） | 代理 + 逐帧 `policy.frame()` + `WS-ISO` 回归 + `test:mux:leak` |
| 宿主按任何 `sessionId` 服务 mux 流 | 普通用户可直接订阅他人会话的流（#6 的上行半边，实测直通时 open 到达宿主） | 每个 `open` 先 `policy.authorize(..., true)`，未授权不转发；`WS-ISO` + `test:mux:leak` |
| 自研 mux（`wireStream` 自建流）与 0.2.0 协议不兼容 | 设置页无限「重新连接中」；且该实现成为死代码与崩溃面 | 升级入口收敛为 `handleUpgrade`，自研 mux 代码已删除 |
| 会话 Cookie 名固定 | 同机多实例**互相踢下线** | 名字按 `DSH_HOME` 唯一化 |

## 8. 审计清单（可直接执行）

```bash
# 1) 全量回归（含安全套件 162 项、主机/客户端冒烟、客户端契约自检、i18n）
npm ci && npm test

# 2) 真实 DSH 0.2.0 实例上的端到端验收（38 项：登录门/载体桥接/Remote 授权/用户隔离/R2 阻断）
node test/live-020-check.mjs          # 需要已启动的隔离实例与 admin 口令

# 3) 商城契约门禁（依赖声明、权限信号、打包内容）
npm run store:check

# 4) 逐项安全用例明细（含类别与用例名）
DSH_SUITE_VERBOSE=1 node test/security-suite.mjs

# 5) mux 跨用户泄漏的双身份真实浏览器 A/B（Issue #6）
#    NO-LEAK → 退出 0；LEAK → 退出 1；VOID（无阳性对照）→ 退出 1
PROBE_URL=http://127.0.0.1:3205 npm run test:mux:leak
```

要点自查（人工）：未登录 → `/` 是否 302 到登录页；`/api/*` 是否 401；
普通用户是否**看不到**他人会话；普通用户是否**不能**调用 `settings/*`、`credentials/*`、`account/*`；
两个不同 `DSH_HOME` 的实例是否能**同时登录**互不影响；分享撤销后是否**立即**失效；
`npm run test:mux:leak` 是否在普通用户侧报 **0** 个可见工作区、且对**他人会话的 `open` 未到达宿主**（管理员侧两项都必须为正，否则是 VOID 而非通过）。

## 9. 漏洞报告

请**不要**在公开 Issue 里贴可利用细节。优先使用仓库的 **Private vulnerability reporting**（Security 标签页），
或在 Issue 中只描述影响面并请求私下沟通渠道。请附：DSH 版本、本插件版本、复现命令、期望与实际行为。
收到后会在修复并发布补丁版本后公开致谢（除非你要求匿名）。

## 10. 相关文档

- 逐项证据与复现：[SECURITY-VERIFICATION](SECURITY-VERIFICATION.md)
- 密钥隔离设计推导：[RBAC-MODEL-PROFILES](RBAC-MODEL-PROFILES.md)
- 部署侧加固：[OPERATIONS](OPERATIONS.md)
- 版本矩阵：[DSH-0.2.0-COMPATIBILITY](DSH-0.2.0-COMPATIBILITY.md)