@mrrisega/dsh-remote 0.6.11 → 0.6.13
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 +123 -2
- package/clients/dsh-remote/mobile-adapter.mjs +108 -4
- package/clients/dsh-remote/test/bridge-gzip.test.mjs +68 -1
- package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +388 -0
- package/dsh-setup.mjs +55 -15
- package/package.json +4 -4
- package/packages/dsh-remote-web/lib/client.js +137 -0
- package/packages/dsh-remote-web/lib/fix-prompt.js +160 -0
- package/packages/dsh-remote-web/lib/index.js +748 -18
- package/packages/dsh-remote-web/lib/offline-update.mjs +66 -0
- package/packages/dsh-remote-web/lib/tarball-update.js +442 -0
- package/packages/dsh-remote-web/package.json +5 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +1572 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +1991 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +728 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +767 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim.mjs +163 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +1679 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/package.json +11 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/src/lifecycle.mjs +45 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +2825 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +1984 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-web/native.html +2526 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +2390 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/LICENSE +20 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/README.md +548 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/browser.js +8 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/index.js +22 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/buffer-util.js +131 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/constants.js +19 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/event-target.js +292 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/extension.js +203 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/limiter.js +55 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/receiver.js +743 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/sender.js +607 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/stream.js +161 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/subprotocol.js +62 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/validation.js +152 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket-server.js +562 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket.js +1407 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/package.json +74 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/wrapper.mjs +21 -0
- package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +396 -0
- package/packages/dsh-remote-web/test/diag-report.test.mjs +236 -0
- package/packages/dsh-remote-web/test/fix-prompt.test.mjs +170 -0
- package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +198 -0
- package/packages/dsh-remote-web/test/tarball-update.test.mjs +460 -0
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
/* dsh-e2ee-shim — 镜像页 E2EE 加密 shim(Phase-4;由 bridge 注入到经隧道回传的
|
|
2
|
+
* 官方 dsh web text/html 文档的 </head> 前,与 mobile-adapter 同一注入管线)。
|
|
3
|
+
*
|
|
4
|
+
* 运行时语义(docs/e2ee-protocol.md §4/§5/§6/§9;字节约定对齐 clients/dsh-remote/
|
|
5
|
+
* e2ee-client.mjs「规范化决定 1–6」与 native.html Phase-3 手机端):
|
|
6
|
+
* 0. document-start:读一次性交接单(sessionStorage dsh-e2ee-handover)→ 立即删除,
|
|
7
|
+
* 用内存重建「已与桥端探针互认」的会话(仅 SHK+saltH+sessId;MK/密码不下放)。
|
|
8
|
+
* 1. 有会话 → patch window.fetch / window.WebSocket:命中 §4.5 策略表(/api 排除
|
|
9
|
+
* SSE、/sidebar、/git、/pet;channel 形态 /remote/{api|sidebar|git|pet}/* 与
|
|
10
|
+
* device 形态 /remote/<dev>/… 归一后同样判定)→ HTTP 封 v2 信封发送、响应信封
|
|
11
|
+
* 解密(含 gzip)重建 Response;WS 追加 &e2ee=<sessId>&w=<8B hex> 逐消息封/解。
|
|
12
|
+
* 静态壳/SSE/manifest/其余路径/跨源/router 错误页 → 原样明文(零行为)。
|
|
13
|
+
* 2. 无交接单 / 刷新丢密钥 / 解封失败 → 数据面不拦截 + 状态徽标显式
|
|
14
|
+
* 「⚠ 未加密/无法解密」并给出重解锁指引,绝不静默。
|
|
15
|
+
* 3. 状态徽标 #dsh-e2ee-badge(固定右上角小药丸,点击展开说明)。
|
|
16
|
+
*
|
|
17
|
+
* 密钥纪律:会话密钥只存本页 JS 内存;不写 localStorage/sessionStorage(交接单读后即删);
|
|
18
|
+
* 不记录任何密钥/明文到控制台。
|
|
19
|
+
*
|
|
20
|
+
* ==DSH-E2EE-SHIM-CORE== 区间(纯 WebCrypto/传输逻辑,无 DOM)由测试抽取后在 node 与
|
|
21
|
+
* e2ee-client.mjs / native.html WC-CORE 对拍(字节一致),见 test/lib-e2ee-shim.mjs。
|
|
22
|
+
*/
|
|
23
|
+
(function () {
|
|
24
|
+
"use strict";
|
|
25
|
+
/* ===DSH-E2EE-SHIM-CORE-START=== */
|
|
26
|
+
var SE_NUL = String.fromCharCode(0);
|
|
27
|
+
var SE_DOMAIN = "dsh-e2ee/v1";
|
|
28
|
+
var SE_VERSION = 2;
|
|
29
|
+
var SE_SESS_RE = /^[0-9a-f]{32}$/i;
|
|
30
|
+
var SE_NONCE_BYTES = 12;
|
|
31
|
+
var SE_TAG_BYTES = 16;
|
|
32
|
+
var SE_KEY_BYTES = 32;
|
|
33
|
+
var SE_DIR_P2B = "p2b";
|
|
34
|
+
var SE_DIR_B2P = "b2p";
|
|
35
|
+
var SE_CT = "application/vnd.dsh.e2ee-v2";
|
|
36
|
+
var SE_HDR = "x-dsh-e2ee";
|
|
37
|
+
var DSH_E2EE_HANDOVER_KEY = "dsh-e2ee-handover";
|
|
38
|
+
/* §4.5 策略表:受保护的上游路径前缀(用户内容)。 */
|
|
39
|
+
var SE_PROTECTED_PREFIXES = ["/api", "/sidebar", "/git", "/pet"];
|
|
40
|
+
|
|
41
|
+
function seErr(code, message) {
|
|
42
|
+
var e = new Error(message || code);
|
|
43
|
+
e.code = code;
|
|
44
|
+
e.seE2ee = true;
|
|
45
|
+
return e;
|
|
46
|
+
}
|
|
47
|
+
function seUtf8(s) { return new TextEncoder().encode(String(s == null ? "" : s)); }
|
|
48
|
+
function seUtf8Decode(u8) { return new TextDecoder().decode(u8); }
|
|
49
|
+
function seB64ToBytes(b64) {
|
|
50
|
+
if (typeof b64 !== "string" || b64 === "") throw seErr("bad_b64", "e2ee: base64url 非法");
|
|
51
|
+
var s = b64.replace(/-/g, "+").replace(/_/g, "/");
|
|
52
|
+
while (s.length % 4) s += "=";
|
|
53
|
+
var bin = atob(s);
|
|
54
|
+
var out = new Uint8Array(bin.length);
|
|
55
|
+
for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
function seBytesToB64(bytes) {
|
|
59
|
+
var u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
60
|
+
var bin = "";
|
|
61
|
+
var CH = 0x8000;
|
|
62
|
+
for (var i = 0; i < u.length; i += CH) bin += String.fromCharCode.apply(null, u.subarray(i, i + CH));
|
|
63
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
64
|
+
}
|
|
65
|
+
function seConcat(parts) {
|
|
66
|
+
var total = 0;
|
|
67
|
+
for (var i = 0; i < parts.length; i++) total += parts[i].length;
|
|
68
|
+
var out = new Uint8Array(total);
|
|
69
|
+
var o = 0;
|
|
70
|
+
for (var j = 0; j < parts.length; j++) { out.set(parts[j], o); o += parts[j].length; }
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
function seRandomBytes(n) {
|
|
74
|
+
var u = new Uint8Array(n);
|
|
75
|
+
crypto.getRandomValues(u);
|
|
76
|
+
return u;
|
|
77
|
+
}
|
|
78
|
+
function seRandomHex(n) {
|
|
79
|
+
var s = "";
|
|
80
|
+
var u = seRandomBytes(n);
|
|
81
|
+
for (var i = 0; i < u.length; i++) s += u[i].toString(16).padStart(2, "0");
|
|
82
|
+
return s;
|
|
83
|
+
}
|
|
84
|
+
function seSchemeFamily(proto) {
|
|
85
|
+
if (proto === "https:" || proto === "wss:") return 2;
|
|
86
|
+
if (proto === "http:" || proto === "ws:") return 1;
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
/** 同源判定:同 host:port 且同 scheme 族(http/ws ↔ wss/https 视为同隧道源)。 */
|
|
90
|
+
function seSameWebContext(u, originStr) {
|
|
91
|
+
try {
|
|
92
|
+
var o = new URL(originStr || "");
|
|
93
|
+
return u.host === o.host && seSchemeFamily(u.protocol) === seSchemeFamily(o.protocol);
|
|
94
|
+
} catch (e) { return false; }
|
|
95
|
+
}
|
|
96
|
+
function seHkdf(ikm, salt, infoStr, len) {
|
|
97
|
+
return crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"])
|
|
98
|
+
.then(function (key) {
|
|
99
|
+
return crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: salt, info: seUtf8(infoStr) }, key, len * 8);
|
|
100
|
+
})
|
|
101
|
+
.then(function (bits) { return new Uint8Array(bits); });
|
|
102
|
+
}
|
|
103
|
+
function seHeaderValueOf(headers, name) {
|
|
104
|
+
var lk = String(name).toLowerCase();
|
|
105
|
+
if (!headers) return "";
|
|
106
|
+
if (typeof headers.get === "function") { try { return String(headers.get(lk) || ""); } catch (e) { return ""; } }
|
|
107
|
+
var out = "";
|
|
108
|
+
try {
|
|
109
|
+
for (var k in headers) {
|
|
110
|
+
if (Object.prototype.hasOwnProperty.call(headers, k) && String(k).toLowerCase() === lk) {
|
|
111
|
+
var v = headers[k];
|
|
112
|
+
out = Array.isArray(v) ? v.join(", ") : String(v);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch (e) { /* ignore */ }
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
function seEntriesOf(headers) {
|
|
119
|
+
var out = [];
|
|
120
|
+
if (!headers) return out;
|
|
121
|
+
if (typeof headers.entries === "function") {
|
|
122
|
+
try {
|
|
123
|
+
var it = headers.entries();
|
|
124
|
+
var step = it.next();
|
|
125
|
+
while (!step.done) { out.push([step.value[0], String(step.value[1])]); step = it.next(); }
|
|
126
|
+
} catch (e) { /* ignore */ }
|
|
127
|
+
} else {
|
|
128
|
+
for (var k in headers) {
|
|
129
|
+
if (Object.prototype.hasOwnProperty.call(headers, k) && headers[k] != null) out.push([k, Array.isArray(headers[k]) ? headers[k].join(", ") : String(headers[k])]);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 一个解锁会话(由交接单重建;与桥端已探针互认的会话同 sessId)。密钥仅内存。 */
|
|
136
|
+
function SeSession(o) {
|
|
137
|
+
if (!o || typeof o.sessId !== "string" || !SE_SESS_RE.test(o.sessId)) throw seErr("bad_session", "e2ee: sessId 非法");
|
|
138
|
+
if (!o.shk || o.shk.length !== SE_KEY_BYTES || !o.saltH || o.saltH.length !== SE_KEY_BYTES) throw seErr("bad_session", "e2ee: SHK/saltH 非法");
|
|
139
|
+
this.sessId = o.sessId;
|
|
140
|
+
this.shk = o.shk;
|
|
141
|
+
this.saltH = o.saltH;
|
|
142
|
+
this.profile = typeof o.profile === "string" ? o.profile : "";
|
|
143
|
+
this.epoch = Number(o.epoch) || 0;
|
|
144
|
+
this.verified = true; // 交接单来源 = 已通过探针的解锁会话
|
|
145
|
+
}
|
|
146
|
+
/** 子密钥(node keyFor / wc keyBytesFor 同构)。 */
|
|
147
|
+
SeSession.prototype.keyBytesFor = function (opt) {
|
|
148
|
+
var kind = opt.kind, dir = opt.dir, use;
|
|
149
|
+
if (kind === "http" || kind === "http-resp") {
|
|
150
|
+
if (!opt.reqNonceB64) throw seErr("bad_keyinfo", "e2ee: http 子密钥需要 reqNonceB64");
|
|
151
|
+
use = "http" + SE_NUL + opt.reqNonceB64 + SE_NUL + dir;
|
|
152
|
+
} else if (kind === "w") {
|
|
153
|
+
if (!opt.wsLabel) throw seErr("bad_keyinfo", "e2ee: ws 子密钥需要 wsLabel");
|
|
154
|
+
use = "ws" + SE_NUL + opt.wsLabel + SE_NUL + dir;
|
|
155
|
+
} else if (kind === "ctrl") {
|
|
156
|
+
use = "ctrl" + SE_NUL + dir;
|
|
157
|
+
} else {
|
|
158
|
+
throw seErr("bad_keyinfo", "e2ee: 未知信封 kind: " + kind);
|
|
159
|
+
}
|
|
160
|
+
if (dir !== SE_DIR_P2B && dir !== SE_DIR_B2P) throw seErr("bad_keyinfo", "e2ee: 未知方向: " + dir);
|
|
161
|
+
return seHkdf(this.shk, this.saltH, SE_DOMAIN + SE_NUL + use, SE_KEY_BYTES);
|
|
162
|
+
};
|
|
163
|
+
/** AAD(与 node aadOf 同构)。 */
|
|
164
|
+
SeSession.prototype.aadBytesOf = function (opt) {
|
|
165
|
+
return seUtf8(SE_DOMAIN + SE_NUL + opt.kind + SE_NUL + this.sessId + SE_NUL + opt.dir + SE_NUL + String(opt.counter == null ? 0 : opt.counter));
|
|
166
|
+
};
|
|
167
|
+
/** 密封一条消息 → 信封对象(node seal 的浏览器异步版)。 */
|
|
168
|
+
SeSession.prototype.seal = async function (opt) {
|
|
169
|
+
var kind = opt.kind, dir = opt.dir;
|
|
170
|
+
var counter = opt.counter == null ? 0 : opt.counter;
|
|
171
|
+
var isText = typeof opt.data === "string";
|
|
172
|
+
var pt = isText ? seUtf8(opt.data) : (opt.data instanceof Uint8Array ? opt.data : new Uint8Array(opt.data));
|
|
173
|
+
var tOut = opt.t !== undefined ? (opt.t === 0 ? 0 : 1) : kind === "w" ? (isText ? 0 : 1) : 0;
|
|
174
|
+
var n = opt.nonceBytes || seRandomBytes(SE_NONCE_BYTES);
|
|
175
|
+
if (!n || n.length !== SE_NONCE_BYTES) throw seErr("bad_nonce", "e2ee: nonce 须为 12B");
|
|
176
|
+
var keyNonce = kind === "http-resp" ? (opt.reqNonceB64 || "") : (opt.reqNonceB64 || seBytesToB64(n));
|
|
177
|
+
var keyBytes = await this.keyBytesFor({ kind: kind, dir: dir, reqNonceB64: keyNonce, wsLabel: opt.wsLabel || "" });
|
|
178
|
+
var key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt"]);
|
|
179
|
+
var enc = await crypto.subtle.encrypt(
|
|
180
|
+
{ name: "AES-GCM", iv: n, additionalData: this.aadBytesOf({ kind: kind, dir: dir, counter: counter }), tagLength: 128 },
|
|
181
|
+
key, pt
|
|
182
|
+
);
|
|
183
|
+
return { v: SE_VERSION, k: kind, s: this.sessId, c: counter, n: seBytesToB64(n), t: tOut, d: seBytesToB64(new Uint8Array(enc)) };
|
|
184
|
+
};
|
|
185
|
+
/** 打开(解密+认证)一条消息。失败抛 code 错误(node open 的浏览器异步版)。 */
|
|
186
|
+
SeSession.prototype.open = async function (opt) {
|
|
187
|
+
var env = opt.env;
|
|
188
|
+
if (!env || typeof env !== "object") throw seErr("bad_envelope", "e2ee: 信封缺失");
|
|
189
|
+
if (env.v !== SE_VERSION) throw seErr("bad_version", "e2ee: 信封版本不受支持: " + env.v);
|
|
190
|
+
if (env.k !== opt.kind) throw seErr("bad_kind", "e2ee: 信封 kind 不符");
|
|
191
|
+
if (env.s !== this.sessId) throw seErr("bad_session", "e2ee: 信封会话不符");
|
|
192
|
+
var raw = null;
|
|
193
|
+
try { raw = seB64ToBytes(env.d); } catch (e) { raw = null; }
|
|
194
|
+
if (!raw || raw.length < SE_TAG_BYTES + 1) throw seErr("bad_cipher", "e2ee: 密文非法");
|
|
195
|
+
var n = null;
|
|
196
|
+
try { n = seB64ToBytes(env.n); } catch (e) { n = null; }
|
|
197
|
+
if (!n || n.length !== SE_NONCE_BYTES) throw seErr("bad_nonce", "e2ee: nonce 非法");
|
|
198
|
+
var keyNonce = opt.kind === "http-resp" ? (opt.reqNonceB64 || "") : (opt.reqNonceB64 || String(env.n || ""));
|
|
199
|
+
var keyBytes = await this.keyBytesFor({ kind: opt.kind, dir: opt.dir, reqNonceB64: keyNonce, wsLabel: opt.wsLabel || "" });
|
|
200
|
+
var key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["decrypt"]);
|
|
201
|
+
var out = null;
|
|
202
|
+
try {
|
|
203
|
+
out = new Uint8Array(await crypto.subtle.decrypt(
|
|
204
|
+
{ name: "AES-GCM", iv: n, additionalData: this.aadBytesOf({ kind: opt.kind, dir: opt.dir, counter: opt.counter == null ? 0 : opt.counter }), tagLength: 128 },
|
|
205
|
+
key, raw
|
|
206
|
+
));
|
|
207
|
+
} catch (e) {
|
|
208
|
+
throw seErr("auth_failed", "e2ee: AEAD 打开失败(密文被篡改或密钥不一致)");
|
|
209
|
+
}
|
|
210
|
+
return { data: out, t: env.t === 1 ? 1 : 0 };
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/* ---- http 明文载荷编解码(与 node encode/decodeHttp*Plain 同构) ---- */
|
|
214
|
+
function seEncodeHttpReqPlain(opt) {
|
|
215
|
+
var b = opt.bodyBytes && opt.bodyBytes.length ? seBytesToB64(opt.bodyBytes) : "";
|
|
216
|
+
return seUtf8(JSON.stringify({ m: String(opt.method || "GET").toUpperCase(), p: opt.path, h: opt.headers || {}, b: b }));
|
|
217
|
+
}
|
|
218
|
+
function seDecodeHttpRespPlain(buf) {
|
|
219
|
+
var text = buf instanceof Uint8Array ? seUtf8Decode(buf) : String(buf);
|
|
220
|
+
var pt = null;
|
|
221
|
+
try { pt = JSON.parse(text); } catch (e) { throw seErr("bad_plain", "e2ee: http 响应明文不是 JSON"); }
|
|
222
|
+
if (!pt || typeof pt !== "object") throw seErr("bad_plain", "e2ee: http 响应明文缺失");
|
|
223
|
+
return {
|
|
224
|
+
status: Number(pt.st) || 502,
|
|
225
|
+
headers: pt.h && typeof pt.h === "object" ? pt.h : {},
|
|
226
|
+
enc: typeof pt.enc === "string" ? pt.enc : "",
|
|
227
|
+
bodyB64: typeof pt.b === "string" ? pt.b : ""
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function seEnvelopeHeaders(sessId) {
|
|
231
|
+
var h = {};
|
|
232
|
+
h["content-type"] = SE_CT;
|
|
233
|
+
h[SE_HDR] = "v=" + SE_VERSION + ";s=" + sessId + ";k=http";
|
|
234
|
+
return h;
|
|
235
|
+
}
|
|
236
|
+
function seIsEnvelopeResponse(res) {
|
|
237
|
+
try {
|
|
238
|
+
var ct = String(res.headers.get("content-type") || "");
|
|
239
|
+
if (ct.split(";")[0].trim().toLowerCase() !== SE_CT) return false;
|
|
240
|
+
return !!res.headers.get(SE_HDR);
|
|
241
|
+
} catch (e) { return false; }
|
|
242
|
+
}
|
|
243
|
+
function seGunzip(u8) {
|
|
244
|
+
try {
|
|
245
|
+
var ds = new DecompressionStream("gzip");
|
|
246
|
+
var stream = new Blob([u8]).stream().pipeThrough(ds);
|
|
247
|
+
return new Response(stream).arrayBuffer().then(function (ab) { return new Uint8Array(ab); });
|
|
248
|
+
} catch (e) { return null; }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/* ---- 上游路径 / channel|device 形态归一(与 router resolveRoute / native wcUpstreamPathOf 同构) ---- */
|
|
252
|
+
function seUpstreamPathOf(fullPath) {
|
|
253
|
+
var p = String(fullPath || "");
|
|
254
|
+
var query = "";
|
|
255
|
+
var qIdx = p.indexOf("?");
|
|
256
|
+
if (qIdx !== -1) { query = p.slice(qIdx + 1); p = p.slice(0, qIdx); }
|
|
257
|
+
var params = new URLSearchParams(query);
|
|
258
|
+
params.delete("device"); // router 对 channel 形态剥 device 参数
|
|
259
|
+
var path = p;
|
|
260
|
+
if (p.indexOf("/remote/") === 0) {
|
|
261
|
+
var rest = p.slice("/remote/".length);
|
|
262
|
+
var slash = rest.indexOf("/");
|
|
263
|
+
var head = slash === -1 ? rest : rest.slice(0, slash);
|
|
264
|
+
var tail = slash === -1 ? "" : rest.slice(slash);
|
|
265
|
+
var marker = head === "api" || head === "sidebar" || head === "git" || head === "pet" || head === "_e2ee";
|
|
266
|
+
path = marker ? "/" + head + tail : (tail || "/");
|
|
267
|
+
}
|
|
268
|
+
var qs = params.size ? "?" + params.toString() : "";
|
|
269
|
+
return path + qs;
|
|
270
|
+
}
|
|
271
|
+
function seIsProtectedPath(path) {
|
|
272
|
+
for (var i = 0; i < SE_PROTECTED_PREFIXES.length; i++) {
|
|
273
|
+
var pre = SE_PROTECTED_PREFIXES[i];
|
|
274
|
+
if (path === pre || path.indexOf(pre + "/") === 0) return true;
|
|
275
|
+
}
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
/** HTTP fetch 是否应加密(§4.5:命中前缀;SSE /api/…/events* 与 event-stream 目标排除)。 */
|
|
279
|
+
function seShouldEncryptHttp(fullPath, headers) {
|
|
280
|
+
var up = seUpstreamPathOf(fullPath);
|
|
281
|
+
var qIdx = up.indexOf("?");
|
|
282
|
+
var path = qIdx === -1 ? up : up.slice(0, qIdx);
|
|
283
|
+
if (!seIsProtectedPath(path)) return false;
|
|
284
|
+
if (/(^|\/)events([/.]|$)/i.test(path)) return false; // SSE 端点保持明文(§4.5)
|
|
285
|
+
var accept = seHeaderValueOf(headers, "accept").toLowerCase();
|
|
286
|
+
if (accept.indexOf("text/event-stream") !== -1) return false;
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
/** 数据 WS 是否应加密(§4.6:命中前缀即加密,SSE 例外只作用于 HTTP)。 */
|
|
290
|
+
function seShouldEncryptWs(fullPath) {
|
|
291
|
+
var up = seUpstreamPathOf(fullPath);
|
|
292
|
+
var qIdx = up.indexOf("?");
|
|
293
|
+
var path = qIdx === -1 ? up : up.slice(0, qIdx);
|
|
294
|
+
if (path.indexOf("/_e2ee/") === 0) return false;
|
|
295
|
+
return seIsProtectedPath(path);
|
|
296
|
+
}
|
|
297
|
+
/** 解析含 e2ee/w 标记的上游路径 → {sessId,w,wsLabel,upstreamPath}(与 bridge parseWsE2eeParams 同构)。 */
|
|
298
|
+
function seWsLabelParts(fullSentPath) {
|
|
299
|
+
var up = seUpstreamPathOf(fullSentPath);
|
|
300
|
+
var qIdx = up.indexOf("?");
|
|
301
|
+
var path = qIdx === -1 ? up : up.slice(0, qIdx);
|
|
302
|
+
if (qIdx === -1) return null;
|
|
303
|
+
var params = new URLSearchParams(up.slice(qIdx + 1));
|
|
304
|
+
var sessId = params.get("e2ee") || "";
|
|
305
|
+
var w = params.get("w") || "";
|
|
306
|
+
if (!sessId || !w || !SE_SESS_RE.test(sessId)) return null;
|
|
307
|
+
params.delete("e2ee");
|
|
308
|
+
var qsLabel = params.size ? "?" + params.toString() : "";
|
|
309
|
+
var labelWithW = path + qsLabel; // w 保留其中
|
|
310
|
+
params.delete("w");
|
|
311
|
+
var qsUp = params.size ? "?" + params.toString() : "";
|
|
312
|
+
return { sessId: sessId, w: w, wsLabel: labelWithW, upstreamPath: path + qsUp };
|
|
313
|
+
}
|
|
314
|
+
/** 构造数据 WS 目标 URL:追加 &w=<8B hex>&e2ee=<sessId>(参数顺序与 native e2eeOpenDataWs 一致)。
|
|
315
|
+
* 幂等:URL 已带同 sessId 的 e2ee(如从 ws.url 重开)→ 保留 w/补 w,不再二次加参;
|
|
316
|
+
* 若带的是其它会话 e2ee → null(不接管,原样放行)。 */
|
|
317
|
+
function seBuildWsTarget(urlStr, sessId, wHex, baseUrl) {
|
|
318
|
+
var u = new URL(urlStr, baseUrl || undefined);
|
|
319
|
+
if (u.protocol === "https:") u.protocol = "wss:";
|
|
320
|
+
else if (u.protocol !== "wss:" && u.protocol !== "ws:") u.protocol = "ws:";
|
|
321
|
+
var params = new URLSearchParams(u.search);
|
|
322
|
+
var existing = params.get("e2ee");
|
|
323
|
+
if (existing && existing !== sessId) return null;
|
|
324
|
+
if (!existing) params.set("e2ee", sessId);
|
|
325
|
+
if (!params.get("w")) params.set("w", wHex);
|
|
326
|
+
u.search = params.toString();
|
|
327
|
+
var fullSent = u.pathname + (params.size ? "?" + params.toString() : "");
|
|
328
|
+
var parts = seWsLabelParts(fullSent);
|
|
329
|
+
if (!parts) return null;
|
|
330
|
+
return { href: u.href, fullSentPath: fullSent, sessId: parts.sessId, w: parts.w, wsLabel: parts.wsLabel, upstreamPath: parts.upstreamPath };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/* ---- 一次性交接单(与 native.html DSH-E2EE-HANDOVER-CORE 同格式) ---- */
|
|
334
|
+
function seHandoverDecode(raw) {
|
|
335
|
+
var obj = null;
|
|
336
|
+
try { obj = JSON.parse(String(raw || "")); } catch (e) { return null; }
|
|
337
|
+
if (!obj || obj.v !== SE_VERSION) return null;
|
|
338
|
+
if (typeof obj.s !== "string" || !SE_SESS_RE.test(obj.s)) return null;
|
|
339
|
+
var shk = null, saltH = null;
|
|
340
|
+
try { shk = seB64ToBytes(obj.shk); saltH = seB64ToBytes(obj.salt); } catch (e) { return null; }
|
|
341
|
+
if (!shk || shk.length !== SE_KEY_BYTES || !saltH || saltH.length !== SE_KEY_BYTES) return null;
|
|
342
|
+
return { v: 2, s: obj.s, shk: shk, saltH: saltH, profile: typeof obj.profile === "string" ? obj.profile : "", epoch: Number(obj.epoch) || 0 };
|
|
343
|
+
}
|
|
344
|
+
function seSessionOfHandover(raw) {
|
|
345
|
+
var h = seHandoverDecode(raw);
|
|
346
|
+
if (!h) return null;
|
|
347
|
+
try {
|
|
348
|
+
return new SeSession({ sessId: h.s, shk: h.shk, saltH: h.saltH, profile: h.profile, epoch: h.epoch });
|
|
349
|
+
} catch (e) { return null; }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/* ---- 失败上报钩子(DOM 徽标接线注入;node 测试可替换为收集器) ---- */
|
|
353
|
+
var seFailureHook = null;
|
|
354
|
+
function seNotifyFail(message) {
|
|
355
|
+
try { if (typeof seFailureHook === "function") seFailureHook(String(message || "")); } catch (e) { /* ignore */ }
|
|
356
|
+
if (typeof console !== "undefined" && console.warn) console.warn("[dsh-e2ee-shim]", message);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/* ---- HTTP 信封传输包装(与 native e2eeFetchEnveloped 同构;输入/输出为 fetch 语义) ---- */
|
|
360
|
+
var SE_OUTER_STRIP = {
|
|
361
|
+
"host": 1, "origin": 1, "referer": 1, "cookie": 1, "connection": 1, "upgrade": 1,
|
|
362
|
+
"keep-alive": 1, "transfer-encoding": 1, "content-length": 1, "accept-encoding": 1,
|
|
363
|
+
"sec-fetch-site": 1, "sec-fetch-mode": 1, "sec-fetch-dest": 1, "sec-fetch-user": 1,
|
|
364
|
+
"te": 1, "trailer": 1, "proxy-connection": 1, "x-forwarded-for": 1, "x-forwarded-proto": 1, "x-forwarded-host": 1
|
|
365
|
+
};
|
|
366
|
+
function seHeadersForEnvelope(headers) {
|
|
367
|
+
var h = {};
|
|
368
|
+
var entries = seEntriesOf(headers);
|
|
369
|
+
for (var i = 0; i < entries.length; i++) {
|
|
370
|
+
var k = entries[i][0], v = entries[i][1];
|
|
371
|
+
var lk = String(k).toLowerCase();
|
|
372
|
+
if (SE_OUTER_STRIP[lk]) continue;
|
|
373
|
+
if (v === undefined || v === null || v === "") continue;
|
|
374
|
+
h[k] = Array.isArray(v) ? v.join(", ") : String(v);
|
|
375
|
+
}
|
|
376
|
+
return h;
|
|
377
|
+
}
|
|
378
|
+
function seErrorResponse(code, message) {
|
|
379
|
+
var hdrs = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-dsh-e2ee-error": String(code || "e2ee_failed") };
|
|
380
|
+
return new Response(JSON.stringify({ error: { code: String(code || "e2ee_failed"), message: message } }), { status: 502, headers: hdrs });
|
|
381
|
+
}
|
|
382
|
+
function seBuildResponse(status, headers, bodyU8) {
|
|
383
|
+
var hdrs = new Headers();
|
|
384
|
+
var entries = seEntriesOf(headers);
|
|
385
|
+
for (var i = 0; i < entries.length; i++) {
|
|
386
|
+
var k = entries[i][0], v = entries[i][1];
|
|
387
|
+
var lk = String(k).toLowerCase();
|
|
388
|
+
if (lk === "content-length" || lk === "content-encoding" || lk === "transfer-encoding") continue;
|
|
389
|
+
try { hdrs.append(k, v); } catch (e) { /* ignore */ }
|
|
390
|
+
}
|
|
391
|
+
try { hdrs.set("content-length", String(bodyU8.length)); } catch (e) { /* ignore */ }
|
|
392
|
+
var noBody = status === 204 || status === 205 || status === 304 || bodyU8.length === 0;
|
|
393
|
+
return new Response(noBody ? null : bodyU8, { status: status, headers: hdrs });
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* fetch 包装核心:命中策略 → 密封→发→响应信封解密→重建 Response;
|
|
397
|
+
* 未命中/跨源/无法读取 body/明文响应 → 原样透传(零行为)。
|
|
398
|
+
* @param {function} origFetch
|
|
399
|
+
* @param {{sess: SeSession, origin: string}} ctx
|
|
400
|
+
*/
|
|
401
|
+
async function seFetchWrapper(origFetch, ctx, input, init) {
|
|
402
|
+
if (!ctx || !ctx.sess) return origFetch(input, init);
|
|
403
|
+
var inputReq = input instanceof Request;
|
|
404
|
+
var req = inputReq ? input : new Request(input, init);
|
|
405
|
+
var urlObj = null;
|
|
406
|
+
try { urlObj = new URL(req.url, ctx.origin); } catch (e) { return origFetch(input, init); }
|
|
407
|
+
if (!seSameWebContext(urlObj, ctx.origin)) return origFetch(input, init); // 仅同隧道源
|
|
408
|
+
var fullPath = urlObj.pathname + urlObj.search;
|
|
409
|
+
if (!seShouldEncryptHttp(fullPath, req.headers)) return origFetch(input, init);
|
|
410
|
+
// body 需可安全读取:已消费/流式 → 明文回退(不打断调用方)
|
|
411
|
+
var bodyBytes = null;
|
|
412
|
+
if (req.body != null) {
|
|
413
|
+
var readTarget = req;
|
|
414
|
+
if (inputReq) {
|
|
415
|
+
if (req.bodyUsed) return origFetch(input, init);
|
|
416
|
+
if (req.body && typeof req.body.getReader === "function") return origFetch(input, init);
|
|
417
|
+
try { readTarget = req.clone(); } catch (e) { return origFetch(input, init); }
|
|
418
|
+
}
|
|
419
|
+
try { bodyBytes = new Uint8Array(await readTarget.arrayBuffer()); } catch (e) { return origFetch(input, init); }
|
|
420
|
+
}
|
|
421
|
+
var upstream = seUpstreamPathOf(fullPath);
|
|
422
|
+
var plain = seEncodeHttpReqPlain({ method: req.method, path: upstream, headers: seHeadersForEnvelope(req.headers), bodyBytes: bodyBytes });
|
|
423
|
+
var n = seRandomBytes(SE_NONCE_BYTES);
|
|
424
|
+
var nonceB64 = seBytesToB64(n);
|
|
425
|
+
var env = await ctx.sess.seal({ kind: "http", dir: SE_DIR_P2B, counter: 0, data: plain, nonceBytes: n });
|
|
426
|
+
var outerHeaders = seEnvelopeHeaders(ctx.sess.sessId);
|
|
427
|
+
var cookieH = seHeaderValueOf(req.headers, "cookie");
|
|
428
|
+
if (cookieH && req.credentials !== "omit") outerHeaders.cookie = cookieH; // node 测试显式 cookie;浏览器同源自动带
|
|
429
|
+
var res;
|
|
430
|
+
try {
|
|
431
|
+
res = await origFetch(urlObj.href, {
|
|
432
|
+
method: "POST",
|
|
433
|
+
headers: outerHeaders,
|
|
434
|
+
body: JSON.stringify(env),
|
|
435
|
+
credentials: req.credentials,
|
|
436
|
+
signal: req.signal
|
|
437
|
+
});
|
|
438
|
+
} catch (e) { throw e; }
|
|
439
|
+
if (!seIsEnvelopeResponse(res)) return res; // router/桥端明文错误页原样透传
|
|
440
|
+
var envResp = null;
|
|
441
|
+
try { envResp = await res.json(); } catch (e) {
|
|
442
|
+
seNotifyFail("响应信封非法");
|
|
443
|
+
return seErrorResponse("bad_envelope", "⚠ 无法解密:响应信封非法");
|
|
444
|
+
}
|
|
445
|
+
var opened = null;
|
|
446
|
+
try {
|
|
447
|
+
opened = await ctx.sess.open({ kind: "http-resp", dir: SE_DIR_B2P, env: envResp, counter: envResp.c == null ? 0 : envResp.c, reqNonceB64: nonceB64 });
|
|
448
|
+
} catch (e) {
|
|
449
|
+
var code = e && e.code ? e.code : "auth_failed";
|
|
450
|
+
seNotifyFail("响应解封失败: " + (e && e.message || code));
|
|
451
|
+
return seErrorResponse(code, "⚠ 无法解密(" + code + "):密钥不一致、传输被篡改或会话已过期。请返回设备列表重新解锁。");
|
|
452
|
+
}
|
|
453
|
+
var rp = null;
|
|
454
|
+
try { rp = seDecodeHttpRespPlain(opened.data); } catch (e) {
|
|
455
|
+
seNotifyFail("响应明文非法");
|
|
456
|
+
return seErrorResponse("bad_plain", "⚠ 无法解密:响应明文非法");
|
|
457
|
+
}
|
|
458
|
+
var bodyU8 = rp.bodyB64 ? seB64ToBytes(rp.bodyB64) : new Uint8Array(0);
|
|
459
|
+
if (rp.enc === "gzip" && bodyU8.length) {
|
|
460
|
+
var un = await seGunzip(bodyU8);
|
|
461
|
+
if (!un) return seErrorResponse("gunzip", "⚠ 无法解压:响应 gzip 解压失败");
|
|
462
|
+
bodyU8 = un;
|
|
463
|
+
}
|
|
464
|
+
return seBuildResponse(rp.status, rp.headers, bodyU8);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/* ---- 数据 WS 代理(逐消息信封;上层看到的是解密后的原文) ---- */
|
|
468
|
+
function SeE2eeWs(NativeWS, url, protocols, meta) {
|
|
469
|
+
var self = this;
|
|
470
|
+
this.url = url;
|
|
471
|
+
this._meta = meta;
|
|
472
|
+
this._inner = new NativeWS(url, protocols);
|
|
473
|
+
this._state = 0; // CONNECTING
|
|
474
|
+
this._p2b = 0;
|
|
475
|
+
this._b2pLast = -1;
|
|
476
|
+
this._msgHandlers = [];
|
|
477
|
+
this._sendQ = Promise.resolve();
|
|
478
|
+
this._recvQ = Promise.resolve();
|
|
479
|
+
this.binaryType = "blob";
|
|
480
|
+
var inner = this._inner;
|
|
481
|
+
inner.onopen = function (ev) { self._state = 1; if (typeof self._onopen === "function") self._onopen(ev || { type: "open", target: self }); };
|
|
482
|
+
inner.onerror = function (ev) { if (typeof self._onerror === "function") self._onerror(ev || { type: "error", target: self }); };
|
|
483
|
+
inner.onclose = function (ev) {
|
|
484
|
+
self._state = 3;
|
|
485
|
+
if (typeof self._onclose === "function") self._onclose(ev || { type: "close", target: self });
|
|
486
|
+
};
|
|
487
|
+
inner.onmessage = function (ev) { self._enqueueInbound(ev); };
|
|
488
|
+
}
|
|
489
|
+
SeE2eeWs.prototype._enqueueInbound = function (ev) {
|
|
490
|
+
var self = this;
|
|
491
|
+
this._recvQ = this._recvQ.then(function () { return self._processInbound(ev); })
|
|
492
|
+
.catch(function (e) {
|
|
493
|
+
var msg = "⚠ 无法解密 WS 消息" + (e && e.message ? ": " + e.message : "");
|
|
494
|
+
seNotifyFail(msg);
|
|
495
|
+
try { self.close(1008, msg); } catch (err) { /* ignore */ }
|
|
496
|
+
});
|
|
497
|
+
};
|
|
498
|
+
SeE2eeWs.prototype._processInbound = async function (ev) {
|
|
499
|
+
var raw = typeof ev.data === "string" ? ev.data : String(ev.data || "");
|
|
500
|
+
var env = null;
|
|
501
|
+
try { env = JSON.parse(raw); } catch (e) { env = null; }
|
|
502
|
+
if (!env || typeof env !== "object" || env.k !== "w") { this._deliver(raw, false); return; } // 明文帧透传(不应出现)
|
|
503
|
+
if (!Number.isInteger(env.c) || env.c <= this._b2pLast) throw seErr("replay", "b2p 计数重放/乱序 " + env.c);
|
|
504
|
+
var opened = await this._meta.sess.open({ kind: "w", dir: SE_DIR_B2P, env: env, counter: env.c, wsLabel: this._meta.wsLabel });
|
|
505
|
+
this._b2pLast = env.c;
|
|
506
|
+
this._deliver(opened.data, opened.t === 1);
|
|
507
|
+
};
|
|
508
|
+
SeE2eeWs.prototype._deliver = function (data, isBinary) {
|
|
509
|
+
var payload;
|
|
510
|
+
if (!isBinary) payload = typeof data === "string" ? data : seUtf8Decode(data);
|
|
511
|
+
else if (this.binaryType === "arraybuffer") payload = data.slice().buffer;
|
|
512
|
+
else if (typeof Blob !== "undefined") payload = new Blob([data]);
|
|
513
|
+
else payload = data;
|
|
514
|
+
var ev = { type: "message", data: payload, target: this, currentTarget: this, origin: "", timeStamp: Date.now() };
|
|
515
|
+
var handlers = this._msgHandlers.slice();
|
|
516
|
+
for (var i = 0; i < handlers.length; i++) {
|
|
517
|
+
var h = handlers[i];
|
|
518
|
+
if (h.once) this._removeMsgHandler(h.fn);
|
|
519
|
+
try { h.fn.call(this, ev); } catch (e) { /* 上层处理错误不阻断 */ }
|
|
520
|
+
}
|
|
521
|
+
if (typeof this._onmessage === "function") {
|
|
522
|
+
try { this._onmessage.call(this, ev); } catch (e) { /* ignore */ }
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
SeE2eeWs.prototype._removeMsgHandler = function (fn) {
|
|
526
|
+
for (var i = 0; i < this._msgHandlers.length; i++) {
|
|
527
|
+
if (this._msgHandlers[i].fn === fn) { this._msgHandlers.splice(i, 1); return; }
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
SeE2eeWs.prototype.send = function (data) {
|
|
531
|
+
var self = this;
|
|
532
|
+
/* ⚠️ 消息体在**调用时刻**归一化(同步完成,保持调用者看到的语义),
|
|
533
|
+
但**加密与发送必须排进 `_sendQ` 队列里做**。
|
|
534
|
+
2026-09-19 修:原先写成
|
|
535
|
+
if (typeof data === "string") p = this._sealSend(data, 0);
|
|
536
|
+
this._sendQ = this._sendQ.then(function () { return p; });
|
|
537
|
+
队列只串了 **promise**,没有串住"开始加密"这个动作 —— 两次背靠背的 `send()`
|
|
538
|
+
会**各自立刻**发起一次 seal(WebCrypto 走线程池,完成顺序无保证),而
|
|
539
|
+
`counter: this._p2b++` 是在 seal **之前**求值的,于是**计数 1 的信封可能先于
|
|
540
|
+
计数 0 发出**。WebSocket 本该保证消息有序,这条路被破坏了。
|
|
541
|
+
(它是间歇性的:端到端用例 `e2ee-shim.test.mjs` 的 WS 顺序断言约 1/5 概率变红,
|
|
542
|
+
一直被当成"flaky 测试",其实是它抓到了真竞态。)
|
|
543
|
+
现在把 `_sealSend` 整体推迟到队列里执行,计数器与发送顺序严格一致。 */
|
|
544
|
+
var norm;
|
|
545
|
+
if (typeof data === "string") norm = Promise.resolve({ payload: data, t: 0 });
|
|
546
|
+
else if (typeof Blob !== "undefined" && data instanceof Blob) norm = data.arrayBuffer().then(function (ab) { return { payload: new Uint8Array(ab), t: 1 }; });
|
|
547
|
+
else if (data instanceof ArrayBuffer) norm = Promise.resolve({ payload: new Uint8Array(data), t: 1 });
|
|
548
|
+
else if (ArrayBuffer.isView(data)) norm = Promise.resolve({ payload: new Uint8Array(data.buffer, data.byteOffset || 0, data.byteLength || data.length), t: 1 });
|
|
549
|
+
else norm = Promise.reject(seErr("bad_ws_data", "e2ee: 不支持的 WS 消息类型"));
|
|
550
|
+
this._sendQ = this._sendQ.then(function () {
|
|
551
|
+
return norm.then(function (m) { return self._sealSend(m.payload, m.t); });
|
|
552
|
+
}).catch(function (e) { seNotifyFail("⚠ 无法加密 WS 消息: " + (e && e.message || e)); });
|
|
553
|
+
};
|
|
554
|
+
SeE2eeWs.prototype._sealSend = async function (payload, t) {
|
|
555
|
+
var env = await this._meta.sess.seal({ kind: "w", dir: SE_DIR_P2B, counter: this._p2b++, data: payload, t: t, wsLabel: this._meta.wsLabel });
|
|
556
|
+
this._inner.send(JSON.stringify(env));
|
|
557
|
+
};
|
|
558
|
+
SeE2eeWs.prototype.close = function (code, reason) { try { this._inner.close(code, reason); } catch (e) { /* ignore */ } };
|
|
559
|
+
SeE2eeWs.prototype.addEventListener = function (type, fn, opts) {
|
|
560
|
+
if (type === "message") { if (typeof fn === "function") this._msgHandlers.push({ fn: fn, once: !!(opts && opts.once) }); return; }
|
|
561
|
+
try { this._inner.addEventListener(type, fn, opts); } catch (e) { /* ignore */ }
|
|
562
|
+
};
|
|
563
|
+
SeE2eeWs.prototype.removeEventListener = function (type, fn) {
|
|
564
|
+
if (type === "message") { this._removeMsgHandler(fn); return; }
|
|
565
|
+
try { this._inner.removeEventListener(type, fn); } catch (e) { /* ignore */ }
|
|
566
|
+
};
|
|
567
|
+
function seWsEventProp(name) {
|
|
568
|
+
Object.defineProperty(SeE2eeWs.prototype, name, {
|
|
569
|
+
configurable: true,
|
|
570
|
+
get: function () { return this["_" + name] || null; },
|
|
571
|
+
set: function (fn) { this["_" + name] = typeof fn === "function" ? fn : null; }
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
seWsEventProp("onopen"); seWsEventProp("onerror"); seWsEventProp("onclose"); seWsEventProp("onmessage");
|
|
575
|
+
Object.defineProperty(SeE2eeWs.prototype, "readyState", { configurable: true, get: function () { return this._state; } });
|
|
576
|
+
Object.defineProperty(SeE2eeWs.prototype, "bufferedAmount", { configurable: true, get: function () { try { return this._inner.bufferedAmount || 0; } catch (e) { return 0; } } });
|
|
577
|
+
Object.defineProperty(SeE2eeWs.prototype, "protocol", { configurable: true, get: function () { try { return this._inner.protocol || ""; } catch (e) { return ""; } } });
|
|
578
|
+
Object.defineProperty(SeE2eeWs.prototype, "extensions", { configurable: true, get: function () { try { return this._inner.extensions || ""; } catch (e) { return ""; } } });
|
|
579
|
+
|
|
580
|
+
/** 生成 window.WebSocket 的替换构造函数:命中策略才套 SeE2eeWs,否则原样放行。 */
|
|
581
|
+
function seMakeWsCtor(NativeWS, ctx) {
|
|
582
|
+
function SeWs(url, protocols) {
|
|
583
|
+
var full = "", should = false;
|
|
584
|
+
try {
|
|
585
|
+
var base = ctx.origin || (typeof window !== "undefined" ? window.location.href : undefined);
|
|
586
|
+
var uo = new URL(url, base);
|
|
587
|
+
if (seSameWebContext(uo, ctx.origin)) { full = uo.pathname + uo.search; should = seShouldEncryptWs(full); }
|
|
588
|
+
} catch (e) { should = false; }
|
|
589
|
+
if (!should) return new NativeWS(url, protocols);
|
|
590
|
+
var target = seBuildWsTarget(url, ctx.sess.sessId, seRandomHex(8), base);
|
|
591
|
+
if (!target) return new NativeWS(url, protocols);
|
|
592
|
+
return new SeE2eeWs(NativeWS, target.href, protocols, { sess: ctx.sess, wsLabel: target.wsLabel });
|
|
593
|
+
}
|
|
594
|
+
SeWs.OPEN = 1; SeWs.CONNECTING = 0; SeWs.CLOSING = 2; SeWs.CLOSED = 3;
|
|
595
|
+
return SeWs;
|
|
596
|
+
}
|
|
597
|
+
/* ===DSH-E2EE-SHIM-CORE-END=== */
|
|
598
|
+
|
|
599
|
+
/* =====================================================================
|
|
600
|
+
* 以下为浏览器接线(不在 ==DSH-E2EE-SHIM-CORE== 抽取区间;node 测试不引用)
|
|
601
|
+
* ===================================================================== */
|
|
602
|
+
var _storage = null;
|
|
603
|
+
try { _storage = window.sessionStorage; } catch (e) { _storage = null; }
|
|
604
|
+
|
|
605
|
+
function _readHandover() {
|
|
606
|
+
if (!_storage) return null;
|
|
607
|
+
var raw = null;
|
|
608
|
+
try { raw = _storage.getItem(DSH_E2EE_HANDOVER_KEY); } catch (e) { return null; }
|
|
609
|
+
if (raw) { try { _storage.removeItem(DSH_E2EE_HANDOVER_KEY); } catch (e) { /* ignore */ } } // 读后即删(一次性)
|
|
610
|
+
return raw || null;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/* ---- 状态徽标 ---- */
|
|
614
|
+
var _badgeCollapseTimer = null; // 自动收缩(仅 ok 态):展示几秒后缩成仅 🔒,不挡界面
|
|
615
|
+
var _badgeState = null; // 最近一次状态 {mode,text,note} —— 供合并后的悬浮菜单读取
|
|
616
|
+
/** 对外只读接口:让统一的「远程控制」悬浮按钮显示加密状态(不暴露任何密钥/会话材料)。 */
|
|
617
|
+
try {
|
|
618
|
+
window.__dshE2eeBadge = {
|
|
619
|
+
state: function () { return _badgeState; },
|
|
620
|
+
open: function () { var el = document.getElementById("dsh-e2ee-badge"); if (el && el.click) el.click(); return !!el; }
|
|
621
|
+
};
|
|
622
|
+
} catch (e) { /* ignore */ }
|
|
623
|
+
function _badgeClearCollapse() {
|
|
624
|
+
try { if (_badgeCollapseTimer) { clearTimeout(_badgeCollapseTimer); _badgeCollapseTimer = null; } } catch (e) {}
|
|
625
|
+
}
|
|
626
|
+
function _badgeArmCollapse(el) {
|
|
627
|
+
_badgeClearCollapse();
|
|
628
|
+
_badgeCollapseTimer = setTimeout(function () {
|
|
629
|
+
try {
|
|
630
|
+
if (!el.classList.contains("expanded") && el.classList.contains("ok")) el.classList.add("collapsed");
|
|
631
|
+
} catch (e) {}
|
|
632
|
+
_badgeCollapseTimer = null;
|
|
633
|
+
}, 5000);
|
|
634
|
+
}
|
|
635
|
+
function _badgeEl() {
|
|
636
|
+
var el = document.getElementById("dsh-e2ee-badge");
|
|
637
|
+
if (el) return el;
|
|
638
|
+
var host = document.body || document.documentElement;
|
|
639
|
+
if (!host) return null;
|
|
640
|
+
el = document.createElement("div");
|
|
641
|
+
el.id = "dsh-e2ee-badge";
|
|
642
|
+
el.setAttribute("role", "status");
|
|
643
|
+
var ico = document.createElement("span");
|
|
644
|
+
ico.className = "dsh-e2ee-badge-ico";
|
|
645
|
+
var txt = document.createElement("span");
|
|
646
|
+
txt.className = "dsh-e2ee-badge-txt";
|
|
647
|
+
var note = document.createElement("div");
|
|
648
|
+
note.className = "dsh-e2ee-badge-note";
|
|
649
|
+
el.appendChild(ico); el.appendChild(txt); el.appendChild(note);
|
|
650
|
+
el.addEventListener("click", function () {
|
|
651
|
+
if (el.classList.contains("collapsed")) {
|
|
652
|
+
// 从仅🔒芯片展开:展示完整文案(文本),不再自动收缩
|
|
653
|
+
el.classList.remove("collapsed");
|
|
654
|
+
el.classList.add("expanded");
|
|
655
|
+
_badgeClearCollapse();
|
|
656
|
+
} else {
|
|
657
|
+
el.classList.toggle("expanded");
|
|
658
|
+
_badgeClearCollapse();
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
el.addEventListener("pointerenter", function () { _badgeClearCollapse(); el.classList.remove("collapsed"); });
|
|
662
|
+
host.appendChild(el);
|
|
663
|
+
return el;
|
|
664
|
+
}
|
|
665
|
+
function _badge(mode, text, note) {
|
|
666
|
+
try {
|
|
667
|
+
// 合并给「远程控制悬浮菜单」(2026-09-19):状态挂到 window.__dshE2eeBadge 并广播事件,
|
|
668
|
+
// 让 mobile-adapter 的统一悬浮按钮显示加密状态、并把独立药丸收起来(只读、不碰加解密逻辑)。
|
|
669
|
+
_badgeState = { mode: String(mode), text: String(text == null ? "" : text), note: String(note == null ? "" : note) };
|
|
670
|
+
try {
|
|
671
|
+
var ev = new CustomEvent("dsh-e2ee-badge", { detail: _badgeState });
|
|
672
|
+
document.dispatchEvent(ev);
|
|
673
|
+
if (window !== document) window.dispatchEvent(ev);
|
|
674
|
+
} catch (e0) { /* 老浏览器:忽略 */ }
|
|
675
|
+
// 桌面宽屏(>820)明文/告警态不加**独立**徽标 —— 与 mobile-adapter 的“桌面零打扰”契约一致;
|
|
676
|
+
// 加密态(ok)或窄屏始终给出明确状态(协议 §2.4“不静默”)。
|
|
677
|
+
// 状态本身照常发布,合并后的悬浮菜单无论宽窄都能看到它。
|
|
678
|
+
if (mode !== "ok" && window.innerWidth > 820) return;
|
|
679
|
+
var el = _badgeEl();
|
|
680
|
+
if (!el) return;
|
|
681
|
+
el.className = mode; // ok | warn | err(重置 expanded/collapsed 由下方状态机管理)
|
|
682
|
+
el.querySelector(".dsh-e2ee-badge-ico").textContent = mode === "ok" ? "🔒" : "⚠";
|
|
683
|
+
el.querySelector(".dsh-e2ee-badge-txt").textContent = text;
|
|
684
|
+
var noteEl = el.querySelector(".dsh-e2ee-badge-note");
|
|
685
|
+
noteEl.textContent = "";
|
|
686
|
+
if (note) {
|
|
687
|
+
noteEl.appendChild(document.createTextNode(note));
|
|
688
|
+
if (mode !== "ok") {
|
|
689
|
+
var link = document.createElement("a");
|
|
690
|
+
link.href = _appUrl();
|
|
691
|
+
link.textContent = " 返回设备列表重新解锁 →";
|
|
692
|
+
noteEl.appendChild(link);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
el.title = text;
|
|
696
|
+
// 自动收缩:仅加密态(ok)几秒后缩成纯 🔒 芯片(warn/err 属安全提示保持文本,不静默)
|
|
697
|
+
if (mode === "ok") _badgeArmCollapse(el);
|
|
698
|
+
} catch (e) { /* 徽标失败不阻断页面 */ }
|
|
699
|
+
}
|
|
700
|
+
function _appUrl() {
|
|
701
|
+
try { return new URL("/app/", window.location.href).href; } catch (e) { return "/app/"; }
|
|
702
|
+
}
|
|
703
|
+
function _reportFail(message) {
|
|
704
|
+
_badge("err", "无法解密", String(message || "会话异常"));
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/* ---- 安装 fetch/WS 加密补丁(有会话才安装) ---- */
|
|
708
|
+
var _installed = false;
|
|
709
|
+
function _install(sess) {
|
|
710
|
+
if (_installed) return;
|
|
711
|
+
_installed = true;
|
|
712
|
+
seFailureHook = _reportFail;
|
|
713
|
+
var ctx = { sess: sess, origin: window.location.origin };
|
|
714
|
+
try {
|
|
715
|
+
if (window.fetch) {
|
|
716
|
+
var origFetch = window.fetch.bind(window);
|
|
717
|
+
window.fetch = function (input, init) { return seFetchWrapper(origFetch, ctx, input, init); };
|
|
718
|
+
}
|
|
719
|
+
} catch (e) { /* ignore */ }
|
|
720
|
+
try {
|
|
721
|
+
if (window.WebSocket) {
|
|
722
|
+
var NativeWS = window.WebSocket;
|
|
723
|
+
window.WebSocket = seMakeWsCtor(NativeWS, ctx);
|
|
724
|
+
}
|
|
725
|
+
} catch (e) { /* ignore */ }
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/* (刷新恢复见 _boot:优先 localStorage 持久交接单,2026-09) */
|
|
729
|
+
/* ---- 启动 ---- */
|
|
730
|
+
function _boot() {
|
|
731
|
+
try {
|
|
732
|
+
if (!(window.crypto && window.crypto.subtle && window.crypto.getRandomValues)) {
|
|
733
|
+
_badge("warn", "未加密:环境不支持", "当前浏览器环境不支持 WebCrypto(需 HTTPS 安全上下文),镜像页数据流保持明文。");
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
var raw = _readHandover();
|
|
737
|
+
if (!raw) {
|
|
738
|
+
// 刷新/重开:sessionStorage 一次性交接单已被读走 → 尝试 localStorage「记住本机」持久交接单
|
|
739
|
+
// (2026-09 用户决策;同一解锁会话的 SHK,刷新镜像页后仍能恢复加密)。
|
|
740
|
+
var lsRaw = null;
|
|
741
|
+
try { lsRaw = localStorage.getItem(DSH_E2EE_HANDOVER_KEY); } catch (e) { lsRaw = null; }
|
|
742
|
+
if (lsRaw) {
|
|
743
|
+
var lsSess = null;
|
|
744
|
+
try { lsSess = seSessionOfHandover(lsRaw); } catch (e) { lsSess = null; }
|
|
745
|
+
if (lsSess) {
|
|
746
|
+
_install(lsSess);
|
|
747
|
+
_badge("ok", "端到端加密已开启", "刷新后已自动恢复端到端加密(记住本机);密钥仅存内存。");
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
_badge("warn", "未加密:未解锁(明文)", "本次为明文连接。密钥只存内存;请返回设备列表重新点选设备解锁后,镜像页的数据请求与 WebSocket 将自动加密。");
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
var sess = null;
|
|
755
|
+
try { sess = seSessionOfHandover(raw); } catch (e) { sess = null; }
|
|
756
|
+
if (!sess) {
|
|
757
|
+
_badge("warn", "未加密:交接单无效", "一次性交接单无法解析(可能已损坏或过期)。请返回设备列表重新解锁。");
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
_install(sess);
|
|
761
|
+
_badge("ok", "端到端加密已开启", "本镜像页的 /api、/sidebar、/git、/pet 数据请求与数据 WebSocket 已加密(密钥仅存内存)。");
|
|
762
|
+
} catch (e) {
|
|
763
|
+
try { if (window.console) console.warn("[dsh-e2ee-shim]", e && e.message); } catch (err) { /* ignore */ }
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
_boot();
|
|
767
|
+
})();
|