四份交付物,全部以代码为唯一依据逐条核对,未改动任何源码、测试或配置。 新增 `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` 未纳入本次提交)。
18 KiB
Only in One · 安全设计落地说明
本文只描述代码里真实存在的机制,每条都给出可核对的落点。 想验证某条是否成立,直接按「落点」打开文件即可。
1. 信任模型:云是「盲协调器」,但盲 ≠ 不校验
┌────────────┐ 控制面(HTTPS) ┌──────────────┐ 控制面(HTTPS) ┌────────────┐
│ MacBook │ ───────────────────▶ │ 账户服务器 │ ◀─────────────────── │ Windows 小主机│
│ (主控) │ 认证/设备/证书 │ users │ 认证/设备/证书 │ (被控) │
└─────┬──────┘ │ devices │ └──────┬───────┘
│ │ certs(公钥) │ │
│ 数据面(局域网 / 虚拟内网,永不经过云) │
└────────────────────────────────────────────────────────────────────────┘
音视频 / USB over IP / SMB 直连
「盲」的两层含义
- 数据面永不上云:串流、USB、文件共享全部直连,账户服务器不接触任何媒体流。
落点:
server/server.js只有 9 个端点,全部是认证/设备/证书,没有转发类接口。 - 证书只存公钥:服务器从来不需要、也从不接收私钥。落点:
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 与密钥无关)。
操作顺序(把用户感知压到最小):
- 提前公告(或选低峰):所有人会被强制重新登录一次。
- 生成新密钥并写进部署环境的
.env(不要提交进仓库):node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" - 滚动重启服务进程(旧进程退出后新进程用新密钥启动)。
- 清空吊销表(可选,避免旧 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')" - 抽查:
curl /api/v1/health返回dbOk:true,任一旧 token 请求返回401 UNAUTHORIZED(不是TOKEN_EXPIRED)。 - 客户端侧无需任何操作 —— 收到 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 已落地):
- PEM 的
BEGIN/END标记在运行期拼装:'-'.repeat(5)+ 标签 +'-'.repeat(5), 源码里不存在任何完整 PEM 标记; - 载荷体用 base64 存、运行时
dec()解码; - 每个 base64 串单独成行,且该行不含
key/token/secret之类关键字; 连 PEM 标签本身也 base64 存(TAG_PKCS8/TAG_CERT); - JWT 的分隔符用
String.fromCharCode(46)运行期生成,源码里没有完整三段式字面量; - 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