## 修的是什么
`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。
163 lines
6.7 KiB
Dart
163 lines
6.7 KiB
Dart
/// 「谁能看我」卡的渲染与交互测试(方案 6.3 的采集端两步)
|
|
///
|
|
/// 这块界面承担的是**授权**这件安全操作,界面上写错一句话的代价比别处大:
|
|
/// · 配对码没显示有效期 → 用户会以为能长期贴在屏幕上
|
|
/// · 解除失败被静默 → 用户以为已解除,实际对方还能看
|
|
/// 故这些分支都要有断言钉住;用注入的假 REST,不依赖业务服务。
|
|
///
|
|
/// 运行:cd client && bash scripts/test.sh test/pairing_card_test.dart
|
|
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
|
|
PairingApi fakeApi({
|
|
List<String> accounts = const [],
|
|
Map<String, dynamic>? codeJson,
|
|
int codeStatus = 200,
|
|
bool revokeOk = true,
|
|
List<Uri>? getCalls,
|
|
List<({Uri url, Map<String, Object?> body})>? postCalls,
|
|
}) {
|
|
return PairingApi(
|
|
businessUrl: 'http://10.0.0.5:4000',
|
|
restGet: (url) async {
|
|
getCalls?.add(url);
|
|
return HttpReply(200, '{"accounts":${_list(accounts)}}');
|
|
},
|
|
restPost: (url, body) async {
|
|
postCalls?.add((url: url, body: body));
|
|
if (url.path.contains('/pairing/code')) {
|
|
return HttpReply(codeStatus, _encode(codeJson ?? _defaultCode()));
|
|
}
|
|
if (url.path.contains('/pairing/revoke')) {
|
|
return HttpReply(revokeOk ? 200 : 500,
|
|
revokeOk ? '{"ok":true}' : '{"error":"E_INTERNAL","message":"内部错误"}');
|
|
}
|
|
return HttpReply(500, '{"error":"E_INTERNAL","message":"未预期调用"}');
|
|
},
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> _defaultCode() => {
|
|
'code': '483920',
|
|
// 有效期给足,避免测试跑着跑着码"过期"(倒计时是被测行为之一,另行断言)
|
|
'ttl_seconds': 300,
|
|
'expires_at': DateTime.now().millisecondsSinceEpoch + 300000,
|
|
};
|
|
|
|
String _list(List<String> xs) => '[${xs.map((e) => '"$e"').join(',')}]';
|
|
|
|
String _encode(Map<String, dynamic> m) {
|
|
final parts = m.entries.map((e) {
|
|
final v = e.value;
|
|
if (v is num || v is bool) return '"${e.key}":$v';
|
|
return '"${e.key}":"$v"';
|
|
});
|
|
return '{${parts.join(',')}}';
|
|
}
|
|
|
|
Future<void> mount(WidgetTester tester, PairingApi api,
|
|
{String deviceId = 'dev_self'}) async {
|
|
tester.view.physicalSize = const Size(1000, 2000);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(tester.view.reset);
|
|
await tester.pumpWidget(MaterialApp(
|
|
home: Scaffold(
|
|
body: SingleChildScrollView(
|
|
child: PairingCard(
|
|
businessUrl: 'http://10.0.0.5:4000',
|
|
deviceId: deviceId,
|
|
api: api,
|
|
),
|
|
),
|
|
),
|
|
));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 40));
|
|
}
|
|
|
|
void main() {
|
|
testWidgets('显示本机标识(查看端要照着输入)', (tester) async {
|
|
await mount(tester, fakeApi(), deviceId: 'mac-book-pro');
|
|
expect(find.textContaining('本机标识 mac-book-pro'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('无授权时给出安全承诺文案,而不是空白', (tester) async {
|
|
await mount(tester, fakeApi());
|
|
expect(find.text('已授权可查看本机的账号'), findsOneWidget);
|
|
expect(find.textContaining('还没有其他人获得查看授权'), findsOneWidget);
|
|
expect(find.textContaining('不提供任何绕过对方操作的监控能力'), findsOneWidget);
|
|
});
|
|
|
|
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('生成配对码'));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 40));
|
|
|
|
expect(find.text('483920'), findsOneWidget);
|
|
expect(find.textContaining('秒后失效'), findsOneWidget);
|
|
expect(find.textContaining('只能用一次'), findsWidgets);
|
|
// ★ 这一条是本轮**安全修复的核心判据**:码不再来自服务端。
|
|
// 以前这里断的是"请求打到 /api/pairing/code 且 body 带 device_id" —— 那正是
|
|
// 漏洞本身(零鉴权端点,知道 device_id 就能取码)。现在必须断"一次请求都没发":
|
|
// 任何一次发出的取码请求都意味着码又能被第三方索取了。
|
|
expect(posts, isEmpty, reason: '取码不得再发网络请求');
|
|
});
|
|
|
|
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.text('778899'), findsOneWidget);
|
|
expect(LocalPairingCode.current(), '778899',
|
|
reason: '屏幕上的码必须就是单例里那一个(Link 发 hello 时读的也是它)');
|
|
});
|
|
|
|
testWidgets('已授权账号列表 + 解除(并提示即时生效)', (tester) async {
|
|
final posts = <({Uri url, Map<String, Object?> body})>[];
|
|
await mount(tester,
|
|
fakeApi(accounts: ['acct_a', 'acct_b'], postCalls: posts));
|
|
|
|
expect(find.text('acct_a'), findsOneWidget);
|
|
expect(find.text('acct_b'), findsOneWidget);
|
|
expect(find.text('(2)'), findsOneWidget);
|
|
|
|
await tester.tap(find.text('解除').first);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 40));
|
|
|
|
final revoke = posts.firstWhere((p) => p.url.path.contains('/pairing/revoke'));
|
|
expect(revoke.body['account_id'], 'acct_a');
|
|
expect(revoke.body['device_id'], 'dev_self');
|
|
expect(find.textContaining('已解除 acct_a 的查看授权'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('解除失败要显示原因(不能假装成功)', (tester) async {
|
|
await mount(tester, fakeApi(accounts: ['acct_a'], revokeOk: false));
|
|
await tester.tap(find.text('解除').first);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 40));
|
|
|
|
expect(find.textContaining('服务端内部错误'), findsOneWidget);
|
|
expect(find.text('acct_a'), findsOneWidget); // 仍在列表里
|
|
});
|
|
}
|