Files
onlyinone/docs/security.md
T
jkooe e6541ab751 docs(T-4): API 契约 / 安全 / 排障 / README 同步(不改源码)
四份交付物,全部以代码为唯一依据逐条核对,未改动任何源码、测试或配置。

新增 `docs/api.md`(566 行)
- 端点清单与代码逐条对齐:`server.js` 共 **10 条路由注册 = 9 个逻辑端点 +
  `/api/health` 兼容别名**(复核命令已写进文档)
- 每个端点:方法/路径/鉴权/请求体字段(类型·必填·校验规则)/成功与失败响应/
  全部错误码/curl 示例
- 写清 6 个易错点:`/api` → `/api/v1` 的 **308**(含 `/api/health` 豁免与
  「中间件在 express.json 之前」)、access 2h / refresh 30d + jti 吊销、
  refresh **一次性轮换**、heartbeat 的 `device_id` 等 4 个字段「非法则忽略并保留
  上次值」的老数据兜底、重复注册 **409 USER_EXISTS**、安全响应头 + 64KB 请求体上限
- 错误码总表 **15 条**(与 `errors.js#ERROR_TABLE` 逐条对齐,附复核命令)+
  端点 × 错误码矩阵;并说明 `FORBIDDEN` / `INVALID_ARGUMENT` 当前无业务触发点
  (保留是为了客户端映射表 100% 覆盖)

新增 `docs/security.md`(283 行)
- 信任模型:云是盲协调器(数据面不上云、只存公钥),但**盲 ≠ 不校验** ——
  服务端强制自算 SHA-256(忽略客户端 fingerprint)+ 客户端下载后**必须本地重算比对**
- 私钥边界四层(发现/上传前/入库前/日志),双端同一规则
- `JWT_SECRET` 轮换 6 步(含影响面:所有 token 立刻失效)+ 准入条件
- 日志脱敏清单:四类输入(敏感键名 / 私钥块 / JWT 字面量 / 超长截断)+
  指纹只留前 8 位 + 递归深度与非预期类型兜底
- **三条真实踩坑**:① gitleaks 8.30.1 **会先 base64 解码再匹配**(所以 base64 存
  假密钥无效,切块会被 generic-api-key 打回)→ 正确姿势是「PEM 标记运行期拼装 +
  载荷 base64 单独成行 + import 期 SELF_CHECK」;② 测试造敏感载荷用
  `fakeSecrets.mjs`(服务端)/ `pemBlock()`+`fakeJwt()`(启动器);
  ③ `~/.gitignore_global` 的 `*.pem` 会让 fixture 静默进不了库 → 用 `.mjs` 导出
- **`TRUST_PROXY` 部署警告**:反向代理后不设 `TRUST_PROXY=1` 会让全团队共享一个
  IP 计数器(一人失败 5 次锁死所有人)
- 已知安全边界 8 项(双向证书、服务端证书区分、TLS 终止、refresh 绑设备、签名…)

新增 `docs/troubleshooting.md`(636 行,**15 条**四段式)
- 开头用醒目框写「**迁移备份只复制主 db 文件**」的未来 WAL 风险(第 10 条正文给出
  `VACUUM INTO` / `PRAGMA wal_checkpoint(TRUNCATE)` 两种改法)
- 覆盖:JWT_SECRET 被拒(含报错原文 + 生成命令)、`node:sqlite`
  `ExperimentalWarning`(正常)、`node --test <目录>` 报 Cannot find module、
  clone 后 fixture ENOENT、gitleaks 阻断、设备一直离线(90s 阈值 / 30s 心跳)、
  换地址后打到旧地址(设置项优先级 > 环境变量,308 只改路径不改 host)、
  限流自锁(含三条 SQL 解锁)、迁移失败回滚与手工恢复(备份只留 3 份)、
  WAL 备份风险、升级后同机两行设备(含合并 SQL)、USB `UNSUPPORTED_ON_PLATFORM`
  (附 8 位 × 3 端能力矩阵)、usbip 提权、SMB 打不开、Linux 边界

