feat(lan): 同步领取码(主干 f16bdc3)
与主干同一份实现。同步前已 diff 确认两文件在编辑前一致,故可安全取; 并顺手把 pubspec 里 crypto 的注释与主干统一 —— 两次插入的文本有引号差异, 虽无害,但同一份清单两处定义正是本项目反复踩的坑,注释也不该例外。 (pubspec 现在只剩 version 一行差异,那是两支刻意不同的版本号。) 验证:客户端 368 → 374/374,analyze 干净。
This commit is contained in:
1 parent
6ade1320db
commit
5f382fbca8
3 files changed
+200
-3
No files matched your search
@@ -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
@@ -43,7 +43,7 @@ dependencies:
|
||||
path: ^1.9.1
|
||||
fl_chart: ^1.2.0
|
||||
# 文件传输的**端到端校验**用。大文件传坏了必须能被发现 ——
|
||||
# 没有校验的传完了只是字节写进去了,不等于内容对。
|
||||
# 没有校验的"传完了"只是"字节写进去了",不等于内容对。
|
||||
# Dart 官方包、纯 Dart、极小,不引原生依赖。
|
||||
crypto: ^3.0.6
|
||||
dev_dependencies:
|
||||
|
||||
@@ -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_'));
|
||||
|
||||
Reference in new issue
Block a user