Files
inkwatch/shared/pairing.js
T
PMon Dev f632672a82 feat: PMon V4.0 功能开发收口(1.0.9)—— 主干
把 V4.0 骨架以来的全部功能开发固化为主干基线,内容包括:
  · client/            全量 Android/macOS 客户端(Flutter + Rust 核心)
  · server/            网关、业务服务、仓储层
  · deploy/            compose、Dockerfile、init.sql + migrate.sql(幂等迁移)
  · shared/ tests/     协议、编解码、指标清单与全部测试套件
  · docs/ README       架构方案与运维手册

本轮之前修复的主要缺陷(详见各文件注释与 client/README.md):
  · 首次部署路径(init.sql 的 device_tokens 建表顺序)
  · 每 20 分钟硬切以外的设备身份漂移与**设备归并**(shared/device-identity.js)
  · 配置无版本号、无校验导致的病态残留(host/config_migration.dart)
  · 数据库迁移从不执行(deploy/migrate.sql + compose 的 migrate 服务)

验证:客户端 282/282、全量回归 20 套件 0 失败。

⚠️ 水墨屏专属实现(看板、水墨屏令牌、einkMode、常亮)**不在本分支**,
   由 feature/eink 承载,主干不受其影响。
2026-09-16 01:06:24 +08:00

