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。
This commit is contained in:
1 parent
f4e24c2c95
commit
cc0aaffffd
10 files changed
+548
-78
No files matched your search
@@ -40,6 +40,7 @@ import 'pmon_core.dart';
|
||||
import 'clock.dart';
|
||||
import 'config_migration.dart';
|
||||
import 'lan_service.dart';
|
||||
import 'pairing_code.dart';
|
||||
|
||||
import '../link/p2p_driver.dart';
|
||||
import '../net/candidates.dart';
|
||||
@@ -568,6 +569,14 @@ class ClientAgent {
|
||||
// 设备令牌(若已认领):服务端校验它绑定的 device_id 与本次声明一致,
|
||||
// 不一致直接拒绝 —— 这是防止别人用你的 device_id 冒充上报的唯一手段
|
||||
if (_deviceToken.isNotEmpty) 'device_token': _deviceToken,
|
||||
// 本机配对码(M4″)。**服务端不再发码**,码只在这里产生、随这条连接上报,
|
||||
// 网关收到即登记、断开即删。它与认领码是**同一个字符串**:用户只有一个
|
||||
// 六格输入框的心智,两条链路各发一个码只会让人念错。
|
||||
//
|
||||
// 必须在 `_sendHello` 里取 `LocalPairingCode.current()` 而不是在卡片里取:
|
||||
// 这里是"上报"的那一侧,先上报再显示,屏幕上的码才不会是一个服务端
|
||||
// 从未登记过的值。见 host/pairing_code.dart 文件头那条硬约束。
|
||||
'claim_code': LocalPairingCode.current(),
|
||||
// 稳定硬件指纹(与 device_id 的派生规则**无关**):服务端据此在做认领时
|
||||
// 认出"这还是同一台机",把历史标识的旧记录并进来。
|
||||
// 见 `host/device_id.dart` 的文件头(device_id 曾掺机型名 → 换了 id 就变新设备)。
|
||||
|
||||
@@ -14,6 +14,8 @@ library;
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'pairing_code.dart';
|
||||
|
||||
/// HTTP 响应(只留状态码与原文,避免引入第三方 http 包)
|
||||
class HttpReply {
|
||||
const HttpReply(this.status, this.body);
|
||||
@@ -145,31 +147,25 @@ class PairingApi {
|
||||
final RestGet _restGet;
|
||||
final RestPost _restPost;
|
||||
|
||||
/// 采集端:生成并展示本机配对码(方案 6.3「被查看设备生成并展示配对码」)
|
||||
/// 采集端:展示本机配对码(M4″ 之后**不再向服务端要码**)
|
||||
///
|
||||
/// 服务端发码的端点已下线(`POST /api/pairing/code` → 410):它零鉴权,知道
|
||||
/// `device_id` 的任何人无需账号即可拿到该设备的码,再换到查看授权。码现在由
|
||||
/// 设备本地生成、随 `agent:hello` 上报、网关登记、断连即删(见
|
||||
/// `host/pairing_code.dart`)。
|
||||
///
|
||||
/// 因此这个方法**幂等**:它只返回当前码,不生成新码、不发网络。
|
||||
/// 别把它改成"每次调用都换一个码" —— 换了却不同时重发 hello,屏幕就会显示一个
|
||||
/// 服务端从未登记过的值,用户念它必然失败,而且看起来像服务端坏了。
|
||||
Future<PairingOutcome> issueCode(String deviceId) async {
|
||||
try {
|
||||
final res = await _restPost(Uri.parse('$businessUrl/api/pairing/code'),
|
||||
{'device_id': deviceId});
|
||||
final j = res.json;
|
||||
if (res.status == 200 && j != null && j['code'] is String) {
|
||||
return PairingOutcome(
|
||||
ok: true,
|
||||
message: '配对码已生成,请在查看端输入',
|
||||
code: j['code'] as String,
|
||||
expiresAtMs: (j['expires_at'] as num?)?.toInt(),
|
||||
deviceId: deviceId,
|
||||
);
|
||||
}
|
||||
final ec = j?['error'] as String?;
|
||||
return PairingOutcome(
|
||||
ok: false,
|
||||
message: pairingErrorText(ec, j?['message'] as String?),
|
||||
errorCode: ec,
|
||||
);
|
||||
} catch (e) {
|
||||
return PairingOutcome(
|
||||
ok: false, message: '无法连接业务服务($businessUrl):$e');
|
||||
}
|
||||
final code = LocalPairingCode.current();
|
||||
return PairingOutcome(
|
||||
ok: true,
|
||||
message: '配对码已就绪,请在采集端设备屏幕上核对',
|
||||
code: code,
|
||||
expiresAtMs: DateTime.now().millisecondsSinceEpoch + 300 * 1000,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
}
|
||||
|
||||
/// 查看端:提交配对码,换取该设备的查看授权
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/// 本机配对码 —— **唯一的生成处**(M4″)。
|
||||
///
|
||||
/// ── 为什么客户端要自己生成(而不是问服务端要)──────────────────────
|
||||
/// 原实现是采集端调 `POST /api/pairing/code` 让服务端生成并把码返回。那个端点
|
||||
/// 零鉴权:知道 `device_id` 的任何人无需账号即可拿到该设备的码,再去查看端输入,
|
||||
/// 换到该设备的查看授权。根因不是"端点忘了鉴权",是**码的来源错了** ——
|
||||
/// 服务端没有任何办法证明"请求方就是那台设备"(`device_id` 是可猜的公开标识,
|
||||
/// `device_token` 只发给已归属设备,新设备会成环)。
|
||||
///
|
||||
/// 唯一不可伪造的是**那条已建立的连接本身**。所以码在设备本地生成,随
|
||||
/// `agent:hello` 上报,服务端只做登记(`gateway._rememberClaimCode` →
|
||||
/// `PairingStore.registerDeviceCode`),连接断开即删。
|
||||
///
|
||||
/// ── 由此得到的一条硬约束(改代码前先读)──────────────────────────
|
||||
/// **屏幕上的码必须与上报给服务端的码是同一个字符串。** 所以生成只能有一处、
|
||||
/// 只能有一个实例:`Link` 发 hello 时用它,配对卡显示也用它。若哪天有人在这里
|
||||
/// 加"重新生成"却没有同时触发重发 hello,屏幕会显示一个服务端从未登记过的码 ——
|
||||
/// 用户念它是必然失败,而且**看起来像服务端坏了**。
|
||||
///
|
||||
/// 换码的正确做法只有一个:让连接重建(重连/重启应用),新码随新 hello 上报。
|
||||
library;
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
/// 位数 —— 与服务端 `shared/pairing.js` 的 `PAIRING_CODE_LENGTH` 同一个数。
|
||||
///
|
||||
/// 客户端无法 import 服务端常量,所以这里是**副本**。它不承担"名单正确"的
|
||||
/// 责任:服务端会用 `PAIRING_CODE_RE` 再校验一次,格式不符就拒登记(并在
|
||||
/// 网关日志里留一行)。这比让客户端"猜对"更可靠。
|
||||
const int kLocalPairingCodeLength = 6;
|
||||
|
||||
final Random _rng = Random.secure();
|
||||
|
||||
/// 生成一个 6 位数字码(含前导零,故必须按字符串处理)。
|
||||
String generatePairingCode() {
|
||||
final buf = StringBuffer();
|
||||
for (var i = 0; i < kLocalPairingCodeLength; i++) {
|
||||
buf.write(_rng.nextInt(10));
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
/// 进程级唯一实例。
|
||||
///
|
||||
/// 为什么是**静态**而不是注入:`Link`(发 hello)与 `PairingCard`(显示)在
|
||||
/// 两条互不相识的路径上,注入要穿过一整条 widget 树才连得起来;而这个值在
|
||||
/// 进程内本来就只有一份语义("本机当前的配对码")。用静态单例把"只有一份"
|
||||
/// 变成编译器就能保证的事,比靠人记得传参可靠。
|
||||
class LocalPairingCode {
|
||||
LocalPairingCode._();
|
||||
|
||||
static String? _value;
|
||||
|
||||
/// 当前码。首次调用时生成 —— **首次调用点必须是 Link 发 hello 之前**,
|
||||
/// 否则卡片可能先显示出一个还没上报过的码(见文件头那条硬约束)。
|
||||
static String current() => _value ??= generatePairingCode();
|
||||
|
||||
/// 是否已经生成过(诊断/测试用,不参与业务判断)
|
||||
static bool get hasValue => _value != null;
|
||||
|
||||
/// 仅供测试:把单例重置到已知状态(避免测试之间互相污染)
|
||||
static void debugReset([String? value]) {
|
||||
_value = value;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ library;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pmon_client/host/pairing_api.dart';
|
||||
import 'package:pmon_client/host/pairing_code.dart';
|
||||
import 'package:pmon_client/ui/pairing_card.dart';
|
||||
|
||||
/// 造一个可编程的假 PairingApi
|
||||
@@ -93,8 +94,10 @@ void main() {
|
||||
expect(find.textContaining('不提供任何绕过对方操作的监控能力'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('生成配对码 → 大字显示 6 位码 + 有效期与一次性说明', (tester) async {
|
||||
testWidgets('显示配对码 → 大字 6 位码 + 有效期与一次性说明,且**不发任何网络请求**', (tester) async {
|
||||
final posts = <({Uri url, Map<String, Object?> body})>[];
|
||||
LocalPairingCode.debugReset('483920');
|
||||
addTearDown(() => LocalPairingCode.debugReset());
|
||||
await mount(tester, fakeApi(postCalls: posts));
|
||||
|
||||
await tester.tap(find.text('生成配对码'));
|
||||
@@ -104,25 +107,28 @@ void main() {
|
||||
expect(find.text('483920'), findsOneWidget);
|
||||
expect(find.textContaining('秒后失效'), findsOneWidget);
|
||||
expect(find.textContaining('只能用一次'), findsWidgets);
|
||||
expect(posts.single.url.path, '/api/pairing/code');
|
||||
expect(posts.single.body['device_id'], 'dev_self');
|
||||
// ★ 这一条是本轮**安全修复的核心判据**:码不再来自服务端。
|
||||
// 以前这里断的是"请求打到 /api/pairing/code 且 body 带 device_id" —— 那正是
|
||||
// 漏洞本身(零鉴权端点,知道 device_id 就能取码)。现在必须断"一次请求都没发":
|
||||
// 任何一次发出的取码请求都意味着码又能被第三方索取了。
|
||||
expect(posts, isEmpty, reason: '取码不得再发网络请求');
|
||||
});
|
||||
|
||||
testWidgets('发码失败不静默 → 显示服务端错误翻译(含"不是码错"的说明)', (tester) async {
|
||||
await mount(
|
||||
tester,
|
||||
fakeApi(codeStatus: 503, codeJson: {
|
||||
'error': 'E_PAIRING_UNAVAILABLE',
|
||||
'message': '排查服务',
|
||||
}),
|
||||
);
|
||||
testWidgets('★ 卡片显示的码必须与上报给服务端的是同一个(否则用户念的码必然失败)', (tester) async {
|
||||
// 这条替代了原来的"发码失败不静默":失败路径已经不存在(不再发请求),
|
||||
// 但**新出现的风险更大** —— 若卡片自己生成一个新码、而 Link 上报的是另一个,
|
||||
// 屏幕上的码永远无效,用户会以为服务端坏了。
|
||||
LocalPairingCode.debugReset('778899');
|
||||
addTearDown(() => LocalPairingCode.debugReset());
|
||||
await mount(tester, fakeApi());
|
||||
|
||||
await tester.tap(find.text('生成配对码'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 40));
|
||||
|
||||
expect(find.textContaining('配对功能暂不可用'), findsOneWidget);
|
||||
expect(find.textContaining('不代表配对码错误'), findsOneWidget);
|
||||
expect(find.text('483920'), findsNothing);
|
||||
expect(find.text('778899'), findsOneWidget);
|
||||
expect(LocalPairingCode.current(), '778899',
|
||||
reason: '屏幕上的码必须就是单例里那一个(Link 发 hello 时读的也是它)');
|
||||
});
|
||||
|
||||
testWidgets('已授权账号列表 + 解除(并提示即时生效)', (tester) async {
|
||||
|
||||
@@ -10,6 +10,7 @@ library;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pmon_client/host/viewer.dart';
|
||||
import 'package:pmon_client/host/pairing_code.dart';
|
||||
import 'package:pmon_client/ui/metric_catalog.dart';
|
||||
|
||||
import 'viewer_fakes.dart';
|
||||
@@ -475,12 +476,20 @@ void main() {
|
||||
await env.session.stop();
|
||||
});
|
||||
|
||||
test('采集端发码 → 返回 6 位码与过期时间', () async {
|
||||
test('采集端取码 → 返回本机码与 300s 有效期,且**不再请求服务端**', () async {
|
||||
// M4″:服务端发码端点已下线(零鉴权取码就是那个洞)。码现在由设备本地生成,
|
||||
// 所以这里不能再断"服务端返回 123456",只能断"返回的是本机单例那个码"。
|
||||
LocalPairingCode.debugReset('123456');
|
||||
addTearDown(() => LocalPairingCode.debugReset());
|
||||
final env = makeViewerEnv();
|
||||
final out = await env.session.issuePairingCode('dev_self');
|
||||
expect(out.ok, isTrue);
|
||||
expect(out.code, '123456');
|
||||
expect(out.expiresAtMs, 999);
|
||||
expect(out.code, LocalPairingCode.current(),
|
||||
reason: '取到的码必须就是上报给服务端的那一个');
|
||||
// 有效期改为本地算(300s),不再由服务端下发 —— 只断"给了一个未来时刻"
|
||||
expect(out.expiresAtMs, isNotNull);
|
||||
expect(out.expiresAtMs! > DateTime.now().millisecondsSinceEpoch, isTrue);
|
||||
await env.session.stop();
|
||||
});
|
||||
|
||||
|
||||
+20
-36
@@ -1430,42 +1430,26 @@ app.post('/api/devices/:deviceId/token', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 生成配对码 ----------
|
||||
// 发码 = 「设备主动授权」的起点(方案 6.3)。码写入 Redis,TTL 300 秒,
|
||||
// 一次性使用;同设备只保留一个有效码(PairingStore.issue 负责)。
|
||||
app.post('/api/pairing/code', async (req, res) => {
|
||||
try {
|
||||
const { device_id } = req.body || {};
|
||||
if (!device_id) return res.status(400).json({ error: 'E_BAD_REQUEST', message: '缺少 device_id' });
|
||||
|
||||
if (repo) {
|
||||
const dev = await repo.getDevice(device_id);
|
||||
if (!dev) return res.status(404).json({ error: 'E_DEVICE_NOT_FOUND', message: '设备不存在' });
|
||||
} else if (!memDb.devices.has(device_id)) {
|
||||
return res.status(404).json({ error: 'E_DEVICE_NOT_FOUND', message: '设备不存在' });
|
||||
}
|
||||
|
||||
// 失败关闭:没有 Redis 就没有配对的共享 TTL 存储,宁可整体不可用
|
||||
if (!pairingStore) {
|
||||
return res.status(503).json({
|
||||
error: 'E_PAIRING_UNAVAILABLE',
|
||||
message: '配对服务暂不可用(存储未就绪),请稍后重试',
|
||||
});
|
||||
}
|
||||
|
||||
const issued = await pairingStore.issue(device_id);
|
||||
if (repo) await repo.audit('pairing_code_issued', null, device_id, { expires_at: issued.expires_at });
|
||||
else memAudit({ event: 'pairing_code_issued', deviceId: device_id });
|
||||
|
||||
res.json(issued);
|
||||
} catch (e) {
|
||||
if (e instanceof PairingUnavailableError) {
|
||||
console.error('[business] pairing code unavailable:', e.message);
|
||||
return res.status(503).json({ error: 'E_PAIRING_UNAVAILABLE', message: '配对服务暂不可用,请稍后重试' });
|
||||
}
|
||||
console.error('[business] pairing code error:', e.message);
|
||||
res.status(500).json({ error: 'E_INTERNAL', message: '生成配对码失败' });
|
||||
}
|
||||
// ---------- 生成配对码:**已下线**(M4″) ----------
|
||||
/**
|
||||
* 这个端点曾经是配对码的唯一来源:`POST {device_id}` → 服务端生成 6 位码 →
|
||||
* **在响应体里返回**。它零鉴权 —— 只要知道一个存在的 `device_id`,任何人不登录、
|
||||
* 不持有任何凭据,就能拿到该设备的配对码,再去查看端输入,换到该设备的查看授权。
|
||||
* 比认领洞更浅:认领洞还要先登录。
|
||||
*
|
||||
* 根因不是"忘了加鉴权",是**码的来源错了**。服务端凭什么相信"请求方就是那台
|
||||
* 设备"?`device_id` 是可猜的公开标识,`device_token` 只发给已归属设备(新设备
|
||||
* 会成环)。唯一不可伪造的是**那条已建立的连接本身** —— 所以码只能由设备本地
|
||||
* 生成、随 `agent:hello` 上报、由网关在那个时刻登记(见 `gateway._rememberClaimCode`)。
|
||||
*
|
||||
* 保留这条路由而不是直接删掉,是为了给出**能读懂的失败**:404 会让人以为路径拼错
|
||||
* 或版本不对,而 410 明确说"它被有意移除了,码从哪来"。
|
||||
*/
|
||||
app.post('/api/pairing/code', (req, res) => {
|
||||
res.status(410).json({
|
||||
error: 'E_ENDPOINT_GONE',
|
||||
message: '配对码改由设备本地生成并随连接上报。请在采集端设备上查看配对码,本端点不再发放。',
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- 提交配对码(建立授权) ----------
|
||||
|
||||
@@ -49,8 +49,8 @@ const ENV_SW = Object.freeze({
|
||||
requireDeviceToken: ENV_REQUIRE_DEVICE_TOKEN,
|
||||
});
|
||||
const codec = require('../../shared/codec');
|
||||
// 认领码的"六位数字"与配对码共用同一份定义(见 normalizeClaimCode 的注释)
|
||||
const { PAIRING_CODE_RE, PAIRING_CODE_LENGTH } = require('../../shared/pairing');
|
||||
// 认领码/配对码的"六位数字"与登记入口共用同一份定义(见 normalizeClaimCode 注释)
|
||||
const { PairingStore, PAIRING_CODE_RE, PAIRING_CODE_LENGTH } = require('../../shared/pairing');
|
||||
const { RedisStore } = require('./store');
|
||||
|
||||
/** 心跳超时(秒)—— 超过此值判「疑似离线」 */
|
||||
@@ -596,6 +596,34 @@ class Gateway {
|
||||
} catch (e) {
|
||||
this.log.warn?.(`[gateway] 认领码写入 Redis 失败(设备仍可正常采集): ${e.message}`);
|
||||
}
|
||||
/**
|
||||
* 同一个码**同时**是配对码(M4″)。
|
||||
*
|
||||
* 服务端从这一刻起**不再生成任何码**:码由设备本地生成、随 hello 报上来,
|
||||
* 网关只做登记。原先 `POST /api/pairing/code` 零鉴权地生成并返回码 ——
|
||||
* 知道 device_id 的任何人无需账号即可换到该设备的查看授权。
|
||||
*
|
||||
* 为什么不在业务服务里登记:登记的时机必须是"设备刚连上"这一刻,只有网关
|
||||
* 知道。`store.js` 那句"配对码是业务服务的职责"指的是**校验**(`verify`
|
||||
* 仍在业务服务),登记只是往 Redis 写两个键,与认领码同一处、同一语义。
|
||||
*/
|
||||
try {
|
||||
const r = await this._pairing().registerDeviceCode(deviceId, code);
|
||||
if (!r.ok) {
|
||||
// 冲突(1/900000)时不覆盖别人的码 —— 覆盖就是授权串号。设备只能换码重报。
|
||||
this.log.warn?.(`[gateway] 配对码登记被拒(${r.reason}),设备需换码后重报`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.log.warn?.(`[gateway] 配对码登记失败(设备仍可正常采集): ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 懒建:只有真收到码时才需要这个实例 */
|
||||
_pairing() {
|
||||
if (!this._pairingStore && this.store && this.store.redis) {
|
||||
this._pairingStore = new PairingStore(this.store.redis);
|
||||
}
|
||||
return this._pairingStore;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -612,6 +640,14 @@ class Gateway {
|
||||
} catch (e) {
|
||||
this.log.warn?.(`[gateway] 认领码清理失败(TTL 兜底): ${e.message}`);
|
||||
}
|
||||
// 同一个码也是配对码,必须一起删(M4″)。只删认领码会留下一个仍然可用的
|
||||
// 配对授权凭据 —— 而连接已经没了,它不代表任何在场状态。
|
||||
try {
|
||||
const p = this._pairing();
|
||||
if (p) await p.forgetDeviceCode(deviceId);
|
||||
} catch (e) {
|
||||
this.log.warn?.(`[gateway] 配对码清理失败(TTL 兜底): ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async onHello(socket, raw) {
|
||||
|
||||
@@ -200,6 +200,82 @@ class PairingStore {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* **登记**设备自己生成的码(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 () => {
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 配对码来源(M4″)回归套件 —— 码只能来自设备那条连接
|
||||
*
|
||||
* ── 修的是什么 ────────────────────────────────────────────────────
|
||||
* `POST /api/pairing/code {device_id}` 原先零鉴权地**生成码并在响应体里返回**:
|
||||
* 只要知道一个存在的 `device_id`,任何人不登录、不持有任何凭据,就能拿到该设备
|
||||
* 的配对码,再去查看端输入,换到该设备的查看授权。比认领洞更浅(认领洞还要登录)。
|
||||
*
|
||||
* 现在码由设备本地生成、随 `agent:hello` 上报、网关登记、**断连即删**。
|
||||
*
|
||||
* ── 这套测试为什么必须**自起服务** ────────────────────────────────
|
||||
* 本仓库的 `pairing-security-test.js`(84 项)默认打 `http://127.0.0.1:4000`,
|
||||
* 那是 docker-compose 的实例 —— 实测其镜像构建于 2026-10-30,而本轮改动在
|
||||
* 2026-11-08。**对着旧镜像跑出来的绿,证明不了仓库里的代码是对的。**
|
||||
* 所以本套件不用任何外部实例:自己起 business + gateway(源码直跑),
|
||||
* 端口随机,跑完关掉。
|
||||
*
|
||||
* ── 判据的方向:先证明"拿不到",再证明"该拿到的人拿得到"────────────
|
||||
* 只验"端点 410"是不够的 —— 一个把配对功能整体做死的实现也能让 410 通过。
|
||||
* 所以 B/C/D/E 四条把合法链路整条走通(hello 登记 → verify 成功),
|
||||
* 再单独证明攻击者路径已封。
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const DATABASE_URL = process.env.DATABASE_URL
|
||||
|| 'postgres://pmon:pmon_dev_only@127.0.0.1:5432/pmon';
|
||||
const REDIS_URL = process.env.REDIS_URL || 'redis://127.0.0.1:6379/12';
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const failures = [];
|
||||
function check(name, cond, detail) {
|
||||
if (cond) { pass += 1; console.log(` ✓ ${name}`); } else {
|
||||
fail += 1; failures.push(name);
|
||||
console.log(` ✗ ${name}${detail === undefined ? '' : ` ${detail}`}`);
|
||||
}
|
||||
}
|
||||
function section(t) { console.log(`\n=== ${t} ===`); }
|
||||
|
||||
const uniq = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
let biz = null; let gw = null;
|
||||
let BIZ = ''; let GW = '';
|
||||
|
||||
function stopChildren() {
|
||||
for (const c of [biz, gw]) {
|
||||
if (!c) continue;
|
||||
try { c.kill('SIGTERM'); } catch { /* 已退 */ }
|
||||
}
|
||||
biz = null; gw = null;
|
||||
}
|
||||
|
||||
async function waitHealth(url, timeoutMs = 20000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const r = await fetch(`${url}/health`);
|
||||
if (r.ok || r.status === 503) return true;
|
||||
} catch { /* 还没起来 */ }
|
||||
await sleep(150);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function post(base, p, body) {
|
||||
const r = await fetch(`${base}${p}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
let json = null;
|
||||
try { json = await r.json(); } catch { /* 非 JSON */ }
|
||||
return { status: r.status, body: json };
|
||||
}
|
||||
|
||||
/** 起一条设备连接并在 hello 里报码;返回 {code, close} */
|
||||
function helloWithCode(deviceId, code) {
|
||||
// 与 tests/claim-proof-test.js 同一解析路径(本仓库既有用法)
|
||||
const { io } = require('socket.io-client');
|
||||
return new Promise((resolve) => {
|
||||
const s = io(GW, { transports: ['websocket'], reconnection: false });
|
||||
let done = false;
|
||||
const out = {
|
||||
code, accepted: false, error: null,
|
||||
close: () => { try { s.close(); } catch { /* ignore */ } },
|
||||
};
|
||||
const finish = (v) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
resolve({ ...out, ...v });
|
||||
};
|
||||
s.on('connect', () => {
|
||||
s.emit('agent:hello', {
|
||||
v: 1, type: 'agent:hello', ts: Date.now(), device_id: deviceId,
|
||||
payload: {
|
||||
device_name: `m4pp-${uniq}`, platform: 'test',
|
||||
encoding: ['json'], claim_code: code,
|
||||
},
|
||||
});
|
||||
});
|
||||
s.on('server:config', () => finish({ accepted: true }));
|
||||
s.on('error', (d) => finish({ error: d && d.payload }));
|
||||
setTimeout(() => finish({}), 4000);
|
||||
});
|
||||
}
|
||||
|
||||
function sixDigits(seed) {
|
||||
const n = Number(String(seed).replace(/\D/g, '').slice(-6).padEnd(6, '0')) % 1000000;
|
||||
return String(n).padStart(6, '0');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let redisMod = null;
|
||||
try {
|
||||
redisMod = require(path.join(ROOT, 'server/business/node_modules/redis'));
|
||||
} catch {
|
||||
try { redisMod = require('redis'); } catch { /* 无 */ }
|
||||
}
|
||||
if (!redisMod) {
|
||||
console.log('\n[skip] 找不到 redis 模块(server/business/node_modules 未安装?)');
|
||||
return;
|
||||
}
|
||||
const redis = redisMod.createClient({ url: REDIS_URL });
|
||||
try {
|
||||
await redis.connect();
|
||||
} catch (e) {
|
||||
console.log(`\n[skip] 连不上 Redis(${REDIS_URL}):${e.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const bizPort = 59400 + Math.floor(Math.random() * 200);
|
||||
const gwPort = 59600 + Math.floor(Math.random() * 200);
|
||||
BIZ = `http://127.0.0.1:${bizPort}`;
|
||||
GW = `http://127.0.0.1:${gwPort}`;
|
||||
const commonEnv = { ...process.env, DATABASE_URL, REDIS_URL };
|
||||
|
||||
biz = spawn(process.execPath, ['server/business/index.js'], {
|
||||
cwd: ROOT, env: { ...commonEnv, PORT: String(bizPort) }, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
gw = spawn(process.execPath, ['server/gateway/index.js'], {
|
||||
cwd: ROOT, env: { ...commonEnv, PORT: String(gwPort) }, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let log = '';
|
||||
for (const c of [biz, gw]) {
|
||||
c.stdout.on('data', (d) => { log += d.toString(); });
|
||||
c.stderr.on('data', (d) => { log += d.toString(); });
|
||||
}
|
||||
const [okBiz, okGw] = await Promise.all([waitHealth(BIZ), waitHealth(GW)]);
|
||||
if (!okBiz || !okGw) {
|
||||
console.log(`\n[skip] 临时服务起不来(biz=${okBiz} gw=${okGw}):`);
|
||||
console.log(log.trim().split('\n').slice(-10).map((l) => ` ${l}`).join('\n'));
|
||||
try { await redis.quit(); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
console.log(`[setup] 源码直跑:business :${bizPort} / gateway :${gwPort} · redis=${REDIS_URL}`);
|
||||
|
||||
// ── 一台已注册设备(hello 需要它存在;register 是公开端点,测试可用)
|
||||
const reg = await post(BIZ, '/api/devices/register', {
|
||||
device_name: `m4pp-${uniq}`, platform: 'test',
|
||||
});
|
||||
const deviceId = reg.body && reg.body.device_id;
|
||||
check('前置:注册一台设备(hello 需要 device_id 存在)', !!deviceId, JSON.stringify(reg.body).slice(0, 120));
|
||||
if (!deviceId) { try { await redis.quit(); } catch { /* ignore */ } return; }
|
||||
const accountId = `acct_m4pp_${uniq}`;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
section('A. 攻击者路径:零鉴权取码的端点已下线');
|
||||
const a1 = await post(BIZ, '/api/pairing/code', { device_id: deviceId });
|
||||
check('★ POST /api/pairing/code → 410(不是 200、不是 404)',
|
||||
a1.status === 410, `status=${a1.status}`);
|
||||
check('★ 且响应里没有任何 6 位码(扫整个响应体)',
|
||||
!/"code"\s*:/.test(JSON.stringify(a1.body || {})), JSON.stringify(a1.body));
|
||||
check('★ 错误码是 E_ENDPOINT_GONE(能读懂的失败,不是"路径拼错")',
|
||||
a1.body && a1.body.error === 'E_ENDPOINT_GONE', JSON.stringify(a1.body));
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
section('B. 设备未连接时,猜到的码一律无效(码不来自服务端)');
|
||||
const ghost = sixDigits(`9${uniq}`);
|
||||
check('Redis 里没有该设备的码(它从没连过)',
|
||||
(await redis.get(`pairing:device:${deviceId}`)) === null);
|
||||
const b1 = await post(BIZ, '/api/pairing/verify',
|
||||
{ code: ghost, device_id: deviceId, account_id: accountId });
|
||||
check('★ 猜一个码去 verify → 被拒(不是 200)', b1.status !== 200, `status=${b1.status}`);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
section('C. 合法链路:设备自报码 → 网关登记 → verify 成功');
|
||||
const code = sixDigits(`1${uniq}`);
|
||||
const h = await helloWithCode(deviceId, code);
|
||||
check('hello 被接受(收到 server:config)', h.accepted === true, JSON.stringify(h.error));
|
||||
// 登记是异步写 Redis,给它一点时间(网关在 hello 处理里 await 了,但仍留余量)
|
||||
let registered = null;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
registered = await redis.get(`pairing:device:${deviceId}`);
|
||||
if (registered === code) break;
|
||||
await sleep(100);
|
||||
}
|
||||
check('★ 网关把设备自报的码登记为 pairing:device:<id>', registered === code,
|
||||
`期望 ${code} 实得 ${registered}`);
|
||||
check('★ 同一个码也在认领码键里(两条链路同一个字符串,用户只有一个输入框的心智)',
|
||||
(await redis.get(`claimcode:${deviceId}`)) === code);
|
||||
|
||||
const c1 = await post(BIZ, '/api/pairing/verify',
|
||||
{ code, device_id: deviceId, account_id: accountId });
|
||||
check('★ 出示该码 + 正确 device_id → 授权成功(200)', c1.status === 200,
|
||||
`status=${c1.status} ${JSON.stringify(c1.body).slice(0, 120)}`);
|
||||
check('★ 码被消费(一次性):Redis 里 pairing:<code> 已消失',
|
||||
(await redis.get(`pairing:${code}`)) === null);
|
||||
// ⚠️ 必须关掉这条连接再进 D 段:网关的清理判据是「这台设备还有没有别的活连接」,
|
||||
// 而 D 段要验的是"断连即删"。若这里不关,E 段关的是**另一条**连接,网关判定
|
||||
// "还有人在线"→ 正确行为是**不删**,测试却会报"没删干净" —— 那是我自己的夹具
|
||||
// 没管好连接,不是实现错(第一版就是这么红的)。
|
||||
h.close();
|
||||
await sleep(400);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
section('D. 设备绑定:码只能用在自己那台设备上');
|
||||
const code2 = sixDigits(`2${uniq}`);
|
||||
const h2 = await helloWithCode(deviceId, code2);
|
||||
check('再次 hello 报新码被接受', h2.accepted === true, JSON.stringify(h2.error));
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if ((await redis.get(`pairing:device:${deviceId}`)) === code2) break;
|
||||
await sleep(100);
|
||||
}
|
||||
const other = await post(BIZ, '/api/devices/register', {
|
||||
device_name: `m4pp-other-${uniq}`, platform: 'test',
|
||||
});
|
||||
const otherId = other.body && other.body.device_id;
|
||||
const d1 = await post(BIZ, '/api/pairing/verify',
|
||||
{ code: code2, device_id: otherId, account_id: accountId });
|
||||
check('★ 拿 A 设备的码去认 B 设备 → 被拒(码劫持防线)', d1.status !== 200,
|
||||
`status=${d1.status}`);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
section('E. 断连即删:码的语义是"设备此刻在这条连接上活着"');
|
||||
h2.close();
|
||||
let gone = null;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
gone = await redis.get(`pairing:device:${deviceId}`);
|
||||
if (gone === null) break;
|
||||
await sleep(100);
|
||||
}
|
||||
check('★ 断连后 pairing:device:<id> 被删(不是靠 TTL 慢慢过期)', gone === null,
|
||||
`实得 ${gone}`);
|
||||
check('★ 认领码同步被删(两条链路同一份在场状态)',
|
||||
(await redis.get(`claimcode:${deviceId}`)) === null);
|
||||
const e1 = await post(BIZ, '/api/pairing/verify',
|
||||
{ code: code2, device_id: deviceId, account_id: accountId });
|
||||
check('★ 断连后原码失效(离线设备的码不能继续换授权)', e1.status !== 200,
|
||||
`status=${e1.status}`);
|
||||
|
||||
try { await redis.quit(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
console.log(`\n ✗ 套件异常中止: ${e && e.stack ? e.stack : e}`);
|
||||
fail += 1;
|
||||
} finally {
|
||||
stopChildren();
|
||||
console.log('\n' + '='.repeat(52));
|
||||
console.log(` 配对码来源(M4″):${pass} 通过 / ${fail} 失败`);
|
||||
if (fail) console.log(` 失败项:\n - ${failures.join('\n - ')}`);
|
||||
console.log('='.repeat(52));
|
||||
process.exitCode = fail === 0 ? 0 : 1;
|
||||
}
|
||||
})();
|
||||
@@ -20,6 +20,8 @@
|
||||
# 并在 plan_requests 缺失时执行 deploy/migrate.sql 里那一段真实 DDL)
|
||||
# 认领在场证明 57(M4′:自报 owner_id 不落归属 / 6 位码随 hello / 双桶限流 / 审计条件化;
|
||||
# 自建临时库 + **独占 Redis db 14**,跑完 FLUSHDB —— 失败计数键不含随机后缀,不隔离会被上一次运行污染)
|
||||
# 配对码来源 16(M4″:零鉴权取码端点已下线为 410 / 码只能由设备随 hello 上报 /
|
||||
# 断连即删 / 码劫持到别的设备被拒;**源码直跑自起 business+gateway**)
|
||||
# 埋点白名单与有界 29(M5:未知名丢弃计 dropped / 超 20 条截断 / 写库失败仍 202 /
|
||||
# **表里真的长出了行**;匿名允许、废令牌 401、body.account_id 自报被忽略)
|
||||
# 客户端 Dart 测试 598(查看端逻辑+界面、配对卡、账号与角色、单位自适应、
|
||||
@@ -282,10 +284,22 @@ if [ "$GW_UP" = "1" ]; then
|
||||
echo -e "${YELLOW}⚠ 未找到 dart($DART_BIN):跳过中继客户端端到端(6 项)。${NC}"
|
||||
fi
|
||||
if [ "$BIZ_UP" = "1" ]; then
|
||||
# ⚠️⚠️ 读这一段再读下面的数字:**这些套件打的是外部实例,不是仓库里的代码。**
|
||||
# 它们的默认地址是 `http://127.0.0.1:4000` / `:3000`,也就是 docker-compose
|
||||
# 起的 pmon-business / pmon-gateway。2026-11-08 实测那两个容器镜像构建于
|
||||
# 2026-10-30 —— 比当天的改动早 9 天。所以「84 项全绿」曾经只证明**旧镜像**
|
||||
# 的行为没变,对"仓库里的代码对不对"一个字都没说。
|
||||
# 已修的那一类:`pairing-code-origin-test.js` 自起源码实例(见下一条)。
|
||||
# 尚未修的:pairing-security / account / admin-api / viewer-e2e 等仍打外部实例。
|
||||
# 在它们改成自起之前,**不要把它们的绿当作本仓代码的回归证据**。
|
||||
# ★ 配对授权绕过缺陷的回归网。旧 e2e 只测"授权后能订阅",
|
||||
# 从不提交错码,于是"完全不校验"与"校验正确"在测试眼里没有区别。
|
||||
# 本套件有一半是"No"型断言(错误码必须被拒)。
|
||||
run "配对码安全(84 项)" node pairing-security-test.js
|
||||
# ★ 配对码来源(M4″):码只能来自设备那条连接。
|
||||
# 打的是**源码直跑的临时实例**(自起 business + gateway,随机端口),
|
||||
# 不是 :4000 的部署实例 —— 见紧随其后的那条警告。
|
||||
run "配对码来源(16 项)" node pairing-code-origin-test.js
|
||||
# ★ 认领的「在场证明」(M4′):`agent:hello` 自报 `owner_id` 不得建立归属、
|
||||
# 6 位码随 hello 上报/断连即删、claim 必须有码、双桶限流、审计条件化。
|
||||
# 断言的是**结果状态**(查库/查 Redis)而不是"请求返回成功" ——
|
||||
|
||||
Reference in new issue
Block a user