@mrrisega/dsh-remote 0.7.5-beta.1 → 0.7.6-beta.1
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/clients/dsh-remote/dsh-bridge.mjs +49 -540
- package/clients/dsh-remote/e2ee-client.mjs +0 -251
- package/clients/dsh-remote/mobile-adapter.mjs +0 -261
- package/clients/dsh-remote/test/bridge-upstream-refresh.test.mjs +8 -26
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +0 -195
- package/packages/dsh-remote-web/lib/index.js +0 -145
- package/packages/dsh-remote-web/package.json +1 -1
- package/clients/dsh-remote/test/bridge-control-priority.test.mjs +0 -206
- package/clients/dsh-remote/test/bridge-heartbeat-adaptive.test.mjs +0 -110
- package/clients/dsh-remote/test/bridge-ws-open-fast-ack.test.mjs +0 -213
- package/clients/dsh-remote/test/e2ee-session-store.test.mjs +0 -254
- package/clients/dsh-remote/test/mobile-adapter-single-page.test.mjs +0 -328
- package/packages/dsh-remote-web/test/community-invite-proxy.test.mjs +0 -343
- package/packages/dsh-remote-web/test/community-invite-ui.test.mjs +0 -375
- package/packages/dsh-remote-web/test/purchase-agreement-proxy.test.mjs +0 -198
- package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +0 -323
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* 心跳自适应回归(2026-10-23)。
|
|
4
|
-
*
|
|
5
|
-
* 生产事故(手机频繁断线主因链 #3):
|
|
6
|
-
* 固定 20s 心跳 + 固定"丢 2 个 pong 就重连"。DSH 主线程被大任务/大响应占住时,pong 在
|
|
7
|
-
* socket 事件里排不上队 → 连续两拍判死 → 重建隧道 → 手机侧全部在途请求断开、页面重连。
|
|
8
|
-
* 全站 13706 次/天 bridge 断开里有一大批是这么来的。
|
|
9
|
-
*
|
|
10
|
-
* 本文件锁「纯函数」契约(真机/集成行为由 bridge 日志与 test:bridge 全量保证):
|
|
11
|
-
* ① 健康链路完全保留旧契约(丢 2 拍才断);
|
|
12
|
-
* ② 本机事件循环延迟高 / RTT 慢 / 已在中断中 → 判定窗口放宽;
|
|
13
|
-
* ③ 放宽有上限(默认 4 拍 / 环境变量可调),不会退化成永不重连;
|
|
14
|
-
* ④ DSH_BRIDGE_HEARTBEAT_ADAPT=0 可退回旧契约。
|
|
15
|
-
*/
|
|
16
|
-
import assert from "node:assert/strict";
|
|
17
|
-
import test from "node:test";
|
|
18
|
-
|
|
19
|
-
import {
|
|
20
|
-
HEARTBEAT_LAG_BUSY_MS,
|
|
21
|
-
HEARTBEAT_MAX_MISSED_CAP_DEFAULT,
|
|
22
|
-
HEARTBEAT_MAX_MISSED_PONGS,
|
|
23
|
-
HEARTBEAT_RTT_SLOW_MS,
|
|
24
|
-
TUNNEL_HEARTBEAT_DEFAULT_MS,
|
|
25
|
-
__setEventLoopLagForTest,
|
|
26
|
-
adaptiveHeartbeat,
|
|
27
|
-
currentEventLoopLagMs,
|
|
28
|
-
heartbeatStep
|
|
29
|
-
} from "../dsh-bridge.mjs";
|
|
30
|
-
|
|
31
|
-
const BASE = TUNNEL_HEARTBEAT_DEFAULT_MS;
|
|
32
|
-
|
|
33
|
-
test("① 健康链路 = 旧契约:收到 pong 就归零,连续 2 拍没 pong 才断", () => {
|
|
34
|
-
const pong = adaptiveHeartbeat({ pongReceived: true, missedPongs: 5, rttMs: 40, eventLoopLagMs: 1 });
|
|
35
|
-
assert.equal(pong.missedPongs, 0);
|
|
36
|
-
assert.equal(pong.terminate, false);
|
|
37
|
-
assert.equal(pong.intervalMs, BASE, "健康链路 ping 间隔不变");
|
|
38
|
-
assert.equal(pong.maxMissed, HEARTBEAT_MAX_MISSED_PONGS);
|
|
39
|
-
|
|
40
|
-
const one = adaptiveHeartbeat({ pongReceived: false, missedPongs: 0, rttMs: 40, eventLoopLagMs: 1 });
|
|
41
|
-
assert.equal(one.missedPongs, 1);
|
|
42
|
-
assert.equal(one.terminate, false, "丢 1 拍不断(旧行为是丢 1 就断)");
|
|
43
|
-
|
|
44
|
-
const two = adaptiveHeartbeat({ pongReceived: false, missedPongs: 1, rttMs: 40, eventLoopLagMs: 1 });
|
|
45
|
-
assert.equal(two.missedPongs, 2);
|
|
46
|
-
assert.equal(two.terminate, true, "健康链路下连续 2 拍没 pong 仍按旧契约判死");
|
|
47
|
-
|
|
48
|
-
// 与旧纯函数 heartbeatStep 在健康链路下结论一致
|
|
49
|
-
for (const missed of [0, 1, 2, 3]) {
|
|
50
|
-
const a = adaptiveHeartbeat({ pongReceived: false, missedPongs: missed, rttMs: 40, eventLoopLagMs: 0 });
|
|
51
|
-
const b = heartbeatStep({ pongReceived: false, missedPongs: missed });
|
|
52
|
-
assert.equal(a.terminate, b.terminate, `健康链路 missed=${missed} 的判定必须与旧契约一致`);
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("② 本机繁忙(事件循环延迟 ≥500ms)→ 多给一拍,瞬时卡顿不再判死", () => {
|
|
57
|
-
const plan = adaptiveHeartbeat({ pongReceived: false, missedPongs: 0, rttMs: 40, eventLoopLagMs: HEARTBEAT_LAG_BUSY_MS });
|
|
58
|
-
assert.equal(plan.maxMissed, HEARTBEAT_MAX_MISSED_PONGS + 1);
|
|
59
|
-
assert.ok(plan.reasons.includes("event_loop_busy"));
|
|
60
|
-
assert.equal(plan.terminate, false);
|
|
61
|
-
// 第 2 拍仍不断(这正是生产里 DSH 主线程卡 3~5 秒的场景)
|
|
62
|
-
const second = adaptiveHeartbeat({ pongReceived: false, missedPongs: 1, rttMs: 40, eventLoopLagMs: HEARTBEAT_LAG_BUSY_MS });
|
|
63
|
-
assert.equal(second.terminate, false, "繁忙时第 2 拍不得判死");
|
|
64
|
-
// 第 3 拍才断
|
|
65
|
-
const third = adaptiveHeartbeat({ pongReceived: false, missedPongs: 2, rttMs: 40, eventLoopLagMs: HEARTBEAT_LAG_BUSY_MS });
|
|
66
|
-
assert.equal(third.terminate, true);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("③ 慢链路(RTT ≥1s)→ 间隔拉长 + 多给一拍;两者叠加有上限", () => {
|
|
70
|
-
const slow = adaptiveHeartbeat({ pongReceived: false, missedPongs: 0, rttMs: HEARTBEAT_RTT_SLOW_MS, eventLoopLagMs: 0 });
|
|
71
|
-
assert.ok(slow.maxMissed > HEARTBEAT_MAX_MISSED_PONGS);
|
|
72
|
-
assert.ok(slow.intervalMs > BASE, "慢链路 ping 间隔应拉长(减少额外负载)");
|
|
73
|
-
assert.ok(slow.intervalMs <= 60_000);
|
|
74
|
-
assert.ok(slow.reasons.includes("rtt_slow"));
|
|
75
|
-
|
|
76
|
-
// 繁忙 + 慢链路 + 已在中断中 → 上限封顶,不会无限放宽
|
|
77
|
-
const worst = adaptiveHeartbeat({
|
|
78
|
-
pongReceived: false,
|
|
79
|
-
missedPongs: 1,
|
|
80
|
-
rttMs: 5000,
|
|
81
|
-
eventLoopLagMs: 3000,
|
|
82
|
-
maxMissedCap: HEARTBEAT_MAX_MISSED_CAP_DEFAULT
|
|
83
|
-
});
|
|
84
|
-
assert.equal(worst.maxMissed, HEARTBEAT_MAX_MISSED_CAP_DEFAULT);
|
|
85
|
-
assert.equal(worst.terminate, false);
|
|
86
|
-
const capped = adaptiveHeartbeat({ pongReceived: false, missedPongs: 3, rttMs: 5000, eventLoopLagMs: 3000 });
|
|
87
|
-
assert.equal(capped.maxMissed, HEARTBEAT_MAX_MISSED_CAP_DEFAULT);
|
|
88
|
-
assert.equal(capped.terminate, true, "到了上限那一拍必须判死(不能永不重连)");
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("④ DSH_BRIDGE_HEARTBEAT_ADAPT=0 等价语义:完全退回固定窗口", () => {
|
|
92
|
-
const plan = adaptiveHeartbeat({
|
|
93
|
-
pongReceived: false,
|
|
94
|
-
missedPongs: 1,
|
|
95
|
-
rttMs: 5000,
|
|
96
|
-
eventLoopLagMs: 3000,
|
|
97
|
-
adaptive: false
|
|
98
|
-
});
|
|
99
|
-
assert.equal(plan.maxMissed, HEARTBEAT_MAX_MISSED_PONGS);
|
|
100
|
-
assert.equal(plan.intervalMs, BASE);
|
|
101
|
-
assert.equal(plan.terminate, true, "退回旧契约:第 2 拍即判死");
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test("⑤ 事件循环延迟探针(测试钩子)", () => {
|
|
105
|
-
assert.equal(currentEventLoopLagMs(), 0);
|
|
106
|
-
__setEventLoopLagForTest(800);
|
|
107
|
-
assert.equal(currentEventLoopLagMs(), 800);
|
|
108
|
-
__setEventLoopLagForTest(-5);
|
|
109
|
-
assert.equal(currentEventLoopLagMs(), 0);
|
|
110
|
-
});
|
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* ws-open 快速应答 / 上游建立期间消息不丢 / 上游失败用 ws-close 收尾(2026-10-23)。
|
|
4
|
-
*
|
|
5
|
-
* 旧契约:收到 ws-open → 连上游 WebSocket → **等上游 open** 才回 ok。
|
|
6
|
-
* 上游连不上(桌面端换端口 / 宿主重启)时,手机要等 router 的 20s 超时;
|
|
7
|
-
* bridge 发送队列被大响应占住时(见 bridge-control-priority),连"回 ok"都排不上。
|
|
8
|
-
*
|
|
9
|
-
* 新契约(本文件锁住):
|
|
10
|
-
* ① 校验通过立刻回 `{ok:true, fast:true}`(同步、<100ms),再连上游;
|
|
11
|
-
* ② 上游建立期间手机发来的 ws-msg 进 pending 队列,open 后按序补发(不丢 mux 首帧);
|
|
12
|
-
* ③ 上游最终失败 → 回 `ws-close`(1011, retryable) 而不是干等 20s(升级早已 ack);
|
|
13
|
-
* ④ E2EE 语义不变:带 &e2ee= 的流先校验会话;未知/未验证 → 1008 +
|
|
14
|
-
* `retryable:true,action:"rehandshake"`(手机可自动重握手,不用让用户重新解锁)。
|
|
15
|
-
*/
|
|
16
|
-
import assert from "node:assert/strict";
|
|
17
|
-
import http from "node:http";
|
|
18
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
19
|
-
import os from "node:os";
|
|
20
|
-
import path from "node:path";
|
|
21
|
-
import test from "node:test";
|
|
22
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
23
|
-
|
|
24
|
-
import { WebSocketServer } from "ws";
|
|
25
|
-
|
|
26
|
-
// ── 上游假 dsh web:三种升级行为 ───────────────────────────────────────────────
|
|
27
|
-
const upstream = http.createServer((req, res) => { res.writeHead(404); res.end(); });
|
|
28
|
-
const wss = new WebSocketServer({ noServer: true });
|
|
29
|
-
wss.on("connection", (ws) => {
|
|
30
|
-
ws.on("message", (data, isBinary) => {
|
|
31
|
-
try { ws.send(data, { binary: isBinary }); } catch { /* 手机侧已断 */ }
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
let upPort = 0;
|
|
35
|
-
await new Promise((resolve) => {
|
|
36
|
-
upstream.listen(0, "127.0.0.1", () => {
|
|
37
|
-
upPort = upstream.address().port;
|
|
38
|
-
resolve();
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
upstream.on("upgrade", (req, socket, head) => {
|
|
42
|
-
let pathname = "";
|
|
43
|
-
try { pathname = new URL(req.url || "/", "http://x").pathname; } catch { pathname = ""; }
|
|
44
|
-
if (pathname === "/ws-slow") {
|
|
45
|
-
// 上游建立要 400ms:足够断言"ok 已经先回来了"
|
|
46
|
-
setTimeout(() => {
|
|
47
|
-
try { wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req)); } catch { /* 手机已断 */ }
|
|
48
|
-
}, 400);
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
if (pathname === "/ws-reject") {
|
|
52
|
-
try { socket.destroy(); } catch { /* 忽略 */ }
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
try { wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req)); } catch { /* 忽略 */ }
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// ── 环境必须在 import dsh-bridge 之前定好(模块级常量在 import 时求值)────────────
|
|
59
|
-
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "dsh-wsopen-"));
|
|
60
|
-
process.env.DSH_BRIDGE_CONFIG = path.join(tmpDir, ".dsh-config.json");
|
|
61
|
-
process.env.DSH_BRIDGE_UPSTREAM = `http://127.0.0.1:${upPort}`;
|
|
62
|
-
process.env.DSH_BRIDGE_TUNNEL_URL = "ws://127.0.0.1:1"; // 本用例不连隧道
|
|
63
|
-
const bridge = await import("../dsh-bridge.mjs");
|
|
64
|
-
const {
|
|
65
|
-
__setE2eeForTest,
|
|
66
|
-
__wsSessionState,
|
|
67
|
-
handleWsClose,
|
|
68
|
-
handleWsMessage,
|
|
69
|
-
handleWsOpen
|
|
70
|
-
} = bridge;
|
|
71
|
-
const e2eeMod = await import("../e2ee-client.mjs");
|
|
72
|
-
|
|
73
|
-
test.after(() => {
|
|
74
|
-
try { wss.close(); } catch { /* 忽略 */ }
|
|
75
|
-
try { upstream.close(); } catch { /* 忽略 */ }
|
|
76
|
-
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* 忽略 */ }
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
/** 记录 bridge → router 的帧(等价于隧道 send)。 */
|
|
80
|
-
function spy() {
|
|
81
|
-
const frames = [];
|
|
82
|
-
const send = (obj) => {
|
|
83
|
-
frames.push({ ...obj, __at: Date.now() });
|
|
84
|
-
return true;
|
|
85
|
-
};
|
|
86
|
-
return { frames, send };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async function waitFor(label, pred, timeoutMs = 4000) {
|
|
90
|
-
const deadline = Date.now() + timeoutMs;
|
|
91
|
-
for (;;) {
|
|
92
|
-
const v = pred();
|
|
93
|
-
if (v) return v;
|
|
94
|
-
if (Date.now() > deadline) throw new Error(`等待超时:${label}`);
|
|
95
|
-
await delay(10);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
test("① 先把 ok 回给手机(<100ms),再连上游;ok 不等于上游已建立", async () => {
|
|
100
|
-
const { frames, send } = spy();
|
|
101
|
-
const t0 = Date.now();
|
|
102
|
-
await handleWsOpen(send, { id: "fast1", type: "ws-open", path: "/ws-slow", headers: {} });
|
|
103
|
-
const elapsed = Date.now() - t0;
|
|
104
|
-
assert.ok(elapsed < 100, `ok 必须立刻回(实测 ${elapsed}ms)`);
|
|
105
|
-
assert.equal(frames.length, 1, "此刻只有 ws-open 回包,没有上游消息");
|
|
106
|
-
assert.equal(frames[0].ok, true);
|
|
107
|
-
assert.equal(frames[0].fast, true, "回包应带 fast:true(可观测:这是快速应答)");
|
|
108
|
-
// 上游还没建立(400ms 后才 open)
|
|
109
|
-
await delay(100);
|
|
110
|
-
assert.equal(__wsSessionState("fast1").opened, false, "100ms 时上游仍未建立");
|
|
111
|
-
await waitFor("上游 open", () => __wsSessionState("fast1")?.opened === true);
|
|
112
|
-
handleWsClose(send, { id: "fast1", code: 1000 });
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test("② 上游建立期间手机发来的 ws-msg 不丢:排队 → open 后按序补发", async () => {
|
|
116
|
-
const { frames, send } = spy();
|
|
117
|
-
await handleWsOpen(send, { id: "fast2", type: "ws-open", path: "/ws-slow", headers: {} });
|
|
118
|
-
assert.equal(frames[0].ok, true);
|
|
119
|
-
// 手机拿到 ok 后立刻发(真实 mux 首帧就是这样):此时上游还没 open
|
|
120
|
-
handleWsMessage(send, { id: "fast2", data: "first-frame", binary: false });
|
|
121
|
-
handleWsMessage(send, { id: "fast2", data: "second-frame", binary: false });
|
|
122
|
-
assert.equal(__wsSessionState("fast2").pending, 2, "两条消息应排队等上游");
|
|
123
|
-
// 上游 open 后:消息被补发 → echo 回来(顺序必须与发出顺序一致)
|
|
124
|
-
await waitFor("两条 echo", () => frames.filter((f) => f.type === "ws-msg").length >= 2, 5000);
|
|
125
|
-
const echoed = frames.filter((f) => f.type === "ws-msg").map((f) => String(f.data));
|
|
126
|
-
assert.deepEqual(echoed, ["first-frame", "second-frame"], "补发顺序必须与手机发出顺序一致");
|
|
127
|
-
handleWsClose(send, { id: "fast2", code: 1000 });
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test("③ 上游建立失败 → ws-close(1011, retryable),不再让手机干等 20s", async () => {
|
|
131
|
-
const { frames, send } = spy();
|
|
132
|
-
await handleWsOpen(send, { id: "fast3", type: "ws-open", path: "/ws-reject", headers: {} });
|
|
133
|
-
assert.equal(frames[0].ok, true, "先快速 ok");
|
|
134
|
-
const close = await waitFor("失败收尾帧", () => frames.find((f) => f.type === "ws-close"), 4000);
|
|
135
|
-
assert.equal(close.code, 1011);
|
|
136
|
-
assert.equal(close.retryable, true);
|
|
137
|
-
assert.match(String(close.reason || ""), /upstream_ws_open_failed/);
|
|
138
|
-
assert.equal(__wsSessionState("fast3"), null, "失败后会话表应清掉");
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test("④ 非法路径仍同步拒绝(400),不做快速 ok", async () => {
|
|
142
|
-
const { frames, send } = spy();
|
|
143
|
-
await handleWsOpen(send, { id: "bad1", type: "ws-open", path: "//evil.example/x", headers: {} });
|
|
144
|
-
assert.equal(frames.length, 1);
|
|
145
|
-
assert.equal(frames[0].ok, false);
|
|
146
|
-
assert.equal(frames[0].code, 400);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test("⑤ E2EE:未知会话 → 1008 + action=rehandshake(手机可自动重握手)", async () => {
|
|
150
|
-
const mk = Buffer.alloc(32, 7);
|
|
151
|
-
const svc = new e2eeMod.E2eeService({ enabled: true, mk, profile: "pbkdf2-sha256-600k", salt: "salt-abc", epoch: 0 });
|
|
152
|
-
__setE2eeForTest(svc);
|
|
153
|
-
try {
|
|
154
|
-
const { frames, send } = spy();
|
|
155
|
-
const sessId = e2eeMod.newSessId();
|
|
156
|
-
await handleWsOpen(send, {
|
|
157
|
-
id: "e2ee-locked",
|
|
158
|
-
type: "ws-open",
|
|
159
|
-
path: `/ws-echo?e2ee=${sessId}&w=0123456789abcdef`,
|
|
160
|
-
headers: {}
|
|
161
|
-
});
|
|
162
|
-
assert.equal(frames.length, 1);
|
|
163
|
-
assert.equal(frames[0].ok, false);
|
|
164
|
-
assert.equal(frames[0].code, 1008);
|
|
165
|
-
assert.equal(frames[0].retryable, true, "★ 必须显式告诉手机这是可自动重试的");
|
|
166
|
-
assert.equal(frames[0].action, "rehandshake", "★ 让手机自动重握手,而不是弹「请重新解锁」");
|
|
167
|
-
assert.match(String(frames[0].reason || ""), /e2ee_session_locked/);
|
|
168
|
-
} finally {
|
|
169
|
-
__setE2eeForTest(new e2eeMod.E2eeService({ enabled: false }));
|
|
170
|
-
}
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test("⑥ E2EE 语义保持:已验证会话 → 快速 ok;加密消息解封→上游 echo→重新封装一致", async () => {
|
|
174
|
-
const mk = Buffer.alloc(32, 7);
|
|
175
|
-
const salt = "salt-abc";
|
|
176
|
-
const profile = "pbkdf2-sha256-600k";
|
|
177
|
-
const svc = new e2eeMod.E2eeService({ enabled: true, mk, profile, salt, epoch: 0 });
|
|
178
|
-
__setE2eeForTest(svc);
|
|
179
|
-
try {
|
|
180
|
-
// 跑 §5.2 握手(与生产同一条路径,只是"手机"在我们进程里)
|
|
181
|
-
const sessId = e2eeMod.newSessId();
|
|
182
|
-
const a = e2eeMod.randomB64(32);
|
|
183
|
-
const { session, ack } = svc.handleHello({ v: 2, type: "e2ee-hello", role: "phone", s: sessId, a: a, salt, profile });
|
|
184
|
-
const { shk, saltH } = e2eeMod.deriveShk(mk, a, ack.b);
|
|
185
|
-
const client = new e2eeMod.E2eeSession({ sessId, shk, saltH, profile, epoch: 0 });
|
|
186
|
-
const probe = client.seal({ kind: "ctrl", dir: "p2b", counter: 0, data: JSON.stringify({ p: "dsh-e2ee-probe-v1", t: 1, c: 0 }) });
|
|
187
|
-
svc.handleProbe(sessId, probe);
|
|
188
|
-
assert.equal(session.verified, true);
|
|
189
|
-
|
|
190
|
-
const w = "0123456789abcdef";
|
|
191
|
-
const wsLabel = `/ws-echo?w=${w}`;
|
|
192
|
-
const { frames, send } = spy();
|
|
193
|
-
await handleWsOpen(send, {
|
|
194
|
-
id: "e2ee-ok",
|
|
195
|
-
type: "ws-open",
|
|
196
|
-
path: `/ws-echo?e2ee=${sessId}&w=${w}`,
|
|
197
|
-
headers: {}
|
|
198
|
-
});
|
|
199
|
-
assert.equal(frames[0].ok, true);
|
|
200
|
-
assert.equal(frames[0].fast, true);
|
|
201
|
-
await waitFor("上游 open", () => __wsSessionState("e2ee-ok")?.opened === true);
|
|
202
|
-
|
|
203
|
-
const envP2b = client.seal({ kind: "w", dir: "p2b", counter: 0, data: "ping-加密-你好", wsLabel });
|
|
204
|
-
handleWsMessage(send, { id: "e2ee-ok", data: JSON.stringify(envP2b), binary: false });
|
|
205
|
-
const back = await waitFor("b2p 信封", () => frames.find((f) => f.type === "ws-msg" && String(f.data).includes('"k":"w"')), 5000);
|
|
206
|
-
const envB2p = JSON.parse(String(back.data));
|
|
207
|
-
const opened = client.open({ kind: "w", dir: "b2p", env: envB2p, counter: envB2p.c, wsLabel });
|
|
208
|
-
assert.equal(opened.data.toString("utf8"), "ping-加密-你好", "端到端加解密后逐字节一致");
|
|
209
|
-
handleWsClose(send, { id: "e2ee-ok", code: 1000 });
|
|
210
|
-
} finally {
|
|
211
|
-
__setE2eeForTest(new e2eeMod.E2eeService({ enabled: false }));
|
|
212
|
-
}
|
|
213
|
-
});
|
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* E2EE 会话库(加密落盘 / 重启恢复 / 失效路径)回归(2026-10-23)。
|
|
4
|
-
*
|
|
5
|
-
* 生产事故(手机反复撞死票,主因链 #2):
|
|
6
|
-
* e2ee-client.mjs 的 sessions 只是内存 Map(24h 滑动 TTL)。bridge 重启/崩一次,
|
|
7
|
-
* 手机端保存的 sessId 就全成死票:HTTP 502 unknown_session、数据 WS 1008
|
|
8
|
-
* e2ee_session_locked,用户必须回设备列表重新解锁(实测某设备 3964 次)。
|
|
9
|
-
*
|
|
10
|
-
* 方案(本文件锁住):
|
|
11
|
-
* ① 会话材料用 MK 派生的 storeKey 做 AES-256-GCM 加密后落 relay 数据目录
|
|
12
|
-
* `.e2ee-sessions.enc`(0600、原子写);文件里**没有** MK/SHK 明文/密码;
|
|
13
|
-
* ② bridge 重启后恢复会话(含 verified 与 ctrl 防重放计数),手机旧票继续可用;
|
|
14
|
-
* ③ 解不开(改密/MK 变)/文件损坏/参数变化 → 丢弃文件并清空内存,返回 discarded 原因
|
|
15
|
-
* (上层据此给手机 retryable=rehandshake 信号,而不是让手机反复撞);
|
|
16
|
-
* ④ 24h 滑动 TTL 仍生效:过期会话不恢复。
|
|
17
|
-
*/
|
|
18
|
-
import assert from "node:assert/strict";
|
|
19
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
20
|
-
import os from "node:os";
|
|
21
|
-
import path from "node:path";
|
|
22
|
-
import test from "node:test";
|
|
23
|
-
|
|
24
|
-
import {
|
|
25
|
-
E2eeService,
|
|
26
|
-
E2eeSession,
|
|
27
|
-
SESSION_STORE_FILE,
|
|
28
|
-
SESSION_STORE_VERSION,
|
|
29
|
-
SESSION_TTL_MS,
|
|
30
|
-
clearSessionStore,
|
|
31
|
-
decryptSessionStore,
|
|
32
|
-
deriveSessionStoreKey,
|
|
33
|
-
deriveShk,
|
|
34
|
-
loadSessionStore,
|
|
35
|
-
newSessId,
|
|
36
|
-
randomB64,
|
|
37
|
-
saveSessionStore,
|
|
38
|
-
sessionStorePath
|
|
39
|
-
} from "../e2ee-client.mjs";
|
|
40
|
-
|
|
41
|
-
const DIRS = [];
|
|
42
|
-
function tmpDir() {
|
|
43
|
-
const d = mkdtempSync(path.join(os.tmpdir(), "dsh-e2ee-store-"));
|
|
44
|
-
DIRS.push(d);
|
|
45
|
-
return d;
|
|
46
|
-
}
|
|
47
|
-
test.after(() => {
|
|
48
|
-
for (const d of DIRS) { try { rmSync(d, { recursive: true, force: true }); } catch { /* 忽略 */ } }
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
const MK = Buffer.alloc(32, 9);
|
|
52
|
-
const SALT = "salt-store-test";
|
|
53
|
-
const PROFILE = "pbkdf2-sha256-600k";
|
|
54
|
-
const mkSvc = () => new E2eeService({ enabled: true, mk: MK, profile: PROFILE, salt: SALT, epoch: 1 });
|
|
55
|
-
|
|
56
|
-
/** 建一个"已解锁且探测通过"的会话(手机侧也留一份,便于断言加解密仍可用)。 */
|
|
57
|
-
function unlock(svc) {
|
|
58
|
-
const sessId = newSessId();
|
|
59
|
-
const a = randomB64(32);
|
|
60
|
-
const { session, ack } = svc.handleHello({ v: 2, type: "e2ee-hello", role: "phone", s: sessId, a: a, salt: SALT, profile: PROFILE });
|
|
61
|
-
const { shk, saltH } = deriveShk(MK, a, ack.b);
|
|
62
|
-
const client = new E2eeSession({ sessId, shk, saltH, profile: PROFILE, epoch: 1 });
|
|
63
|
-
const probe = client.seal({ kind: "ctrl", dir: "p2b", counter: 0, data: JSON.stringify({ p: "dsh-e2ee-probe-v1", t: 1, c: 0 }) });
|
|
64
|
-
svc.handleProbe(sessId, probe);
|
|
65
|
-
assert.equal(session.verified, true);
|
|
66
|
-
return { sessId, client, session };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
test("① 加密落盘:文件里没有 MK/SHK/salt 明文,权限 0600,格式可自描述", () => {
|
|
70
|
-
const dir = tmpDir();
|
|
71
|
-
const svc = mkSvc();
|
|
72
|
-
const { sessId } = unlock(svc);
|
|
73
|
-
svc.attachStore(dir, { flushMs: 0 }); // unlock 时已即时落盘;attach 再恢复一次(空目录)
|
|
74
|
-
const r = svc.flushStore();
|
|
75
|
-
assert.equal(r.ok, true, "落盘应成功");
|
|
76
|
-
const file = sessionStorePath(dir);
|
|
77
|
-
assert.equal(path.basename(file), SESSION_STORE_FILE);
|
|
78
|
-
assert.ok(existsSync(file), "会话库文件应存在");
|
|
79
|
-
const raw = readFileSync(file, "utf8");
|
|
80
|
-
const shkB64 = svc.sessions.get(sessId).shk.toString("base64url");
|
|
81
|
-
assert.ok(!raw.includes(shkB64), "★ 文件里不得出现 SHK 明文");
|
|
82
|
-
assert.ok(!raw.includes(MK.toString("base64url")), "★ 文件里不得出现 MK 明文");
|
|
83
|
-
assert.ok(!raw.includes(SALT), "文件里不该有 salt 明文");
|
|
84
|
-
const blob = JSON.parse(raw);
|
|
85
|
-
assert.equal(blob.v, SESSION_STORE_VERSION);
|
|
86
|
-
assert.equal(blob.alg, "aes-256-gcm");
|
|
87
|
-
assert.ok(blob.iv && blob.tag && blob.ct, "iv/tag/ct 三件套齐全");
|
|
88
|
-
if (process.platform !== "win32") {
|
|
89
|
-
assert.equal(statSync(file).mode & 0o777, 0o600, "文件权限必须是 0600");
|
|
90
|
-
}
|
|
91
|
-
// 解密得回 payload(密钥只由 MK 派生)
|
|
92
|
-
const payload = decryptSessionStore(MK, blob);
|
|
93
|
-
assert.equal(payload.salt, SALT);
|
|
94
|
-
assert.equal(payload.sessions.length, 1);
|
|
95
|
-
assert.equal(payload.sessions[0].s, sessId);
|
|
96
|
-
// 用错误 MK 解密必须失败(信任边界:拿到文件也解不开)
|
|
97
|
-
assert.throws(() => decryptSessionStore(Buffer.alloc(32, 1), blob), /store_auth_failed|无法解密/);
|
|
98
|
-
svc.disposeStore();
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("② 重启恢复:新 E2eeService 只凭 MK + 目录恢复 verified 会话 + ctrl 计数,旧票可直接用", () => {
|
|
102
|
-
const dir = tmpDir();
|
|
103
|
-
const svc1 = mkSvc();
|
|
104
|
-
const { sessId, client } = unlock(svc1);
|
|
105
|
-
svc1.attachStore(dir, { flushMs: 0 });
|
|
106
|
-
svc1.ctrlCounters.get(sessId).p2b.last = 3; // 模拟解锁后又跑过探测
|
|
107
|
-
svc1.flushStore();
|
|
108
|
-
svc1.disposeStore();
|
|
109
|
-
|
|
110
|
-
// "重启":全新服务实例,只凭 MK + 同一目录恢复
|
|
111
|
-
const svc2 = mkSvc();
|
|
112
|
-
const restored = svc2.attachStore(dir, { flushMs: 0 });
|
|
113
|
-
assert.equal(restored.restored, 1);
|
|
114
|
-
assert.equal(restored.discarded, "", "没有作废");
|
|
115
|
-
const session = svc2.sessionOf(sessId);
|
|
116
|
-
assert.ok(session, "★ 旧 sessId 必须仍可用(这正是持久化的目的)");
|
|
117
|
-
assert.equal(session.verified, true, "verified 也要恢复(否则数据 WS 仍被判 locked)");
|
|
118
|
-
assert.equal(svc2.ctrlCounters.get(sessId).p2b.last, 3, "ctrl 防重放计数必须恢复(重启不能重置)");
|
|
119
|
-
// 恢复的会话密钥仍能正确解密手机端的信封(HTTP 请求)
|
|
120
|
-
const plain = Buffer.from(JSON.stringify({ m: "GET", p: "/x", h: {}, b: "" }), "utf8");
|
|
121
|
-
const env = client.seal({ kind: "http", dir: "p2b", counter: 1, data: plain });
|
|
122
|
-
assert.doesNotThrow(() => svc2.guardHttpRequest(sessId, env), "恢复后的会话必须能过 guardHttpRequest");
|
|
123
|
-
const opened = session.open({ kind: "http", dir: "p2b", env, counter: env.c });
|
|
124
|
-
assert.equal(JSON.parse(opened.data.toString("utf8")).p, "/x");
|
|
125
|
-
svc2.disposeStore();
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("③ 失效路径:MK 变了(改密)→ 丢弃文件 + 清空内存 + 返回 key_mismatch", () => {
|
|
129
|
-
const dir = tmpDir();
|
|
130
|
-
const svc1 = mkSvc();
|
|
131
|
-
unlock(svc1);
|
|
132
|
-
svc1.attachStore(dir, { flushMs: 0 });
|
|
133
|
-
svc1.flushStore();
|
|
134
|
-
svc1.disposeStore();
|
|
135
|
-
|
|
136
|
-
const svc2 = new E2eeService({ enabled: true, mk: Buffer.alloc(32, 1), profile: PROFILE, salt: SALT, epoch: 1 });
|
|
137
|
-
const r = svc2.attachStore(dir, { flushMs: 0 });
|
|
138
|
-
assert.equal(r.restored, 0);
|
|
139
|
-
assert.equal(r.discarded, "key_mismatch", "必须报告失效原因(上层据此给手机 rehandshake 信号)");
|
|
140
|
-
assert.equal(svc2.sessions.size, 0, "不得把解不开的旧会话当活会话");
|
|
141
|
-
assert.equal(existsSync(sessionStorePath(dir)), false, "坏文件必须删掉,别让下次继续撞");
|
|
142
|
-
svc2.disposeStore();
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
test("④ 失效路径:salt 变化 → params_changed 丢弃;磁盘损坏 → corrupt/key_mismatch 丢弃", () => {
|
|
146
|
-
const dir = tmpDir();
|
|
147
|
-
const svc1 = mkSvc();
|
|
148
|
-
unlock(svc1);
|
|
149
|
-
svc1.attachStore(dir, { flushMs: 0 });
|
|
150
|
-
svc1.flushStore();
|
|
151
|
-
svc1.disposeStore();
|
|
152
|
-
|
|
153
|
-
// salt 变了(服务端参数更换)→ 即便 MK 碰巧能解开也不能复用(MK 域不同)
|
|
154
|
-
const svc2 = new E2eeService({ enabled: true, mk: MK, profile: PROFILE, salt: "salt-CHANGED", epoch: 1 });
|
|
155
|
-
const r2 = svc2.attachStore(dir, { flushMs: 0 });
|
|
156
|
-
assert.equal(r2.discarded, "params_changed");
|
|
157
|
-
assert.equal(svc2.sessions.size, 0);
|
|
158
|
-
assert.equal(existsSync(sessionStorePath(dir)), false);
|
|
159
|
-
svc2.disposeStore();
|
|
160
|
-
|
|
161
|
-
// 损坏文件(半截写 / 手工改):认证失败 → 丢弃
|
|
162
|
-
const dir2 = tmpDir();
|
|
163
|
-
saveSessionStore(dir2, MK, { v: SESSION_STORE_VERSION, salt: SALT, profile: PROFILE, epoch: 1, sessions: [] });
|
|
164
|
-
const file = sessionStorePath(dir2);
|
|
165
|
-
const blob = JSON.parse(readFileSync(file, "utf8"));
|
|
166
|
-
blob.ct = `${blob.ct.slice(0, 8)}${blob.ct.slice(8).replace(/[A-Za-z]/g, "Z")}`;
|
|
167
|
-
writeFileSync(file, JSON.stringify(blob));
|
|
168
|
-
const svc3 = mkSvc();
|
|
169
|
-
const r3 = svc3.attachStore(dir2, { flushMs: 0 });
|
|
170
|
-
assert.equal(r3.restored, 0);
|
|
171
|
-
assert.ok(["corrupt", "key_mismatch"].includes(r3.discarded), `损坏文件必须被丢弃(实际 ${r3.discarded})`);
|
|
172
|
-
assert.equal(svc3.sessions.size, 0);
|
|
173
|
-
svc3.disposeStore();
|
|
174
|
-
|
|
175
|
-
// 完全不是 JSON
|
|
176
|
-
const dir3 = tmpDir();
|
|
177
|
-
writeFileSync(sessionStorePath(dir3), "not-json");
|
|
178
|
-
const svc4 = mkSvc();
|
|
179
|
-
assert.equal(svc4.attachStore(dir3, { flushMs: 0 }).discarded, "corrupt");
|
|
180
|
-
svc4.disposeStore();
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
test("⑤ 24h 滑动 TTL 仍生效:过期会话不恢复,未过期的保留", () => {
|
|
184
|
-
const dir = tmpDir();
|
|
185
|
-
const now = Date.now();
|
|
186
|
-
const row = (sessId, usedAt) => ({
|
|
187
|
-
s: sessId,
|
|
188
|
-
shk: Buffer.alloc(32, 5).toString("base64url"),
|
|
189
|
-
saltH: Buffer.alloc(32, 6).toString("base64url"),
|
|
190
|
-
profile: PROFILE,
|
|
191
|
-
epoch: 1,
|
|
192
|
-
verified: true,
|
|
193
|
-
createdAt: usedAt - 1000,
|
|
194
|
-
usedAt,
|
|
195
|
-
p2b: -1,
|
|
196
|
-
b2p: -1
|
|
197
|
-
});
|
|
198
|
-
const fresh = newSessId();
|
|
199
|
-
const stale = newSessId();
|
|
200
|
-
const nearBoundary = newSessId();
|
|
201
|
-
saveSessionStore(dir, MK, {
|
|
202
|
-
v: SESSION_STORE_VERSION,
|
|
203
|
-
salt: SALT,
|
|
204
|
-
profile: PROFILE,
|
|
205
|
-
epoch: 1,
|
|
206
|
-
at: now,
|
|
207
|
-
sessions: [row(fresh, now - 60_000), row(stale, now - SESSION_TTL_MS - 5_000), row(nearBoundary, now - SESSION_TTL_MS + 60_000)]
|
|
208
|
-
});
|
|
209
|
-
const svc = mkSvc();
|
|
210
|
-
const r = svc.attachStore(dir, { flushMs: 0 });
|
|
211
|
-
assert.equal(r.restored, 2, "过期的那条必须被丢掉");
|
|
212
|
-
assert.ok(svc.sessionOf(fresh));
|
|
213
|
-
assert.ok(svc.sessionOf(nearBoundary), "TTL 内(哪怕接近边界)都要保留");
|
|
214
|
-
assert.equal(svc.sessionOf(stale), null);
|
|
215
|
-
svc.disposeStore();
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
test("⑥ 未启用 / 未给目录 / 目录不可写 → 静默降级(绝不因为会话库把隧道拖挂)", () => {
|
|
219
|
-
const dir = tmpDir();
|
|
220
|
-
const disabled = new E2eeService({ enabled: false, reason: "no_password" });
|
|
221
|
-
assert.equal(disabled.attachStore(dir).skipped, "e2ee_disabled");
|
|
222
|
-
assert.equal(existsSync(sessionStorePath(dir)), false, "未启用时不得写文件");
|
|
223
|
-
|
|
224
|
-
const svc = mkSvc();
|
|
225
|
-
assert.equal(svc.attachStore("").skipped, "no_dir");
|
|
226
|
-
assert.deepEqual(svc.flushStore(), { ok: false, skipped: true });
|
|
227
|
-
|
|
228
|
-
// 目录不可写(拿一个普通文件当目录)→ flush 返回 ok:false,但绝不抛
|
|
229
|
-
const fileAsDir = path.join(tmpDir(), "not-a-dir");
|
|
230
|
-
writeFileSync(fileAsDir, "x");
|
|
231
|
-
svc.attachStore(fileAsDir, { flushMs: 0 });
|
|
232
|
-
const r = svc.flushStore();
|
|
233
|
-
assert.equal(r.ok, false, "写失败只返回失败,不抛");
|
|
234
|
-
svc.disposeStore();
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
test("⑦ 原语契约:save→load→clear 往返;版本/AAD 域隔离;storeKey 确定性", () => {
|
|
238
|
-
const dir = tmpDir();
|
|
239
|
-
const payload = { v: SESSION_STORE_VERSION, salt: SALT, profile: PROFILE, epoch: 1, sessions: [{ s: newSessId(), usedAt: Date.now() }] };
|
|
240
|
-
assert.equal(saveSessionStore(dir, MK, payload).ok, true);
|
|
241
|
-
const loaded = loadSessionStore(dir, MK);
|
|
242
|
-
assert.equal(loaded.ok, true);
|
|
243
|
-
assert.equal(loaded.payload.sessions.length, 1);
|
|
244
|
-
// 换版本号 → 解不开(旧格式不能冒名顶替)
|
|
245
|
-
const blob = JSON.parse(readFileSync(sessionStorePath(dir), "utf8"));
|
|
246
|
-
assert.throws(() => decryptSessionStore(MK, { ...blob, v: 2 }), /会话库版本|bad_store/);
|
|
247
|
-
// clear 后读回 absent
|
|
248
|
-
assert.equal(clearSessionStore(dir), true);
|
|
249
|
-
assert.equal(loadSessionStore(dir, MK).reason, "absent");
|
|
250
|
-
// storeKey:同一个 MK 确定性一致;MK 非法直接拒绝(不静默)
|
|
251
|
-
assert.deepEqual(deriveSessionStoreKey(MK), deriveSessionStoreKey(MK));
|
|
252
|
-
assert.notDeepEqual(deriveSessionStoreKey(MK), deriveSessionStoreKey(Buffer.alloc(32, 1)));
|
|
253
|
-
assert.throws(() => deriveSessionStoreKey(Buffer.alloc(16)), /MK 非法/);
|
|
254
|
-
});
|