feat(lan): 同步领取码(主干 f16bdc3)

与主干同一份实现。同步前已 diff 确认两文件在编辑前一致,故可安全取;
并顺手把 pubspec 里 crypto 的注释与主干统一 —— 两次插入的文本有引号差异,
虽无害,但同一份清单两处定义正是本项目反复踩的坑,注释也不该例外。
(pubspec 现在只剩 version 一行差异,那是两支刻意不同的版本号。)

验证:客户端 368 → 374/374,analyze 干净。
This commit is contained in:
PMon Dev committed 2026-09-18 23:00:29 +08:00
1 parent 6ade1320db
commit 5f382fbca8
3 files changed
+200 -3

No files matched your search

+112 -2
View File
@@ -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<int>.generate(32, (_) => r.nextInt(256));
final b = List<int>.generate(16, (_) => r.nextInt(256));
return base64Url.encode(b).replaceAll('=', '');
}
@@ -457,3 +462,108 @@ Future<LanDownloadResult> 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 = <int>[];
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;
}
+1 -1
View File
@@ -43,7 +43,7 @@ dependencies:
path: ^1.9.1
fl_chart: ^1.2.0
# 文件传输的**端到端校验**用。大文件传坏了必须能被发现 ——
# 没有校验的传完了只是字节写进去了,不等于内容对。
# 没有校验的"传完了"只是"字节写进去了",不等于内容对。
# Dart 官方包、纯 Dart、极小,不引原生依赖。
crypto: ^3.0.6
dev_dependencies:
+87
View File
@@ -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<String, dynamic> 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_'));