@mrrisega/dsh-remote 0.6.0-beta.7 → 0.6.0-beta.8
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 +36 -1
- package/packages/dsh-remote-web/lib/index.js +29 -1
- package/packages/dsh-remote-web/test/e2ee-state.test.mjs +141 -0
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +75 -0
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +1 -1
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* e2ee-shim.test.mjs — 镜像页 E2EE shim(Phase-4)契约测试(纯函数/内存,mock 传输):
|
|
4
|
+
* A. 注入契约:injectE2eeShim / shouldInjectE2eeShim / maybeInjectE2eeShim(env 开关/
|
|
5
|
+
* 非 html/防重复/非 UTF-8)与脚本内容含交接键与徽标 id;
|
|
6
|
+
* B. 上游路径归一与 §4.5 策略表(/api 排除 SSE、/sidebar、/git、/pet;channel/device
|
|
7
|
+
* 形态;/plugins|/assets|/manifest|/_e2ee|跨源明文);
|
|
8
|
+
* C. 信封字节一致性:镜像 shim(浏览器 WebCrypto 抽取)↔ 桥端 node e2ee-client(同一
|
|
9
|
+
* 会话材料 → 信封逐字段一致、交叉开包、篡改拒绝);
|
|
10
|
+
* D. 一次性交接单:native.html 写入函数(抽取)→ 镜像 shim 读取函数(抽取)跨文件
|
|
11
|
+
* 往返一致;非法输入双端拒绝;write/clear 语义;
|
|
12
|
+
* E. fetch 包装(mock 传输):命中 → 外层信封 POST + 响应信封解密重建;gzip 响应;
|
|
13
|
+
* 篡改 → 显式 502 + x-dsh-e2ee-error;未命中/无会话/跨源/SSE → 原样透传;
|
|
14
|
+
* F. WS 包装(mock native socket):URL 追加 &e2ee=&w=、逐消息封/解、方向计数、
|
|
15
|
+
* binaryType=arraybuffer、重放 → 1008 关闭、非策略路径 → 原样放行。
|
|
16
|
+
*
|
|
17
|
+
* 真实本地 router+bridge 互通(注入 / HTTP 信封 / WS 数据流)见 e2ee-bridge.test.mjs。
|
|
18
|
+
* 用法: node --test test/e2ee-shim.test.mjs
|
|
19
|
+
*/
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import test from "node:test";
|
|
22
|
+
import { gzipSync } from "node:zlib";
|
|
23
|
+
import {
|
|
24
|
+
E2eeSession,
|
|
25
|
+
decodeHttpRequestPlain,
|
|
26
|
+
decodeHttpResponsePlain,
|
|
27
|
+
deriveMasterKey,
|
|
28
|
+
deriveShk,
|
|
29
|
+
encodeHttpRequestPlain,
|
|
30
|
+
encodeHttpResponsePlain,
|
|
31
|
+
envelopeRequestHeaders,
|
|
32
|
+
newSessId,
|
|
33
|
+
parseWsE2eeParams,
|
|
34
|
+
randomB64
|
|
35
|
+
} from "../e2ee-client.mjs";
|
|
36
|
+
import {
|
|
37
|
+
injectE2eeShim,
|
|
38
|
+
shouldInjectE2eeShim,
|
|
39
|
+
maybeInjectE2eeShim,
|
|
40
|
+
e2eeShimEnabled
|
|
41
|
+
} from "../e2ee-shim.mjs";
|
|
42
|
+
import { loadShimCore, extractShimCore } from "./lib-e2ee-shim.mjs";
|
|
43
|
+
import { loadNativeHandoverCore, extractNativeHandoverCore, loadNativeWcCore } from "./lib-native-e2ee.mjs";
|
|
44
|
+
|
|
45
|
+
const OFFICIAL_HTML = `<!doctype html>
|
|
46
|
+
<html lang="en">
|
|
47
|
+
<head><base href="/"><script>window.__ModuleLoader__ = { mode: "queue" }</script>
|
|
48
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
49
|
+
<title>DeepSeek Harness</title></head>
|
|
50
|
+
<body><div id="root"></div></body></html>`;
|
|
51
|
+
|
|
52
|
+
const shim = await loadShimCore();
|
|
53
|
+
const nativeHo = await loadNativeHandoverCore();
|
|
54
|
+
const wc = await loadNativeWcCore();
|
|
55
|
+
|
|
56
|
+
/** 用同一会话材料建 node 对端(桥端视角)。 */
|
|
57
|
+
function peerNodeSession(seSess) {
|
|
58
|
+
return new E2eeSession({
|
|
59
|
+
sessId: seSess.sessId,
|
|
60
|
+
shk: Buffer.from(seSess.shk),
|
|
61
|
+
saltH: Buffer.from(seSess.saltH),
|
|
62
|
+
profile: seSess.profile,
|
|
63
|
+
epoch: seSess.epoch
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function makeSeSession(over = {}) {
|
|
67
|
+
const mk = deriveMasterKey("shim-parity-password", "AAECAwQFBgcICQoLDA0ODw");
|
|
68
|
+
const { shk, saltH } = deriveShk(mk, randomB64(32), randomB64(32));
|
|
69
|
+
return new shim.SeSession({ sessId: newSessId(), shk: new Uint8Array(shk), saltH: new Uint8Array(saltH), ...over });
|
|
70
|
+
}
|
|
71
|
+
const NONCE_12 = () => new Uint8Array(Buffer.from("00112233445566778899aabb", "hex"));
|
|
72
|
+
|
|
73
|
+
// ============ A. 注入契约 ============
|
|
74
|
+
|
|
75
|
+
test("injectE2eeShim:官方 html 注入 style/script 于 </head> 前,且为纯 splice", () => {
|
|
76
|
+
const r = injectE2eeShim(OFFICIAL_HTML);
|
|
77
|
+
assert.equal(r.injected, true);
|
|
78
|
+
const outHeadIdx = r.html.toLowerCase().lastIndexOf("</head>");
|
|
79
|
+
const cssAt = r.html.indexOf("dsh-e2ee-shim-css");
|
|
80
|
+
const jsAt = r.html.indexOf("dsh-e2ee-shim-js");
|
|
81
|
+
assert.ok(cssAt > 0 && cssAt < outHeadIdx && jsAt > 0 && jsAt < outHeadIdx, "style/script 应在 </head> 之前");
|
|
82
|
+
const headIdx = OFFICIAL_HTML.toLowerCase().lastIndexOf("</head>");
|
|
83
|
+
assert.ok(r.html.startsWith(OFFICIAL_HTML.slice(0, headIdx)), "head 前缀原样(纯 splice)");
|
|
84
|
+
assert.ok(r.html.endsWith(OFFICIAL_HTML.slice(headIdx)), "head 之后原样(纯 splice)");
|
|
85
|
+
// 内容:交接键 + 徽标 + 核心区间;脚本正文不得含未转义 </script>(HTML 解析歧义)
|
|
86
|
+
assert.match(r.html, /dsh-e2ee-handover/);
|
|
87
|
+
assert.match(r.html, /dsh-e2ee-badge/);
|
|
88
|
+
assert.match(r.html, /DSH-E2EE-SHIM-CORE-START/);
|
|
89
|
+
const body = r.html.slice(jsAt).split("</script>")[0];
|
|
90
|
+
assert.ok(!/<\/script/i.test(body), "shim 脚本正文不应含未转义 </script");
|
|
91
|
+
assert.ok(!body.includes("<script"), "shim 脚本正文不应含嵌套 <script");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("injectE2eeShim:无 </head> / 已有标记 → 原样(防重复)", () => {
|
|
95
|
+
assert.equal(injectE2eeShim("<div>partial</div>").injected, false);
|
|
96
|
+
const once = injectE2eeShim(OFFICIAL_HTML).html;
|
|
97
|
+
const again = injectE2eeShim(once);
|
|
98
|
+
assert.equal(again.injected, false);
|
|
99
|
+
assert.equal(again.html, once);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("shouldInjectE2eeShim:仅官方 text/html 含 </head> 非替换字符", () => {
|
|
103
|
+
assert.equal(shouldInjectE2eeShim({ contentType: "text/html; charset=utf-8", html: OFFICIAL_HTML }), true);
|
|
104
|
+
assert.equal(shouldInjectE2eeShim({ contentType: "text/html", html: "<html><head><title>x</title></head></html>" }), false);
|
|
105
|
+
assert.equal(shouldInjectE2eeShim({ contentType: "application/json", html: OFFICIAL_HTML }), false);
|
|
106
|
+
assert.equal(shouldInjectE2eeShim({ contentType: "text/html", html: "<html><title>x</title>" }), false);
|
|
107
|
+
const latin1 = "\uFFFD" + "<html><head></head></html>" + OFFICIAL_HTML; // 含替换字符 → 不冒险改写
|
|
108
|
+
assert.equal(shouldInjectE2eeShim({ contentType: "text/html", html: latin1 }), false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("maybeInjectE2eeShim:Buffer 级入口默认注入;DSH_E2EE_SHIM=0 关闭", () => {
|
|
112
|
+
const { buf, injected } = maybeInjectE2eeShim({ buf: Buffer.from(OFFICIAL_HTML, "utf8"), contentType: "text/html" });
|
|
113
|
+
assert.equal(injected, true);
|
|
114
|
+
assert.ok(buf.toString("utf8").includes("data-dsh-e2ee-shim"));
|
|
115
|
+
process.env.DSH_E2EE_SHIM = "0";
|
|
116
|
+
try {
|
|
117
|
+
assert.equal(e2eeShimEnabled(), false);
|
|
118
|
+
assert.equal(maybeInjectE2eeShim({ buf: Buffer.from(OFFICIAL_HTML, "utf8"), contentType: "text/html" }).injected, false);
|
|
119
|
+
} finally {
|
|
120
|
+
delete process.env.DSH_E2EE_SHIM;
|
|
121
|
+
}
|
|
122
|
+
assert.equal(e2eeShimEnabled(), true);
|
|
123
|
+
assert.equal(maybeInjectE2eeShim({ buf: Buffer.alloc(0), contentType: "text/html" }).injected, false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("e2ee-shim-script.js 核心区间存在且可抽取(>0 行)", () => {
|
|
127
|
+
const core = extractShimCore();
|
|
128
|
+
assert.ok(core.split("\n").length > 200);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ============ B. 归一与策略表 ============
|
|
132
|
+
|
|
133
|
+
test("seUpstreamPathOf:channel/device 形态归一(与 router/native 同构)", () => {
|
|
134
|
+
assert.equal(shim.seUpstreamPathOf("/remote/dev-abc123/api/session/search?b=2"), "/api/session/search?b=2");
|
|
135
|
+
assert.equal(shim.seUpstreamPathOf("/remote/dev-abc123/api/session/search"), "/api/session/search");
|
|
136
|
+
assert.equal(shim.seUpstreamPathOf("/remote/api/session/search?device=d1&b=2"), "/api/session/search?b=2");
|
|
137
|
+
assert.equal(shim.seUpstreamPathOf("/api/session/search?b=2"), "/api/session/search?b=2");
|
|
138
|
+
assert.equal(shim.seUpstreamPathOf("/remote/dev-abc123/sidebar"), "/sidebar");
|
|
139
|
+
assert.equal(shim.seUpstreamPathOf("/remote/dev-abc123/"), "/");
|
|
140
|
+
assert.equal(shim.seUpstreamPathOf("/"), "/");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("HTTP 策略表:命中 /api|/sidebar|/git|/pet;SSE/静态/ctrl/跨源类排除", () => {
|
|
144
|
+
const h = { accept: "application/json" };
|
|
145
|
+
assert.equal(shim.seShouldEncryptHttp("/api/session/search", h), true);
|
|
146
|
+
assert.equal(shim.seShouldEncryptHttp("/api/session/search?a=1", h), true);
|
|
147
|
+
assert.equal(shim.seShouldEncryptHttp("/remote/dev-x/api/echo", h), true);
|
|
148
|
+
assert.equal(shim.seShouldEncryptHttp("/remote/api/echo?device=d", h), true);
|
|
149
|
+
assert.equal(shim.seShouldEncryptHttp("/sidebar/tree", h), true);
|
|
150
|
+
assert.equal(shim.seShouldEncryptHttp("/git/x", h), true);
|
|
151
|
+
assert.equal(shim.seShouldEncryptHttp("/pet/x", h), true);
|
|
152
|
+
// SSE/静态/manifest/ctrl/非前缀 → 明文
|
|
153
|
+
assert.equal(shim.seShouldEncryptHttp("/api/session/s1/events", h), false);
|
|
154
|
+
assert.equal(shim.seShouldEncryptHttp("/api/session/s1/events/stream", h), false);
|
|
155
|
+
assert.equal(shim.seShouldEncryptHttp("/api/events", h), false);
|
|
156
|
+
assert.equal(shim.seShouldEncryptHttp("/api/events.mux", h), false); // fetch 视作 SSE 形态
|
|
157
|
+
assert.equal(shim.seShouldEncryptHttp("/api/x", { accept: "text/event-stream" }), false);
|
|
158
|
+
assert.equal(shim.seShouldEncryptHttp("/plugins/??@deepseek-ai/x.js", h), false);
|
|
159
|
+
assert.equal(shim.seShouldEncryptHttp("/assets/app.js", h), false);
|
|
160
|
+
assert.equal(shim.seShouldEncryptHttp("/manifest.json", h), false);
|
|
161
|
+
assert.equal(shim.seShouldEncryptHttp("/favicon.ico", h), false);
|
|
162
|
+
assert.equal(shim.seShouldEncryptHttp("/_e2ee/ctrl", h), false);
|
|
163
|
+
assert.equal(shim.seShouldEncryptHttp("/dsh-remote/status", h), false);
|
|
164
|
+
assert.equal(shim.seShouldEncryptHttp("/api2", h), false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("WS 策略:/api 数据流(含 events.mux)加密;静态/ctrl 放行", () => {
|
|
168
|
+
assert.equal(shim.seShouldEncryptWs("/api/events.mux?x=1"), true);
|
|
169
|
+
assert.equal(shim.seShouldEncryptWs("/api/session/s1/events"), true);
|
|
170
|
+
assert.equal(shim.seShouldEncryptWs("/remote/dev-x/api/events.mux"), true);
|
|
171
|
+
assert.equal(shim.seShouldEncryptWs("/_e2ee/ctrl"), false);
|
|
172
|
+
assert.equal(shim.seShouldEncryptWs("/plugins/x"), false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("wsLabel 解析与桥端 parseWsE2eeParams 逐字段一致(字节同构)", () => {
|
|
176
|
+
const sessId = newSessId();
|
|
177
|
+
const w = "0011223344556677";
|
|
178
|
+
const path = `/api/events.mux?a=1&e2ee=${sessId}&w=${w}`;
|
|
179
|
+
const se = shim.seWsLabelParts(path);
|
|
180
|
+
const node = parseWsE2eeParams(path);
|
|
181
|
+
assert.equal(se.sessId, node.sessId);
|
|
182
|
+
assert.equal(se.w, node.w);
|
|
183
|
+
assert.equal(se.wsLabel, node.wsLabel);
|
|
184
|
+
assert.equal(se.upstreamPath, node.upstreamPath);
|
|
185
|
+
// 通道/设备形态:先归一(剥 /remote)再解析 —— 与 bridge 收到的帧 path 一致
|
|
186
|
+
const path2 = `/remote/dev-x${path}`;
|
|
187
|
+
const se2 = shim.seWsLabelParts(path2);
|
|
188
|
+
assert.equal(se2.wsLabel, node.wsLabel);
|
|
189
|
+
assert.equal(se2.upstreamPath, node.upstreamPath);
|
|
190
|
+
assert.equal(shim.seWsLabelParts("/api/events.mux"), null);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ============ C. 信封字节一致 ============
|
|
194
|
+
|
|
195
|
+
test("信封逐字段一致:http/http-resp/w(文本·二进制)/ctrl(shim ↔ node)", async () => {
|
|
196
|
+
const seS = makeSeSession();
|
|
197
|
+
const nodeS = peerNodeSession(seS);
|
|
198
|
+
const plain = Buffer.from('{"hello":"世界"}');
|
|
199
|
+
const envSE = await seS.seal({ kind: "http", dir: "p2b", counter: 7, data: plain, nonceBytes: NONCE_12() });
|
|
200
|
+
const envNode = nodeS.seal({ kind: "http", dir: "p2b", counter: 7, data: plain, nonce: Buffer.from(NONCE_12()) });
|
|
201
|
+
assert.deepEqual(envSE, envNode);
|
|
202
|
+
// 交叉开包
|
|
203
|
+
const backNode = nodeS.open({ kind: "http", dir: "p2b", env: envSE, counter: envSE.c });
|
|
204
|
+
assert.equal(Buffer.from(backNode.data).toString("utf8"), '{"hello":"世界"}');
|
|
205
|
+
|
|
206
|
+
const respPlain = encodeHttpResponsePlain({ status: 200, headers: { "content-type": "application/json", "content-encoding": "gzip" }, bodyBuffer: Buffer.from("aGk=", "base64") });
|
|
207
|
+
const envRespN = nodeS.seal({ kind: "http-resp", dir: "b2p", counter: 3, data: respPlain, reqNonceB64: envSE.n, nonce: Buffer.from(NONCE_12()) });
|
|
208
|
+
const envRespS = await seS.seal({ kind: "http-resp", dir: "b2p", counter: 3, data: respPlain, reqNonceB64: envSE.n, nonceBytes: NONCE_12() });
|
|
209
|
+
assert.deepEqual(envRespS, envRespN);
|
|
210
|
+
const openedS = await seS.open({ kind: "http-resp", dir: "b2p", env: envRespN, counter: envRespN.c, reqNonceB64: envSE.n });
|
|
211
|
+
const rp = shim.seDecodeHttpRespPlain(openedS.data);
|
|
212
|
+
assert.equal(rp.status, 200);
|
|
213
|
+
assert.equal(rp.enc, "gzip");
|
|
214
|
+
|
|
215
|
+
const wsLabel = "/api/events.mux?w=0011223344556677";
|
|
216
|
+
const wsText = "ping-shim-你好";
|
|
217
|
+
const envWN = nodeS.seal({ kind: "w", dir: "p2b", counter: 0, data: wsText, wsLabel, nonce: Buffer.from(NONCE_12()) });
|
|
218
|
+
const envWS = await seS.seal({ kind: "w", dir: "p2b", counter: 0, data: wsText, wsLabel, nonceBytes: NONCE_12() });
|
|
219
|
+
assert.deepEqual(envWS, envWN);
|
|
220
|
+
const wBin = new Uint8Array([0, 1, 2, 253, 254, 255]);
|
|
221
|
+
const envBinS = await seS.seal({ kind: "w", dir: "p2b", counter: 1, data: wBin, t: 1, wsLabel });
|
|
222
|
+
const backBin = nodeS.open({ kind: "w", dir: "p2b", env: envBinS, counter: 1, wsLabel });
|
|
223
|
+
assert.deepEqual(Buffer.from(backBin.data), Buffer.from(wBin));
|
|
224
|
+
// 篡改拒绝
|
|
225
|
+
const tampered = { ...envWN, d: (() => { const b = Buffer.from(envWN.d, "base64url"); b[3] ^= 0xff; return b.toString("base64url"); })() };
|
|
226
|
+
assert.throws(() => nodeS.open({ kind: "w", dir: "p2b", env: tampered, counter: tampered.c, wsLabel }), (e) => e.code === "auth_failed");
|
|
227
|
+
await assert.rejects(() => seS.open({ kind: "w", dir: "p2b", env: tampered, counter: tampered.c, wsLabel }), (e) => e && e.code === "auth_failed");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("请求信封明文编解码与信封标记头同构(node 交叉)", () => {
|
|
231
|
+
const plainShim = shim.seEncodeHttpReqPlain({ method: "POST", path: "/api/echo?b=2", headers: { "content-type": "application/json", "x-t": "v" }, bodyBytes: new Uint8Array(Buffer.from("载荷", "utf8")) });
|
|
232
|
+
const plainNode = encodeHttpRequestPlain({ method: "POST", path: "/api/echo?b=2", headers: { "content-type": "application/json", "x-t": "v" }, bodyB64: Buffer.from("载荷", "utf8").toString("base64") });
|
|
233
|
+
assert.deepEqual(Buffer.from(plainShim), Buffer.from(plainNode));
|
|
234
|
+
const sessId = newSessId();
|
|
235
|
+
assert.deepEqual(shim.seEnvelopeHeaders(sessId), envelopeRequestHeaders(sessId, "http"));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ============ D. 一次性交接单(native → 镜像) ============
|
|
239
|
+
|
|
240
|
+
test("交接单:native 写入(抽取真实代码)→ shim 读取(抽取真实代码)跨文件往返一致", async () => {
|
|
241
|
+
const mk = await wc.wcDeriveMasterKey("handover-pw", "AAECAwQFBgcICQoLDA0ODw");
|
|
242
|
+
const aB64 = wc.wcRandomB64(32);
|
|
243
|
+
const bB64 = wc.wcRandomB64(32);
|
|
244
|
+
const { shk, saltH } = await wc.wcDeriveShk(mk, aB64, bB64);
|
|
245
|
+
const sessLike = { sessId: wc.wcNewSessId(), shk, saltH, profile: "pbkdf2-sha256-600k", epoch: 3 };
|
|
246
|
+
|
|
247
|
+
const raw = nativeHo.dshE2eeHandoverEncode(sessLike);
|
|
248
|
+
assert.ok(raw);
|
|
249
|
+
const obj = JSON.parse(raw);
|
|
250
|
+
assert.equal(obj.v, 2);
|
|
251
|
+
assert.equal(obj.s, sessLike.sessId);
|
|
252
|
+
assert.equal(obj.profile, "pbkdf2-sha256-600k");
|
|
253
|
+
assert.equal(obj.epoch, 3);
|
|
254
|
+
// shim 侧解码 → 会话重建(字节一致)
|
|
255
|
+
const parsed = shim.seHandoverDecode(raw);
|
|
256
|
+
assert.ok(parsed);
|
|
257
|
+
assert.equal(parsed.s, sessLike.sessId);
|
|
258
|
+
assert.deepEqual(Buffer.from(parsed.shk), Buffer.from(shk));
|
|
259
|
+
assert.deepEqual(Buffer.from(parsed.saltH), Buffer.from(saltH));
|
|
260
|
+
const sess = shim.seSessionOfHandover(raw);
|
|
261
|
+
assert.ok(sess && sess.sessId === sessLike.sessId);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("交接单:非法输入 native/shim 双端拒绝;write/clear 语义(mock storage)", () => {
|
|
265
|
+
assert.equal(nativeHo.dshE2eeHandoverEncode(null), null);
|
|
266
|
+
assert.equal(nativeHo.dshE2eeHandoverEncode({ sessId: "zz", shk: new Uint8Array(32), saltH: new Uint8Array(32) }), null);
|
|
267
|
+
assert.equal(nativeHo.dshE2eeHandoverEncode({ sessId: "a".repeat(32), shk: new Uint8Array(8), saltH: new Uint8Array(32) }), null);
|
|
268
|
+
assert.equal(shim.seHandoverDecode("not json"), null);
|
|
269
|
+
assert.equal(shim.seHandoverDecode(JSON.stringify({ v: 1, s: "a".repeat(32) })), null);
|
|
270
|
+
assert.equal(shim.seHandoverDecode(JSON.stringify({ v: 2, s: "a".repeat(32), shk: "!!", salt: "!!" })), null);
|
|
271
|
+
|
|
272
|
+
const storage = new Map();
|
|
273
|
+
const mock = { getItem: (k) => storage.has(k) ? storage.get(k) : null, setItem: (k, v) => storage.set(k, v), removeItem: (k) => storage.delete(k) };
|
|
274
|
+
nativeHo.dshE2eeHandoverWrite({ sessId: "a".repeat(32), shk: new Uint8Array(32).fill(1), saltH: new Uint8Array(32).fill(2) }, mock);
|
|
275
|
+
assert.equal(storage.has(nativeHo.DSH_E2EE_HANDOVER_KEY), true);
|
|
276
|
+
nativeHo.dshE2eeHandoverClear(mock);
|
|
277
|
+
assert.equal(storage.has(nativeHo.DSH_E2EE_HANDOVER_KEY), false);
|
|
278
|
+
// write 对非法会话不落键;storage 异常不抛
|
|
279
|
+
nativeHo.dshE2eeHandoverWrite({ sessId: "bad" }, mock);
|
|
280
|
+
assert.equal(storage.has(nativeHo.DSH_E2EE_HANDOVER_KEY), false);
|
|
281
|
+
nativeHo.dshE2eeHandoverClear({ removeItem() { throw new Error("x"); } });
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ============ E. fetch 包装(mock 传输) ============
|
|
285
|
+
|
|
286
|
+
/** 建一个「桥端」node 会话作为 mock 传输对端。 */
|
|
287
|
+
function bridgePeer(sessId, shkBytes, saltHBytes) {
|
|
288
|
+
return new E2eeSession({ sessId, shk: Buffer.from(shkBytes), saltH: Buffer.from(saltHBytes) });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
test("fetch 包装:命中策略 → 外层信封 POST + 响应信封解密重建(含 gzip)", async () => {
|
|
292
|
+
const seS = makeSeSession();
|
|
293
|
+
const peer = bridgePeer(seS.sessId, seS.shk, seS.saltH);
|
|
294
|
+
const ORIGIN = "http://relay.local";
|
|
295
|
+
const sent = [];
|
|
296
|
+
const mockTransport = async (input, init) => {
|
|
297
|
+
sent.push({ input: String(input), init });
|
|
298
|
+
const envReq = JSON.parse(init.body);
|
|
299
|
+
assert.equal(init.method, "POST");
|
|
300
|
+
assert.ok(init.headers["content-type"].startsWith("application/vnd.dsh.e2ee-v2"));
|
|
301
|
+
// 桥端打开请求信封 → 校验内容
|
|
302
|
+
const opened = peer.open({ kind: "http", dir: "p2b", env: envReq, counter: envReq.c ?? 0 });
|
|
303
|
+
const req = decodeHttpRequestPlain(opened.data);
|
|
304
|
+
assert.equal(req.method, "PUT");
|
|
305
|
+
assert.equal(req.path, "/api/session/s1/messages?x=1");
|
|
306
|
+
assert.equal(Buffer.from(req.bodyB64, "base64").toString("utf8"), "你好 body");
|
|
307
|
+
// 响应(压缩先于加密)→ 封回
|
|
308
|
+
const gz = gzipSync("mirror html 内容-".repeat(20));
|
|
309
|
+
const plain = encodeHttpResponsePlain({ status: 201, headers: { "content-type": "application/json", "content-encoding": "gzip", "x-up": "yes" }, bodyBuffer: gz });
|
|
310
|
+
const envResp = peer.seal({ kind: "http-resp", dir: "b2p", counter: envReq.c ?? 0, data: plain, reqNonceB64: envReq.n });
|
|
311
|
+
return new Response(JSON.stringify(envResp), { status: 200, headers: { "content-type": "application/vnd.dsh.e2ee-v2", "x-dsh-e2ee": "v=2;s=" + seS.sessId + ";k=http-resp" } });
|
|
312
|
+
};
|
|
313
|
+
const res = await shim.seFetchWrapper(mockTransport, { sess: seS, origin: ORIGIN }, ORIGIN + "/api/session/s1/messages?x=1", {
|
|
314
|
+
method: "PUT",
|
|
315
|
+
headers: { "content-type": "application/json", "x-custom": "abc" },
|
|
316
|
+
body: "你好 body"
|
|
317
|
+
});
|
|
318
|
+
assert.equal(res.status, 201);
|
|
319
|
+
assert.equal(res.headers.get("content-type"), "application/json");
|
|
320
|
+
assert.equal(res.headers.get("x-up"), "yes");
|
|
321
|
+
assert.equal(await res.text(), "mirror html 内容-".repeat(20));
|
|
322
|
+
assert.equal(sent.length, 1);
|
|
323
|
+
assert.ok(String(sent[0].input).endsWith("/api/session/s1/messages?x=1"), "外层 URL 保持不变");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("fetch 包装:明文响应(无信封标记)→ 原样透传;篡改响应 → 显式 502 ⚠", async () => {
|
|
327
|
+
const seS = makeSeSession();
|
|
328
|
+
const peer = bridgePeer(seS.sessId, seS.shk, seS.saltH);
|
|
329
|
+
const ORIGIN = "http://relay.local";
|
|
330
|
+
|
|
331
|
+
// 1) 明文响应(如 router 402/502 错误页)
|
|
332
|
+
const plainTransport = async () => new Response('{"error":"quota"}', { status: 402, headers: { "content-type": "application/json" } });
|
|
333
|
+
const r1 = await shim.seFetchWrapper(plainTransport, { sess: seS, origin: ORIGIN }, ORIGIN + "/api/x", { method: "GET" });
|
|
334
|
+
assert.equal(r1.status, 402);
|
|
335
|
+
assert.equal(await r1.text(), '{"error":"quota"}');
|
|
336
|
+
|
|
337
|
+
// 2) 篡改密文 → 502 + x-dsh-e2ee-error + ⚠(绝不静默/绝不误转发)
|
|
338
|
+
const tamperTransport = async (_input, init) => {
|
|
339
|
+
const envReq = JSON.parse(init.body);
|
|
340
|
+
const gz = gzipSync("secret");
|
|
341
|
+
const plain = encodeHttpResponsePlain({ status: 200, headers: { "content-type": "text/plain" }, bodyBuffer: gz });
|
|
342
|
+
const envResp = peer.seal({ kind: "http-resp", dir: "b2p", counter: envReq.c ?? 0, data: plain, reqNonceB64: envReq.n });
|
|
343
|
+
const buf = Buffer.from(envResp.d, "base64url");
|
|
344
|
+
buf[7] ^= 0xaa; // 篡改密文
|
|
345
|
+
envResp.d = buf.toString("base64url");
|
|
346
|
+
return new Response(JSON.stringify(envResp), { status: 200, headers: { "content-type": "application/vnd.dsh.e2ee-v2", "x-dsh-e2ee": "v=2;s=" + seS.sessId + ";k=http-resp" } });
|
|
347
|
+
};
|
|
348
|
+
const r2 = await shim.seFetchWrapper(tamperTransport, { sess: seS, origin: ORIGIN }, ORIGIN + "/api/x", { method: "GET" });
|
|
349
|
+
assert.equal(r2.status, 502);
|
|
350
|
+
assert.equal(r2.headers.get("x-dsh-e2ee-error"), "auth_failed");
|
|
351
|
+
assert.match(await r2.text(), /⚠/);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("fetch 包装:未命中策略/无会话/跨源/SSE/无法读取 body → 原样透传(零行为)", async () => {
|
|
355
|
+
const seS = makeSeSession();
|
|
356
|
+
const ORIGIN = "http://relay.local";
|
|
357
|
+
let calls = 0;
|
|
358
|
+
const passthrough = async (input, init) => { calls++; return new Response("plain", { status: 200 }); };
|
|
359
|
+
// 无会话
|
|
360
|
+
assert.equal(await (await shim.seFetchWrapper(passthrough, { sess: null, origin: ORIGIN }, ORIGIN + "/api/x", {})).text(), "plain");
|
|
361
|
+
// 未命中策略(静态)
|
|
362
|
+
await shim.seFetchWrapper(passthrough, { sess: seS, origin: ORIGIN }, ORIGIN + "/plugins/app.js", {});
|
|
363
|
+
// 跨源
|
|
364
|
+
await shim.seFetchWrapper(passthrough, { sess: seS, origin: ORIGIN }, "https://other.example/api/x", {});
|
|
365
|
+
// SSE(accept)
|
|
366
|
+
await shim.seFetchWrapper(passthrough, { sess: seS, origin: ORIGIN }, ORIGIN + "/api/events", { headers: { accept: "text/event-stream" } });
|
|
367
|
+
assert.equal(calls, 4);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// ============ F. WS 包装(mock native socket) ============
|
|
371
|
+
|
|
372
|
+
class FakeNative {
|
|
373
|
+
constructor(url, protocols) {
|
|
374
|
+
this.url = url;
|
|
375
|
+
this.protocols = protocols;
|
|
376
|
+
this.readyState = 0;
|
|
377
|
+
this.sent = [];
|
|
378
|
+
this.onopen = null; this.onmessage = null; this.onclose = null; this.onerror = null;
|
|
379
|
+
this.binaryType = "blob"; this.protocol = ""; this.extensions = ""; this.bufferedAmount = 0;
|
|
380
|
+
}
|
|
381
|
+
send(data) { this.sent.push(data); }
|
|
382
|
+
close(code, reason) {
|
|
383
|
+
this.readyState = 3;
|
|
384
|
+
if (this.onclose) this.onclose({ type: "close", code: code, reason: reason, target: this });
|
|
385
|
+
}
|
|
386
|
+
addEventListener() {}
|
|
387
|
+
removeEventListener() {}
|
|
388
|
+
fireOpen() { this.readyState = 1; if (this.onopen) this.onopen({ type: "open", target: this }); }
|
|
389
|
+
fireMessage(data) { if (this.onmessage) this.onmessage({ type: "message", data: data, target: this }); }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const flush = () => new Promise((r) => setTimeout(r, 0));
|
|
393
|
+
const until = async (cond, ms = 1500) => {
|
|
394
|
+
const t0 = Date.now();
|
|
395
|
+
while (Date.now() - t0 < ms) {
|
|
396
|
+
if (cond()) return;
|
|
397
|
+
await new Promise((r) => setTimeout(r, 3));
|
|
398
|
+
}
|
|
399
|
+
throw new Error("条件未满足(超时 " + ms + "ms)");
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
test("WS 包装:URL 追加 &e2ee=&w=,逐消息封包(计数递增)且桥端可解", async () => {
|
|
403
|
+
const seS = makeSeSession();
|
|
404
|
+
const peer = bridgePeer(seS.sessId, seS.shk, seS.saltH);
|
|
405
|
+
const ORIGIN = "http://relay.local";
|
|
406
|
+
const patched = shim.seMakeWsCtor(FakeNative, { sess: seS, origin: ORIGIN });
|
|
407
|
+
const ws = patched(ORIGIN + "/api/events.mux?x=1");
|
|
408
|
+
assert.ok(ws instanceof shim.SeE2eeWs, "命中策略 → 套代理");
|
|
409
|
+
const inner = ws._inner;
|
|
410
|
+
assert.ok(inner.url.includes("e2ee=" + seS.sessId), "URL 带 e2ee");
|
|
411
|
+
assert.match(inner.url, /[?&]w=[0-9a-f]{16}/, "URL 带 8B hex w");
|
|
412
|
+
// 发送文本 → 外层是 w 信封,桥端逐包解开
|
|
413
|
+
ws.send("ping-你好");
|
|
414
|
+
ws.send("second");
|
|
415
|
+
await until(() => inner.sent.length === 2);
|
|
416
|
+
assert.equal(inner.sent.length, 2);
|
|
417
|
+
const full = inner.url.slice(inner.url.indexOf("/api"));
|
|
418
|
+
const parts = shim.seWsLabelParts(full);
|
|
419
|
+
for (let i = 0; i < 2; i++) {
|
|
420
|
+
const env = JSON.parse(inner.sent[i]);
|
|
421
|
+
assert.equal(env.k, "w");
|
|
422
|
+
assert.equal(env.c, i);
|
|
423
|
+
const opened = peer.open({ kind: "w", dir: "p2b", env, counter: env.c, wsLabel: parts.wsLabel });
|
|
424
|
+
const text = Buffer.from(opened.data).toString("utf8");
|
|
425
|
+
assert.equal(text, i === 0 ? "ping-你好" : "second");
|
|
426
|
+
}
|
|
427
|
+
ws.close(1000);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("WS 包装:下行信封解密→原文事件;binaryType=arraybuffer;重放 → 1008 关闭", async () => {
|
|
431
|
+
const seS = makeSeSession();
|
|
432
|
+
const peer = bridgePeer(seS.sessId, seS.shk, seS.saltH);
|
|
433
|
+
const ORIGIN = "http://relay.local";
|
|
434
|
+
const patched = shim.seMakeWsCtor(FakeNative, { sess: seS, origin: ORIGIN });
|
|
435
|
+
const ws = patched(ORIGIN + "/api/events.mux");
|
|
436
|
+
const inner = ws._inner;
|
|
437
|
+
inner.fireOpen();
|
|
438
|
+
const full = inner.url.slice(inner.url.indexOf("/api"));
|
|
439
|
+
const parts = shim.seWsLabelParts(full);
|
|
440
|
+
|
|
441
|
+
// 下行文本信封 → onmessage 收到原文
|
|
442
|
+
let msgEvt = null;
|
|
443
|
+
ws.onmessage = (ev) => { msgEvt = ev; };
|
|
444
|
+
const env0 = peer.seal({ kind: "w", dir: "b2p", counter: 0, data: "下行你好", wsLabel: parts.wsLabel });
|
|
445
|
+
inner.fireMessage(JSON.stringify(env0));
|
|
446
|
+
await until(() => msgEvt && msgEvt.data === "下行你好");
|
|
447
|
+
assert.equal(msgEvt.data, "下行你好");
|
|
448
|
+
|
|
449
|
+
// 明文帧(非信封)→ 原样透传
|
|
450
|
+
ws.onmessage = (ev) => { msgEvt = ev; };
|
|
451
|
+
inner.fireMessage("raw-plain");
|
|
452
|
+
await until(() => msgEvt && msgEvt.data === "raw-plain");
|
|
453
|
+
assert.equal(msgEvt.data, "raw-plain");
|
|
454
|
+
|
|
455
|
+
// 下行二进制(arraybuffer)
|
|
456
|
+
let binEvt = null;
|
|
457
|
+
ws.binaryType = "arraybuffer";
|
|
458
|
+
ws.onmessage = (ev) => { binEvt = ev; };
|
|
459
|
+
const env1 = peer.seal({ kind: "w", dir: "b2p", counter: 1, data: Buffer.from([9, 8, 7]), t: 1, wsLabel: parts.wsLabel });
|
|
460
|
+
inner.fireMessage(JSON.stringify(env1));
|
|
461
|
+
await until(() => binEvt && binEvt.data instanceof ArrayBuffer);
|
|
462
|
+
assert.deepEqual([...new Uint8Array(binEvt.data)], [9, 8, 7]);
|
|
463
|
+
|
|
464
|
+
// 重放(计数回退)→ 1008 关闭,显式失败
|
|
465
|
+
let closed = null;
|
|
466
|
+
ws.onclose = (ev) => { closed = ev; };
|
|
467
|
+
inner.fireMessage(JSON.stringify(env0)); // c=0 ≤ last=1 → replay
|
|
468
|
+
await until(() => closed, 800);
|
|
469
|
+
assert.ok(closed, "重放应触发 close");
|
|
470
|
+
assert.equal(closed.code, 1008);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("WS 包装:非策略路径原样放行;同会话已标记 URL 幂等接管;异会话标记放行", () => {
|
|
474
|
+
const seS = makeSeSession();
|
|
475
|
+
const patched = shim.seMakeWsCtor(FakeNative, { sess: seS, origin: "http://relay.local" });
|
|
476
|
+
const plain = patched("http://relay.local/dsh-remote/ws");
|
|
477
|
+
assert.ok(plain instanceof FakeNative, "非数据 WS → 原生");
|
|
478
|
+
assert.ok(!(plain instanceof shim.SeE2eeWs));
|
|
479
|
+
// 同会话 e2ee 已标记(重开场景)→ 幂等接管,不二次加参
|
|
480
|
+
const w = "0011223344556677";
|
|
481
|
+
const again = patched(`http://relay.local/api/events.mux?e2ee=${seS.sessId}&w=${w}`);
|
|
482
|
+
assert.ok(again instanceof shim.SeE2eeWs, "同会话已标记 URL 应幂等接管(避免原文走加密流)");
|
|
483
|
+
assert.equal(again._inner.url, `ws://relay.local/api/events.mux?e2ee=${seS.sessId}&w=${w}`);
|
|
484
|
+
// 异会话标记 → 原样放行(不误接管别家会话流)
|
|
485
|
+
const other = patched(`http://relay.local/api/events.mux?e2ee=${"f".repeat(32)}&w=${w}`);
|
|
486
|
+
assert.ok(other instanceof FakeNative);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("fetch 包装:真实字节往返(shim 密封 → 桥端打开 → 封回 → shim 打开)自洽 5 轮", async () => {
|
|
490
|
+
for (let i = 0; i < 5; i++) {
|
|
491
|
+
const seS = makeSeSession();
|
|
492
|
+
const peer = bridgePeer(seS.sessId, seS.shk, seS.saltH);
|
|
493
|
+
const ORIGIN = "http://relay.local";
|
|
494
|
+
const body = "round-" + i + "-中文";
|
|
495
|
+
const mock = async (_input, init) => {
|
|
496
|
+
const envReq = JSON.parse(init.body);
|
|
497
|
+
const opened = peer.open({ kind: "http", dir: "p2b", env: envReq, counter: envReq.c ?? 0 });
|
|
498
|
+
const req = decodeHttpRequestPlain(opened.data);
|
|
499
|
+
const plain = encodeHttpResponsePlain({ status: 200, headers: { "content-type": "text/plain", "x-round": String(i) }, bodyBuffer: Buffer.from("echo:" + Buffer.from(req.bodyB64, "base64").toString("utf8")) });
|
|
500
|
+
const envResp = peer.seal({ kind: "http-resp", dir: "b2p", counter: envReq.c ?? 0, data: plain, reqNonceB64: envReq.n });
|
|
501
|
+
return new Response(JSON.stringify(envResp), { status: 200, headers: { "content-type": "application/vnd.dsh.e2ee-v2", "x-dsh-e2ee": "v=2;s=" + seS.sessId + ";k=http-resp" } });
|
|
502
|
+
};
|
|
503
|
+
const res = await shim.seFetchWrapper(mock, { sess: seS, origin: ORIGIN }, ORIGIN + "/api/echo", { method: "POST", body });
|
|
504
|
+
assert.equal(await res.text(), "echo:" + body);
|
|
505
|
+
assert.equal(res.headers.get("x-round"), String(i));
|
|
506
|
+
}
|
|
507
|
+
});
|