把 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 承载,主干不受其影响。
384 lines
16 KiB
JavaScript
384 lines
16 KiB
JavaScript
/**
|
||
* 配对码契约 —— 网关与业务服务共用的**唯一真相源**(方案 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,
|
||
};
|