384 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 配对码契约 —— 网关与业务服务共用的**唯一真相源**(方案 6.3 / 9.4 / 表 13)
*
* ── 为什么必须单独成文件 ────────────────────────────────────────────
* 本仓库同一模式已翻车四次:一份清单被多处各写一套,交集为空却全绿。
* 1. 指标字段(Rust 采集 / 协议白名单 / 表列 / 仓储 INSERT)
* 2. msgpack 线格式(客户端写法 vs 网关解包)
* 3. Dockerfile COPY 清单
* 4. **配对码**(本次):`server/gateway/store.js` 里躺着完整的
* setPairingCode / consumePairingAttempt / checkPairingRateLimit,
* 但**全仓库无人调用**;业务服务的 /api/pairing/verify 只做正则格式校验,
* 从未比对配对码本身 → 任意 6 位数字都能建立设备授权(授权绕过)。
* 实测证据:POST {"code":"000000","device_id":"mac-autostart-test"} 返回
* {"ok":true,"authorized":true}。
*
* 故此处收敛为**单一实现**:键名、TTL、失败次数、限流窗口一律从本模块派生,
* 调用方不得再硬编码 `pairing:` 前缀或 300 / 5 / 10 这些数字。
* 一致性由 `tests/pairing-security-test.js` 锁定(含源码解析型断言)。
*
* ── 键契约(方案表 13)────────────────────────────────────────────
* pairing:{code} STRING JSON 值,TTL 300 秒 —— 配对码本体
* pairing:device:{id} STRING 该设备当前有效码,TTL 300 秒 —— 一设备仅一码
* ratelimit:pairing:acct:{account_id} STRING 计数,TTL 3600 秒
* ratelimit:pairing:ip:{ip} STRING 计数,TTL 3600 秒
*
* ↑ 后三个键是本轮新增。方案表 13 只列了 pairing:{code},但 6.3 明确要求
* 「单账号每小时最多发起 10 次验证」「按来源 IP 限流」,这些约束必须有
* 落脚点。已同步补进方案文档表 13,避免文档与实现再次分叉。
*
* ── 失败关闭(fail-closed)────────────────────────────────────────
* Redis 故障时所有方法抛 PairingUnavailableError,调用方**必须**返回 503,
* 绝不允许退化成「查不到记录即通过」。安全判定链路上不存在可用性降级路径:
* 宁可配对功能整体不可用,也不能出现「谁都能授权」的窗口。
*/
'use strict';
const crypto = require('crypto');
/** 配对码有效期(秒)—— 方案 6.3:「300 秒有效」 */
const PAIRING_TTL_SEC = 300;
/** 配对码位数 —— 方案 6.3:「6 位数字」 */
const PAIRING_CODE_LENGTH = 6;
/** 单个配对码允许的最大失败次数 —— 方案 6.3:「验证失败 5 次即作废」 */
const PAIRING_MAX_ATTEMPTS = 5;
/** 单账号限流 —— 方案 6.3:「单账号每小时最多发起 10 次验证」 */
const PAIRING_ACCOUNT_LIMIT = 10;
const PAIRING_ACCOUNT_WINDOW_SEC = 3600;
/**
* 单来源 IP 限流 —— 方案 6.3:「网关按来源 IP 限流」。
*
* 阈值取 30 而非 10:同一 NAT 出口(办公室/机房/移动网络)后面多个合法账号
* 共享一个 IP,卡到 10 会把正常用户一起误伤。IP 维度的职责是拦住
* 「同一来源批量扫号」,不是替代账号维度。可用 PAIRING_IP_LIMIT 覆盖。
*/
const PAIRING_IP_LIMIT = 30;
const PAIRING_IP_WINDOW_SEC = 3600;
/** 配对码格式(唯一判定处:业务服务与测试都从这里取,不再各写正则) */
const PAIRING_CODE_RE = new RegExp(`^\\d{${PAIRING_CODE_LENGTH}}$`);
/** 发码时的最大换码重试次数(防碰撞,见 issue 注释) */
const PAIRING_ISSUE_RETRY = 5;
/**
* 单次存储操作的等待上限(毫秒)。
*
* 为什么安全路径必须有超时:Redis 可能"活着但不应答"——
* 容器被 pause、宿主机网络黑洞、内核丢包。此时 TCP 连接仍然是 ESTABLISHED,
* socket 既不关闭也不报错,node-redis 的离线队列机制也帮不上忙
* (它只处理"连接已断"的情况),命令会**无限期挂起**。
* 表现就是配对接口假死:客户端既拿不到通过也拿不到拒绝,挂起的请求
* 还会持续占用服务端连接。
*
* 超时后统一抛 PairingUnavailableError → 调用方返回 503:
* 语义从"悬而未决"变成"明确拒绝,可重试",且等待时间有上界。
* 1.5s 是权衡:正常 Redis 操作在毫秒级,留足数量级余量;
* 又远小于客户端 10s 级超时,不会把挂起传导到用户侧。
*/
const PAIRING_OP_TIMEOUT_MS = Number(process.env.PAIRING_OP_TIMEOUT_MS || 1500);
/** 校验结果枚举 —— 调用方按此分支,禁止用字符串字面量比较 */
const PAIRING_RESULT = Object.freeze({
OK: 'ok',
NOT_FOUND: 'not_found',
DEVICE_MISMATCH: 'device_mismatch',
EXHAUSTED: 'exhausted',
});
/** Redis 不可用 —— 调用方必须映射为 503(失败关闭) */
class PairingUnavailableError extends Error {
constructor(message = '配对码存储不可用(Redis 未就绪)') {
super(message);
this.name = 'PairingUnavailableError';
this.code = 'E_PAIRING_UNAVAILABLE';
}
}
const pairingKey = (code) => `pairing:${code}`;
const pairingDeviceKey = (deviceId) => `pairing:device:${deviceId}`;
const pairingAccountRateKey = (accountId) => `ratelimit:pairing:acct:${accountId}`;
const pairingIpRateKey = (ip) => `ratelimit:pairing:ip:${ip}`;
/** 6 位数字码用加密安全随机数(不用 Math.random) */
function generatePairingCode() {
const max = 10 ** PAIRING_CODE_LENGTH;
return String(crypto.randomInt(0, max)).padStart(PAIRING_CODE_LENGTH, '0');
}
/**
* 配对码存储。
*
* **比对、一次性消费、失败扣次、限流计数** 全部集中在此处。
* 调用方只消费返回值,不自行「先查记录是否存在、再决定是否授权」
* —— 判定与授权写在两处,正是本次绕过缺陷的成因。
*/
class PairingStore {
constructor(redis) {
this.redis = redis;
}
/**
* 统一出口:把任何 Redis 层异常收敛成 PairingUnavailableError,
* 并给每次操作套上等待上限。
*
* 收敛异常是为了让调用方只需判一种错误即可保证失败关闭,不必知道
* node-redis 各种连接异常的名字(ClientClosedError / SocketClosedUnexpectedlyError …);
* 套超时是为了覆盖"连接没断但对面不应答"这种默认会无限挂起的形态。
*/
async _run(label, fn) {
if (!this.redis) throw new PairingUnavailableError();
const work = Promise.resolve().then(fn);
// 超时后 work 可能才失败:先挂一个空 catch,避免变成 unhandledRejection
// (Node ≥15 默认会因此终止进程 —— 一个卡住的 Redis 不该让服务整体退出)
work.catch(() => {});
let timer = null;
try {
return await Promise.race([
work,
new Promise((_resolve, reject) => {
timer = setTimeout(
() => reject(new PairingUnavailableError(
`配对码存储超时(${label} > ${PAIRING_OP_TIMEOUT_MS}ms,连接存在但不应答)`)),
PAIRING_OP_TIMEOUT_MS,
);
}),
]);
} catch (e) {
if (e instanceof PairingUnavailableError) throw e;
throw new PairingUnavailableError(`配对码存储故障(${label}):${e.message}`);
} finally {
if (timer) clearTimeout(timer);
}
}
/** GET + DEL 同事务取走,返回值;未取到返回 null */
async _claimAtomically(key) {
const [raw] = await this.redis.multi().get(key).del(key).exec();
return raw || null;
}
/**
* 发码:写 pairing:{code} 与 pairing:device:{id},TTL 均 300 秒。
*
* SET NX 防碰撞:6 位空间 90 万,同一时刻有效码不多,但一旦碰撞而直接覆盖,
* 先发码那台设备的码会指向新设备 → 授权串号。NX 失败即换码重试。
*
* 同时作废旧码:屏幕只显示一个码,那台设备就只能有一个有效码。
* 否则设备连点两次「生成」会留下两个可用的码,攻击面翻倍。
*/
issue(deviceId) {
return this._run('issue', async () => {
for (let i = 0; i < PAIRING_ISSUE_RETRY; i++) {
const code = generatePairingCode();
const record = {
device_id: deviceId,
issued_at: Date.now(),
attempts_left: PAIRING_MAX_ATTEMPTS,
};
const written = await this.redis.set(pairingKey(code), JSON.stringify(record), {
EX: PAIRING_TTL_SEC,
NX: true,
});
if (!written) continue; // 撞上已有码,换一个
const prev = await this._claimAtomically(pairingDeviceKey(deviceId));
if (prev && prev !== code) await this.redis.del(pairingKey(prev));
await this.redis.set(pairingDeviceKey(deviceId), code, { EX: PAIRING_TTL_SEC });
return {
code,
ttl_seconds: PAIRING_TTL_SEC,
expires_at: Date.now() + PAIRING_TTL_SEC * 1000,
};
}
throw new PairingUnavailableError('配对码生成失败(连续碰撞,请重试)');
});
}
/** 只读查询,不消费(供设备重绘界面与排障用) */
peek(code) {
return this._run('peek', async () => {
const raw = await this.redis.get(pairingKey(code));
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
// 值损坏:当作无效并清掉,避免它卡在 TTL 里反复进入判定
await this.redis.del(pairingKey(code));
return null;
}
});
}
/** 该设备当前有效码(界面重绘复用,避免刷码) */
currentCode(deviceId) {
return this._run('currentCode', () => this.redis.get(pairingDeviceKey(deviceId)));
}
/**
* 校验并**消费**配对码(一次性,方案 6.3)。
*
* 成功路径用 MULTI 包住 GET + DEL:Redis 的 MULTI 原子执行,并发两个请求
* 都拿正确码时只有一个能取到值,另一个拿到 null → 判为 not_found。
* 若拆成「先 GET 再 DEL」两步,两步之间就是重放窗口。
*
* 设备绑定:码由设备 A 生成,就只能授权设备 A。缺这一条,
* 任何知道码的人都能拿它去认领任意设备(码劫持)。
*
* 扣次对象(易错,单独说明):
* 方案 6.3 的「单个配对码验证失败 5 次即作废」在本接口里只能落在
* **目标设备当前展示的那个码**上 —— 因为攻击者猜的码根本不存在于 Redis
* (每个猜测值都是一条独立记录),拿猜测值去扣次会扣空气,
* 5 次作废的约束就完全失效(本套件 D19 曾因此变红)。
* 故:
* - 提交的码存在 → 扣**它自己**(含跨设备滥用)
* - 提交的码不存在 → 扣**该设备当前待用码**
* 这带来一个固有副作用:攻击者可以靠连猜 5 次作废掉用户正在展示的码
* (轻量 DoS)。这是方案原文取舍的一部分,用户重新生成一次即可;
* 真正的护栏是账号级与 IP 级限流(见 consumeAccountRate / consumeIpRate)。
*/
verify(code, deviceId) {
return this._run('verify', async () => {
const submittedKey = pairingKey(code);
const raw = await this.redis.get(submittedKey);
if (raw) {
let record;
try {
record = JSON.parse(raw);
} catch {
await this.redis.del(submittedKey);
return { status: PAIRING_RESULT.NOT_FOUND };
}
if (!record.device_id || record.device_id !== deviceId) {
const left = await this._registerFailure(submittedKey, record);
return {
status: left <= 0 ? PAIRING_RESULT.EXHAUSTED : PAIRING_RESULT.DEVICE_MISMATCH,
attemptsLeft: left,
boundDeviceId: record.device_id || null,
};
}
const claimed = await this._claimAtomically(submittedKey);
if (!claimed) return { status: PAIRING_RESULT.NOT_FOUND }; // 被并发请求取走
await this.redis.del(pairingDeviceKey(deviceId));
return { status: PAIRING_RESULT.OK, issuedAt: record.issued_at || null };
}
// 提交的码不存在:把这次失败记到该设备当前展示的码上
const left = await this._registerFailureOnPending(deviceId);
return {
status: PAIRING_RESULT.NOT_FOUND,
attemptsLeft: left,
// 仅用于服务端审计:告诉运维"用户屏幕上那个码刚被猜废了",
// 客户端响应对此保持沉默(统一 E_CODE_INVALID)。
pendingBurned: left === 0,
};
});
}
/** 提交的码不存在时,扣该设备当前待用码的次数;归零即连带作废索引键 */
async _registerFailureOnPending(deviceId) {
const code = await this.redis.get(pairingDeviceKey(deviceId));
if (!code) return null;
const key = pairingKey(code);
const raw = await this.redis.get(key);
if (!raw) {
await this.redis.del(pairingDeviceKey(deviceId));
return 0;
}
let record;
try {
record = JSON.parse(raw);
} catch {
await this.redis.del(key, pairingDeviceKey(deviceId));
return 0;
}
const left = await this._registerFailure(key, record);
if (left <= 0) await this.redis.del(pairingDeviceKey(deviceId));
return left;
}
/** 显式撤销(设备侧取消展示配对码时用) */
revoke(code, deviceId) {
return this._run('revoke', async () => {
await this.redis.del(pairingKey(code));
if (deviceId) {
const cur = await this.redis.get(pairingDeviceKey(deviceId));
if (cur === code) await this.redis.del(pairingDeviceKey(deviceId));
}
});
}
/**
* 失败扣次:attempts_left 减一,归零即删码作废(方案 6.3)。
*
* 写回时保留**剩余 TTL**而不是重置为 300:否则反复试错能把码的寿命
* 无限续下去,「5 次作废」的约束就被稀释了。
*/
async _registerFailure(key, record) {
const left = Number(record.attempts_left ?? PAIRING_MAX_ATTEMPTS) - 1;
if (left <= 0) {
await this.redis.del(key);
return 0;
}
const ttl = await this.redis.ttl(key);
await this.redis.set(key, JSON.stringify({ ...record, attempts_left: left }), {
EX: ttl > 0 ? ttl : PAIRING_TTL_SEC,
});
return left;
}
/**
* 限流计数:**进入即计数**。
*
* 本次修复的第二个要点。旧实现把限流建立在
* 「audit_log 里 event='pairing_verify' 的行数」上,而审计只在**成功**时写
* → 计数恒为 0,10 次/小时的约束形同虚设,爆破者可以无限试。
* 现在计数发生在任何结果之前(成功/失败都算),失败尝试同样消耗额度。
*/
async _consumeRate(key, limit, windowSec) {
const count = await this.redis.incr(key);
if (count === 1) await this.redis.expire(key, windowSec);
let retryAfterSec = await this.redis.ttl(key);
if (retryAfterSec < 0) retryAfterSec = windowSec;
return { allowed: count <= limit, count, limit, retryAfterSec };
}
consumeAccountRate(accountId, limit = PAIRING_ACCOUNT_LIMIT, windowSec = PAIRING_ACCOUNT_WINDOW_SEC) {
return this._run('consumeAccountRate', () => this._consumeRate(pairingAccountRateKey(accountId), limit, windowSec));
}
consumeIpRate(ip, limit = PAIRING_IP_LIMIT, windowSec = PAIRING_IP_WINDOW_SEC) {
return this._run('consumeIpRate', () => this._consumeRate(pairingIpRateKey(ip), limit, windowSec));
}
}
module.exports = {
PAIRING_TTL_SEC,
PAIRING_CODE_LENGTH,
PAIRING_MAX_ATTEMPTS,
PAIRING_ACCOUNT_LIMIT,
PAIRING_ACCOUNT_WINDOW_SEC,
PAIRING_IP_LIMIT,
PAIRING_IP_WINDOW_SEC,
PAIRING_CODE_RE,
PAIRING_ISSUE_RETRY,
PAIRING_OP_TIMEOUT_MS,
PAIRING_RESULT,
PairingUnavailableError,
PairingStore,
generatePairingCode,
pairingKey,
pairingDeviceKey,
pairingAccountRateKey,
pairingIpRateKey,
};