修改 `README.md`(92 → 310 行,R-P2-10)
- 阶段映射表全部换成与代码一致的状态(✅/⏳),**删掉所有过时 TODO**
- 补:Node **>=22.5(推荐 ≥24)**、`.env` 现在靠 `--env-file-if-exists=.env`
  真正加载(并说明旧 README 的「放好即可」为何不准确)
- 补:双端完整命令表(`smoke` / `lint` / `format:check` / `typecheck` /
  `test:coverage`)+ 覆盖率门禁口径与实测值(server **75.31%** / launcher **91.06%**)
- 补:平台能力矩阵、Linux 支持边界、P2 13 条状态表、文档索引
- 补:未签名应用首次打开提示(macOS 右键→打开 / Windows SmartScreen 仍要运行 +
  核对 SHA-256),避免用户误以为中毒
- 补:**如何新增平台分支**(能力表 + 实现文件 + 单向依赖 + 中文 reason +
  禁业务代码写 `process.platform` + `PlatformAdapter` 只增不改)

修改 `docs/packaging.md`(121 → 149 行)
- 按架构 §11.7 第 13 步新增「Linux 支持边界与未来纳入需补什么」9 条清单
  (README / security / troubleshooting 三处引用它,避免指向不存在的章节)

⚠️ 文档自身也遵守密钥扫描规则:写本文时实测撞了一次 gitleaks ——
`docs/security.md` 的 curl 示例与 `docs/troubleshooting.md` 引用的扫描输出里
原本含 PEM 字面量,已改成「用变量替换 / 占位描述」,**没有**加 `.gitleaksignore`、
**没有** `--no-verify`。

验证:文档内所有数字均为实测 —— 端点 10/9、错误码 15、troubleshooting 15 条、
smoke 13 步、server 43 例 / 75.31%、launcher 390 例 / 91.06%。
**未改动任何源码**(QA 的 `server/test/qa-security.test.mjs` 与
`launcher/test/qa-contract-e2e.test.ts` 未纳入本次提交)。
2026-10-26 01:40:02 +08:00

18 KiB
Raw Blame History

Only in One · 安全设计落地说明

本文只描述代码里真实存在的机制,每条都给出可核对的落点。 想验证某条是否成立,直接按「落点」打开文件即可。


1. 信任模型:云是「盲协调器」,但盲 ≠ 不校验

┌────────────┐   控制面(HTTPS)    ┌──────────────┐   控制面(HTTPS)    ┌────────────┐
│  MacBook   │ ───────────────────▶ │  账户服务器   │ ◀─────────────────── │ Windows 小主机│
│  (主控)     │  认证/设备/证书      │  users        │   认证/设备/证书      │  (被控)      │
└─────┬──────┘                     │  devices      │                     └──────┬───────┘
      │                            │  certs(公钥)  │                            │
      │       数据面(局域网 / 虚拟内网,永不经过云)                              │
      └────────────────────────────────────────────────────────────────────────┘
                        音视频 / USB over IP / SMB 直连

「盲」的两层含义

  1. 数据面永不上云:串流、USB、文件共享全部直连,账户服务器不接触任何媒体流。 落点:server/server.js 只有 9 个端点,全部是认证/设备/证书,没有转发类接口。
  2. 证书只存公钥:服务器从来不需要、也从不接收私钥。落点:POST /api/v1/certs 的 parsePublicCertPem()(server/crypto.js)。

「盲 ≠ 不校验」的三条硬机制

# 机制 为什么必须存在 落点
1 服务端强制自算指纹,请求体里的 fingerprint 无条件忽略 客户端可以在任何环节被篡改/劫持;若服务端信任客户端给的指纹,攻击者只要「上传自己的证书 + 填目标设备的指纹」就能顶替任意设备 server/server.js(sha256hex(parsed.pem)),注释明写「无条件由服务端计算」
2 客户端下载后必须本地重算比对,不一致拒绝写盘 服务端可信不代表传输/分发点可信(CDN、代理、被入侵的服务器都能改 cert_pem 而保留旧 fingerprint)。客户端重算才是唯一能发现改包的环节 launcher/src/certs.ts#fingerprintMatches();不一致 → reason:'fingerprint_mismatch',不写文件
3 服务端校验 X509 可解析 + 边界 + 拒私钥 只做字符串匹配会把垃圾塞进库,客户端拿到后再由 Sunshine 报错就晚了一步 server/crypto.js#parsePublicCertPem():边界 → new X509Certificate() → 失败抛 INVALID_CERT

