## 修的是什么
`POST /api/pairing/code {device_id}` 原先**零鉴权地生成码并在响应体里返回**:
```
POST /api/pairing/code {"device_id":"<任何存在的设备>"}
→ 200 {"code":"483920","expires_at":...}
```
只要知道一个存在的 `device_id`,**任何人不登录、不持有任何凭据**,就能拿到该设备
的配对码,再去查看端输入,换到该设备的查看授权。比认领洞更浅:认领洞还要先登录。
根因不是"端点忘了加鉴权",是**码的来源错了**。服务端凭什么相信"请求方就是那台
设备"?`device_id` 是可猜的公开标识;`device_token` 只发给已归属设备(新设备会
成环,M4′ 里已经推演过)。唯一不可伪造的是**那条已建立的连接本身**。
## 改成什么
码由设备本地生成(`client/lib/host/pairing_code.dart`),随 `agent:hello` 的
`payload.claim_code` 上报,网关在这一刻登记为配对码(`PairingStore.registerDeviceCode`),
**断连即删**(`forgetDeviceCode`),TTL 300s 只是崩溃兜底。
于是码的语义变成真话:**"设备此刻在这条连接上活着"**。屏幕上那个码因此永远是服务端
认可的那一个,不存在"屏幕显示 A、服务端认 B"。
**配对码与认领码是同一个字符串**:用户只有一个六格输入框的心智,两条链路各发一个码
只会让人念错。
`POST /api/pairing/code` 保留为 **410 `E_ENDPOINT_GONE`** 而不是删掉:404 会让人以为
路径拼错或版本不对,410 明确说"它被有意移除了,码从哪来"。
## 客户端
`pairing_api.issueCode` 改为**幂等只读**:返回单例里的当前码,不发网络。别把它改成
"每次调用都换一个码" —— 换了却不同时重发 hello,屏幕就会显示一个服务端从未登记过的
值,用户念它必然失败,而且看起来像服务端坏了。
## 顺带补上 M4′ 在客户端的缺口
上一轮 M4′ 只做了服务端:`grep claim_code client/lib` 命中 **0**。也就是服务端能力建好
了、测试全绿,而**真实客户端从不上报码** —— 生产上认领只能走 `isNew` 路径。同一个
"必须有真实产生方"的坑在 M5 里刚踩过一次(表恒空)。本次 `link.dart` 的 hello 已带
`claim_code`,两条链路同时有了产生方。
## ⚠️ 一个比原洞更大的发现:84 项配对回归网跑的是 9 天前的镜像
`tests/pairing-security-test.js` 默认打 `http://127.0.0.1:4000`,那是 docker-compose
的实例。实测 `pmon-business-1` / `pmon-gateway-1` 镜像构建于 **2026-10-30**,而本轮
改动在 **2026-11-08** —— 早 9 天。所以那些"84 项全绿"只证明旧镜像行为没变,对仓库里的
代码一个字都没说。
所以本套件(`pairing-code-origin-test.js`)**自起源码实例**(business + gateway,
随机端口,源码直跑),并在 `run-all.sh` 里那一段加了大字警告:在其余套件改成自起
之前,**不要把它们的绿当作本仓代码的回归证据**。
## 测试
`tests/pairing-code-origin-test.js` **16 项全绿**,已接入 `run-all.sh`。16 条里 13 条是
"拿不到"型(410 / 无码返回 / 猜码被拒 / 跨设备被拒 / 断连失效),3 条是"该拿到的人
拿得到"(hello 登记、verify 成功、一次性消费)—— 只验前者的话,一个把配对整体做死的
实现也能全绿。
客户端:`pairing_card_test` 6/6、全量 **595 通过 / 2 失败**(两条仍是 `libpmon_core.dylib`
未编译)。两条断言按行为变更改写而不是删除:
- "发码失败不静默" → 改为"**一次请求都不发**"(失败路径已不存在,但新风险更大:
任何一次发出的取码请求都意味着码又能被第三方索取);
- "服务端返回 123456" → 改为"卡片显示的码必须与上报给服务端的是同一个"。
第一次跑本套件时 E 段红了两条,原因是我自己的夹具没关掉前一条连接 —— 网关判定
"这台设备还有别的活连接",正确行为就是不删码。已修,并在测试里写清这个坑。
回归:认领 57/0、埋点 29/0、权限 60/0、schema 116/0。
465 lines
20 KiB
JavaScript
465 lines
20 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('配对码生成失败(连续碰撞,请重试)');
|
||
});
|
||
}
|
||
|
||
/**
|
||
* **登记**设备自己生成的码(M4″)—— 这不是"发码",服务端不给任何码。
|
||
*
|
||
* ── 为什么必须改成这个形状 ────────────────────────────────────────
|
||
* 原实现是 `POST /api/pairing/code {device_id}` → `issue()` 生成码并在响应体里
|
||
* 返回。那个端点**零鉴权**:只要知道一个存在的 `device_id`,任何人不登录、
|
||
* 不持有任何凭据,就能拿到该设备的配对码,再去查看端输入,换到该设备的查看
|
||
* 授权。它比认领洞更浅(认领洞还要先登录)。
|
||
*
|
||
* 根因不是"忘了加鉴权",而是**码的来源错了**:服务端凭什么相信"请求方就是
|
||
* 那台设备"?`device_id` 是可猜的公开标识,`device_token` 只发给已归属设备
|
||
* (新设备会成环)。唯一不可伪造的东西是**那条已建立的连接本身** —— 所以码
|
||
* 只能由设备本地生成、随 `agent:hello` 上报,由网关在此刻登记。
|
||
*
|
||
* ── 由此得到的语义(比原来更强,且是真话)────────────────────────
|
||
* 码的语义变成"设备此刻在这条连接上活着":连接在 → 码有效;断开 → 立即删
|
||
* (`forgetDeviceCode`),TTL 300s 只是崩溃兜底。屏幕上那个码因此**永远**是
|
||
* 服务端认可的那一个,不存在"屏幕显示 A、服务端认 B"。
|
||
*
|
||
* @returns {{ok:true,code:string,ttl_seconds:number,expires_at:number}
|
||
* | {ok:false,reason:'bad_code'|'code_taken'}}
|
||
*/
|
||
registerDeviceCode(deviceId, code) {
|
||
return this._run('registerDeviceCode', async () => {
|
||
const c = String(code);
|
||
if (!PAIRING_CODE_RE.test(c)) return { ok: false, reason: 'bad_code' };
|
||
|
||
const key = pairingKey(c);
|
||
const record = {
|
||
device_id: deviceId,
|
||
issued_at: Date.now(),
|
||
attempts_left: PAIRING_MAX_ATTEMPTS,
|
||
};
|
||
const written = await this.redis.set(key, JSON.stringify(record), {
|
||
EX: PAIRING_TTL_SEC,
|
||
NX: true,
|
||
});
|
||
if (!written) {
|
||
// 码已被占用。若占用者**就是本设备**(重连时同一个码再报一次),刷新 TTL;
|
||
// 若是别的设备,返回冲突 —— 绝不覆盖:覆盖会让先登记那台设备的码改指向
|
||
// 新设备,那正是"授权串号"。调用方(网关)只能让设备换码,不能替它决定。
|
||
const raw = await this.redis.get(key);
|
||
let owner = null;
|
||
try { owner = raw ? JSON.parse(raw).device_id : null; } catch { owner = null; }
|
||
if (owner !== deviceId) return { ok: false, reason: 'code_taken' };
|
||
await this.redis.set(key, JSON.stringify(record), { EX: PAIRING_TTL_SEC });
|
||
}
|
||
|
||
// 屏幕只显示一个码:该设备上一个码必须作废,否则攻击面翻倍
|
||
const prev = await this.redis.get(pairingDeviceKey(deviceId));
|
||
if (prev && prev !== c) await this.redis.del(pairingKey(prev));
|
||
await this.redis.set(pairingDeviceKey(deviceId), c, { EX: PAIRING_TTL_SEC });
|
||
|
||
return {
|
||
ok: true,
|
||
code: c,
|
||
ttl_seconds: PAIRING_TTL_SEC,
|
||
expires_at: Date.now() + PAIRING_TTL_SEC * 1000,
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 设备断开即删码(TTL 只是崩溃兜底)。
|
||
*
|
||
* 必须删的理由与认领码同源:码代表"在场",连接没了而码还在,等于把一个仍然
|
||
* 可用的授权凭据留给任何知道 `device_id` 的人 —— 而它已经不代表任何在场状态。
|
||
*/
|
||
forgetDeviceCode(deviceId) {
|
||
return this._run('forgetDeviceCode', async () => {
|
||
const code = await this.redis.get(pairingDeviceKey(deviceId));
|
||
if (code) await this.redis.del(pairingKey(code));
|
||
await this.redis.del(pairingDeviceKey(deviceId));
|
||
});
|
||
}
|
||
|
||
/** 只读查询,不消费(供设备重绘界面与排障用) */
|
||
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 {
|
||
// ⚠️ 传数组而不是多个参数:本类由 `server/business` 实例化,那里的
|
||
// node-redis 是 v5+ 语义,`del('a','b')` **只删 'a'** 且不报错
|
||
// (实测 2026-11-08)。旧写法会让 `pairing:device:<id>` 这个反向索引
|
||
// 在本该失效时留着,指向一个已被删掉的码 —— 而下一轮发码正是靠它
|
||
// 找回旧码去删(`issue` 里的 prev 分支),留着比删掉更坏。
|
||
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,
|
||
};
|