From cc0aaffffda2a623ffcae1d30dab9f8a58c1990e Mon Sep 17 00:00:00 2001 From: jkooe <62494645+jkooe@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:57:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(pairing):=20=E9=85=8D=E5=AF=B9=E7=A0=81?= =?UTF-8?q?=E6=94=B9=E7=94=B1=E8=AE=BE=E5=A4=87=E6=9C=AC=E5=9C=B0=E7=94=9F?= =?UTF-8?q?=E6=88=90=E3=80=81=E9=9A=8F=E8=BF=9E=E6=8E=A5=E4=B8=8A=E6=8A=A5?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E9=9B=B6=E9=89=B4=E6=9D=83=E5=8F=96?= =?UTF-8?q?=E7=A0=81=E7=AB=AF=E7=82=B9=E4=B8=8B=E7=BA=BF=E4=B8=BA=20410?= =?UTF-8?q?=EF=BC=88M4=E2=80=B3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 修的是什么 `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。 --- client/lib/host/link.dart | 9 + client/lib/host/pairing_api.dart | 44 +++-- client/lib/host/pairing_code.dart | 65 +++++++ client/test/pairing_card_test.dart | 34 ++-- client/test/viewer_test.dart | 13 +- server/business/index.js | 56 +++--- server/gateway/gateway.js | 40 ++++- shared/pairing.js | 76 ++++++++ tests/pairing-code-origin-test.js | 275 +++++++++++++++++++++++++++++ tests/run-all.sh | 14 ++ 10 files changed, 548 insertions(+), 78 deletions(-) create mode 100644 client/lib/host/pairing_code.dart create mode 100644 tests/pairing-code-origin-test.js diff --git a/client/lib/host/link.dart b/client/lib/host/link.dart index 0fe7e0a..3150eb7 100644 --- a/client/lib/host/link.dart +++ b/client/lib/host/link.dart @@ -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 就变新设备)。 diff --git a/client/lib/host/pairing_api.dart b/client/lib/host/pairing_api.dart index fcee3c5..0a89c61 100644 --- a/client/lib/host/pairing_api.dart +++ b/client/lib/host/pairing_api.dart @@ -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 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, + ); } /// 查看端:提交配对码,换取该设备的查看授权 diff --git a/client/lib/host/pairing_code.dart b/client/lib/host/pairing_code.dart new file mode 100644 index 0000000..e426fc7 --- /dev/null +++ b/client/lib/host/pairing_code.dart @@ -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; + } +} diff --git a/client/test/pairing_card_test.dart b/client/test/pairing_card_test.dart index f6ea387..a13323e 100644 --- a/client/test/pairing_card_test.dart +++ b/client/test/pairing_card_test.dart @@ -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 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 { diff --git a/client/test/viewer_test.dart b/client/test/viewer_test.dart index e12d541..910d344 100644 --- a/client/test/viewer_test.dart +++ b/client/test/viewer_test.dart @@ -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(); }); diff --git a/server/business/index.js b/server/business/index.js index d9884f9..6c5fefd 100644 --- a/server/business/index.js +++ b/server/business/index.js @@ -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: '配对码改由设备本地生成并随连接上报。请在采集端设备上查看配对码,本端点不再发放。', + }); }); // ---------- 提交配对码(建立授权) ---------- diff --git a/server/gateway/gateway.js b/server/gateway/gateway.js index d452629..8b0435f 100644 --- a/server/gateway/gateway.js +++ b/server/gateway/gateway.js @@ -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) { diff --git a/shared/pairing.js b/shared/pairing.js index 646d1a7..8fa4dc8 100644 --- a/shared/pairing.js +++ b/shared/pairing.js @@ -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 () => { diff --git a/tests/pairing-code-origin-test.js b/tests/pairing-code-origin-test.js new file mode 100644 index 0000000..623e11c --- /dev/null +++ b/tests/pairing-code-origin-test.js @@ -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:', 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: 已消失', + (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: 被删(不是靠 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; + } +})(); diff --git a/tests/run-all.sh b/tests/run-all.sh index a17a43e..b5c5fe3 100755 --- a/tests/run-all.sh +++ b/tests/run-all.sh @@ -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)而不是"请求返回成功" ——