diff --git a/client/lib/host/lan_service.dart b/client/lib/host/lan_service.dart index c776c04..de236a8 100644 --- a/client/lib/host/lan_service.dart +++ b/client/lib/host/lan_service.dart @@ -397,14 +397,19 @@ class LanDownloadResult { final bool verified; } -/// 生成一次性令牌:32 字节密码学随机 → base64url(去填充)。 +/// 生成一次性令牌:**16 字节**密码学随机 → base64url(去填充)。 +/// +/// 为什么是 16 而不是 32:领用它的场景是"同一局域网 + 5 分钟 TTL", +/// 128 位随机在这个窗口内爆破不现实;而令牌要**塞进领取码里发给对方**, +/// 每多 16 字节,码就长 22 个字符 —— 用户抄错、二维码变密的代价是真实的。 +/// 32 位是给"长期有效凭据"用的强度,这里不是那个场景。 /// /// 为什么不用"同网段"当授权(**合规红线**):那等于把物理接近 /// 当成授权依据,正是隐蔽监控的形态。令牌由**上层显式发放**, /// 谁拿到谁才能取 —— 与配对码是同一套思路。 String _newToken() { final r = Random.secure(); - final b = List.generate(32, (_) => r.nextInt(256)); + final b = List.generate(16, (_) => r.nextInt(256)); return base64Url.encode(b).replaceAll('=', ''); } @@ -457,3 +462,108 @@ Future downloadLanFile({ client.close(force: true); } } + +// ══════════════════════════════════════════════════════════════════════ +// 领取码 —— 把"去哪取、凭什么取"编码成一段可以转给对方的字符串 +// +// ── 为什么用码,而不是走服务端信令 ────────────────────────────────── +// 走服务端最顺(对端直接弹出来),但需要给采集端加寻址 + 改下游事件白名单 + 部署。 +// 先用"码"把**可用闭环**做出来:发送方出示、接收方粘贴, +// 数据仍然是**两端直连、不经服务器**。体验优化留给下一步。 +// +// 码里放什么:端点、令牌、**文件名与大小**(接收方在下载前就能看到"要收什么", +// 这是"先确认再收"的前提)。**不放哈希** —— 完整哈希从响应头拿, +// 放进码里只会让它变长而不增加保证。 +// ══════════════════════════════════════════════════════════════════════ + +const String _kTicketPrefix = 'INKW-FT1.'; + +/// 把"去哪取、凭什么取"编成领取码。 +String encodeLanTicket({ + required String endpoint, + required String token, + required String name, + required int size, +}) { + final payload = jsonEncode({'e': endpoint, 't': token, 'n': name, 's': size}); + return _kTicketPrefix + base64Url.encode(utf8.encode(payload)).replaceAll('=', ''); +} + +/// 解析领取码。 +/// +/// **对垃圾输入必须给明确结果而不是抛得莫名其妙** —— 用户是从聊天窗口里 +/// 复制粘贴过来的,多一个空格、少一个字符都很常见。 +/// 返回 null 表示"这不是一个有效的领取码",调用方据此给出人话提示。 +LanTicket? parseLanTicket(String raw) { + final s = raw.trim(); + if (!s.startsWith(_kTicketPrefix)) return null; + final body = s.substring(_kTicketPrefix.length); + if (body.isEmpty) return null; + try { + // base64Url.decode 要求长度合规;补回填充再解 + final padded = body + '=' * ((4 - body.length % 4) % 4); + final decoded = utf8.decode(base64Url.decode(padded)); + final j = jsonDecode(decoded); + if (j is! Map) return null; + final e = j['e']; + final t = j['t']; + final n = j['n']; + final sz = j['s']; + if (e is! String || t is! String || n is! String || sz is! int) return null; + if (e.isEmpty || t.isEmpty || sz < 0) return null; + // ★ 端点必须**只允许私有网段 IPv4**。 + // + // 这一条是安全底线,不是"格式检查":码是**从外部粘贴进来**的, + // 里面的端点会被拿去做 HTTP 请求 —— 若放行任意主机名/公网地址, + // 粘贴一条码就等于让应用去连任意地址(SSRF 面)。 + // 而且本功能的前提就是"同一局域网",公网地址本来也不该出现在这里。 + final parsed = _parsePrivateV4Endpoint(e); + if (parsed == null) return null; + // 令牌字符集收一道:它会被拼进 URL 路径,不能让奇怪东西混进去 + if (!RegExp(r'^[A-Za-z0-9_\-]{8,64}$').hasMatch(t)) return null; + return LanTicket(endpoint: parsed, token: t, name: n, size: sz); + } catch (_) { + return null; // 不是 base64、不是 JSON、字段类型不对 —— 统统算"无效码" + } +} + +/// 校验并规范化 `ip:port`,**只接受私有网段 IPv4**。 +/// +/// ⚠️ 服务端 `server/shared/lan-endpoint.js` 有一份语义相同的 JS 实现 —— +/// 那不是重复,而是**纵深防御**:两端处在不同进程、不同信任位置, +/// 客户端这道是"别去连不该连的地方",服务端那道是"别把不该发的发出去"。 +/// 跨语言无法共享代码,故两处都要有,且**判据必须一致**。 +String? _parsePrivateV4Endpoint(String s) { + final m = RegExp(r'^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5})$') + .firstMatch(s.trim()); + if (m == null) return null; + final parts = m.group(1)!.split('.'); + final n = []; + for (final x in parts) { + final v = int.tryParse(x); + if (v == null || v > 255) return null; + n.add(v); + } + final isPrivate = n[0] == 10 || + (n[0] == 192 && n[1] == 168) || + (n[0] == 172 && n[1] >= 16 && n[1] <= 31); + if (!isPrivate) return null; + final port = int.tryParse(m.group(2)!); + if (port == null || port < 1 || port > 65535) return null; + return '${m.group(1)}:$port'; +} + +/// 解析出来的领取信息。 +class LanTicket { + const LanTicket({ + required this.endpoint, + required this.token, + required this.name, + required this.size, + }); + + final String endpoint; + final String token; + final String name; + final int size; +} diff --git a/client/pubspec.yaml b/client/pubspec.yaml index e27fdd3..d179ddf 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -43,7 +43,7 @@ dependencies: path: ^1.9.1 fl_chart: ^1.2.0 # 文件传输的**端到端校验**用。大文件传坏了必须能被发现 —— - # 没有校验的传完了只是字节写进去了,不等于内容对。 + # 没有校验的"传完了"只是"字节写进去了",不等于内容对。 # Dart 官方包、纯 Dart、极小,不引原生依赖。 crypto: ^3.0.6 dev_dependencies: diff --git a/client/test/lan_service_test.dart b/client/test/lan_service_test.dart index 172a13a..5752e2a 100644 --- a/client/test/lan_service_test.dart +++ b/client/test/lan_service_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:math'; @@ -94,6 +95,92 @@ void main() { }); }); + group('④ 领取码:能往返,且**垃圾输入必须被拒**', () { + test('正常往返(含中文文件名与大小)', () { + final code = encodeLanTicket( + endpoint: '192.168.1.5:49504', + token: 'deadbeefdeadbeef_-xy', + name: '我的报告 2026.pdf', + size: 12345678); + final t = parseLanTicket(code); + expect(t, isNotNull); + expect(t!.endpoint, '192.168.1.5:49504'); + expect(t.token, 'deadbeefdeadbeef_-xy'); + expect(t.name, '我的报告 2026.pdf'); + expect(t.size, 12345678); + }); + + test('★ 端点必须是 ip:port 形状 —— 否则粘贴一条码就等于让应用去连任意地址', () { + // 这条是安全底线:码是**从外部粘贴进来**的,里面的端点会被拿去连接。 + for (final evil in [ + 'evil.example.com:80', + 'http://192.168.1.5:80', + '192.168.1.5', + '192.168.1.5:99999', + '192.168.1.5:80/../../etc/passwd', + '; rm -rf /', + '', + ]) { + final code = encodeLanTicket( + endpoint: evil, token: 'deadbeefdeadbeef', name: 'x', size: 1); + expect(parseLanTicket(code), isNull, + reason: '端点「$evil」不该被接受'); + } + }); + + test('★ 令牌字符集受限(防止把奇怪东西塞进 URL 路径)', () { + for (final bad in ['../etc', 'a b', 'tok/en', '', 'x']) { + final code = encodeLanTicket( + endpoint: '192.168.1.5:80', token: bad, name: 'x', size: 1); + expect(parseLanTicket(code), isNull, reason: '令牌「$bad」不该被接受'); + } + }); + + test('★ 各种垃圾输入 → null(而不是抛异常)', () { + // 用户是从聊天窗口复制的:多个空格、少个字符、贴了别的东西,都很常见。 + // 解析器必须**安静地**说"这不是有效码",让调用方给人话提示。 + for (final junk in [ + '', + ' ', + 'hello world', + 'INKW-FT1.', + 'INKW-FT1.!!!!', // 不是 base64 + 'INKW-FT1.aGVsbG8', // 是 base64,但不是 JSON + 'INKW-FT2.abc', // 前缀版本不对 + 'inkw-ft1.abc', // 大小写敏感 + ]) { + expect(parseLanTicket(junk), isNull, reason: '「$junk」不该被接受'); + } + }); + + test('★ 字段缺失/类型不对 → null', () { + String mk(Map m) { + final b = base64Url.encode(utf8.encode(jsonEncode(m))).replaceAll('=', ''); + return 'INKW-FT1.$b'; + } + + expect(parseLanTicket(mk({'e': '1.2.3.4:1', 't': 'AbCdEf0123', 'n': 'x'})), + isNull, reason: '缺 size'); + expect( + parseLanTicket(mk( + {'e': '1.2.3.4:1', 't': 'AbCdEf0123', 'n': 'x', 's': 'big'})), + isNull, + reason: 'size 类型不对'); + expect( + parseLanTicket( + mk({'e': '1.2.3.4:1', 't': 'AbCdEf0123', 'n': 'x', 's': -1})), + isNull, + reason: '负数大小'); + }); + + test('前后空白与换行被容忍(复制粘贴常态)', () { + final code = encodeLanTicket( + endpoint: '10.0.0.7:1234', token: 'deadbeefdeadbeef', name: 'a', size: 5); + final padded = ' $code \n'; + expect(parseLanTicket(padded), isNotNull); + }); + }); + group('③ 文件传输:不经服务器、且必须验完整', () { late Directory tmp; setUp(() async => tmp = await Directory.systemTemp.createTemp('lan_ft_'));