一句话:服务端保证「存进来的是我算过指纹的公钥证书」,客户端保证「写进 Sunshine 的证书是我自己算过指纹的那一份」。 两端各算一次,中间任何一环被改都会被其中一端发现。


2. 私钥边界:双端同规则,私钥永不上云

层 规则 落点
客户端发现本机证书 只接受 isPublicCertPem() 通过的候选文件;候选里若是 device.key(私钥)→ 跳过,不上传 launcher/src/certs.ts#discoverLocalCert()
客户端上传前 isPublicCertPem():1B–8KB、BEGIN/END CERTIFICATE 边界、/PRIVATE KEY/i 命中即拒 launcher/src/core/validate.ts#isPublicCertPem()
服务端入库前 isPublicCertPem()(同一套边界)+ parsePublicCertPem()(再验 X509) server/validate.js + server/crypto.js
日志 任何字符串里出现 PRIVATE KEY → 整串替换为 *** server/logger.js#redactString()、launcher/src/core/logger.ts

PRIVATE KEY 的匹配是 /PRIVATE KEY/i,覆盖 RSA / EC / OPENSSH / ENCRYPTED 全部变体 (它们都包含 PRIVATE KEY 子串)。

为什么客户端也要拦一遍:如果只靠服务端拦,私钥已经离开过本机(进了 HTTP 请求体、 可能进了代理访问日志)。客户端先拦,私钥连本机都不出。


3. 认证与令牌

项 实现 落点
密码哈希 scrypt(N=16384 默认参数由 Node 决定),存储格式 salt:hash(均 hex) server/crypto.js#hashPassword()
密码校验 safeVerify() fail-closed:存储值畸形 / 长度不对 / 非 hex → 返回 false,绝不抛异常;比较用 crypto.timingSafeEqual server/crypto.js#safeVerify()
账号枚举防护 「用户不存在」与「密码错误」走完全相同的 401 分支,响应体逐字节一致(INVALID_CREDENTIALS 无 details) server/server.js 登录分支 + errors.js#buildErrorBody()
令牌 HS256,access 2h / refresh 30d,均带 jti(UUID) server/tokens.js
吊销 token_revocations 表;isRevoked() 查询失败按已吊销处理(fail-closed) server/tokens.js
刷新 一次性轮换:签发新 token 前先 revoke(旧 jti) server/server.js /auth/refresh
令牌存放 只在 Electron 主进程内存(+ 可选 safeStorage 加密落盘);renderer 永远拿不到 launcher/src/main.ts、platform/credentialStore.ts
渲染进程隔离 contextIsolation: true、nodeIntegration: false、sandbox: true,只经 contextBridge 暴露白名单方法 main.ts#createWindow()、preload.ts
IPC 边界 所有 handler 经 makeSafeHandler(),异常转成 {ok:false,error:{code,message,hint}},绝不把堆栈/原始 message 给 renderer core/ipc.ts

3.1 JWT_SECRET 轮换

影响面:JWT_SECRET 一换,所有已签发的 access 与 refresh 立刻失效(签名校验失败 → UNAUTHORIZED), 所有在线客户端会在下一次请求时被踢回登录页。这不会丢数据(users/devices/certs 与密钥无关)。

操作顺序(把用户感知压到最小):

  1. 提前公告(或选低峰):所有人会被强制重新登录一次。
  2. 生成新密钥并写进部署环境的 .env(不要提交进仓库):
    node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
    
  3. 滚动重启服务进程(旧进程退出后新进程用新密钥启动)。
  4. 清空吊销表(可选,避免旧 jti 残留到 expires_at):
    node -e "const {DatabaseSync}=require('node:sqlite');const db=new DatabaseSync('data/onlyinone.db');db.exec('DELETE FROM token_revocations');console.log('cleaned')"
    
  5. 抽查:curl /api/v1/health 返回 dbOk:true,任一旧 token 请求返回 401 UNAUTHORIZED(不是 TOKEN_EXPIRED)。
  6. 客户端侧无需任何操作 —— 收到 401 会自动回登录页(auth:logged-out 广播)。

