@mrrisega/dsh-remote 0.6.0-beta.7 → 0.6.0-beta.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/clients/dsh-remote/dsh-bridge.mjs +291 -4
- package/clients/dsh-remote/e2ee-client.mjs +711 -0
- package/clients/dsh-remote/e2ee-shim-script.js +697 -0
- package/clients/dsh-remote/e2ee-shim.mjs +158 -0
- package/clients/dsh-remote/test/e2ee-bridge.test.mjs +630 -0
- package/clients/dsh-remote/test/e2ee-client.test.mjs +365 -0
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +507 -0
- package/clients/dsh-remote/test/e2ee-wecrypto-parity.test.mjs +200 -0
- package/clients/dsh-remote/test/lib-e2ee-shim.mjs +59 -0
- package/clients/dsh-remote/test/lib-native-e2ee.mjs +92 -0
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +214 -37
- package/packages/dsh-remote-web/lib/index.js +92 -2
- package/packages/dsh-remote-web/test/access-key-sessions.test.mjs +79 -9
- package/packages/dsh-remote-web/test/e2ee-state.test.mjs +141 -0
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +174 -2
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +72 -3
package/README.md
CHANGED
|
@@ -85,6 +85,36 @@ npx @mrrisega/dsh-remote setup --server wss://<你的域名>:端口 --key <访
|
|
|
85
85
|
源码安装(开发 / 自建服务器):`git clone https://github.com/mrRisega/dsh-remote.git`
|
|
86
86
|
并 `npm install`,见下文各组件说明。
|
|
87
87
|
|
|
88
|
+
## 安全与隐私
|
|
89
|
+
|
|
90
|
+
**传输与会话**
|
|
91
|
+
|
|
92
|
+
- 全程 HTTPS/WSS(TLS)加密传输;远程访问需先在电脑端登录你的手机号账号,登录会话不
|
|
93
|
+
在中继之外暴露,bridge 按需代持浏览器会话仅供你本人手机使用。
|
|
94
|
+
- 电脑端生成的访问链接为**一次性扫码登录**:30 分钟有效、访问一次即失效、可随时在
|
|
95
|
+
「已授权设备」里取消配对;链接不会二次使用,不用时也可点「刷新」立即作废旧链接。
|
|
96
|
+
|
|
97
|
+
**端到端加密(E2EE,灰度开启中)**
|
|
98
|
+
|
|
99
|
+
- 开启后,手机 ↔ 电脑之间远程操作的**消息内容**(对话、工具执行、审批、凭据与文件等
|
|
100
|
+
正文及 WebSocket 消息)在离开手机前用**你的账号密码派生密钥**加密、进入电脑后才解密;
|
|
101
|
+
中继(router/nginx/enterprise)只可见路由所需信息——目标路径、数据大小与时间——**无法读取内容**。
|
|
102
|
+
- **服务端与中继不保存你的密码明文,也不保存解密密钥**:只保存密码经不可逆 KDF 派生的
|
|
103
|
+
校验值用于登录,与加密密钥域分离(详见 [docs/e2ee-protocol.md](docs/e2ee-protocol.md))。
|
|
104
|
+
- 请**牢记账号密码**:修改/重置密码会使全部旧设备会话失效,且服务端不保存你的内容、
|
|
105
|
+
无法代为解密旧会话;改密后需在电脑端面板重新登录并重启 bridge,手机端重新解锁。
|
|
106
|
+
- 电脑端本机配置(`.dsh-config.json`,0600)会保存账号密码用于自动登录,等于该账号
|
|
107
|
+
内容的“解密权”,请妥善保护电脑;电脑被他人使用期间请退出登录。
|
|
108
|
+
|
|
109
|
+
**边界与建议(如实告知)**
|
|
110
|
+
|
|
111
|
+
- E2EE 保护的是**内容**:HTTPS 下的静态页面壳与路由元数据(页面骨架、路径、大小、时间、
|
|
112
|
+
是否加密)仍对中继可见;既有登录、配额与审计不受影响。
|
|
113
|
+
- E2EE 为**灰度功能**:以服务端开关逐步放量(默认关闭 = 走 HTTPS 明文回退),面板会显示
|
|
114
|
+
当前加密状态与原因(已启用 / 等待服务端开启 / 普通安全连接等),不会静默降级。
|
|
115
|
+
- 建议上线前用**真机回归**一次完整链路:手机登录解锁 → 🔒 加密访问(对话/工具/审批/凭据)→
|
|
116
|
+
明文回退提示 → 修改密码后旧会话全部失效、重新登录恢复。
|
|
117
|
+
|
|
88
118
|
## 自建部署(开源版)
|
|
89
119
|
|
|
90
120
|
1. 在有公网 HTTPS 入口的服务器上部署 `relay-router`(见 [docs/self-hosting.md](docs/self-hosting.md)):
|
|
@@ -42,6 +42,23 @@
|
|
|
42
42
|
* DSH_BRIDGE_LOCAL_KEY 开源自部署:访问密钥(设后经 router POST /_login 换本地 JWT,免账号体系)
|
|
43
43
|
* DSH_BRIDGE_HEARTBEAT_MS 隧道心跳间隔(默认 15000ms)
|
|
44
44
|
* DSH_MOBILE_ADAPTER 经隧道访问的官方 dsh web 移动端适配注入开关:0 关闭(默认开启,仅 ≤820px 生效)
|
|
45
|
+
*
|
|
46
|
+
* E2EE(Phase-2,见 docs/e2ee-protocol.md):
|
|
47
|
+
* - 账号模式(非 DSH_BRIDGE_LOCAL_KEY)启动时用 device-login 同一账号密码派生 MK(仅内存,
|
|
48
|
+
* 不落盘);服务端 /api/e2ee-params 返回 enabled=false / 401 / 无密码 / 派生失败 → 明文 v1 回退。
|
|
49
|
+
* - 开关与状态写入 <relayDir>/.e2ee-state.json {enabled, reason, profile, epoch, caps};
|
|
50
|
+
* DSH_BRIDGE_E2EE=0 或配置 e2ee:false 可本地关闭;DSH_BRIDGE_E2EE_STATE 可覆盖状态文件路径。
|
|
51
|
+
* - 帧体带信封标记(content-type: application/vnd.dsh.e2ee-v2 或 x-dsh-e2ee)= 已加密:
|
|
52
|
+
* HTTP 解封→转发上游→响应封回;WS 逐消息封/解。任何解封失败显式
|
|
53
|
+
* 502 + x-dsh-e2ee-error / ws-close 1008,绝不静默降级。
|
|
54
|
+
* - 控制通道 /_e2ee/ctrl(device/channel 形态)不连上游,跑 §5.2 握手(hello/ack/探针);
|
|
55
|
+
* 数据流 ws-open 携带 &e2ee=<sessId>&w=<8B hex>,探针通过前不转发任何数据。
|
|
56
|
+
*
|
|
57
|
+
* E2EE Phase-4(镜像页 shim,见 e2ee-shim.mjs / e2ee-shim-script.js):
|
|
58
|
+
* - doHttp 在 text/html 注入 mobile-adapter 之后、压缩/封包之前,对「e2ee.enabled ∧
|
|
59
|
+
* DSH_E2EE_SHIM≠0」的官方 dsh web 响应注入镜像页加密 shim(读 native.html 一次性
|
|
60
|
+
* 交接单重建会话 → 拦截其 /api·/sidebar·/git·/pet 与数据 WS,信封密文化)。
|
|
61
|
+
* - 灰度默认关(e2ee.enabled=false)→ 不注入、镜像页保持既有明文路径(零行为)。
|
|
45
62
|
*/
|
|
46
63
|
|
|
47
64
|
import WebSocket from "ws";
|
|
@@ -55,6 +72,20 @@ import { promisify } from "node:util";
|
|
|
55
72
|
import { gzip as gzipCb } from "node:zlib";
|
|
56
73
|
// 移动端适配层(经隧道访问的官方 dsh web 窄屏注入;DSH_MOBILE_ADAPTER=0 可关闭,默认开启)
|
|
57
74
|
import { maybeInjectMobileAdapter } from "./mobile-adapter.mjs";
|
|
75
|
+
// 镜像页 E2EE 加密 shim(Phase-4):text/html 注入;DSH_E2EE_SHIM=0 可关闭,叠加 e2ee.enabled 灰度门
|
|
76
|
+
import { maybeInjectE2eeShim } from "./e2ee-shim.mjs";
|
|
77
|
+
// E2EE(端到端加密)客户端基建(Phase-2):MK 派生/会话密钥/信封/握手/开关
|
|
78
|
+
// 仅在 runTunnel(账号模式)里初始化;被测试 import(未走 main)时保持禁用 → v1 路径不变。
|
|
79
|
+
import {
|
|
80
|
+
E2eeError,
|
|
81
|
+
E2eeService,
|
|
82
|
+
ENVELOPE_CONTENT_TYPE,
|
|
83
|
+
decodeHttpRequestPlain,
|
|
84
|
+
encodeHttpResponsePlain,
|
|
85
|
+
hasEnvelopeMarker,
|
|
86
|
+
parseWsE2eeParams,
|
|
87
|
+
writeE2eeStateFile
|
|
88
|
+
} from "./e2ee-client.mjs";
|
|
58
89
|
|
|
59
90
|
// ---------- 强制直连:清除代理环境变量 ----------
|
|
60
91
|
// 家庭网络常配 Clash 等代理(127.0.0.1:7890),node 的 ws/fetch 会继承
|
|
@@ -194,6 +225,16 @@ const CHUNK_SIZE = 200 * 1024;
|
|
|
194
225
|
// 单个 HTTP 请求的兜底超时(秒)。dsh 的 prompt 等操作可能跑很久,给足余量。
|
|
195
226
|
const HTTP_TIMEOUT_MS = 120_000;
|
|
196
227
|
|
|
228
|
+
// ---------- E2EE 运行时(默认禁用;main() 启动时按配置/服务端开关初始化) ----------
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* E2EE 服务实例。enabled=false 时所有帧处理走原 v1 明文路径;
|
|
232
|
+
* 只有「账号模式 ∧ 配置未关闭 ∧ 服务端 e2ee.enabled ∧ 有密码且 MK 派生成功」才为 enabled。
|
|
233
|
+
*/
|
|
234
|
+
let e2ee = new E2eeService({ enabled: false, reason: "not_initialized" });
|
|
235
|
+
/** 控制通道(/_e2ee/ctrl)连接:frameId → { kind:"ctrl", sessId|null }(不连上游)。 */
|
|
236
|
+
const ctrlConns = new Map();
|
|
237
|
+
|
|
197
238
|
// 转发请求时剥离的浏览器/代理头(围栏只认 Host + Origin + Sec-Fetch-*):
|
|
198
239
|
// - Host:fetch 自动取上游 authority(127.0.0.1:3080)→ loopback 围栏通过;
|
|
199
240
|
// ws 库需要显式设置(见 buildWsHeaders)。
|
|
@@ -419,6 +460,14 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
|
419
460
|
console.log(`[bridge] mobile-adapter 注入 ${path}: ${(buf.length / 1024).toFixed(0)}KB → ${(m.buf.length / 1024).toFixed(0)}KB`);
|
|
420
461
|
buf = m.buf;
|
|
421
462
|
}
|
|
463
|
+
// E2EE 镜像页加密 shim(Phase-4):仅桥端 e2ee 启用时注入(e2ee.enabled=false → 零注入零行为)
|
|
464
|
+
if (e2ee.enabled) {
|
|
465
|
+
const s = maybeInjectE2eeShim({ buf, contentType });
|
|
466
|
+
if (s.injected) {
|
|
467
|
+
console.log(`[bridge] e2ee-shim 注入 ${path}: ${(buf.length / 1024).toFixed(0)}KB → ${(s.buf.length / 1024).toFixed(0)}KB`);
|
|
468
|
+
buf = s.buf;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
422
471
|
}
|
|
423
472
|
const compressed = await maybeCompressResponse({
|
|
424
473
|
buf,
|
|
@@ -445,6 +494,48 @@ export async function handleHttpFrame(dchOrSend, frame) {
|
|
|
445
494
|
const send = toSender(dchOrSend);
|
|
446
495
|
const { id, method = "GET", path = "/", headers = {}, body, bodyBase64: isB64 } = frame;
|
|
447
496
|
const t0 = Date.now();
|
|
497
|
+
// E2EE 信封标记即信号(§4.3):有标记=加密,无标记=明文 v1。
|
|
498
|
+
// 解封失败绝不静默降级 → 明文错误 + x-dsh-e2ee-error 头 + 日志。
|
|
499
|
+
if (hasEnvelopeMarker(headers)) {
|
|
500
|
+
if (!e2ee.enabled) {
|
|
501
|
+
console.error(`[bridge] e2ee http ${method} ${path}: bridge 未启用 E2EE,拒绝解封(不静默降级)`);
|
|
502
|
+
return sendE2eeHttpError(send, id, new E2eeError("e2ee_disabled", "bridge 端未启用端到端加密(请检查账号密码/服务端开关)"));
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
const bodyText = Buffer.from(String(body || ""), "base64").toString("utf8");
|
|
506
|
+
let env;
|
|
507
|
+
try {
|
|
508
|
+
env = JSON.parse(bodyText);
|
|
509
|
+
} catch {
|
|
510
|
+
throw new E2eeError("bad_envelope", "帧体不是合法 E2EE 信封 JSON");
|
|
511
|
+
}
|
|
512
|
+
// 防重放 + 会话校验(unknown_session / not_verified / replay 在此抛出)
|
|
513
|
+
const session = e2ee.guardHttpRequest(String(env?.s || ""), env);
|
|
514
|
+
const opened = session.open({ kind: "http", dir: "p2b", env, counter: env?.c ?? 0 });
|
|
515
|
+
const req = decodeHttpRequestPlain(opened.data);
|
|
516
|
+
const reply = await doHttp(req.method, req.path, req.headers, req.bodyB64, true);
|
|
517
|
+
const bodyBuffer = Buffer.from(reply.body, "base64");
|
|
518
|
+
const plain = encodeHttpResponsePlain({ status: reply.status, headers: reply.headers, bodyBuffer });
|
|
519
|
+
// 响应压缩已发生在 doHttp(明文侧,gzip 在加密前 §4.7);信封用 http-resp kind
|
|
520
|
+
const respEnv = session.seal({ kind: "http-resp", dir: "b2p", counter: env?.c ?? 0, data: plain, reqNonceB64: String(env?.n || "") });
|
|
521
|
+
send({
|
|
522
|
+
id,
|
|
523
|
+
type: "http",
|
|
524
|
+
status: 200, // 外层一律 200,真实状态在信封明文 st 里(§4.3)
|
|
525
|
+
headers: {
|
|
526
|
+
"content-type": ENVELOPE_CONTENT_TYPE,
|
|
527
|
+
"x-dsh-e2ee": `v=2;s=${env.s};k=http-resp`
|
|
528
|
+
},
|
|
529
|
+
body: Buffer.from(JSON.stringify(respEnv)).toString("base64"),
|
|
530
|
+
bodyBase64: true
|
|
531
|
+
});
|
|
532
|
+
console.log(`[bridge] e2ee http ${req.method} ${req.path} → ${reply.status} (${Date.now() - t0}ms, 信封 ${(reply.body.length * 3 / 4 / 1024).toFixed(0)}KB)`);
|
|
533
|
+
return;
|
|
534
|
+
} catch (e) {
|
|
535
|
+
console.error(`[bridge] e2ee http ${method} ${path} 解封失败: ${e.message || e}(不回退明文)`);
|
|
536
|
+
return sendE2eeHttpError(send, id, e);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
448
539
|
try {
|
|
449
540
|
const reply = await doHttp(method, path, headers, body, !!isB64);
|
|
450
541
|
reply.id = id;
|
|
@@ -457,6 +548,24 @@ export async function handleHttpFrame(dchOrSend, frame) {
|
|
|
457
548
|
}
|
|
458
549
|
}
|
|
459
550
|
|
|
551
|
+
/** E2EE 失败回包:明文(无信封标记)+ x-dsh-e2ee-error,绝不把无法解密的密文当正文转发。 */
|
|
552
|
+
function sendE2eeHttpError(send, id, err) {
|
|
553
|
+
const code = err instanceof E2eeError ? err.code : "e2ee_failed";
|
|
554
|
+
const message = `⚠ 无法解密/未加密(${code}): ${err?.message || code}`;
|
|
555
|
+
send({
|
|
556
|
+
id,
|
|
557
|
+
type: "http",
|
|
558
|
+
status: 502,
|
|
559
|
+
headers: {
|
|
560
|
+
"content-type": "application/json; charset=utf-8",
|
|
561
|
+
"cache-control": "no-store",
|
|
562
|
+
"x-dsh-e2ee-error": code
|
|
563
|
+
},
|
|
564
|
+
body: Buffer.from(JSON.stringify({ error: { code, message } })).toString("base64"),
|
|
565
|
+
bodyBase64: true
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
460
569
|
/** 旧协议(无 type):body 为原始文本,回包 body 为原始文本。 */
|
|
461
570
|
export async function handleLegacyFrame(dchOrSend, frame) {
|
|
462
571
|
const send = toSender(dchOrSend);
|
|
@@ -477,24 +586,138 @@ export async function handleLegacyFrame(dchOrSend, frame) {
|
|
|
477
586
|
// ---- WebSocket 透传 ----
|
|
478
587
|
|
|
479
588
|
// ws 会话表:frame id → ws 客户端。DataChannel 断开时统一关闭。
|
|
589
|
+
// - 普通/数据流:{ ws, opened, e2ee? };
|
|
590
|
+
// - e2ee 数据流额外带 e2ee:{ s(E2eeSession), wsLabel, p2bLast, b2pLast };
|
|
591
|
+
// - 控制通道(/_e2ee/ctrl)不连上游,单列 ctrlConns。
|
|
480
592
|
const wsSessions = new Map();
|
|
481
593
|
|
|
594
|
+
/** E2EE 错误码 → 手机可读文案(明文 e2ee-error;绝不静默)。 */
|
|
595
|
+
function e2eeCodeOf(err) {
|
|
596
|
+
return err instanceof E2eeError ? err.code : "e2ee_failed";
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function sendCtrlMsg(send, id, obj) {
|
|
600
|
+
send({ id, type: "ws-msg", data: JSON.stringify(obj), binary: false });
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function sendCtrlError(send, id, code, extra = "") {
|
|
604
|
+
console.error(`[bridge] e2ee ctrl 错误: ${code}${extra ? " — " + extra : ""}`);
|
|
605
|
+
sendCtrlMsg(send, id, { v: 2, type: "e2ee-error", code, ...(extra ? { message: extra } : {}) });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** 控制通道 ws-open:不连上游,应答 ok 后按 §5.2 跑握手(hello/ack → 探针)。 */
|
|
609
|
+
function openControlWs(send, id) {
|
|
610
|
+
if (!e2ee.enabled) {
|
|
611
|
+
console.log(`[bridge] e2ee ctrl 被拒(桥端未启用): id=${id}`);
|
|
612
|
+
send({ id, type: "ws-open", ok: false, code: 1008, reason: "e2ee_disabled:bridge 未启用端到端加密" });
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
wsSessions.delete(id);
|
|
616
|
+
ctrlConns.set(id, { sessId: null });
|
|
617
|
+
send({ id, type: "ws-open", ok: true });
|
|
618
|
+
console.log(`[bridge] e2ee ctrl 通道已开 (id=${id}),等待 hello`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** 控制通道消息:明文 hello / AEAD 探针信封(§5.2)。 */
|
|
622
|
+
function handleCtrlMessage(send, id, rawText) {
|
|
623
|
+
const conn = ctrlConns.get(id);
|
|
624
|
+
if (!conn) return;
|
|
625
|
+
let obj;
|
|
626
|
+
try {
|
|
627
|
+
obj = JSON.parse(rawText);
|
|
628
|
+
} catch {
|
|
629
|
+
sendCtrlError(send, id, "bad_msg", "非 JSON 控制消息");
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (!obj || typeof obj !== "object") return;
|
|
633
|
+
if (obj.type === "e2ee-hello") {
|
|
634
|
+
try {
|
|
635
|
+
const { session, ack } = e2ee.handleHello(obj);
|
|
636
|
+
conn.sessId = session.sessId;
|
|
637
|
+
sendCtrlMsg(send, id, ack);
|
|
638
|
+
} catch (e) {
|
|
639
|
+
sendCtrlError(send, id, e2eeCodeOf(e), e?.message || "");
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (obj.v === 2 && obj.k === "ctrl") {
|
|
644
|
+
// AEAD 探针(密文信封);失败 → bad_key 提示手机“密码/密钥不一致”,绝不静默
|
|
645
|
+
if (!conn.sessId) {
|
|
646
|
+
sendCtrlError(send, id, "unknown_session", "探针先于 hello");
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
const { reply } = e2ee.handleProbe(conn.sessId, obj);
|
|
651
|
+
sendCtrlMsg(send, id, reply);
|
|
652
|
+
} catch (e) {
|
|
653
|
+
const code = e2eeCodeOf(e);
|
|
654
|
+
// 探针解密失败对手机语义统一为 bad_key(§5.2;密码不一致/改密未更新)
|
|
655
|
+
sendCtrlError(send, id, code === "auth_failed" || code === "bad_key" ? "bad_key" : code, e?.message || "");
|
|
656
|
+
}
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
// 其余控制消息忽略
|
|
660
|
+
}
|
|
661
|
+
|
|
482
662
|
export async function handleWsOpen(dchOrSend, frame) {
|
|
483
663
|
const send = toSender(dchOrSend);
|
|
484
664
|
const { id, path = "/", headers = {} } = frame;
|
|
665
|
+
// 控制通道(device 形态 /remote/<dev>/_e2ee/ctrl 与 channel 形态都归一到 /_e2ee/ctrl)
|
|
666
|
+
if (path.startsWith("/_e2ee/")) {
|
|
667
|
+
return openControlWs(send, id);
|
|
668
|
+
}
|
|
669
|
+
// e2ee 标记数据流:&e2ee=<sessId>&w=<8B hex>(§4.6);未启用/会话未验证 → 显式拒绝
|
|
670
|
+
const tagged = parseWsE2eeParams(path);
|
|
671
|
+
if (tagged) {
|
|
672
|
+
if (!e2ee.enabled) {
|
|
673
|
+
send({ id, type: "ws-open", ok: false, code: 1008, reason: "e2ee_disabled:bridge 未启用端到端加密(请用明文连接)" });
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const session = e2ee.sessionOf(tagged.sessId);
|
|
677
|
+
if (!session || !session.verified) {
|
|
678
|
+
console.error(`[bridge] e2ee ws 拒绝(会话未解锁/未验证): ${path}`);
|
|
679
|
+
send({ id, type: "ws-open", ok: false, code: 1008, reason: "e2ee_session_locked:请先解锁完成握手(检查密码)" });
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
// 探测前不转发任何数据(§4.6/§5.2:探针通过后数据流才可建);此时已验证 → 安全
|
|
683
|
+
}
|
|
485
684
|
if (wsSessions.has(id)) { try { wsSessions.get(id).ws.terminate(); } catch {} wsSessions.delete(id); }
|
|
486
|
-
const safe = safePath(path);
|
|
685
|
+
const safe = safePath(tagged ? tagged.upstreamPath : path);
|
|
487
686
|
if (safe === null) { send({ id, type: "ws-open", ok: false, code: 400, reason: "非法路径" }); return; }
|
|
488
687
|
const url = `${UPSTREAM.replace(/^http/, "ws")}${safe}`;
|
|
489
688
|
const ws = new WebSocket(url, { headers: buildWsHeaders(headers), followRedirects: false });
|
|
490
689
|
const session = { ws, opened: false };
|
|
690
|
+
if (tagged && e2ee.enabled) {
|
|
691
|
+
// 会话必须已 verified(上面已校验),给该流独立的收发密钥与方向计数
|
|
692
|
+
session.e2ee = { s: e2ee.sessionOf(tagged.sessId), wsLabel: tagged.wsLabel, p2bLast: -1, b2pLast: -1 };
|
|
693
|
+
}
|
|
491
694
|
wsSessions.set(id, session);
|
|
492
695
|
ws.on("open", () => {
|
|
493
696
|
session.opened = true;
|
|
494
|
-
console.log(`[bridge] ws-open ${
|
|
697
|
+
console.log(`[bridge] ws-open ${session.e2ee ? "(e2ee) " : ""}${safe} (id=${id})`);
|
|
495
698
|
send({ id, type: "ws-open", ok: true });
|
|
496
699
|
});
|
|
497
700
|
ws.on("message", (data, isBinary) => {
|
|
701
|
+
// 上游 → 手机:加密流逐条封信封(b2p 独立计数);明文流原样透传
|
|
702
|
+
if (session.e2ee) {
|
|
703
|
+
try {
|
|
704
|
+
const e2 = session.e2ee;
|
|
705
|
+
e2.b2pLast += 1;
|
|
706
|
+
const plain = isBinary ? Buffer.from(data) : Buffer.from(String(data), "utf8");
|
|
707
|
+
const env = e2.s.seal({
|
|
708
|
+
kind: "w",
|
|
709
|
+
dir: "b2p",
|
|
710
|
+
counter: e2.b2pLast,
|
|
711
|
+
data: plain,
|
|
712
|
+
t: isBinary ? 1 : 0,
|
|
713
|
+
wsLabel: e2.wsLabel
|
|
714
|
+
});
|
|
715
|
+
send({ id, type: "ws-msg", data: JSON.stringify(env), binary: false });
|
|
716
|
+
} catch (e) {
|
|
717
|
+
console.error(`[bridge] e2ee ws 上游→手机 封包失败(id=${id}): ${e.message}`);
|
|
718
|
+
}
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
498
721
|
const payload = isBinary ? Buffer.from(data).toString("base64") : data.toString();
|
|
499
722
|
send({ id, type: "ws-msg", data: payload, binary: isBinary });
|
|
500
723
|
});
|
|
@@ -515,8 +738,37 @@ export async function handleWsOpen(dchOrSend, frame) {
|
|
|
515
738
|
|
|
516
739
|
export function handleWsMessage(_dchOrSend, frame) {
|
|
517
740
|
const { id, data, binary } = frame;
|
|
741
|
+
const conn = ctrlConns.get(id);
|
|
742
|
+
if (conn) {
|
|
743
|
+
// 控制通道消息不走上游
|
|
744
|
+
if (binary) {
|
|
745
|
+
sendCtrlError(toSender(_dchOrSend), id, "bad_msg", "控制通道只接受文本消息");
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
handleCtrlMessage(toSender(_dchOrSend), id, String(data));
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
518
751
|
const session = wsSessions.get(id);
|
|
519
752
|
if (!session || !session.opened || session.ws.readyState !== WebSocket.OPEN) return;
|
|
753
|
+
if (session.e2ee) {
|
|
754
|
+
// 加密流:逐条解封 → 原文转发上游;AEAD 失败 → 1008 关闭并回告(不静默降级)
|
|
755
|
+
try {
|
|
756
|
+
const env = JSON.parse(binary ? Buffer.from(data, "base64").toString("utf8") : String(data));
|
|
757
|
+
const e2 = session.e2ee;
|
|
758
|
+
if (!env || env.v !== 2 || env.k !== "w") throw new E2eeError("bad_envelope", "e2ee ws 消息不是 w 信封");
|
|
759
|
+
if (!Number.isInteger(env.c) || env.c <= e2.p2bLast) throw new E2eeError("replay", `e2ee ws 计数重放/乱序 ${env.c}`);
|
|
760
|
+
const opened = e2.s.open({ kind: "w", dir: "p2b", env, counter: env.c, wsLabel: e2.wsLabel });
|
|
761
|
+
e2.p2bLast = env.c;
|
|
762
|
+
if (opened.t === 1) session.ws.send(opened.data);
|
|
763
|
+
else session.ws.send(opened.data.toString("utf8"));
|
|
764
|
+
} catch (e) {
|
|
765
|
+
console.error(`[bridge] e2ee ws 解封失败(id=${id}): ${e.message || e}(不回退明文,1008 关闭)`);
|
|
766
|
+
try { session.ws.close(1008, "⚠ e2ee 消息无法解密"); } catch {}
|
|
767
|
+
wsSessions.delete(id);
|
|
768
|
+
try { toSender(_dchOrSend)({ id, type: "ws-close", code: 1008, reason: "⚠ e2ee 消息无法解密" }); } catch {}
|
|
769
|
+
}
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
520
772
|
try {
|
|
521
773
|
if (binary) session.ws.send(Buffer.from(data, "base64"));
|
|
522
774
|
else session.ws.send(String(data));
|
|
@@ -525,17 +777,23 @@ export function handleWsMessage(_dchOrSend, frame) {
|
|
|
525
777
|
|
|
526
778
|
export function handleWsClose(_dchOrSend, frame) {
|
|
527
779
|
const { id, code, reason } = frame;
|
|
780
|
+
const conn = ctrlConns.get(id);
|
|
781
|
+
if (conn) {
|
|
782
|
+
ctrlConns.delete(id);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
528
785
|
const session = wsSessions.get(id);
|
|
529
786
|
if (!session) return;
|
|
530
787
|
try { session.ws.close(code && typeof code === "number" ? code : 1000, reason || ""); } catch {}
|
|
531
788
|
}
|
|
532
789
|
|
|
533
|
-
/** DataChannel 断开:关闭所有 ws
|
|
790
|
+
/** DataChannel 断开:关闭所有 ws 会话与控制通道。 */
|
|
534
791
|
export function closeAllWsSessions() {
|
|
535
792
|
for (const session of wsSessions.values()) {
|
|
536
793
|
try { session.ws.terminate(); } catch {}
|
|
537
794
|
}
|
|
538
795
|
wsSessions.clear();
|
|
796
|
+
ctrlConns.clear();
|
|
539
797
|
}
|
|
540
798
|
|
|
541
799
|
// ============================================================
|
|
@@ -674,7 +932,8 @@ function connectTunnel(token) {
|
|
|
674
932
|
tunnelRetry = 0;
|
|
675
933
|
console.log(`[bridge] 隧道已连 ${endpoint},注册 ${DEVICE_ID}...`);
|
|
676
934
|
try {
|
|
677
|
-
|
|
935
|
+
// caps:bridge E2EE 能力上报(§6.2/§7.1;router 纯透传)。enabled=false → 空数组(明文回退)
|
|
936
|
+
ws.send(JSON.stringify({ type: "tunnel-register", deviceId: DEVICE_ID, token, name: os.hostname() || "dsh-bridge", caps: e2ee.caps() }));
|
|
678
937
|
} catch (e) { console.log(`[bridge] 注册发送失败: ${e.message}`); }
|
|
679
938
|
heartbeat = setInterval(() => {
|
|
680
939
|
if (!pongReceived) {
|
|
@@ -715,12 +974,40 @@ function connectTunnel(token) {
|
|
|
715
974
|
ws.on("error", (e) => console.log(`[bridge] 隧道错误: ${e.message || ""}`));
|
|
716
975
|
}
|
|
717
976
|
|
|
977
|
+
/**
|
|
978
|
+
* E2EE 启动初始化(账号模式):拉取服务端参数 → 用本机账号密码派生 MK(仅内存)。
|
|
979
|
+
* 任何一步不满足都禁用 E2EE(reason 记录),bridge 走原 v1 明文路径,不影响隧道。
|
|
980
|
+
* 启用结果写入 relayDir/.e2ee-state.json(供 Phase-4 UI 读取:{enabled, reason, ...})。
|
|
981
|
+
* 状态文件路径策略:显式 DSH_BRIDGE_CONFIG / DSH_BRIDGE_E2EE_STATE 时落盘(生产必设),
|
|
982
|
+
* 否则只打日志(避免污染仓库/非托管目录)。
|
|
983
|
+
*/
|
|
984
|
+
async function initE2ee(token) {
|
|
985
|
+
const cfg = loadLocalConfig();
|
|
986
|
+
const localMode = Boolean(process.env.DSH_BRIDGE_LOCAL_KEY); // 自建模式不启用(§6.6)
|
|
987
|
+
const userDisabled = process.env.DSH_BRIDGE_E2EE === "0" || cfg.e2ee === false;
|
|
988
|
+
const allowed = !localMode && !userDisabled;
|
|
989
|
+
// 密码与 device-login 同源:env 优先,其次 .dsh-config.json(本机 0600 保存的账号密码)
|
|
990
|
+
const password = PASSWORD || (typeof cfg.password === "string" && cfg.password ? cfg.password : "");
|
|
991
|
+
e2ee = await E2eeService.init({ apiBase: API_BASE, token, password, allowed });
|
|
992
|
+
const st = e2ee.state();
|
|
993
|
+
const stateFileDir = process.env.DSH_BRIDGE_E2EE_STATE || path.dirname(CONFIG_PATH);
|
|
994
|
+
if (process.env.DSH_BRIDGE_CONFIG || process.env.DSH_BRIDGE_E2EE_STATE) {
|
|
995
|
+
writeE2eeStateFile(stateFileDir, { enabled: st.enabled, reason: st.reason, profile: st.profile, epoch: st.epoch, caps: e2ee.caps() });
|
|
996
|
+
}
|
|
997
|
+
if (st.enabled) {
|
|
998
|
+
console.log(`[bridge] 🔒 E2EE 已启用(caps=[${e2ee.caps().join(",")}], profile=${st.profile}, epoch=${st.epoch});MK 仅驻内存`);
|
|
999
|
+
} else {
|
|
1000
|
+
console.log(`[bridge] e2ee 未启用(${st.reason}) → 明文 v1 路径照常(能力不上报)`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
718
1004
|
async function runTunnel() {
|
|
719
1005
|
const token = await resolveToken();
|
|
720
1006
|
if (!token) {
|
|
721
1007
|
console.error("[bridge] 隧道模式需要账号认证:请设 DSH_BRIDGE_TOKEN,或 DSH_BRIDGE_PHONE+DSH_BRIDGE_PASSWORD");
|
|
722
1008
|
process.exit(1);
|
|
723
1009
|
}
|
|
1010
|
+
await initE2ee(token);
|
|
724
1011
|
await registerDeviceInAccount(token);
|
|
725
1012
|
connectTunnel(token);
|
|
726
1013
|
setTimeout(() => {
|