@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,728 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-remote E2EE 客户端基建(bridge 端,Phase-2)
|
|
4
|
+
*
|
|
5
|
+
* 依据 docs/e2ee-protocol.md(评审稿 v0.1)§3/§4/§5/§9 实现,零外部依赖(node:crypto)。
|
|
6
|
+
* 本模块是 **bridge(电脑端)一侧** 的 E2EE 能力:MK 派生 / HKDF 会话密钥调度 /
|
|
7
|
+
* AES-256-GCM 信封(HTTP 请求-响应与 WS 逐消息)/ 控制通道握手(§5.2 桥端应答)/
|
|
8
|
+
* 防重放(http nonce 滑动窗口 + ws 方向计数)。
|
|
9
|
+
*
|
|
10
|
+
* 关键边界(与协议一致):
|
|
11
|
+
* - MK 只驻内存,绝不落盘、不进日志;密码只在本进程内参与派生。
|
|
12
|
+
* - 明文回退由「开关」表达:service.enabled===false 时调用方必须走原 v1 路径。
|
|
13
|
+
* - 本模块不做任何网络层代理,只提供原语与最小会话状态机;帧编排在 dsh-bridge.mjs。
|
|
14
|
+
*
|
|
15
|
+
* ── 本阶段对评审稿的“实现规范化”决定(Phase-3 手机端必须按同样规则实现)──
|
|
16
|
+
* 1. 域/info 分隔符统一用 \x00;所有 HKDF info 形如 "dsh-e2ee/v1\0<用途>"。
|
|
17
|
+
* 2. SHK = HKDF-SHA256(ikm=MK, salt=sha256(a‖b), info="dsh-e2ee/v1\0shk", 32)
|
|
18
|
+
* 子密钥 = HKDF-SHA256(ikm=SHK, salt=同 H(a‖b), info=域串, 32)
|
|
19
|
+
* (extract 阶段 salt 固定 = H(a‖b),与协议 §4.1“salt 取 H(a‖b)”一致;浏览器
|
|
20
|
+
* WebCrypto deriveBits 同样表达,Phase-3 可直接对齐。)
|
|
21
|
+
* 3. 子密钥 info:
|
|
22
|
+
* http 请求 "dsh-e2ee/v1\0http\0<reqNonceB64>\0p2b"(reqNonce=请求信封 n)
|
|
23
|
+
* http 响应 "…\0http\0<reqNonceB64>\0b2p"(响应信封 n 回显请求 n)
|
|
24
|
+
* ws 流 "…\0ws\0<wsLabel>\0p2b|b2p"(wsLabel 见 parseWsE2eeParams)
|
|
25
|
+
* ctrl 探针 "…\0ctrl\0p2b|b2p"
|
|
26
|
+
* 4. AAD = UTF8("dsh-e2ee/v1\0<kind>\0<sessId>\0<dir>\0<counter>")。
|
|
27
|
+
* [偏差] 评审稿 §4.3 建议把路径摘要也放进 AAD,但因解密方在打开前并不知道
|
|
28
|
+
* 明文里的路径(循环依赖),v1 规范化 AAD 不含路径;模块保留 extra 参数供后续绑定。
|
|
29
|
+
* 5. 信封字段(§4.2):{ v:2, k, s, c, n, t, d };n = base64(12B nonce),
|
|
30
|
+
* d = base64(AES-256-GCM 密文 ‖ 16B tag);AAD 在加解密两侧按 4 计算。
|
|
31
|
+
* 6. ctrl 通道(/_e2ee/ctrl)上:hello/hello-ack/error 为不带信封的明文 JSON
|
|
32
|
+
* {v:2,type,…};e2ee-probe / e2ee-probe-ok 是 k:"ctrl" 的 AEAD 信封。
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
createCipheriv,
|
|
37
|
+
createDecipheriv,
|
|
38
|
+
createHash,
|
|
39
|
+
hkdfSync,
|
|
40
|
+
pbkdf2Sync,
|
|
41
|
+
randomBytes
|
|
42
|
+
} from "node:crypto";
|
|
43
|
+
import fs from "node:fs";
|
|
44
|
+
|
|
45
|
+
// ---------- 常量(协议 §9.1) ----------
|
|
46
|
+
|
|
47
|
+
export const E2EE_DOMAIN = "dsh-e2ee/v1";
|
|
48
|
+
export const E2EE_MK_LABEL = `${E2EE_DOMAIN}/mk\x00`; // 域标签 + NUL + 盐
|
|
49
|
+
export const E2EE_SALT_BYTES = 16; // e2ee_salt 长度
|
|
50
|
+
export const MK_BYTES = 32; // MK/SHK/子密钥 32B
|
|
51
|
+
export const NONCE_BYTES = 12; // AES-GCM 96-bit nonce
|
|
52
|
+
export const TAG_BYTES = 16; // GCM tag 128-bit
|
|
53
|
+
export const SESS_ID_RE = /^[0-9a-f]{32}$/i; // sessId = 128-bit hex
|
|
54
|
+
export const E2EE_VERSION = 2;
|
|
55
|
+
export const ENVELOPE_CONTENT_TYPE = "application/vnd.dsh.e2ee-v2";
|
|
56
|
+
export const ENVELOPE_HEADER = "x-dsh-e2ee";
|
|
57
|
+
export const E2EE_CAP = "e2ee-v2";
|
|
58
|
+
export const CTRL_HELLO = "e2ee-hello";
|
|
59
|
+
export const CTRL_HELLO_ACK = "e2ee-hello-ack";
|
|
60
|
+
export const CTRL_ERROR = "e2ee-error";
|
|
61
|
+
export const DIR_P2B = "p2b"; // phone → bridge
|
|
62
|
+
export const DIR_B2P = "b2p"; // bridge → phone
|
|
63
|
+
export const HTTP_NONCE_WINDOW_MS = 5 * 60_000; // http 防重放滑动窗口 5min
|
|
64
|
+
export const HTTP_NONCE_CACHE_MAX = 4096; // 每会话上限
|
|
65
|
+
export const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // SHK 24h 滑动 TTL(§9.1)
|
|
66
|
+
export const SESSION_CAP = 32;
|
|
67
|
+
|
|
68
|
+
// 参数缺省(服务端 /api/e2ee-params 未下发参数时的协议 profile;§3.3 profile v1)
|
|
69
|
+
export const DEFAULT_MK_KDF = Object.freeze({ alg: "pbkdf2-sha256", iter: 600000, dkLen: MK_BYTES, hash: "sha256" });
|
|
70
|
+
export const DEFAULT_MK_PROFILE = Object.freeze({ profile: "pbkdf2-sha256-600k", kdf: DEFAULT_MK_KDF });
|
|
71
|
+
|
|
72
|
+
// ---------- 密码 / 盐 / MK(§3.3,与 enterprise e2ee.js 参考实现一致) ----------
|
|
73
|
+
|
|
74
|
+
/** 密码 NFKC 归一化(协议:桥 node 与手机 WebCrypto 两端一致)。 */
|
|
75
|
+
export function normalizePassword(password) {
|
|
76
|
+
return String(password ?? "").normalize("NFKC");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 解码 base64url e2ee_salt → 16B Buffer;非法返回 null。 */
|
|
80
|
+
export function decodeSalt(saltB64) {
|
|
81
|
+
if (typeof saltB64 !== "string" || saltB64 === "") return null;
|
|
82
|
+
let buf;
|
|
83
|
+
try {
|
|
84
|
+
buf = Buffer.from(saltB64, "base64url");
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return buf.length === E2EE_SALT_BYTES ? buf : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 派生客户端主密钥 MK(协议 §3.3):
|
|
93
|
+
* kdfInput = UTF8("dsh-e2ee/v1/mk\0") ‖ rawSalt(16B)
|
|
94
|
+
* MK(32B) = PBKDF2-HMAC-SHA256(password=NFKC, salt=kdfInput, iter, dkLen, hash)
|
|
95
|
+
* 参数(kdf.iter/dkLen/hash)按服务端下发的执行,绝不写死。
|
|
96
|
+
* @param {string} password 账号密码(原始输入,内部 NFKC)
|
|
97
|
+
* @param {string} saltB64 e2ee_salt(base64url,16B)
|
|
98
|
+
* @param {{alg?:string,iter?:number,dkLen?:number,hash?:string}|null} [kdf]
|
|
99
|
+
* @returns {Buffer} 32B MK
|
|
100
|
+
*/
|
|
101
|
+
export function deriveMasterKey(password, saltB64, kdf = null) {
|
|
102
|
+
const salt = decodeSalt(saltB64);
|
|
103
|
+
if (!salt) throw new Error(`e2ee: salt 非法(须为 16B base64url),got=${JSON.stringify(saltB64)}`);
|
|
104
|
+
const { iter, dkLen, hash } = normalizeKdf(kdf);
|
|
105
|
+
const kdfInput = Buffer.concat([Buffer.from(E2EE_MK_LABEL, "utf8"), salt]);
|
|
106
|
+
return pbkdf2Sync(normalizePassword(password), kdfInput, iter, dkLen, hash);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 把任意 kdf 参数容错归一(兼容 iterations/iter 两种写法);非法抛错。 */
|
|
110
|
+
export function normalizeKdf(kdf) {
|
|
111
|
+
if (!kdf || typeof kdf !== "object") return { ...DEFAULT_MK_KDF };
|
|
112
|
+
const iter = Number(kdf.iter ?? kdf.iterations);
|
|
113
|
+
const dkLen = Number(kdf.dkLen ?? kdf.keyLen ?? kdf.length ?? MK_BYTES);
|
|
114
|
+
const hash = typeof kdf.hash === "string" && kdf.hash ? kdf.hash : "sha256";
|
|
115
|
+
if (!Number.isInteger(iter) || iter < 1) throw new Error("e2ee: kdf.iter 非法");
|
|
116
|
+
if (!Number.isInteger(dkLen) || dkLen < 1 || dkLen > 64) throw new Error("e2ee: kdf.dkLen 非法");
|
|
117
|
+
if (!/^sha(256|384|512)$/.test(hash)) throw new Error(`e2ee: kdf.hash 不受支持: ${hash}`);
|
|
118
|
+
return { alg: typeof kdf.alg === "string" && kdf.alg ? kdf.alg : "pbkdf2-sha256", iter, dkLen, hash };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------- 参数端点(§3.4 / §6.1) ----------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 拉取并解析 GET {apiBase}/api/e2ee-params(Bearer token)。
|
|
125
|
+
* 容错契约(协议 §3.4):任何非 200 / 无 e2ee 字段 / enabled≠true / 材料非法
|
|
126
|
+
* → { enabled:false, reason }(调用方据此明文回退,绝不抛网络异常)。
|
|
127
|
+
* @returns {Promise<{enabled:boolean, reason:string, status?:number, profile?:string,
|
|
128
|
+
* salt?:string, epoch?:number, kdf?:object}>}
|
|
129
|
+
*/
|
|
130
|
+
export async function fetchE2eeParams(apiBase, token, { timeoutMs = 8000 } = {}) {
|
|
131
|
+
const base = String(apiBase || "").replace(/\/+$/, "");
|
|
132
|
+
if (!base) return { enabled: false, reason: "no_api" };
|
|
133
|
+
let res;
|
|
134
|
+
try {
|
|
135
|
+
res = await fetch(`${base}/api/e2ee-params`, {
|
|
136
|
+
headers: {
|
|
137
|
+
authorization: `Bearer ${String(token || "")}`,
|
|
138
|
+
accept: "application/json"
|
|
139
|
+
},
|
|
140
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
141
|
+
});
|
|
142
|
+
} catch {
|
|
143
|
+
return { enabled: false, reason: "params_unreachable" };
|
|
144
|
+
}
|
|
145
|
+
if (res.status !== 200) {
|
|
146
|
+
return { enabled: false, reason: res.status === 401 ? "server_unauthorized" : res.status === 404 ? "server_no_params" : `http_${res.status}`, status: res.status };
|
|
147
|
+
}
|
|
148
|
+
let data;
|
|
149
|
+
try {
|
|
150
|
+
data = await res.json();
|
|
151
|
+
} catch {
|
|
152
|
+
return { enabled: false, reason: "bad_params", status: 200 };
|
|
153
|
+
}
|
|
154
|
+
const e2 = data && typeof data === "object" ? data.e2ee : null;
|
|
155
|
+
if (!e2 || typeof e2 !== "object") return { enabled: false, reason: "server_no_params", status: 200 };
|
|
156
|
+
if (e2.enabled !== true) return { enabled: false, reason: "server_disabled", status: 200 };
|
|
157
|
+
if (typeof e2.salt !== "string" || !decodeSalt(e2.salt)) return { enabled: false, reason: "bad_params", status: 200 };
|
|
158
|
+
let kdf;
|
|
159
|
+
try {
|
|
160
|
+
kdf = normalizeKdf(e2.kdf);
|
|
161
|
+
} catch {
|
|
162
|
+
return { enabled: false, reason: "bad_params", status: 200 };
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
enabled: true,
|
|
166
|
+
reason: "ok",
|
|
167
|
+
status: 200,
|
|
168
|
+
profile: typeof e2.profile === "string" && e2.profile ? e2.profile : DEFAULT_MK_PROFILE.profile,
|
|
169
|
+
salt: e2.salt,
|
|
170
|
+
epoch: Number(e2.epoch) || 0,
|
|
171
|
+
kdf
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------- 随机数 / 随机工具 ----------
|
|
176
|
+
|
|
177
|
+
export function randomB64(byteLen) {
|
|
178
|
+
return randomBytes(byteLen).toString("base64url");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function randomHex(byteLen) {
|
|
182
|
+
return randomBytes(byteLen).toString("hex");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 新建会话 id(128-bit hex,32 字符)。 */
|
|
186
|
+
export function newSessId() {
|
|
187
|
+
return randomHex(16);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function sha256(...bufs) {
|
|
191
|
+
const h = createHash("sha256");
|
|
192
|
+
for (const b of bufs) h.update(b);
|
|
193
|
+
return h.digest();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------- 会话密钥调度(§4.1 规范化,见文件头决定 1–6) ----------
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 由 MK 与握手随机数 a/b 派生 SHK:
|
|
200
|
+
* saltH = sha256(a‖b)(32B)
|
|
201
|
+
* SHK = HKDF-SHA256(ikm=MK, salt=saltH, info="dsh-e2ee/v1\0shk", 32)
|
|
202
|
+
* @param {Buffer} mk 32B 主密钥
|
|
203
|
+
* @param {string|Buffer} a 手机侧随机数(32B)
|
|
204
|
+
* @param {string|Buffer} b bridge 侧随机数(32B)
|
|
205
|
+
* @returns {{shk: Buffer, saltH: Buffer}}
|
|
206
|
+
*/
|
|
207
|
+
export function deriveShk(mk, a, b) {
|
|
208
|
+
if (!Buffer.isBuffer(mk) || mk.length !== MK_BYTES) throw new Error("e2ee: MK 非法");
|
|
209
|
+
const aBuf = Buffer.isBuffer(a) ? a : Buffer.from(String(a), "base64url");
|
|
210
|
+
const bBuf = Buffer.isBuffer(b) ? b : Buffer.from(String(b), "base64url");
|
|
211
|
+
if (aBuf.length !== MK_BYTES || bBuf.length !== MK_BYTES) throw new Error("e2ee: 握手随机数须为 32B");
|
|
212
|
+
const saltH = sha256(aBuf, bBuf);
|
|
213
|
+
// 注意:新版 node 的 hkdfSync 返回 ArrayBuffer(未传 encoding 时),统一 Buffer 化
|
|
214
|
+
const shk = Buffer.from(hkdfSync("sha256", mk, saltH, Buffer.from(`${E2EE_DOMAIN}\x00shk`, "utf8"), MK_BYTES));
|
|
215
|
+
return { shk, saltH };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 会话级错误,带稳定 code(bridge 据此记日志/回显;不得携带密钥材料)。 */
|
|
219
|
+
export class E2eeError extends Error {
|
|
220
|
+
constructor(code, message, extra = {}) {
|
|
221
|
+
super(message || code);
|
|
222
|
+
this.name = "E2eeError";
|
|
223
|
+
this.code = code;
|
|
224
|
+
Object.assign(this, extra);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 一个解锁会话(一「隧道页 × 设备」一次握手一个;密钥仅内存)。
|
|
230
|
+
* 提供子密钥派生 + 信封加解密原语;不持有密码/MK。
|
|
231
|
+
*/
|
|
232
|
+
export class E2eeSession {
|
|
233
|
+
/**
|
|
234
|
+
* @param {object} o
|
|
235
|
+
* @param {string} o.sessId 32hex
|
|
236
|
+
* @param {Buffer} o.shk 32B
|
|
237
|
+
* @param {Buffer} o.saltH sha256(a‖b)
|
|
238
|
+
* @param {string} [o.profile]
|
|
239
|
+
* @param {number} [o.epoch]
|
|
240
|
+
*/
|
|
241
|
+
constructor({ sessId, shk, saltH, profile = "", epoch = 0 }) {
|
|
242
|
+
if (typeof sessId !== "string" || !SESS_ID_RE.test(sessId)) throw new E2eeError("bad_session", `e2ee: sessId 非法: ${sessId}`);
|
|
243
|
+
if (!Buffer.isBuffer(shk) || shk.length !== MK_BYTES) throw new E2eeError("bad_session", "e2ee: SHK 非法");
|
|
244
|
+
if (!Buffer.isBuffer(saltH) || saltH.length !== MK_BYTES) throw new E2eeError("bad_session", "e2ee: saltH 非法");
|
|
245
|
+
this.sessId = sessId;
|
|
246
|
+
this.shk = shk;
|
|
247
|
+
this.saltH = saltH;
|
|
248
|
+
this.profile = profile;
|
|
249
|
+
this.epoch = epoch;
|
|
250
|
+
this.verified = false; // 探针通过前不接受任何数据流/请求
|
|
251
|
+
this.createdAt = Date.now();
|
|
252
|
+
this.usedAt = this.createdAt;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 子密钥(§4.1 / 文件头决定 3)。kind∈http|http-resp|w|ctrl。 */
|
|
256
|
+
keyFor({ kind, dir, reqNonceB64 = "", wsLabel = "" }) {
|
|
257
|
+
let use;
|
|
258
|
+
if (kind === "http" || kind === "http-resp") {
|
|
259
|
+
if (!reqNonceB64) throw new E2eeError("bad_keyinfo", "e2ee: http 子密钥需要 reqNonceB64");
|
|
260
|
+
use = `http\x00${reqNonceB64}\x00${dir}`;
|
|
261
|
+
} else if (kind === "w") {
|
|
262
|
+
if (!wsLabel) throw new E2eeError("bad_keyinfo", "e2ee: ws 子密钥需要 wsLabel");
|
|
263
|
+
use = `ws\x00${wsLabel}\x00${dir}`;
|
|
264
|
+
} else if (kind === "ctrl") {
|
|
265
|
+
use = `ctrl\x00${dir}`;
|
|
266
|
+
} else {
|
|
267
|
+
throw new E2eeError("bad_keyinfo", `e2ee: 未知信封 kind: ${kind}`);
|
|
268
|
+
}
|
|
269
|
+
if (dir !== DIR_P2B && dir !== DIR_B2P) throw new E2eeError("bad_keyinfo", `e2ee: 未知方向: ${dir}`);
|
|
270
|
+
return Buffer.from(hkdfSync("sha256", this.shk, this.saltH, Buffer.from(`${E2EE_DOMAIN}\x00${use}`, "utf8"), MK_BYTES));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** AAD(文件头决定 4)。 */
|
|
274
|
+
aadOf({ kind, dir, counter }) {
|
|
275
|
+
return Buffer.from(`${E2EE_DOMAIN}\x00${kind}\x00${this.sessId}\x00${dir}\x00${String(counter ?? 0)}`, "utf8");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 密封一条消息 → 信封对象。
|
|
280
|
+
* @param {object} o
|
|
281
|
+
* @param {"http"|"http-resp"|"w"|"ctrl"} o.kind
|
|
282
|
+
* @param {string} o.dir p2b|b2p
|
|
283
|
+
* @param {number} o.counter 方向计数(c 字段;http 侧仅日志/调试)
|
|
284
|
+
* @param {Buffer|string} o.data 明文(文本传 string,二进制传 Buffer)
|
|
285
|
+
* @param {0|1} [o.t] 0=文本 1=二进制(默认按 data 推断)
|
|
286
|
+
* @param {string} [o.reqNonceB64] http/http-resp 用:请求 nonce
|
|
287
|
+
* @param {string} [o.wsLabel] w 用:ws 流标签
|
|
288
|
+
* @param {Buffer} [o.nonce] 显式 nonce(默认随机 12B;测试/回放用)
|
|
289
|
+
* @returns {{v:number,k:string,s:string,c:number,n:string,t:number,d:string}}
|
|
290
|
+
*/
|
|
291
|
+
seal({ kind, dir, counter = 0, data, t, reqNonceB64 = "", wsLabel = "", nonce }) {
|
|
292
|
+
const pt = Buffer.isBuffer(data) ? data : Buffer.from(String(data ?? ""), "utf8");
|
|
293
|
+
// t 语义:ws 消息按载荷区分文本/二进制;http/http-resp/ctrl 载荷一律为文本 JSON(t=0)
|
|
294
|
+
const isText = t !== undefined ? t === 0 : kind === "w" ? !Buffer.isBuffer(data) : true;
|
|
295
|
+
const n = nonce || randomBytes(NONCE_BYTES);
|
|
296
|
+
if (!Buffer.isBuffer(n) || n.length !== NONCE_BYTES) throw new E2eeError("bad_nonce", "e2ee: nonce 须为 12B");
|
|
297
|
+
// 规范:http 请求信封的 key-info reqNonce 默认即本信封 n(自描述,开包方无需额外状态);
|
|
298
|
+
// http-resp 的 key-info reqNonce 必须是其对应请求的 n(由桥端显式传入)。
|
|
299
|
+
const keyNonce = kind === "http-resp" ? reqNonceB64 : reqNonceB64 || n.toString("base64url");
|
|
300
|
+
const key = this.keyFor({ kind, dir, reqNonceB64: keyNonce, wsLabel });
|
|
301
|
+
const aad = this.aadOf({ kind, dir, counter });
|
|
302
|
+
const c = createCipheriv("aes-256-gcm", key, n);
|
|
303
|
+
c.setAAD(aad);
|
|
304
|
+
const enc = Buffer.concat([c.update(pt), c.final()]);
|
|
305
|
+
const tag = c.getAuthTag();
|
|
306
|
+
this.touch();
|
|
307
|
+
return {
|
|
308
|
+
v: E2EE_VERSION,
|
|
309
|
+
k: kind,
|
|
310
|
+
s: this.sessId,
|
|
311
|
+
c: counter,
|
|
312
|
+
n: n.toString("base64url"),
|
|
313
|
+
t: isText ? 0 : 1,
|
|
314
|
+
d: Buffer.concat([enc, tag]).toString("base64url")
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 打开(解密+认证)一条消息。认证失败/字段不符抛 E2eeError。
|
|
320
|
+
* @returns {{data: Buffer, t: number}} 明文(二进制原样;文本 utf8 由调用方转)
|
|
321
|
+
*/
|
|
322
|
+
open({ kind, dir, env, counter, reqNonceB64 = "", wsLabel = "" }) {
|
|
323
|
+
if (!env || typeof env !== "object") throw new E2eeError("bad_envelope", "e2ee: 信封缺失");
|
|
324
|
+
if (env.v !== E2EE_VERSION) throw new E2eeError("bad_version", `e2ee: 信封版本 ${env.v} 不受支持`);
|
|
325
|
+
if (env.k !== kind) throw new E2eeError("bad_kind", `e2ee: 信封 kind=${env.k} 期望 ${kind}`);
|
|
326
|
+
if (env.s !== this.sessId) throw new E2eeError("bad_session", "e2ee: 信封会话不符");
|
|
327
|
+
const raw = typeof env.d === "string" ? Buffer.from(env.d, "base64url") : null;
|
|
328
|
+
if (!raw || raw.length < TAG_BYTES + 1) throw new E2eeError("bad_cipher", "e2ee: 密文非法");
|
|
329
|
+
const n = Buffer.from(String(env.n || ""), "base64url");
|
|
330
|
+
if (n.length !== NONCE_BYTES) throw new E2eeError("bad_nonce", "e2ee: nonce 非法");
|
|
331
|
+
// 与 seal 对称:http 请求开包默认用信封 n 做 key-info;http-resp 须显式传对应请求 n
|
|
332
|
+
const keyNonce = kind === "http-resp" ? reqNonceB64 : reqNonceB64 || String(env.n || "");
|
|
333
|
+
const key = this.keyFor({ kind, dir, reqNonceB64: keyNonce, wsLabel });
|
|
334
|
+
const aad = this.aadOf({ kind, dir, counter });
|
|
335
|
+
const ct = raw.subarray(0, raw.length - TAG_BYTES);
|
|
336
|
+
const tag = raw.subarray(raw.length - TAG_BYTES);
|
|
337
|
+
const d = createDecipheriv("aes-256-gcm", key, n);
|
|
338
|
+
d.setAAD(aad);
|
|
339
|
+
d.setAuthTag(tag);
|
|
340
|
+
let out;
|
|
341
|
+
try {
|
|
342
|
+
out = Buffer.concat([d.update(ct), d.final()]);
|
|
343
|
+
} catch {
|
|
344
|
+
throw new E2eeError("auth_failed", "e2ee: AEAD 打开失败(密文被篡改或密钥不一致)");
|
|
345
|
+
}
|
|
346
|
+
this.touch();
|
|
347
|
+
return { data: out, t: env.t === 1 ? 1 : 0 };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
touch() {
|
|
351
|
+
this.usedAt = Date.now();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---------- 防重放原语(§4.4) ----------
|
|
356
|
+
|
|
357
|
+
/** HTTP 防重放:(sessId, nonce) 5 分钟滑动窗口,LRU 上限 4096。 */
|
|
358
|
+
export class HttpNonceGuard {
|
|
359
|
+
constructor({ windowMs = HTTP_NONCE_WINDOW_MS, max = HTTP_NONCE_CACHE_MAX } = {}) {
|
|
360
|
+
this.windowMs = windowMs;
|
|
361
|
+
this.max = max;
|
|
362
|
+
this.seen = new Map(); // "sessId\0n" → ts(插入序即 LRU 序)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** 全新 nonce → true;窗口内重复 → false。 */
|
|
366
|
+
check(sessId, nonceB64) {
|
|
367
|
+
const key = `${sessId}\x00${nonceB64}`;
|
|
368
|
+
const now = Date.now();
|
|
369
|
+
if (this.seen.has(key)) {
|
|
370
|
+
const ts = this.seen.get(key);
|
|
371
|
+
if (now - ts < this.windowMs) return false; // 重放
|
|
372
|
+
this.seen.delete(key); // 过期条目允许再次出现
|
|
373
|
+
}
|
|
374
|
+
// 顺带清扫过期条目(防止 Map 无限增长)
|
|
375
|
+
if (this.seen.size >= 256) {
|
|
376
|
+
for (const [k, ts] of this.seen) {
|
|
377
|
+
if (now - ts >= this.windowMs) this.seen.delete(k);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (this.seen.size >= this.max) {
|
|
381
|
+
const oldest = this.seen.keys().next().value;
|
|
382
|
+
if (oldest !== undefined) this.seen.delete(oldest);
|
|
383
|
+
}
|
|
384
|
+
this.seen.set(key, now);
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** WS/ctrl 方向计数:每 (会话,流,方向) 单调 +1;重复/回退即拒绝。 */
|
|
390
|
+
export class SequenceGuard {
|
|
391
|
+
constructor() {
|
|
392
|
+
this.last = -1;
|
|
393
|
+
}
|
|
394
|
+
/** c 必须严格大于 last(首条可为 0)。 */
|
|
395
|
+
check(c) {
|
|
396
|
+
if (!Number.isInteger(c) || c < 0) throw new E2eeError("bad_counter", `e2ee: 计数非法: ${c}`);
|
|
397
|
+
if (c <= this.last) throw new E2eeError("replay", `e2ee: 计数重放/乱序 ${c} <= ${this.last}`);
|
|
398
|
+
this.last = c;
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---------- ws 流标签(§4.6 规范化) ----------
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* 解析数据 WS 打开路径中的 e2ee 标记(query 的 &e2ee=<sessId>&w=<8B hex>)。
|
|
407
|
+
* upstreamPath: 去掉 e2ee 与 w 两参后的干净上游路径;
|
|
408
|
+
* wsLabel: 仅去掉 e2ee 的重组 query(path+query,内含 w → 每流唯一)。
|
|
409
|
+
* 返回 null 表示非 e2ee 标记流。
|
|
410
|
+
*/
|
|
411
|
+
export function parseWsE2eeParams(fullPath) {
|
|
412
|
+
if (typeof fullPath !== "string" || !fullPath.startsWith("/")) return null;
|
|
413
|
+
const qIdx = fullPath.indexOf("?");
|
|
414
|
+
const path = qIdx === -1 ? fullPath : fullPath.slice(0, qIdx);
|
|
415
|
+
if (qIdx === -1) return null;
|
|
416
|
+
const params = new URLSearchParams(fullPath.slice(qIdx + 1));
|
|
417
|
+
const sessId = params.get("e2ee") || "";
|
|
418
|
+
const w = params.get("w") || "";
|
|
419
|
+
if (!sessId || !w || !SESS_ID_RE.test(sessId)) return null;
|
|
420
|
+
params.delete("e2ee");
|
|
421
|
+
const qsLabel = params.size ? `?${params.toString()}` : "";
|
|
422
|
+
const labelWithW = `${path}${qsLabel}`; // w 保留其中(协议 “剥离后 path+query ‖ w”)
|
|
423
|
+
params.delete("w");
|
|
424
|
+
const qsUp = params.size ? `?${params.toString()}` : "";
|
|
425
|
+
return { sessId, w, wsLabel: labelWithW, upstreamPath: `${path}${qsUp}` };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---------- 信封标记头(§4.3:头即信号) ----------
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* 组装外 HTTP 信封标记头(手机 shim / 测试端封包用):
|
|
432
|
+
* content-type: application/vnd.dsh.e2ee-v2
|
|
433
|
+
* x-dsh-e2ee: v=2;s=<sessId>;k=<kind>
|
|
434
|
+
*/
|
|
435
|
+
export function envelopeRequestHeaders(sessId, kind = "http") {
|
|
436
|
+
return {
|
|
437
|
+
"content-type": ENVELOPE_CONTENT_TYPE,
|
|
438
|
+
[ENVELOPE_HEADER]: `v=${E2EE_VERSION};s=${sessId};k=${kind}`
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** 解析 x-dsh-e2ee 头 → {v,s,k} | null。 */
|
|
443
|
+
export function parseEnvelopeMarker(headers = {}) {
|
|
444
|
+
const val = headerValueOf(headers, ENVELOPE_HEADER);
|
|
445
|
+
if (!val) return null;
|
|
446
|
+
const m = /^v=(\d+);s=([0-9a-f]{32});k=([a-z-]+)$/i.exec(val.trim());
|
|
447
|
+
return m ? { v: Number(m[1]), s: m[2], k: m[3] } : null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** 头里是否带信封信号(content-type 或 x-dsh-e2ee)。 */
|
|
451
|
+
export function hasEnvelopeMarker(headers = {}) {
|
|
452
|
+
const ct = headerValueOf(headers, "content-type").split(";")[0].trim().toLowerCase();
|
|
453
|
+
if (ct === ENVELOPE_CONTENT_TYPE) return true;
|
|
454
|
+
return parseEnvelopeMarker(headers) !== null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function headerValueOf(headers, name) {
|
|
458
|
+
const lk = name.toLowerCase();
|
|
459
|
+
for (const [k, v] of Object.entries(headers || {})) {
|
|
460
|
+
if (String(k).toLowerCase() === lk) return Array.isArray(v) ? v.join(", ") : String(v);
|
|
461
|
+
}
|
|
462
|
+
return "";
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---------- http 明文载荷编解码(§4.3) ----------
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* 请求信封明文(§4.3):{ "m":method, "p":path(含 query), "h":{头部}, "b":<b64 正文> }
|
|
469
|
+
* @returns {{method:string, path:string, headers:object, bodyB64:string, hasBody:boolean}}
|
|
470
|
+
*/
|
|
471
|
+
export function decodeHttpRequestPlain(buf) {
|
|
472
|
+
let pt;
|
|
473
|
+
try {
|
|
474
|
+
pt = JSON.parse(Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf));
|
|
475
|
+
} catch {
|
|
476
|
+
throw new E2eeError("bad_plain", "e2ee: http 请求明文不是 JSON");
|
|
477
|
+
}
|
|
478
|
+
if (!pt || typeof pt !== "object") throw new E2eeError("bad_plain", "e2ee: http 请求明文缺失");
|
|
479
|
+
const method = typeof pt.m === "string" && pt.m ? pt.m.toUpperCase() : "GET";
|
|
480
|
+
const path = typeof pt.p === "string" && pt.p ? pt.p : "/";
|
|
481
|
+
const headers = pt.h && typeof pt.h === "object" ? pt.h : {};
|
|
482
|
+
const b = typeof pt.b === "string" ? pt.b : "";
|
|
483
|
+
return { method, path, headers, bodyB64: b, hasBody: b !== "" };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** 编码请求信封明文(测试端/手机侧对称实现用;b 一律为 base64 正文)。 */
|
|
487
|
+
export function encodeHttpRequestPlain({ method = "GET", path = "/", headers = {}, bodyB64 = "", bodyBase64 = true } = {}) {
|
|
488
|
+
const b = bodyBase64 ? String(bodyB64 ?? "") : Buffer.from(String(bodyB64 ?? ""), "utf8").toString("base64");
|
|
489
|
+
return Buffer.from(JSON.stringify({ m: String(method).toUpperCase(), p: path, h: headers, b }), "utf8");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* 响应信封明文(§4.3):{ "st":status, "h":{头, 去 content-length/encoding}, "enc":"gzip|", "b":<b64> }
|
|
494
|
+
* @returns {{status:number, headers:object, enc:string, bodyBuffer:Buffer}}
|
|
495
|
+
*/
|
|
496
|
+
export function decodeHttpResponsePlain(buf) {
|
|
497
|
+
let pt;
|
|
498
|
+
try {
|
|
499
|
+
pt = JSON.parse(Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf));
|
|
500
|
+
} catch {
|
|
501
|
+
throw new E2eeError("bad_plain", "e2ee: http 响应明文不是 JSON");
|
|
502
|
+
}
|
|
503
|
+
if (!pt || typeof pt !== "object") throw new E2eeError("bad_plain", "e2ee: http 响应明文缺失");
|
|
504
|
+
const status = Number(pt.st) || 502;
|
|
505
|
+
const headers = pt.h && typeof pt.h === "object" ? pt.h : {};
|
|
506
|
+
const enc = typeof pt.enc === "string" ? pt.enc : "";
|
|
507
|
+
let bodyBuffer = Buffer.alloc(0);
|
|
508
|
+
if (typeof pt.b === "string" && pt.b) {
|
|
509
|
+
try {
|
|
510
|
+
bodyBuffer = Buffer.from(pt.b, "base64");
|
|
511
|
+
} catch {
|
|
512
|
+
throw new E2eeError("bad_plain", "e2ee: http 响应 body base64 非法");
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return { status, headers, enc, bodyBuffer };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** 编码响应信封明文(status/headers/原始正文;content-encoding 提取到 enc)。 */
|
|
519
|
+
export function encodeHttpResponsePlain({ status = 200, headers = {}, bodyBuffer = Buffer.alloc(0) }) {
|
|
520
|
+
const h = {};
|
|
521
|
+
let enc = "";
|
|
522
|
+
for (const [k, v] of Object.entries(headers || {})) {
|
|
523
|
+
const lk = String(k).toLowerCase();
|
|
524
|
+
if (["content-length", "content-encoding", "transfer-encoding", "connection", "keep-alive"].includes(lk)) {
|
|
525
|
+
if (lk === "content-encoding") enc = String(v);
|
|
526
|
+
continue; // 实体头进 enc/由外层重建,不进密文内头部
|
|
527
|
+
}
|
|
528
|
+
h[k] = Array.isArray(v) ? v.join(", ") : String(v);
|
|
529
|
+
}
|
|
530
|
+
return Buffer.from(JSON.stringify({
|
|
531
|
+
st: Number(status) || 200,
|
|
532
|
+
h,
|
|
533
|
+
enc,
|
|
534
|
+
b: Buffer.isBuffer(bodyBuffer) && bodyBuffer.length ? bodyBuffer.toString("base64") : ""
|
|
535
|
+
}), "utf8");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ---------- 桥端会话服务(启动派生 + 握手 + 帧级编排辅助) ----------
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* E2EE 运行时开关/服务。enabled=false 时调用方必须完全走 v1 明文路径。
|
|
542
|
+
* 构造后仅持有:params 元数据 + MK(Buffer,内存)。
|
|
543
|
+
*/
|
|
544
|
+
export class E2eeService {
|
|
545
|
+
/**
|
|
546
|
+
* @param {object} o
|
|
547
|
+
* @param {boolean} o.enabled
|
|
548
|
+
* @param {string} o.reason enabled=false 时的原因(见 init)
|
|
549
|
+
* @param {Buffer|null} o.mk
|
|
550
|
+
* @param {string} [o.profile] [o.salt] [o.epoch] [o.kdf]
|
|
551
|
+
*/
|
|
552
|
+
constructor({ enabled = false, reason = "disabled", mk = null, profile = "", salt = "", epoch = 0, kdf = null } = {}) {
|
|
553
|
+
this.enabled = enabled && Buffer.isBuffer(mk) && mk.length === MK_BYTES;
|
|
554
|
+
this.reason = this.enabled ? "ok" : reason;
|
|
555
|
+
this.profile = profile;
|
|
556
|
+
this.salt = salt;
|
|
557
|
+
this.epoch = epoch;
|
|
558
|
+
this.kdf = kdf;
|
|
559
|
+
this.mk = this.enabled ? mk : null;
|
|
560
|
+
this.sessions = new Map(); // sessId → E2eeSession
|
|
561
|
+
this.ctrlCounters = new Map(); // sessId → { p2b: SequenceGuard, b2p: SequenceGuard }
|
|
562
|
+
this.httpNonces = new HttpNonceGuard();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** 静态工厂:启动派生(失败即禁用并说明原因;绝不抛)。 */
|
|
566
|
+
static async init({ apiBase, token, password, allowed = true, profileHint = "" } = {}) {
|
|
567
|
+
if (!allowed) return new E2eeService({ enabled: false, reason: "disabled_by_config" });
|
|
568
|
+
if (String(token || "") === "") return new E2eeService({ enabled: false, reason: "no_token" });
|
|
569
|
+
if (String(password ?? "") === "") return new E2eeService({ enabled: false, reason: "no_password" });
|
|
570
|
+
const params = await fetchE2eeParams(apiBase, token);
|
|
571
|
+
if (!params.enabled) return new E2eeService({ enabled: false, reason: params.reason });
|
|
572
|
+
let mk;
|
|
573
|
+
try {
|
|
574
|
+
mk = deriveMasterKey(password, params.salt, params.kdf);
|
|
575
|
+
} catch {
|
|
576
|
+
return new E2eeService({ enabled: false, reason: "derive_failed" });
|
|
577
|
+
}
|
|
578
|
+
return new E2eeService({
|
|
579
|
+
enabled: true,
|
|
580
|
+
reason: "ok",
|
|
581
|
+
mk,
|
|
582
|
+
profile: profileHint || params.profile || DEFAULT_MK_PROFILE.profile,
|
|
583
|
+
salt: params.salt,
|
|
584
|
+
epoch: params.epoch || 0,
|
|
585
|
+
kdf: params.kdf
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
caps() {
|
|
590
|
+
return this.enabled ? [E2EE_CAP] : [];
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* 桌面授权引导(方案A,2026-09 用户决策):供同账号手机端在「从未持有密码」的场景(扫码/
|
|
595
|
+
* 一次性链接登录)下免输密码建立 E2EE。桥端在内存中持有派生 MK,此处一次性下发 MK
|
|
596
|
+
* (base64url,仅内存转发;不落盘、不写日志)。调用前提:请求已由 relay-router 按
|
|
597
|
+
* dsh_token cookie 完成「同账号」授权(device/channel 形态都只到本设备)——
|
|
598
|
+
* 信任模型见 docs/e2ee-protocol.md §5.4 [DECISION]:引导瞬间以「持有效扫码会话=账号本人」
|
|
599
|
+
* 为准,存在被中继主动冒充的理论窗口(与扫码登录产品一致);引导后数据面照常走
|
|
600
|
+
* hello/probe 探针(双方已同持 MK)与 AEAD 信封。
|
|
601
|
+
* @returns {{mk:string, profile:string, epoch:number}}
|
|
602
|
+
*/
|
|
603
|
+
introGrant() {
|
|
604
|
+
if (!this.enabled || !Buffer.isBuffer(this.mk) || this.mk.length !== MK_BYTES) {
|
|
605
|
+
throw new E2eeError(this.reason || "e2ee_disabled", "电脑端未启用端到端加密,无法授权引导(请先在电脑端登录并确认密码已生效)");
|
|
606
|
+
}
|
|
607
|
+
return { mk: this.mk.toString("base64url"), profile: this.profile, epoch: this.epoch };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
state() {
|
|
611
|
+
return { enabled: this.enabled, reason: this.reason, profile: this.profile, epoch: this.epoch };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
prune() {
|
|
615
|
+
const now = Date.now();
|
|
616
|
+
if (this.sessions.size > SESSION_CAP) {
|
|
617
|
+
const oldest = [...this.sessions.entries()].sort((a, b) => a[1].usedAt - b[1].usedAt).slice(0, this.sessions.size - SESSION_CAP);
|
|
618
|
+
for (const [k] of oldest) this.dropSession(k);
|
|
619
|
+
}
|
|
620
|
+
for (const [k, s] of this.sessions) {
|
|
621
|
+
if (now - s.usedAt > SESSION_TTL_MS) this.dropSession(k);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
dropSession(sessId) {
|
|
626
|
+
this.sessions.delete(sessId);
|
|
627
|
+
this.ctrlCounters.delete(sessId);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
sessionOf(sessId) {
|
|
631
|
+
this.prune();
|
|
632
|
+
const s = this.sessions.get(sessId);
|
|
633
|
+
if (s) s.touch();
|
|
634
|
+
return s || null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* 桥端应答 e2ee-hello(§5.2 步骤 1→2)。
|
|
639
|
+
* @returns {{session:E2eeSession, ack:object}} 或抛 E2eeError
|
|
640
|
+
*/
|
|
641
|
+
handleHello(hello) {
|
|
642
|
+
if (!this.enabled) throw new E2eeError("disabled", "e2ee: bridge 未启用端到端加密");
|
|
643
|
+
if (!hello || typeof hello !== "object") throw new E2eeError("bad_msg", "e2ee: hello 缺失");
|
|
644
|
+
if (hello.v !== E2EE_VERSION || hello.type !== CTRL_HELLO) throw new E2eeError("bad_msg", "e2ee: hello 字段非法");
|
|
645
|
+
const sessId = String(hello.s || "");
|
|
646
|
+
if (!SESS_ID_RE.test(sessId)) throw new E2eeError("bad_msg", "e2ee: hello.s 非法");
|
|
647
|
+
if (hello.role !== "phone") throw new E2eeError("bad_msg", `e2ee: hello.role=${hello.role}`);
|
|
648
|
+
const a = String(hello.a || "");
|
|
649
|
+
let aBuf;
|
|
650
|
+
try {
|
|
651
|
+
aBuf = Buffer.from(a, "base64url");
|
|
652
|
+
} catch {
|
|
653
|
+
aBuf = Buffer.alloc(0);
|
|
654
|
+
}
|
|
655
|
+
if (aBuf.length !== MK_BYTES) throw new E2eeError("bad_msg", "e2ee: hello.a 须为 32B");
|
|
656
|
+
// 手机侧必须使用与 bridge 相同的 e2ee_salt/profile(否则 MK 域不一致 → 明文回退提示)
|
|
657
|
+
if (hello.salt !== this.salt) throw new E2eeError("params", "e2ee: 手机端 e2ee_salt 与本机不一致(可能密码/服务端参数已更换)");
|
|
658
|
+
if (this.profile && hello.profile && hello.profile !== this.profile) throw new E2eeError("params", "e2ee: 手机端 profile 与本机不一致");
|
|
659
|
+
this.prune();
|
|
660
|
+
if (this.sessions.has(sessId)) this.dropSession(sessId); // 同页重解锁 → 新随机数新会话
|
|
661
|
+
const b = randomB64(MK_BYTES);
|
|
662
|
+
const { shk, saltH } = deriveShk(this.mk, aBuf, b);
|
|
663
|
+
const session = new E2eeSession({ sessId, shk, saltH, profile: this.profile, epoch: this.epoch });
|
|
664
|
+
this.sessions.set(sessId, session);
|
|
665
|
+
this.ctrlCounters.set(sessId, { p2b: new SequenceGuard(), b2p: new SequenceGuard() });
|
|
666
|
+
const ack = {
|
|
667
|
+
v: E2EE_VERSION,
|
|
668
|
+
type: CTRL_HELLO_ACK,
|
|
669
|
+
s: sessId,
|
|
670
|
+
b,
|
|
671
|
+
caps: this.caps(),
|
|
672
|
+
ts: Date.now()
|
|
673
|
+
};
|
|
674
|
+
return { session, ack };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* 桥端应答 e2ee-probe(§5.2 步骤 4→5)。成功后会话 verified=true。
|
|
679
|
+
* @returns {{reply: object, session: E2eeSession}} reply 为 probe-ok 信封(b2p)
|
|
680
|
+
*/
|
|
681
|
+
handleProbe(sessId, env) {
|
|
682
|
+
const session = this.sessionOf(sessId);
|
|
683
|
+
if (!session) throw new E2eeError("unknown_session", "e2ee: 会话不存在或已过期(请重新解锁)");
|
|
684
|
+
// 先解密成功再推进计数:失败的尝试(密钥不符)不得消耗合法探针的计数
|
|
685
|
+
const opened = session.open({ kind: "ctrl", dir: DIR_P2B, env, counter: env?.c ?? 0, reqNonceB64: env?.n });
|
|
686
|
+
let pt;
|
|
687
|
+
try {
|
|
688
|
+
pt = JSON.parse(opened.data.toString("utf8"));
|
|
689
|
+
} catch {
|
|
690
|
+
throw new E2eeError("bad_key", "e2ee: 探针明文非法(密钥不一致?)");
|
|
691
|
+
}
|
|
692
|
+
if (pt?.p !== "dsh-e2ee-probe-v1") throw new E2eeError("bad_key", "e2ee: 探针载荷不符");
|
|
693
|
+
const guards = this.ctrlCounters.get(sessId) || { p2b: new SequenceGuard(), b2p: new SequenceGuard() };
|
|
694
|
+
guards.p2b.check(Number(env?.c) || 0); // 成功后推进,后续重放被拒
|
|
695
|
+
session.verified = true;
|
|
696
|
+
const reply = session.seal({
|
|
697
|
+
kind: "ctrl",
|
|
698
|
+
dir: DIR_B2P,
|
|
699
|
+
counter: 0,
|
|
700
|
+
data: JSON.stringify({ p: "dsh-e2ee-probe-ok", t: pt.t ?? 0, c: 0 })
|
|
701
|
+
});
|
|
702
|
+
this.ctrlCounters.set(sessId, guards);
|
|
703
|
+
return { reply, session };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** 对 http 请求信封做打开前的会话语义校验(取会话/查重放)。 */
|
|
707
|
+
guardHttpRequest(sessId, env) {
|
|
708
|
+
const session = this.sessionOf(sessId);
|
|
709
|
+
if (!session) throw new E2eeError("unknown_session", "e2ee: 会话不存在或已过期(请重新解锁)");
|
|
710
|
+
if (!session.verified) throw new E2eeError("not_verified", "e2ee: 会话未完成探针校验");
|
|
711
|
+
if (!this.httpNonces.check(sessId, env.n)) throw new E2eeError("replay", "e2ee: HTTP 信封 nonce 重放");
|
|
712
|
+
return session;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** 把 E2EE 开关状态写入 relayDir/.e2ee-state.json(供 Phase-4 面板 UI 读取)。 */
|
|
717
|
+
export function writeE2eeStateFile(dir, state) {
|
|
718
|
+
try {
|
|
719
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
720
|
+
fs.writeFileSync(
|
|
721
|
+
`${dir}/.e2ee-state.json`,
|
|
722
|
+
JSON.stringify({ ...state, at: Date.now() }, null, 2),
|
|
723
|
+
{ mode: 0o600 }
|
|
724
|
+
);
|
|
725
|
+
} catch {
|
|
726
|
+
/* 状态文件非关键路径:写失败只记日志,不影响隧道 */
|
|
727
|
+
}
|
|
728
|
+
}
|