⚠️ 不要为了「让用户不重登」而把新旧密钥都放进可接受列表:本项目 jwt.verify 只接受 单一 secret,且双密钥共存会显著扩大攻击面。轮换就是一次强制重登。

JWT_SECRET 的准入条件(server/config.js#inspectJwtSecret):

条件 不满足时
非空 拒绝启动(exit 1),打印生成命令
长度 ≥32 拒绝启动
不在内置弱值黑名单(dev-only-change-me / secret / changeme / todo …) 拒绝启动
不是全同字符(aaaa…) 拒绝启动

唯一的放宽通道是 ALLOW_WEAK_SECRET=1 且 NODE_ENV !== 'production',并会打印醒目告警。


4. 日志脱敏清单

红线:日志里绝不出现口令原文、token 全文、证书全文、私钥块。

四类脱敏输入 + 两条附加规则(server/logger.js#redact):

# 类别 处理 触发方式
1 敏感字段名 值替换为 *** 键名归一化(小写、去 _/-/空格)后命中:password passwd pwd token accesstoken refreshtoken jwt jwtsecret secret authorization cookie setcookie certpem privatekey devicekey apikey
2 含 PRIVATE KEY 的字符串 整串替换为 *** /PRIVATE KEY/i
3 形如 JWT 的字面量(三段 base64url,eyJ 开头) 整串替换为 *** /^ey[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}$/
4 超长字符串(>4096B) 截断为前 256B + …[truncated NB] 长度阈值
附1 fingerprint 字段 只保留前 8 位(足以跨日志关联,不足以复原证书) 键名精确等于 fingerprint(大小写不敏感)
附2 递归深度 >6 / 函数 / Symbol / Buffer [max-depth] / [function] / 字符串 / [buffer NB] 防日志对象过大与循环引用

客户端同规则:launcher/src/core/logger.ts 的 redact() / redactSnippet() 覆盖同样的四类, 指纹同样只留前 8 位(shortFingerprint())。

已落地的使用方式(举例):

  • 登录成功日志:{ userId } —— 不含用户名以外的凭据,不含 token
  • 证书入库日志:{ userId, deviceName, fingerprint } → 实际写入日志时指纹被裁成前 8 位
  • 畸形 password_hash 警告:{ userId } —— 不含哈希原文
  • 5xx:日志含完整错误,但响应体只给 requestId

5. 请求侧防护

防护 值 落点
请求体上限 64kb(超限 413 PAYLOAD_TOO_LARGE) server.js:express.json({limit:'64kb'})
证书上限 8KB(MAX_CERT_BYTES) validate.js / core/validate.ts 双端一致
安全响应头 X-Content-Type-Options: nosniff、X-Frame-Options: DENY、Referrer-Policy: no-referrer、CSP default-src 'none'、Cache-Control: no-store errors.js#securityHeaders()
指纹/版本泄露 app.disable('x-powered-by') server.js
CORS 不发任何 Access-Control-Allow-* ⇒ 浏览器默认拒绝跨源读取 errors.js 注释 + 无任何 CORS 中间件
登录限流 同 IP 或 同用户名,60s 内失败 ≥5 次 ⇒ 锁 15 分钟(落库,重启仍生效) ratelimit.js + login_failures 表
白名单校验 用户名/密码/设备名/IP/busid/共享名/证书全部白名单(非黑名单),先剔控制字符再匹配 validate.js、core/validate.ts
命令注入 全部外部命令走 core/runner.ts:execFile/spawn + 参数数组 + shell:false;AppleScript 做两层转义 core/runner.ts
路径穿越 写/删证书文件名过白名单 + resolve 后断言是目录直接子项 core/validate.ts#safeJoin()
原子写 同目录 *.tmp-<rand> → rename certs.ts#writeFileAtomic()
误删防护 四层:严格命名 / 必须解析出设备名 / lstat 只处理普通文件 / 服务端集合为空整体不删 certs.ts#pruneStalePeerCerts()
网络挂起 客户端 8s 超时(AbortController) api.ts
崩溃兜底 服务端 uncaughtException → 记日志 → exit(1);客户端同样 + 弹窗 server.js#installProcessGuards()、main.ts#installCrashGuards()

6. 本项目真实踩过的三个坑(务必别再踩)

这三条都是「写了看似正确、结果被工具或环境打回」的教训,与算法无关, 但对能不能提交代码是决定性的。

6.1 gitleaks 会先 base64 解码再匹配

现象:把假私钥 base64 后写进测试文件,git commit 仍被全局 pre-commit hook (gitleaks 8.30.1)阻断,命中的规则是 private-key,并且日志里带 Tags: [decoded:base64 decode-depth:1]。

原因:gitleaks 的解码器会先对字符串做 base64 解码再跑规则匹配。 所以「base64 存假密钥」完全无效。换成把 base64 切开拼接也不行 —— 切块后的高熵串会 命中 generic-api-key(按「关键字邻近(key/token/secret…)+ 高熵」判定)。

正确姿势(server/test/fixtures/fakeSecrets.mjs 已落地):

  1. PEM 的 BEGIN/END 标记在运行期拼装:'-'.repeat(5) + 标签 + '-'.repeat(5), 源码里不存在任何完整 PEM 标记;
  2. 载荷体用 base64 存、运行时 dec() 解码;
  3. 每个 base64 串单独成行,且该行不含 key / token / secret 之类关键字; 连 PEM 标签本身也 base64 存(TAG_PKCS8 / TAG_CERT);
  4. JWT 的分隔符用 String.fromCharCode(46) 运行期生成,源码里没有完整三段式字面量;
  5. import 期跑一次 SELF_CHECK(长度 + 关键片段),保证重构后载荷逐字节一致、 断言语义零漂移。

绝对不要:加 .gitleaksignore 白名单、或用 git commit --no-verify 绕过。 那等于把整个仓库的密钥扫描关掉,之后真有密钥泄漏也不会有人发现。

6.2 测试里造敏感载荷的正确姿势

语言 用什么 现成实现
服务端(.mjs) fakeSecrets.mjs 导出的 FAKE_PRIVATE_KEY_PEM / FAKE_RSA_PRIVATE_KEY_PEM / FAKE_EC_PRIVATE_KEY_PEM / FAKE_ACCESS_TOKEN / FAKE_REFRESH_TOKEN_VALUE / FAKE_CERTIFICATE_PEM server/test/fixtures/fakeSecrets.mjs(内部 dec() + pemBlock() + tokenLike())
启动器(.ts) 本地 pemBlock(label, body) + fakeJwt() + DASH5 / DOT 运行期生成 launcher/test/errors.test.ts;launcher/test/certs.test.ts 的 DASH 同理

另外两条:

  • 证书 fixture 用 .mjs 导出而非 .pem 文件:server/test/fixtures/testCert.mjs 导出 TEST_CERT_PEM([...].join('\n') 生成)。原因见 6.3。
  • 断言要落在安全语义上(「含私钥块必须 400」「日志不含原文」),而不是落在 「这个字符串等于那个字符串」——后者一旦为了绕过扫描改编码就会大面积失效。

6.3 ~/.gitignore_global 可能含 *.pem

现象:本地 server/test/fixtures/test-cert.pem 存在,测试全绿;git status 里 看不到它,git commit 也不报错。别人 clone 后 npm test 直接 ENOENT。

原因:很多机器的全局 gitignore(~/.gitignore_global,常见第 8 行)有 *.pem, 于是 .pem 文件永远进不了版本库。git add -f 能绕过一次,但下一个新 fixture 会再踩一次。

正确姿势:证书 fixture 用 .mjs 模块导出字符串(server/test/fixtures/testCert.mjs), 不落地任何 .pem 文件。改完请用下面命令确认 fixture 真的在版本库里:

git ls-files | grep -i fixtures        # 必须能看到 testCert.mjs / fakeSecrets.mjs
git check-ignore -v server/test/fixtures/any.pem   # 若命中,说明全局规则仍在

7. TRUST_PROXY 部署警告(反向代理必读)

默认 TRUST_PROXY=false:不信任 X-Forwarded-For,req.ip 取直连地址。

部署在 Nginx / Caddy / 云负载均衡之后时,如果不设 TRUST_PROXY=1:

  • 所有请求的 req.ip 都会是代理的地址(常见是 127.0.0.1);
  • 登录限流的「同 IP 60s 内失败 ≥5 次」就变成全局共享的一个计数器 —— 攻击者故意失败 5 次即可把所有人锁死 15 分钟(拒绝服务);
  • 反过来,如果为了绕过误伤而把限流放宽,等于形同虚设。

正确做法:

# ✅ Nginx / Caddy 单层代理
TRUST_PROXY=1

# ✅ 两层(CDN + 反向代理)
TRUST_PROXY=2

# ❌ 不要在直连公网的场景设 true —— 客户端可随意伪造 X-Forwarded-For 绕过限流
TRUST_PROXY=false   # 默认值,直连部署就该保持这个

落点:server/config.js#parseTrustProxy()(只接受 false / true / 跳数 0–10, 非法值会让配置校验失败并拒绝启动),server.js 的 app.set('trust proxy', config.trustProxy)。


8. 已知安全边界(明确不在本轮范围)

项 现状 后续
双向证书认证 服务器不校验「上传者是否真的拥有该证书对应的私钥」(无法校验:我们只收公钥) V2:客户端用私钥对挑战签名
服务端证书区分 certs 表不区分「用户自签的 peer 证书」与「其他来源」 V2:证书来源标注 + 信任链
传输层 依赖部署方提供 HTTPS(本服务只监听 HTTP) 部署要求:公网必须前置 TLS 终止
refresh token 绑定设备 未绑定,任何持有 refresh 的地方都能换新 token V2:绑定 device_id + 轮换链检测(重复使用同一 refresh ⇒ 全家吊销)
客户端代码签名 electron-builder.yml 里 mac.identity: null(团队内部分发) 正式发布需 Apple 公证 + Windows 代码签名
Linux 真机验证 代码分支齐备(autostart / pkexec / Flatpak 路径),未在真机验证 纳入 Linux 时按 docs/packaging.md §「未来纳入 Linux 需补什么」逐条走
限流维度 只有 IP + 用户名两维 V2:可加设备维度,但会引入"换个设备名就绕过"的新问题
审计日志 只有滚动文件日志(5MB × 3 份),无集中收集 V2:结构化日志外发

9. 自检清单(改代码后可逐条跑)

# 1) 私钥永不出现在日志(用假私钥触发脱敏断言)
cd server && npm test            # 含 logger 脱敏与证书拒绝用例

# 2) 服务端拒绝含私钥块的 PEM
#    ⚠️ 本文**故意不写 PEM 字面量**(哪怕假的也会被 pre-commit 的密钥扫描器拦下,
#    原因见 §6.1)。$PEM_TEXT 用你本机任意 .key 文件内容替换即可:
export PEM_TEXT="$(cat /path/to/device.key)"
curl -s -X POST http://127.0.0.1:3000/api/v1/certs \
  -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \
  -d "$(node -e 'process.stdout.write(JSON.stringify({device_name:"x",cert_pem:process.env.PEM_TEXT}))')" 
# 期望:400,code = INVALID_CERT

# 3) 认证失败不泄露账号是否存在(两次响应体应逐字节相同)
curl -s -X POST http://127.0.0.1:3000/api/v1/auth/login -H 'Content-Type: application/json' \
  -d '{"username":"definitely-not-exist","password":"WrongPass123"}'
curl -s -X POST http://127.0.0.1:3000/api/v1/auth/login -H 'Content-Type: application/json' \
  -d '{"username":"kou","password":"WrongPass123"}'

# 4) 客户端不信任分发点
cd launcher && npm test          # 含 fingerprintMatches 的篡改用例

# 5) 提交前确认没有被扫描器绕过
git log --oneline -1 && git status --porcelain   # 有 hook 且通过才会产生 commit