dsh-mobile-plugin 0.1.99 → 0.2.0
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 +14 -5
- package/dsh-mobile-credentials/lib/credentials.js +405 -84
- package/lib/client.js +31 -8
- package/lib/http/routing.js +165 -9
- package/lib/impl.js +79 -5
- package/lib/index.js +1 -1
- package/lib/mirror/pusher.cjs +35 -4
- package/lib/mobile/approvals.js +62 -15
- package/lib/mobile/auth.js +46 -26
- package/lib/mobile/credential-upgrade.js +129 -0
- package/lib/mobile/entry-window.js +32 -0
- package/lib/mobile/events.js +14 -4
- package/lib/mobile/extensions.js +279 -0
- package/lib/mobile/history-derive.js +159 -0
- package/lib/mobile/host-bridge/event-synthesis.js +40 -1
- package/lib/mobile/pairing.js +150 -64
- package/lib/mobile/plugin-self.js +187 -0
- package/lib/mobile/push-notify-route.js +37 -0
- package/lib/mobile/push-notify.js +84 -0
- package/lib/mobile/questions.js +75 -10
- package/lib/mobile/relay-control.js +534 -0
- package/lib/mobile/session-cache.js +19 -1
- package/lib/mobile/session-file.js +78 -4
- package/lib/mobile/session-list-shortcut.js +38 -0
- package/lib/mobile/session-model-options.js +3 -1
- package/lib/mobile/session-models.js +15 -3
- package/lib/mobile/session-send.js +21 -3
- package/lib/mobile/session-snapshot.js +12 -11
- package/lib/mobile/sessions.js +20 -0
- package/lib/mobile/stats-collect.cjs +55 -12
- package/lib/mobile/stats-ledger.cjs +140 -21
- package/lib/mobile/stream-registry.js +73 -0
- package/lib/mobile/stream.js +103 -18
- package/lib/mobile/uploads.js +79 -3
- package/lib/mobile/workspaces.js +39 -8
- package/lib/panel/index.html +11 -11
- package/lib/relay/core.js +25 -2
- package/lib/relay/lan-proxy.js +283 -122
- package/lib/relay/protocol.js +4 -2
- package/lib/relay/tunnel.js +20 -14
- package/lib/routes/control-plane.js +135 -9
- package/lib/routes/mobile-api.js +87 -7
- package/lib/shared/e2ee-pair.js +91 -0
- package/lib/shared/e2ee-plain.js +79 -0
- package/lib/shared/e2ee-request.js +331 -0
- package/lib/shared/e2ee.js +173 -0
- package/lib/shared/http-util.js +27 -4
- package/lib/shared/list-signal.js +16 -0
- package/lib/shared/pair-throttle.js +150 -0
- package/lib/shared/sse-write.js +16 -5
- package/lib/shared/via-header.js +65 -0
- package/lib/shell/runtime.js +147 -42
- package/lib/shell/self-check-after-swap.js +93 -0
- package/lib/shell/updater.js +36 -2
- package/lib/sidecar-facade-worker.cjs +70 -6
- package/package.json +3 -2
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 宿主侧的协议 v2 请求信封:验签、解封、封响应。
|
|
5
|
+
*
|
|
6
|
+
* 挂在移动端路由守卫(`http/routing.js` 的 `createGuarded`)里,**业务 handler 之前解、
|
|
7
|
+
* 之后封**。这样每个域 handler 拿到的仍是「已解析的明文 JSON」与「可以照常 `sendJson` 的
|
|
8
|
+
* res」,一行业务代码都不用知道信封存在。
|
|
9
|
+
*
|
|
10
|
+
* 几条容易写错的(都按 §8.3(4) 冻结的口径):
|
|
11
|
+
* - 签的是**密文**:先解密再验签会让「把 A 接口的密文搬到 B 接口」成立。
|
|
12
|
+
* - `PATH` 从 `API_BASE` 起算(中转会把部署前缀剥掉),`QUERY` 按线上原样、不排序。
|
|
13
|
+
* - 摘要是小写十六进制 64 字符。
|
|
14
|
+
* - 错误响应**永远明文**:`401/403/503` 与所有 `code` 错误的响应都必须让 App 能直接读懂。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const e2ee = require('../shared/e2ee');
|
|
18
|
+
const { ACCEPT_ENC_HEADER, compressPlain, decompressPlain } = require('../shared/e2ee-plain');
|
|
19
|
+
|
|
20
|
+
/** 重放窗口:手机时钟可能偏几分钟,5 分钟是给它留的余量,真正的防线是 nonce 表。 */
|
|
21
|
+
const TS_WINDOW_MS = 5 * 60 * 1000;
|
|
22
|
+
/** 每台设备保留最近多少条写请求的 nonce,过期 5 分钟后淘汰。 */
|
|
23
|
+
const NONCE_TABLE_SIZE = 256;
|
|
24
|
+
|
|
25
|
+
/** keyId → { nonce: ts } 的环形表。GET 不记:换通道重投会复用同一 nonce。 */
|
|
26
|
+
const nonces = new Map();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 读一个请求头(大小写不敏感、数组取第一个)。
|
|
30
|
+
*
|
|
31
|
+
* 中转会把头名压成小写,但假 req、手表升级那条路、以及 Node 不同版本的行为不保证如此,
|
|
32
|
+
* 靠大小写绕过太便宜。
|
|
33
|
+
*/
|
|
34
|
+
function headerOf(req, name) {
|
|
35
|
+
const headers = req?.headers;
|
|
36
|
+
if (!headers || typeof headers !== 'object') return '';
|
|
37
|
+
const target = name.toLowerCase();
|
|
38
|
+
for (const [key, raw] of Object.entries(headers)) {
|
|
39
|
+
if (key.toLowerCase() !== target) continue;
|
|
40
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
41
|
+
return typeof value === 'string' ? value : '';
|
|
42
|
+
}
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 会从 URL 搬进 `X-Dsh-Params` 的查询参数(白名单,不做通用机制)。
|
|
48
|
+
*
|
|
49
|
+
* `path` / `query` / `refs` 直接暴露「他在哪个目录下干活」「他搜了什么」「他在配哪把 key」。
|
|
50
|
+
* 其余(`sessionId` / `limit` / `offset` / `beforeSeq` / `afterSeq` / `rev` / `fresh` /
|
|
51
|
+
* `archived`)留在 URL 上:`rev` 与分页要留给中转判断镜像能否命中,塞进密文等于废掉镜像。
|
|
52
|
+
*/
|
|
53
|
+
const PRIVATE_QUERY_KEYS = ['path', 'query', 'refs'];
|
|
54
|
+
|
|
55
|
+
/** 这条请求是不是协议 v2。 */
|
|
56
|
+
const isProto2 = (req) => headerOf(req, 'x-dsh-proto').trim() === '2';
|
|
57
|
+
|
|
58
|
+
/** `/api/mobile/v1/...` 这一段,去掉部署前缀(中转会剥掉,App 签名时也截到 API_BASE)。 */
|
|
59
|
+
function signedPath(req) {
|
|
60
|
+
const raw = String(req?.url || '/');
|
|
61
|
+
const at = raw.indexOf('?');
|
|
62
|
+
const pathname = at >= 0 ? raw.slice(0, at) : raw;
|
|
63
|
+
const base = '/api/mobile/v1/';
|
|
64
|
+
const hit = pathname.indexOf(base);
|
|
65
|
+
return hit >= 0 ? pathname.slice(hit) : pathname;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 查询串按线上原样(含 `?`),不排序:它必须与 App 真正发出去的那串一致。 */
|
|
69
|
+
function signedQuery(req) {
|
|
70
|
+
const raw = String(req?.url || '');
|
|
71
|
+
const at = raw.indexOf('?');
|
|
72
|
+
return at >= 0 ? raw.slice(at) : '';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 校验并解封一条 v2 请求。
|
|
77
|
+
*
|
|
78
|
+
* @param {object} req
|
|
79
|
+
* @param {object} deps
|
|
80
|
+
* @param {(keyId: string) => { deviceKey: string } | null} deps.keyFor 按 keyId 找设备密钥
|
|
81
|
+
* @param {Buffer} [deps.rawBody] 已读到的请求体原文(没读过就给 undefined,这里会自己读)
|
|
82
|
+
* @returns {{ ok: true, body: object|undefined, keys: object, device: object }
|
|
83
|
+
* | { ok: false, status: number, code: string, extra?: object }}
|
|
84
|
+
*/
|
|
85
|
+
function openRequest(req, deps, rawBody) {
|
|
86
|
+
const keyId = headerOf(req, 'x-dsh-key-id').trim();
|
|
87
|
+
if (!keyId) return { ok: false, status: 400, code: 'e2ee-bad-envelope', extra: { reason: 'missing x-dsh-key-id' } };
|
|
88
|
+
const record = deps.keyFor(keyId);
|
|
89
|
+
if (!record || !record.deviceKey) return { ok: false, status: 401, code: 'e2ee-unknown-device' };
|
|
90
|
+
|
|
91
|
+
const keys = e2ee.deriveSessionKeys(Buffer.from(record.deviceKey, 'base64url'));
|
|
92
|
+
|
|
93
|
+
// ── 签名(签的是密文,所以先验签再解)──
|
|
94
|
+
const ts = Number(headerOf(req, 'x-dsh-ts'));
|
|
95
|
+
const nonce = headerOf(req, 'x-dsh-nonce').trim();
|
|
96
|
+
const sig = headerOf(req, 'x-dsh-sig').trim();
|
|
97
|
+
if (!Number.isFinite(ts) || !nonce || !sig) {
|
|
98
|
+
return { ok: false, status: 400, code: 'e2ee-bad-envelope', extra: { reason: 'missing signature parts' } };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const method = String(req.method || 'GET').toUpperCase();
|
|
102
|
+
const canonical = e2ee.canonicalString({
|
|
103
|
+
method,
|
|
104
|
+
path: signedPath(req),
|
|
105
|
+
query: signedQuery(req),
|
|
106
|
+
ts,
|
|
107
|
+
nonce,
|
|
108
|
+
bodyBytes: rawBody || Buffer.alloc(0),
|
|
109
|
+
});
|
|
110
|
+
if (!e2ee.verifySignature(keys.sig, canonical, sig)) {
|
|
111
|
+
return { ok: false, status: 400, code: 'e2ee-bad-signature' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── 时钟 ──
|
|
115
|
+
const skew = Date.now() - ts;
|
|
116
|
+
if (Math.abs(skew) > TS_WINDOW_MS) {
|
|
117
|
+
// 回宿主的时间让 App 校准一次;这条响应必须明文,否则 App 读不到原因。
|
|
118
|
+
return { ok: false, status: 400, code: 'e2ee-clock-skew', extra: { serverTime: Date.now() } };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── 重放(只记改状态的方法:GET 会被换通道重投复用同一个 nonce)──
|
|
122
|
+
if (method !== 'GET') {
|
|
123
|
+
const seen = nonces.get(keyId);
|
|
124
|
+
const table = seen || new Map();
|
|
125
|
+
if (table.has(nonce)) return { ok: false, status: 409, code: 'e2ee-replay' };
|
|
126
|
+
table.set(nonce, ts);
|
|
127
|
+
// 过期淘汰 + 上限:表只增不减的话一台设备长期使用会把它撑大。
|
|
128
|
+
if (table.size > NONCE_TABLE_SIZE) {
|
|
129
|
+
for (const [key, stamp] of table) {
|
|
130
|
+
if (Date.now() - stamp > TS_WINDOW_MS || table.size > NONCE_TABLE_SIZE) table.delete(key);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
nonces.set(keyId, table);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── 解封请求体 ──
|
|
137
|
+
let body;
|
|
138
|
+
if (rawBody && rawBody.length > 0) {
|
|
139
|
+
let envelope;
|
|
140
|
+
try {
|
|
141
|
+
envelope = JSON.parse(rawBody.toString('utf8'));
|
|
142
|
+
} catch {
|
|
143
|
+
return { ok: false, status: 400, code: 'e2ee-bad-envelope' };
|
|
144
|
+
}
|
|
145
|
+
if (!envelope || Number(envelope.v) !== 2 || typeof envelope.c !== 'string') {
|
|
146
|
+
return { ok: false, status: 400, code: 'e2ee-bad-envelope' };
|
|
147
|
+
}
|
|
148
|
+
let plain;
|
|
149
|
+
try {
|
|
150
|
+
plain = e2ee.open(keys.up, Buffer.from(envelope.c, 'base64'), e2ee.upAad(method, signedPath(req)));
|
|
151
|
+
} catch {
|
|
152
|
+
return { ok: false, status: 400, code: 'e2ee-decrypt-failed' };
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
body = JSON.parse(plain.toString('utf8'));
|
|
156
|
+
} catch {
|
|
157
|
+
return { ok: false, status: 400, code: 'e2ee-bad-envelope' };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── 加密头里的私密查询参数(§8.4)──
|
|
162
|
+
const params = headerOf(req, 'x-dsh-params').trim();
|
|
163
|
+
if (params) {
|
|
164
|
+
let decoded;
|
|
165
|
+
try {
|
|
166
|
+
const plain = e2ee.open(keys.up, Buffer.from(params, 'base64'), e2ee.paramsAad(ts, nonce));
|
|
167
|
+
decoded = JSON.parse(plain.toString('utf8'));
|
|
168
|
+
} catch {
|
|
169
|
+
return { ok: false, status: 400, code: 'e2ee-decrypt-failed' };
|
|
170
|
+
}
|
|
171
|
+
if (decoded && typeof decoded === 'object') {
|
|
172
|
+
// 把搬走的参数**合并回 `req.url` 的查询串**(§8.4):业务 handler 一行都不用改,
|
|
173
|
+
// 它读到的仍是原来那个查询串。只搬白名单三个键 —— 分页与 `rev` 留在 URL 上,
|
|
174
|
+
// 镜像要靠它们判「这是不是同一条读」。
|
|
175
|
+
//
|
|
176
|
+
// 只挂在 `req.__dshParams` 上是不够的:那是个没人读的字段,三条路由
|
|
177
|
+
// (工作区目录、会话检索词、凭据引用名)拿不到参数,表现为「搜索永远是空结果」。
|
|
178
|
+
const moved = []
|
|
179
|
+
for (const key of PRIVATE_QUERY_KEYS) {
|
|
180
|
+
const value = decoded[key]
|
|
181
|
+
if (typeof value !== 'string' || !value) continue
|
|
182
|
+
moved.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
|
183
|
+
}
|
|
184
|
+
if (moved.length) {
|
|
185
|
+
const raw = String(req.url || '')
|
|
186
|
+
const at = raw.indexOf('?')
|
|
187
|
+
// 剩下那串保持**原样**:它的顺序参与签名,重排会让宿主的验签结论与 App 不一致。
|
|
188
|
+
const kept = at >= 0 ? raw.slice(at + 1) : ''
|
|
189
|
+
const merged = kept ? `${kept}&${moved.join('&')}` : moved.join('&')
|
|
190
|
+
req.url = `${at >= 0 ? raw.slice(0, at) : raw}?${merged}`
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { ok: true, body, keys, device: record, req: { ts, nonce } };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 封一条响应。
|
|
200
|
+
*
|
|
201
|
+
* `401/403/503` 例外,见文件头。其余一律封——包括业务层的 `{ ok: false, error }`:
|
|
202
|
+
* 那些是**业务**错误,不是「解不开」,中转不该看见。
|
|
203
|
+
*
|
|
204
|
+
* **先压再封**(`x-dsh-accept-enc` 带 `g` 时才压):压缩必须发生在 AEAD 之前,密文没有
|
|
205
|
+
* 可压缩的余量。`z` 挂在信封上,客户端据此决定解不解压 —— 老 App 不带那个头,所以它
|
|
206
|
+
* 收到的永远是明文信封,一个字节都不用改。
|
|
207
|
+
*/
|
|
208
|
+
function sealResponse(req, res, keys, request, payload) {
|
|
209
|
+
if (!keys || !request) return payload;
|
|
210
|
+
const status = res?.statusCode || 200;
|
|
211
|
+
// 这三个状态码永远明文:App 必须先读懂「被吊销 / 时钟不对 / 宿主不在线」才谈得上别的。
|
|
212
|
+
if (status === 401 || status === 403 || status === 503) return payload;
|
|
213
|
+
const method = String(req.method || 'GET').toUpperCase();
|
|
214
|
+
const aad = e2ee.downAad(method, signedPath(req), request.ts, request.nonce);
|
|
215
|
+
const acceptEnc = headerOf(req, ACCEPT_ENC_HEADER);
|
|
216
|
+
const seal = (body) => {
|
|
217
|
+
const { payload: plain, z } = compressPlain(Buffer.from(JSON.stringify(body)), acceptEnc);
|
|
218
|
+
return wrapper(e2ee.seal(keys.down, plain, aad), z);
|
|
219
|
+
};
|
|
220
|
+
// 列表类响应要**把三样元数据留在明文**(§8.5):中转的离线镜像有三处硬读 ——
|
|
221
|
+
// `is_ok()` 找外层 `ok: true`、`revision` 用来判「这份还是不是同一份」、`stale` 由它在
|
|
222
|
+
// 回包里注入。把 ok 藏进密文等于镜像永远存不进去,宿主一离线手机什么都看不到。
|
|
223
|
+
const keep = listMetaOf(payload);
|
|
224
|
+
if (keep) {
|
|
225
|
+
const body = { ...payload };
|
|
226
|
+
delete body.ok;
|
|
227
|
+
delete body.revision;
|
|
228
|
+
delete body.generatedAt;
|
|
229
|
+
return { ...keep, env: seal(body) };
|
|
230
|
+
}
|
|
231
|
+
return seal(payload);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 这条响应要不要走「外层留元数据、正文进信封」的形状(§8.5)。
|
|
236
|
+
*
|
|
237
|
+
* 只认**列表类**那两条(会话列表 / 工作区列表):它们带 `revision` 与 `generatedAt`,
|
|
238
|
+
* 而镜像读的就是这两个键。其余响应整包进信封 —— 给每条响应都开一层外壳只会让
|
|
239
|
+
* 「哪些字段是中转能看见的」变成一张需要维护的表。
|
|
240
|
+
*
|
|
241
|
+
* @returns {null | { ok: true, revision?: string, generatedAt?: number }}
|
|
242
|
+
*/
|
|
243
|
+
function listMetaOf(payload) {
|
|
244
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
245
|
+
if (payload.ok !== true) return null;
|
|
246
|
+
const revision = typeof payload.revision === 'string' ? payload.revision : '';
|
|
247
|
+
const generatedAt = Number.isFinite(payload.generatedAt) ? payload.generatedAt : undefined;
|
|
248
|
+
if (!revision || generatedAt === undefined) return null;
|
|
249
|
+
return { ok: true, revision, generatedAt };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 响应信封的形状:`{ v: 2, n: <12 字节 base64url>, c: <base64 的密文+tag> }`。
|
|
254
|
+
*
|
|
255
|
+
* `n` 放在信封里是因为响应没有请求头可用。**注意 `seal` 的返回值里已经包含 nonce**
|
|
256
|
+
* (形状是 `nonce || 密文 || tag`),所以这里要把它切成两段分别放进 `n` 与 `c`;
|
|
257
|
+
* 而 App 侧 `open` 期待的入参是 `nonce || 密文 || tag` 一整个 —— 两边拼法不同是刻意的,
|
|
258
|
+
* 拆开是为了让信封自己带上 nonce。
|
|
259
|
+
*
|
|
260
|
+
* AAD 绑的是**请求**的 ts 与 nonce(不是响应自己的):中转不能把「上一个请求的答复」
|
|
261
|
+
* 塞给「这个请求」,同一个路径上两条并发请求之间也换不了。
|
|
262
|
+
*
|
|
263
|
+
* `z` 只有真的压过明文时才有;没压就一个字段都不多写 —— 信封的形状要与老客户端
|
|
264
|
+
* 认得的那一种逐字节一致。
|
|
265
|
+
*/
|
|
266
|
+
function wrapper(sealedWithNonce, z) {
|
|
267
|
+
const buf = Buffer.from(sealedWithNonce);
|
|
268
|
+
return {
|
|
269
|
+
v: 2,
|
|
270
|
+
...(z ? { z } : {}),
|
|
271
|
+
n: buf.subarray(0, e2ee.NONCE_BYTES).toString('base64url'),
|
|
272
|
+
c: buf.subarray(e2ee.NONCE_BYTES).toString('base64'),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 把 `{ n, c }` 拼回 `open` 要的形状(App 侧同样要这么做,别只取 `c`)。 */
|
|
277
|
+
function unwrapper(wire) {
|
|
278
|
+
return Buffer.concat([Buffer.from(String(wire.n), 'base64url'), Buffer.from(String(wire.c), 'base64')]);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 读原始请求体(信封要求「按线上原样」算摘要,所以不能先 JSON.parse)。
|
|
283
|
+
*
|
|
284
|
+
* 上限按 §8.3(9) 给的数:body 上限按 4/3 放大,判定仍按明文。这里取 8MB,够文件分块那
|
|
285
|
+
* 一档的最大片;超了就当 413,别让一个畸形请求把内存吃光。
|
|
286
|
+
*/
|
|
287
|
+
// 线上那一侧的上限:最大的明文是上传片(11MB 那一档),信封再加 4/3。
|
|
288
|
+
// 明文侧的判定在各自路由里(`readJsonBody(req, ...)`),两边不共用同一个数。
|
|
289
|
+
const MAX_SEALED_BODY = Math.ceil(11 * 1024 * 1024 * 4 / 3);
|
|
290
|
+
|
|
291
|
+
function readRawBody(req, maxBytes = MAX_SEALED_BODY) {
|
|
292
|
+
// **没有请求体的请求不能等 `end`**:SSE 客户端(XHR)发出来的 GET 有时不会像普通
|
|
293
|
+
// fetch 那样把请求流收尾,而守卫在读体这一步就把处理器挡住了 —— 表现是「手机一直在
|
|
294
|
+
// 重连、宿主侧连开流日志都没有」(2026-10-24 现场)。GET/HEAD 且既没有 content-length
|
|
295
|
+
// 也没有 transfer-encoding,就是没有体,直接回空。
|
|
296
|
+
const method = String((req && req.method) || 'GET').toUpperCase();
|
|
297
|
+
const headers = (req && req.headers) || {};
|
|
298
|
+
if ((method === 'GET' || method === 'HEAD') && !headers['content-length'] && !headers['transfer-encoding']) {
|
|
299
|
+
return Promise.resolve(Buffer.alloc(0));
|
|
300
|
+
}
|
|
301
|
+
return new Promise((resolve, reject) => {
|
|
302
|
+
const chunks = [];
|
|
303
|
+
let total = 0;
|
|
304
|
+
req.on('data', (chunk) => {
|
|
305
|
+
total += chunk.length;
|
|
306
|
+
if (total > maxBytes) {
|
|
307
|
+
req.destroy();
|
|
308
|
+
reject(new Error('too large'));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
chunks.push(Buffer.from(chunk));
|
|
312
|
+
});
|
|
313
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
314
|
+
req.on('error', reject);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = {
|
|
319
|
+
openRequest,
|
|
320
|
+
sealResponse,
|
|
321
|
+
wrapper,
|
|
322
|
+
unwrapper,
|
|
323
|
+
isProto2,
|
|
324
|
+
headerOf,
|
|
325
|
+
signedPath,
|
|
326
|
+
signedQuery,
|
|
327
|
+
readRawBody,
|
|
328
|
+
MAX_SEALED_BODY,
|
|
329
|
+
TS_WINDOW_MS,
|
|
330
|
+
NONCE_TABLE_SIZE,
|
|
331
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 链路加密(协议 v2)的字节级原语。App 侧有一份逐字节等价实现(`services/e2ee.ts`),
|
|
5
|
+
* 两边的正确性靠同一份测试向量保证(`test/e2ee-vectors.json`),不靠互相阅读代码。
|
|
6
|
+
*
|
|
7
|
+
* 这一层只做「算字节」,不认识路由、不认识业务:信封放在哪里、什么时候加解密,由
|
|
8
|
+
* `http/routing.js`(宿主侧)与 `services/http.ts`(App 侧)负责。
|
|
9
|
+
*
|
|
10
|
+
* 算法只用三样,且都是两端现成的:AES-256-GCM、HKDF-SHA256、HMAC-SHA256。
|
|
11
|
+
*
|
|
12
|
+
* **AEAD 用 AES-256-GCM 而不是 ChaCha20-Poly1305**(2026-10-24 改):桌面宿主跑在
|
|
13
|
+
* Electron 里,那份 Node 链接的是 BoringSSL,`crypto.getCiphers()` 里根本没有
|
|
14
|
+
* `chacha20-poly1305`(实测 `createCipheriv` 直接 `Unknown cipher`,`openssl 0.0.0`)。
|
|
15
|
+
* 换过来之前,每一条协议 v2 的请求在宿主侧都会在解封那一步抛错 —— 表现成扫码配对
|
|
16
|
+
* 一律「配对码错误」、已配对设备全线 401,而两端的离线测试(用真实 node:crypto)
|
|
17
|
+
* 全绿,因为测试进程的 Node 是带 chacha 的那一种。
|
|
18
|
+
* 不引 HPKE、不引新依赖 —— 理由(Node 不暴露 `crypto.hpke`,App 侧也没有
|
|
19
|
+
* 现成的 X25519,而二维码里那张一次性票据已经解决了「怎么秘密交付密钥」)。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const crypto = require('node:crypto');
|
|
23
|
+
|
|
24
|
+
const PROTO_VERSION = 2;
|
|
25
|
+
/** 线上信封里的 `v`。与 `X-Dsh-Proto` 同值,但要分开取:前者是信封形状,后者是会话属性。 */
|
|
26
|
+
const ENVELOPE_VERSION = 2;
|
|
27
|
+
const NONCE_BYTES = 12;
|
|
28
|
+
const TAG_BYTES = 16;
|
|
29
|
+
const KEY_BYTES = 32;
|
|
30
|
+
/**
|
|
31
|
+
* AEAD 算法名。
|
|
32
|
+
*
|
|
33
|
+
* **必须是两端 Node / BoringSSL 都认的那一个**:宿主侧跑在 Electron 里(BoringSSL,
|
|
34
|
+
* 没有 chacha20-poly1305),App 侧跑 `@noble/ciphers`。AES-256-GCM 两边都在,
|
|
35
|
+
* 且实测密文逐字节一致(`test/e2ee-vectors.json` 的重算就是这条证据)。
|
|
36
|
+
*/
|
|
37
|
+
const AEAD = 'aes-256-gcm';
|
|
38
|
+
|
|
39
|
+
/* ── 编码 ───────────────────────────────────────────────────────────── */
|
|
40
|
+
|
|
41
|
+
const b64url = (buf) => Buffer.from(buf).toString('base64url');
|
|
42
|
+
const unb64url = (text) => {
|
|
43
|
+
// Buffer 的 base64 解码是宽容的(坏字符会被跳过),所以先按字符集挡一道:
|
|
44
|
+
// 一个畸形串静默解成一段字节,最后表现为「认证标签不过」,很难查。
|
|
45
|
+
const value = String(text || '');
|
|
46
|
+
if (value !== '' && !/^[A-Za-z0-9_-]*={0,2}$/.test(value)) throw new Error('bad base64url');
|
|
47
|
+
return Buffer.from(value, 'base64url');
|
|
48
|
+
};
|
|
49
|
+
const b64 = (buf) => Buffer.from(buf).toString('base64');
|
|
50
|
+
const unb64 = (text) => {
|
|
51
|
+
const value = String(text || '');
|
|
52
|
+
if (value !== '' && !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) throw new Error('bad base64');
|
|
53
|
+
return Buffer.from(value, 'base64');
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/* ── 密钥派生 ───────────────────────────────────────────────────────── */
|
|
57
|
+
|
|
58
|
+
function hkdf32(ikm, salt, info) {
|
|
59
|
+
return Buffer.from(crypto.hkdfSync('sha256', ikm, salt, Buffer.from(info, 'utf8'), KEY_BYTES));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 二维码里那张一次性票据 → 配对密钥。
|
|
64
|
+
*
|
|
65
|
+
* `salt` 取实例号,把两张不同宿主的二维码绑死在各自的机器上:换个实例号就算同一张票据,
|
|
66
|
+
* 派出来的密钥也不同。
|
|
67
|
+
*/
|
|
68
|
+
function derivePairKey(ticket, instanceId) {
|
|
69
|
+
return hkdf32(ticket, Buffer.from(String(instanceId), 'utf8'), 'dsh-pair-v2');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 设备主密钥 → 三把方向/用途分开的密钥。
|
|
74
|
+
*
|
|
75
|
+
* **上下行必须分开**:两个方向各自随机产 nonce,而 AEAD 的 keystream 只由「密钥 + nonce」
|
|
76
|
+
* 决定、AAD 不参与(实测:同 key 同 nonce、只换 AAD,正文密文逐字节相同)。共用一把时
|
|
77
|
+
* 两个方向一旦撞 nonce,两条消息的正文直接 XOR 掉。签名用第三把,不和加密共用。
|
|
78
|
+
*/
|
|
79
|
+
function deriveSessionKeys(deviceKey) {
|
|
80
|
+
return {
|
|
81
|
+
up: hkdf32(deviceKey, Buffer.alloc(0), 'dsh-v2|up'),
|
|
82
|
+
down: hkdf32(deviceKey, Buffer.alloc(0), 'dsh-v2|down'),
|
|
83
|
+
sig: hkdf32(deviceKey, Buffer.alloc(0), 'dsh-v2|sig'),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* ── AEAD ──────────────────────────────────────────────────────────── */
|
|
88
|
+
|
|
89
|
+
/** 封:`nonce(12) || 密文 || tag(16)`。 */
|
|
90
|
+
const sealWithNonce = (key, nonce, plain, aad) => {
|
|
91
|
+
const cipher = crypto.createCipheriv(AEAD, key, nonce, { authTagLength: TAG_BYTES });
|
|
92
|
+
if (aad) cipher.setAAD(Buffer.from(aad));
|
|
93
|
+
return Buffer.concat([Buffer.from(nonce), cipher.update(Buffer.from(plain)), cipher.final(), cipher.getAuthTag()]);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** 封(自己产 nonce)。 */
|
|
97
|
+
const seal = (key, plain, aad) => sealWithNonce(key, crypto.randomBytes(NONCE_BYTES), plain, aad);
|
|
98
|
+
|
|
99
|
+
/** 解:入参是 `nonce || 密文 || tag`。认证不过抛错,绝不返回半截明文。 */
|
|
100
|
+
function open(key, sealed, aad) {
|
|
101
|
+
const buf = Buffer.from(sealed);
|
|
102
|
+
if (buf.length < NONCE_BYTES + TAG_BYTES) throw new Error('ciphertext too short');
|
|
103
|
+
const nonce = buf.subarray(0, NONCE_BYTES);
|
|
104
|
+
const body = buf.subarray(NONCE_BYTES, buf.length - TAG_BYTES);
|
|
105
|
+
const tag = buf.subarray(buf.length - TAG_BYTES);
|
|
106
|
+
const decipher = crypto.createDecipheriv(AEAD, key, nonce, { authTagLength: TAG_BYTES });
|
|
107
|
+
if (aad) decipher.setAAD(Buffer.from(aad));
|
|
108
|
+
decipher.setAuthTag(tag);
|
|
109
|
+
return Buffer.concat([decipher.update(body), decipher.final()]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ── AAD / 签名口径 ─────────────────────────────────────────────────── */
|
|
113
|
+
|
|
114
|
+
const pairAad = (instanceId) => `pair|v2|${instanceId}`;
|
|
115
|
+
const upAad = (method, path) => `v2|up|${method}|${path}`;
|
|
116
|
+
const downAad = (method, path, ts, nonce) => `v2|down|${method}|${path}|${ts}|${nonce}`;
|
|
117
|
+
const sseAad = (path, ts, nonce, seq) => `v2|down|sse|${path}|${ts}|${nonce}|${seq}`;
|
|
118
|
+
const fileAad = (path, nonce, seq) => `v2|down|file|${path}|${nonce}|${seq}`;
|
|
119
|
+
const paramsAad = (ts, nonce) => `v2|params|${ts}|${nonce}`;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 签名的规范串。**全部按线上原样**,不做任何规范化:
|
|
123
|
+
*
|
|
124
|
+
* `v2 \n METHOD \n PATH \n QUERY \n TS \n NONCE \n HEX(sha256(body))`
|
|
125
|
+
*
|
|
126
|
+
* `PATH` 从 `API_BASE` 起算(中转会把部署前缀剥掉再交给宿主,按完整路径签必然对不上)。
|
|
127
|
+
* `QUERY` 带 `?`、按实际发出的顺序,不排序:它必须和真发出去的那串一致。
|
|
128
|
+
* 摘要用小写十六进制 64 字符,不是 base64。
|
|
129
|
+
*/
|
|
130
|
+
function canonicalString({ method, path, query, ts, nonce, bodyBytes }) {
|
|
131
|
+
const digest = crypto.createHash('sha256').update(bodyBytes || Buffer.alloc(0)).digest('hex');
|
|
132
|
+
return ['v2', String(method || 'GET').toUpperCase(), path, query || '', String(ts), nonce, digest].join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const sign = (kSig, canonical) => b64url(crypto.createHmac('sha256', kSig).update(Buffer.from(canonical, 'utf8')).digest());
|
|
136
|
+
|
|
137
|
+
function verifySignature(kSig, canonical, presented) {
|
|
138
|
+
let got;
|
|
139
|
+
try {
|
|
140
|
+
got = unb64url(presented);
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
if (got.length !== 32) return false;
|
|
145
|
+
const want = crypto.createHmac('sha256', kSig).update(Buffer.from(canonical, 'utf8')).digest();
|
|
146
|
+
return crypto.timingSafeEqual(want, got);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
PROTO_VERSION,
|
|
151
|
+
ENVELOPE_VERSION,
|
|
152
|
+
NONCE_BYTES,
|
|
153
|
+
TAG_BYTES,
|
|
154
|
+
KEY_BYTES,
|
|
155
|
+
b64url,
|
|
156
|
+
unb64url,
|
|
157
|
+
b64,
|
|
158
|
+
unb64,
|
|
159
|
+
derivePairKey,
|
|
160
|
+
deriveSessionKeys,
|
|
161
|
+
seal,
|
|
162
|
+
sealWithNonce,
|
|
163
|
+
open,
|
|
164
|
+
pairAad,
|
|
165
|
+
upAad,
|
|
166
|
+
downAad,
|
|
167
|
+
sseAad,
|
|
168
|
+
fileAad,
|
|
169
|
+
paramsAad,
|
|
170
|
+
canonicalString,
|
|
171
|
+
sign,
|
|
172
|
+
verifySignature,
|
|
173
|
+
};
|
package/lib/shared/http-util.js
CHANGED
|
@@ -16,6 +16,15 @@ const zlib = require('node:zlib');
|
|
|
16
16
|
*/
|
|
17
17
|
const JSON_GZIP_MIN_BYTES = 1024;
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* 普通 JSON 请求体的默认上限(**明文**字节数)。
|
|
21
|
+
*
|
|
22
|
+
* 原来是 256KB。协议 v2 的信封让线上字节变成 4/3,若仍按 256KB 卡线上长度,等于悄悄
|
|
23
|
+
* 砍掉用户四分之一的可用额度,而错误信息只会是「太大」。所以按 §8.3(9) 抬到 344KB,
|
|
24
|
+
* 并且判定一律按**解封之后的明文**算 —— 线上那一侧另有一道(`MAX_SEALED_BODY`)。
|
|
25
|
+
*/
|
|
26
|
+
const JSON_MAX_BODY = Math.ceil(256 * 1024 * 4 / 3);
|
|
27
|
+
|
|
19
28
|
/**
|
|
20
29
|
* 对方是否声明支持 gzip。
|
|
21
30
|
*
|
|
@@ -57,7 +66,12 @@ function sendJson(res, status, body) {
|
|
|
57
66
|
const text = JSON.stringify(body);
|
|
58
67
|
const headers = { 'content-type': 'application/json; charset=utf-8', vary: 'accept-encoding' };
|
|
59
68
|
const acceptEncoding = res && res.req && res.req.headers ? res.req.headers['accept-encoding'] : undefined;
|
|
60
|
-
|
|
69
|
+
// **协议 v2 的响应不在这里压**:守卫是在 `res.end` 上把 JSON 解出来再封成信封的,
|
|
70
|
+
// 先压成 gzip 的话那一层只能看到二进制,`JSON.parse` 失败就退化成「原样写出、没封」——
|
|
71
|
+
// 结果是最大的那批响应(列表 / 消息 / 统计)反而不加密。压缩改由信封写出去之前做,
|
|
72
|
+
// 那一层拿到的仍是 JSON 文本(见 `lib/http/routing.js` 的 `res.end` 钩子)。
|
|
73
|
+
const sealed = !!(res && res.req && res.req.__dshE2ee);
|
|
74
|
+
const encoded = encodeJsonBody(text == null ? '' : text, sealed ? undefined : acceptEncoding);
|
|
61
75
|
if (encoded.gzip) headers['content-encoding'] = 'gzip';
|
|
62
76
|
res.writeHead(status, headers);
|
|
63
77
|
res.end(encoded.body);
|
|
@@ -88,11 +102,20 @@ function sendHtml(res, status, html) {
|
|
|
88
102
|
/**
|
|
89
103
|
* 读取请求体并 JSON.parse;超限/非法 JSON/IO 错误 → null。
|
|
90
104
|
*
|
|
105
|
+
* **协议 v2 的请求体已经被守卫解开了**(`req.body`),这里直接返回它:那条请求的字节流
|
|
106
|
+
* 早被读走拿去验签与解封,再读只会拿到空串,于是所有 POST 都退化成「请求体不是合法
|
|
107
|
+
* JSON」。上限按**明文**判(与线上字节的 4/3 放大分开算,见 §8.3(9))。
|
|
108
|
+
*
|
|
91
109
|
* @param {object} req node:http IncomingMessage
|
|
92
|
-
* @param {number} [maxBytes]
|
|
110
|
+
* @param {number} [maxBytes] 请求体上限(字节,按明文),缺省 344KB
|
|
93
111
|
* @returns {Promise<object|null>}
|
|
94
112
|
*/
|
|
95
|
-
function readJsonBody(req, maxBytes =
|
|
113
|
+
function readJsonBody(req, maxBytes = JSON_MAX_BODY) {
|
|
114
|
+
if (req && req.body !== undefined && req.body !== null) {
|
|
115
|
+
const body = req.body;
|
|
116
|
+
if (Buffer.byteLength(JSON.stringify(body), 'utf8') > maxBytes) return Promise.resolve(null);
|
|
117
|
+
return Promise.resolve(body);
|
|
118
|
+
}
|
|
96
119
|
return new Promise((resolve) => {
|
|
97
120
|
let raw = '';
|
|
98
121
|
req.on('data', (chunk) => {
|
|
@@ -114,4 +137,4 @@ function readJsonBody(req, maxBytes = 256 * 1024) {
|
|
|
114
137
|
});
|
|
115
138
|
}
|
|
116
139
|
|
|
117
|
-
module.exports = { sendJson, sendHtml, readJsonBody, encodeJsonBody, wantsGzip, JSON_GZIP_MIN_BYTES };
|
|
140
|
+
module.exports = { sendJson, sendHtml, readJsonBody, encodeJsonBody, wantsGzip, JSON_GZIP_MIN_BYTES, JSON_MAX_BODY };
|
|
@@ -28,6 +28,10 @@ function createListSignal() {
|
|
|
28
28
|
let watchers = 0;
|
|
29
29
|
let revision = '';
|
|
30
30
|
let at = 0;
|
|
31
|
+
// 工作区清单自己的 revision。与上面那条分开:清单变了不代表工作区变了(反之亦然),
|
|
32
|
+
// 两条读各自的条件刷新要用各自的信号,否则「工作区改个名字」永远等不到刷新。
|
|
33
|
+
let workspaceRevision = '';
|
|
34
|
+
let workspaceAt = 0;
|
|
31
35
|
|
|
32
36
|
return {
|
|
33
37
|
/**
|
|
@@ -77,8 +81,20 @@ function createListSignal() {
|
|
|
77
81
|
|
|
78
82
|
watching() { return watchers > 0; },
|
|
79
83
|
|
|
84
|
+
/** 广播一次工作区清单变更(幂等)。 */
|
|
85
|
+
bumpWorkspaces(nextRevision) {
|
|
86
|
+
const value = typeof nextRevision === 'string' ? nextRevision : '';
|
|
87
|
+
if (!value || value === workspaceRevision) return false;
|
|
88
|
+
workspaceRevision = value;
|
|
89
|
+
workspaceAt = Date.now();
|
|
90
|
+
return true;
|
|
91
|
+
},
|
|
92
|
+
|
|
80
93
|
/** 最近一次变更(供诊断面板观测)。 */
|
|
81
94
|
snapshot() { return { revision, at }; },
|
|
95
|
+
|
|
96
|
+
/** 工作区清单最近一次变更(条件刷新的「构建前短路」用它)。 */
|
|
97
|
+
workspaceSnapshot() { return { revision: workspaceRevision, at: workspaceAt }; },
|
|
82
98
|
};
|
|
83
99
|
}
|
|
84
100
|
|