Files
inkwatch/shared/pairing.js
T
jkooe cc0aaffffd fix(pairing): 配对码改由设备本地生成、随连接上报 —— 零鉴权取码端点下线为 410(M4″)
## 修的是什么

`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。
2026-11-08 19:57:57 +08:00

465 lines
20 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('配对码生成失败(连续碰撞,请重试)');
});
}
/**
* **登记**设备自己生成的码(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,
};