dsh-mobile-plugin 0.1.100 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +88 -9
- package/lib/index.js +3 -2
- 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 +137 -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 +209 -11
- package/lib/mobile/host-bridge/event-synthesis.js +61 -10
- 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 +547 -0
- package/lib/mobile/session-cache.js +60 -2
- package/lib/mobile/session-file.js +133 -34
- 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 +30 -27
- package/lib/mobile/session-snapshot.js +12 -12
- 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 +382 -31
- package/lib/mobile/subagents.js +11 -2
- package/lib/mobile/uploads.js +91 -5
- package/lib/mobile/workspaces.js +39 -8
- package/lib/panel/index.html +11 -11
- package/lib/relay/core.js +28 -2
- package/lib/relay/lan-network.cjs +47 -0
- 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 +139 -9
- package/lib/routes/mobile-api.js +92 -9
- 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 +44 -3
- package/lib/sidecar-facade-worker.cjs +70 -6
- package/lib/update-install.cjs +13 -5
- package/package.json +3 -2
package/lib/relay/tunnel.js
CHANGED
|
@@ -15,6 +15,7 @@ const { WebSocket } = require('ws');
|
|
|
15
15
|
const { T, jsonFrame, encodeFrame, createFrameDecoder, parseJson, encodeClosePayload, decodeClosePayload } = require('./protocol.js');
|
|
16
16
|
const { isFreshRequest } = require('../shared/fresh-request');
|
|
17
17
|
const { encodeJsonBody } = require('../shared/http-util');
|
|
18
|
+
const { withViaHeader } = require('../shared/via-header');
|
|
18
19
|
|
|
19
20
|
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
20
21
|
// Relay currently expires an idle tunnel after 60s. Keep a wider client-side
|
|
@@ -43,7 +44,7 @@ class RelayTunnel {
|
|
|
43
44
|
* @param {object} opts
|
|
44
45
|
* @param {string} opts.relayUrl 隧道 WSS 地址(含 /tunnel),如 wss://relay.example.com/tunnel
|
|
45
46
|
* @param {string} opts.instanceId
|
|
46
|
-
* @param {string} opts.pairingCode
|
|
47
|
+
* @param {string} [opts.pairingCode] 仅本进程自用(面板显示);**不发往中转**,见 registrationPayload
|
|
47
48
|
* @param {string} [opts.name]
|
|
48
49
|
* @param {string[]} [opts.lanUrls] LAN sidecar candidates (metadata only)
|
|
49
50
|
* @param {object[]} [opts.lanCandidates] LAN candidate metadata (metadata only)
|
|
@@ -60,7 +61,7 @@ class RelayTunnel {
|
|
|
60
61
|
constructor(opts) {
|
|
61
62
|
this.relayUrl = opts.relayUrl;
|
|
62
63
|
this.instanceId = opts.instanceId;
|
|
63
|
-
this.pairingCode = opts.pairingCode;
|
|
64
|
+
this.pairingCode = opts.pairingCode || '';
|
|
64
65
|
this.name = opts.name || '';
|
|
65
66
|
this.lanUrls = Array.isArray(opts.lanUrls) ? opts.lanUrls.slice() : [];
|
|
66
67
|
this.lanCandidates = Array.isArray(opts.lanCandidates) ? opts.lanCandidates.slice() : [];
|
|
@@ -387,19 +388,28 @@ class RelayTunnel {
|
|
|
387
388
|
}
|
|
388
389
|
|
|
389
390
|
/**
|
|
390
|
-
*
|
|
391
|
-
*
|
|
391
|
+
* 更新配对码。
|
|
392
|
+
*
|
|
393
|
+
* **不再告诉中转**(§5.2)。中转那边从来没用过它 —— `ws.rs` 写明「不保存也不校验
|
|
394
|
+
* 注册帧里的实例号 / 配对码」,唯一的用处是控制台准入校验的 `license` 字段。删掉之后
|
|
395
|
+
* 「中转看得见配对码」这个既成事实就不存在了,而这一轮之后配对码本身也不再是
|
|
396
|
+
* 新协议的凭证(票据才是)。
|
|
397
|
+
*
|
|
398
|
+
* 保留这个方法只因为 `updateRegistration` 那条路还在用它更新本地值。
|
|
392
399
|
*/
|
|
393
400
|
updatePairingCode(code) {
|
|
394
401
|
this.pairingCode = code;
|
|
395
|
-
if (this.connected) this.sendJson(T.updatePairing, 0, { pairingCode: code });
|
|
396
402
|
}
|
|
397
403
|
|
|
398
|
-
/**
|
|
404
|
+
/**
|
|
405
|
+
* 注册帧的载荷。连接建立时发一次,之后元信息变了再由 updateRegistration 重发。
|
|
406
|
+
*
|
|
407
|
+
* **不带配对码**(§5.2):中转从来没读过它,而带上它就等于把「谁能配这台机器」的
|
|
408
|
+
* 一半凭证交给链路上的第三方。删掉零成本。
|
|
409
|
+
*/
|
|
399
410
|
registrationPayload() {
|
|
400
411
|
return {
|
|
401
412
|
instanceId: this.instanceId,
|
|
402
|
-
pairingCode: this.pairingCode,
|
|
403
413
|
name: this.name,
|
|
404
414
|
// 可选:与中转端 TUNNEL_REGISTER_SECRET 对应,防止他人抢注 instanceId。
|
|
405
415
|
...(this.registerSecret ? { secret: this.registerSecret } : {}),
|
|
@@ -582,7 +592,7 @@ class RelayTunnel {
|
|
|
582
592
|
// 读取隔离:命中本地快照时也走有界发送队列,避免快照响应在buffer高水位时丢帧。
|
|
583
593
|
// 例外:带 ?fresh=1 的写后刷新必须回源(判定与实测见 lib/shared/fresh-request.js)。
|
|
584
594
|
if (this.readSnapshot && !isFreshRequest(msg.path) && (msg.method === 'GET' || msg.method === undefined)) {
|
|
585
|
-
const body = this.readSnapshot({ method: msg.method, path: msg.path });
|
|
595
|
+
const body = this.readSnapshot({ method: msg.method, path: msg.path, headers: msg.headers });
|
|
586
596
|
if (body && body.ok) {
|
|
587
597
|
// 这条捷径回的是列表 / 工作区 / 消息 / 统计这几条最重的读取,压缩的收益全在这儿。
|
|
588
598
|
const encoded = encodeJsonBody(JSON.stringify(body), msg.headers?.['accept-encoding']);
|
|
@@ -606,11 +616,7 @@ class RelayTunnel {
|
|
|
606
616
|
port: this.upstream.port,
|
|
607
617
|
method: msg.method,
|
|
608
618
|
path: msg.path,
|
|
609
|
-
headers:
|
|
610
|
-
...(msg.headers || {}),
|
|
611
|
-
// 保留手机端原始 Host(信任围栏按 Host 判定);缺省回退本地上游
|
|
612
|
-
host: (msg.headers && msg.headers.host) || this.upstream.host,
|
|
613
|
-
},
|
|
619
|
+
headers: withViaHeader(msg.headers, 'relay', this.upstream.host),
|
|
614
620
|
},
|
|
615
621
|
(res) => {
|
|
616
622
|
entry.res = res;
|
|
@@ -749,7 +755,7 @@ class RelayTunnel {
|
|
|
749
755
|
const url = `ws://${this.upstream.host}${path}`;
|
|
750
756
|
let client;
|
|
751
757
|
try {
|
|
752
|
-
client = new WebSocket(url, { headers });
|
|
758
|
+
client = new WebSocket(url, { headers: withViaHeader(headers, 'relay', this.upstream.host) });
|
|
753
759
|
} catch (err) {
|
|
754
760
|
if (!this.cancelledWs.has(id)) this.sendJson(T.wsError, id, { message: err.message });
|
|
755
761
|
return;
|
|
@@ -256,6 +256,13 @@ function registerControlPlaneRoutes(deps) {
|
|
|
256
256
|
// 路由住在代里 —— 代被换掉之后服务还在,接口也就跟着活下来。
|
|
257
257
|
// 缺省返回 null:`lib/impl.js` 单独挂载时(测试 / 老宿主)没有外壳,如实报不可用。
|
|
258
258
|
getPluginRuntime = () => null,
|
|
259
|
+
/**
|
|
260
|
+
* 收掉某台设备已经开着的 SSE 流(吊销时用)。
|
|
261
|
+
*
|
|
262
|
+
* token 换了只保证「下一次请求」被拒,已经开着的流不会自己停。缺省返回 0(老宿主 /
|
|
263
|
+
* 测试里没有流登记):那时吊销仍然对下一次请求生效,只是收不了在途的流。
|
|
264
|
+
*/
|
|
265
|
+
dropDeviceStreams = () => 0,
|
|
259
266
|
} = deps;
|
|
260
267
|
const selfCheck = createSelfCheck({
|
|
261
268
|
ctx,
|
|
@@ -293,6 +300,48 @@ function registerControlPlaneRoutes(deps) {
|
|
|
293
300
|
// 不能等到第一次 status 轮询(那之前设备行画不出图标)。被拒时不给,没有意义。
|
|
294
301
|
return sendJson(res, 200, { data: { approved: decision.approved, reason: decision.reason, ...(decision.approved ? pairingApproval.hostPlatform() : {}) } })
|
|
295
302
|
} })
|
|
303
|
+
/**
|
|
304
|
+
* 旁路子进程的签发通道(§3.7)。局域网配对跑在旁路里,而凭据库只有一个写方 —— 主进程。
|
|
305
|
+
* 旁路把「给这台设备签一枚」交到这里,主进程落盘后把凭据回给它。
|
|
306
|
+
*
|
|
307
|
+
* 身份同 `/internal/approval`:sidecarToken,不走 localOnly。
|
|
308
|
+
*/
|
|
309
|
+
registerRoute({ kind: 'exact', path: '/api/relay/internal/issue-credential', handler: async (req, res) => {
|
|
310
|
+
if (req.headers.authorization !== `Bearer ${sidecarToken}`) return sendJson(res, 401, { error: 'unauthorized' })
|
|
311
|
+
let body = {}
|
|
312
|
+
try { body = (await readJson(req)) || {} } catch { body = {} }
|
|
313
|
+
const device = body.device && typeof body.device === 'object' ? body.device : {}
|
|
314
|
+
const extra = body.extra && typeof body.extra === 'object' ? body.extra : {}
|
|
315
|
+
try {
|
|
316
|
+
const issued = credentials.issue(state.instanceId, device, '', undefined, extra)
|
|
317
|
+
return sendJson(res, 200, { data: issued })
|
|
318
|
+
} catch (error) {
|
|
319
|
+
return sendJson(res, 500, { ok: false, error: { code: 'internal', message: String(error?.message || error) } })
|
|
320
|
+
}
|
|
321
|
+
} })
|
|
322
|
+
/**
|
|
323
|
+
* 局域网配对成功之后换码 + 换票据(§5.1)的内部通道。
|
|
324
|
+
*
|
|
325
|
+
* 为什么需要一条内部路由:局域网代理跑在旁路子进程里,换码要写宿主的状态文件,而**唯一
|
|
326
|
+
* 能写的地方是主进程**。旁路配对成功后发一条到这里,主进程落盘、再让旁路重读。
|
|
327
|
+
* 身份同 `/internal/approval`:sidecarToken,不走 localOnly(没有本机 socket 可言)。
|
|
328
|
+
*
|
|
329
|
+
* 少了这条,局域网配对成功后码与票据都还是旧的 —— 截图里那张二维码长期有效,而它里面
|
|
330
|
+
* 是整条链路的密钥,正是 §8.10 第 3 条要堵的那条。旁路那边一句 `.catch(() => {})`
|
|
331
|
+
* 会把 404 吞掉,现场只表现为「配完还能拿旧图再配一次」。
|
|
332
|
+
*/
|
|
333
|
+
registerRoute({ kind: 'exact', path: '/api/relay/internal/rotate-pairing', handler: async (req, res) => {
|
|
334
|
+
if (req.headers.authorization !== `Bearer ${sidecarToken}`) return sendJson(res, 401, { error: 'unauthorized' })
|
|
335
|
+
let next
|
|
336
|
+
try {
|
|
337
|
+
next = core.rotatePairing(state)
|
|
338
|
+
} catch (error) {
|
|
339
|
+
return sendJson(res, 500, { ok: false, error: { code: 'internal', message: String(error?.message || error) } })
|
|
340
|
+
}
|
|
341
|
+
// 落盘之后要让旁路重读:它手上那份还是旧码旧票,不推下去等于只换了主进程那一份。
|
|
342
|
+
try { await syncSidecar() } catch { /* 旁路不可达时下次 configure 会带上新值 */ }
|
|
343
|
+
return sendJson(res, 200, { ok: true, data: next })
|
|
344
|
+
} })
|
|
296
345
|
// 控制面状态里额外带上面板页的绝对地址:客户端半边(lib/client.js)那一项现在只是
|
|
297
346
|
// 一个「打开配置页面」的入口,链接必须是绝对的(原因见 panelUrlOf)。地址只在这里
|
|
298
347
|
// 补,不动 createStatusView —— 那份 JSON 手机端也在读,127.0.0.1 的地址对它没意义。
|
|
@@ -318,9 +367,9 @@ function registerControlPlaneRoutes(deps) {
|
|
|
318
367
|
* ——读到的是浏览器拦截,不是真实耗时,而且会把可用入口误判成不通。宿主是本机进程,
|
|
319
368
|
* 出站没有 CORS 可言,测出来的才是用户实际能用的那条。
|
|
320
369
|
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
370
|
+
* 判定分两步:`/healthz` 回 2xx **且** `/api/mobile/v1/health` 回的 `service` 是 `dsh-mobile-relay`。
|
|
371
|
+
* 只看 `/healthz` 不行——任何静态站都可能回 200,随手敲一个域名就被当成「可用中转」。
|
|
372
|
+
* 超时的照实报 `null`(前端按「不通」排在后面),不编造数字。
|
|
324
373
|
*/
|
|
325
374
|
registerRoute({ kind: 'exact', path: '/api/relay/probe-relays', handler: localOnly('POST', async (req, res) => {
|
|
326
375
|
const body = await readJson(req)
|
|
@@ -336,10 +385,21 @@ function registerControlPlaneRoutes(deps) {
|
|
|
336
385
|
const controller = new AbortController()
|
|
337
386
|
const timer = setTimeout(() => controller.abort(), RELAY_PROBE_TIMEOUT_MS)
|
|
338
387
|
const response = await fetch(`${url}/healthz`, { signal: controller.signal, redirect: 'follow' })
|
|
339
|
-
clearTimeout(timer)
|
|
340
388
|
// 读完响应体再停表:只等到响应头会把「连得上但发不出东西」算成很快。
|
|
341
|
-
await response.text().catch(() => '')
|
|
342
|
-
|
|
389
|
+
const text = await response.text().catch(() => '')
|
|
390
|
+
if (!response.ok) {
|
|
391
|
+
clearTimeout(timer)
|
|
392
|
+
return { url, ms: null }
|
|
393
|
+
}
|
|
394
|
+
// **只认运维意义上「这一台确实是中转」**:`/healthz` 回 200 的站点满互联网都是,
|
|
395
|
+
// 光看它等于把 qq.com 也当可用中转(2026-10-24 用户实测随手敲一个就过)。
|
|
396
|
+
// 中转自己那条地址体检 `/api/mobile/v1/health` 会带回 `service`,对不上就不是它。
|
|
397
|
+
const who = await fetch(`${url}/api/mobile/v1/health`, { signal: controller.signal, redirect: 'follow' })
|
|
398
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
399
|
+
.catch(() => null)
|
|
400
|
+
clearTimeout(timer)
|
|
401
|
+
if (!who || who.service !== 'dsh-mobile-relay') return { url, ms: null }
|
|
402
|
+
return { url, ms: Date.now() - started, service: who.service, version: who.version }
|
|
343
403
|
} catch {
|
|
344
404
|
return { url, ms: null }
|
|
345
405
|
}
|
|
@@ -453,13 +513,40 @@ function registerControlPlaneRoutes(deps) {
|
|
|
453
513
|
if (!guard) return sendJson(res, 200, { ok: true, installed: false, faults: [], stats: null })
|
|
454
514
|
return sendJson(res, 200, { ok: true, installed: true, faults: guard.faults(), stats: guard.stats() })
|
|
455
515
|
}) })
|
|
456
|
-
registerRoute({ kind: 'exact', path: '/api/relay/pairing', handler: localOnly('GET', async (
|
|
516
|
+
registerRoute({ kind: 'exact', path: '/api/relay/pairing', handler: localOnly('GET', async (req, res) => {
|
|
517
|
+
// **二维码只在本机屏幕上出现**。
|
|
518
|
+
//
|
|
519
|
+
// 二维码里现在装的是一次性票据 —— 用它能派生出整条链路的密钥。而「经中转/局域网
|
|
520
|
+
// 代理转发来的请求在判据眼里长得像本机」这件事已经被实测证实过,所以这条不能只靠
|
|
521
|
+
// `localOnly`;它要的是**可信本机界面**这个更强的判据(socket 回环 + Host 回环 +
|
|
522
|
+
// 没有任何代理来源头)。网页管理窗口(两小时窗口那套)打开时能看到设备列表,但看
|
|
523
|
+
// 不到这张码 —— 窗口本身是从浏览器发起的,`trustedLocalUi` 对它不成立。
|
|
524
|
+
//
|
|
525
|
+
// 拿不到就回 423 而不是 403:面板那边据此显示「请在这台电脑上打开面板」,
|
|
526
|
+
// 也**不给**「点这里强制显示」——那等于把刚堵上的口子又开一遍。
|
|
527
|
+
if (typeof trustedLocalUi !== 'function' || !trustedLocalUi(req)) {
|
|
528
|
+
return sendJson(res, 423, {
|
|
529
|
+
ok: false,
|
|
530
|
+
error: { code: 'pairing_local_only', message: '配对二维码只在本机屏幕显示,请在这台电脑上打开面板' },
|
|
531
|
+
})
|
|
532
|
+
}
|
|
457
533
|
if (!state.enabled) {
|
|
458
534
|
sendJson(res, 409, { ok: false, error: '请先启用远程控制并保存配置' })
|
|
459
535
|
return
|
|
460
536
|
}
|
|
461
537
|
await resolveLanPort()
|
|
462
|
-
|
|
538
|
+
/**
|
|
539
|
+
* `?relayUrl=` 是**只看不写**的预览:按这个候选地址生成二维码,但一个字都不落盘。
|
|
540
|
+
*
|
|
541
|
+
* 给「自建中转填完就想看到码」用(2026-10-24 用户要求)。以前那版是「填完就把地址写进
|
|
542
|
+
* 配置」,等于拿一条还没验过的地址覆盖正在用的设置;现在预览走这里,真写入只在用户
|
|
543
|
+
* 明确按下「连接中转」时发生。
|
|
544
|
+
*/
|
|
545
|
+
const preview = typeof req.url === 'string' ? new URL(req.url, 'http://local').searchParams.get('relayUrl') : null
|
|
546
|
+
const source = preview && /^https?:\/\/[^\s]+$/i.test(preview.trim())
|
|
547
|
+
? { ...state, relayUrl: preview.trim().replace(/\/+$/, '') }
|
|
548
|
+
: state
|
|
549
|
+
const { payload, text } = pairingPayload(source, lanPortInUse())
|
|
463
550
|
if (!payload.relayUrl && payload.lanUrls.length === 0) {
|
|
464
551
|
sendJson(res, 409, { ok: false, error: '没有可用的局域网或中转连接地址' })
|
|
465
552
|
return
|
|
@@ -509,6 +596,44 @@ function registerControlPlaneRoutes(deps) {
|
|
|
509
596
|
core.saveState(state)
|
|
510
597
|
sendJson(res, 200, status())
|
|
511
598
|
}) })
|
|
599
|
+
/**
|
|
600
|
+
* 已绑定的设备列表(§3.4)。
|
|
601
|
+
*
|
|
602
|
+
* 只回「面板画一行需要的东西」,**不回 `hash` / `hostToken`** —— 那些是凭据材料,
|
|
603
|
+
* 面板只是显示谁连着、什么时候来的。
|
|
604
|
+
*
|
|
605
|
+
* 与二维码那条不一样,这条**进** `WEB_ACCESS_ROUTES`:设备列表里没有密钥材料,
|
|
606
|
+
* 两小时窗口打开时能看、能踢是它的用途;而二维码里是整条链路的密钥,绝不能。
|
|
607
|
+
*
|
|
608
|
+
* `serverNow` 是给面板算「几分钟前」用的:手机与宿主的时钟可能差得多,但面板只需要
|
|
609
|
+
* 一个同一台机器上的基准。
|
|
610
|
+
*/
|
|
611
|
+
registerRoute({ kind: 'exact', path: '/api/relay/devices', handler: localOnly('GET', async (_req, res) => {
|
|
612
|
+
sendJson(res, 200, {
|
|
613
|
+
ok: true,
|
|
614
|
+
serverNow: Date.now(),
|
|
615
|
+
devices: credentials.listDevices(state.instanceId),
|
|
616
|
+
})
|
|
617
|
+
}) })
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* 解除一台设备的绑定(§3.4)。
|
|
621
|
+
*
|
|
622
|
+
* 吊销该 deviceId 下所有仍有效的记录 —— 同一台手机重配过几次就是几条,只吊最新那条
|
|
623
|
+
* 等于旧的还能用。已建立的 SSE 要主动断开:token 换了只保证「下一次请求」被拒,
|
|
624
|
+
* 一条已经开着的流不会自己停。
|
|
625
|
+
*/
|
|
626
|
+
registerRoute({ kind: 'exact', path: '/api/relay/devices/revoke', handler: localOnly('POST', async (req, res) => {
|
|
627
|
+
const body = await readJson(req)
|
|
628
|
+
const deviceId = body && typeof body.deviceId === 'string' ? body.deviceId.trim() : ''
|
|
629
|
+
if (!deviceId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 deviceId' } })
|
|
630
|
+
const revoked = credentials.revokeDevice(state.instanceId, deviceId)
|
|
631
|
+
// 已建立的流主动收掉:否则被踢的设备还能继续收消息直到它自己重连。
|
|
632
|
+
let closed = 0
|
|
633
|
+
try { closed = typeof dropDeviceStreams === 'function' ? dropDeviceStreams(deviceId) : 0 } catch { /* 尽力 */ }
|
|
634
|
+
return sendJson(res, 200, { ok: true, revoked, closed })
|
|
635
|
+
}) })
|
|
636
|
+
|
|
512
637
|
registerRoute({ kind: 'exact', path: '/api/relay/revoke-all', handler: localOnly('POST', async (_req, res) => {
|
|
513
638
|
// 清除所有授权 = 中转清空本实例全部设备授权 + 本机吊销局域网设备凭据 +
|
|
514
639
|
// 轮换 lanSecret/配对码。三处都要做:只清中转的话,同网设备仍能用旧的
|
|
@@ -540,6 +665,10 @@ function registerControlPlaneRoutes(deps) {
|
|
|
540
665
|
if (typeof body?.preferredLanAddress === 'string' && body.preferredLanAddress.length <= 64) {
|
|
541
666
|
patch.preferredLanAddress = body.preferredLanAddress.trim()
|
|
542
667
|
}
|
|
668
|
+
// 用户手填的直连地址(内网穿透域名 / 公网 IP)。空串合法 = 清掉,理由同上。
|
|
669
|
+
if (typeof body?.manualLanUrl === 'string' && body.manualLanUrl.length <= 200) {
|
|
670
|
+
patch.manualLanUrl = body.manualLanUrl.trim().replace(/\/+$/, '')
|
|
671
|
+
}
|
|
543
672
|
await settings.update(patch)
|
|
544
673
|
await syncSidecar().catch(() => undefined)
|
|
545
674
|
sendJson(res, 200, status())
|
|
@@ -817,10 +946,11 @@ const WEB_ACCESS_ROUTES = new Set([
|
|
|
817
946
|
'/api/relay/panel/setup',
|
|
818
947
|
'/api/relay/panel/settings',
|
|
819
948
|
'/api/relay/faults',
|
|
820
|
-
'/api/relay/pairing',
|
|
821
949
|
'/api/relay/toggle',
|
|
822
950
|
'/api/relay/regenerate',
|
|
823
951
|
'/api/relay/onboarded',
|
|
952
|
+
'/api/relay/devices',
|
|
953
|
+
'/api/relay/devices/revoke',
|
|
824
954
|
'/api/relay/revoke-all',
|
|
825
955
|
'/api/relay/config',
|
|
826
956
|
'/api/relay/check-port',
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
const { sendJson } = require('../http/routing.js');
|
|
23
|
-
const { orderCandidates } = require('../relay/lan-network.cjs');
|
|
23
|
+
const { orderCandidates, withManualCandidate } = require('../relay/lan-network.cjs');
|
|
24
24
|
|
|
25
25
|
const sessions = require('../mobile/sessions.js');
|
|
26
26
|
const sessionModelOptions = require('../mobile/session-model-options.js');
|
|
@@ -43,13 +43,17 @@ const subagents = require('../mobile/subagents.js');
|
|
|
43
43
|
const monitor = require('../mobile/monitor.js');
|
|
44
44
|
const references = require('../mobile/references.js');
|
|
45
45
|
const schedule = require('../mobile/schedule.js');
|
|
46
|
-
const plugins = require('../mobile/plugins.js');
|
|
46
|
+
const pushNotifyRoute = require('../mobile/push-notify-route.js');const plugins = require('../mobile/plugins.js');
|
|
47
47
|
const pluginUpdate = require('../mobile/plugin-update.js');
|
|
48
|
+
const pluginSelf = require('../mobile/plugin-self.js');
|
|
49
|
+
const { createRelayControlHandlers } = require('../mobile/relay-control.js');
|
|
48
50
|
const uploads = require('../mobile/uploads.js');
|
|
49
51
|
const inboxClean = require('../mobile/inbox-clean.js');
|
|
50
52
|
const git = require('../mobile/git.js');
|
|
51
53
|
const pairing = require('../mobile/pairing.js');
|
|
52
54
|
const { credentialsEntry } = require('../shared/credentials-entry.js');
|
|
55
|
+
const { handleCredentialUpgrade } = require('../mobile/credential-upgrade.js');
|
|
56
|
+
const extensions = require('../mobile/extensions.js');
|
|
53
57
|
// 体检接口要自报身份:版本号只有一个来源,就是包自己的 package.json(与 relay/status.js 同)。
|
|
54
58
|
const pkg = require('../../package.json');
|
|
55
59
|
const desktopView = require('../desktop-view.cjs');
|
|
@@ -83,19 +87,33 @@ const API_VERSION = '1';
|
|
|
83
87
|
* 一次拒绝或一次握手。
|
|
84
88
|
*/
|
|
85
89
|
function registerMobileRoutes(deps) {
|
|
86
|
-
const { registerRoute, registerUpgradeRoute, guarded, state, hostMetadata, getSidecarSnapshot, safeLanCandidates, safeIpv6Candidates, lanPortInUse, preview, core, pairingApproval } = deps;
|
|
90
|
+
const { registerRoute, registerUpgradeRoute, guarded, state, hostMetadata, getSidecarSnapshot, safeLanCandidates, safeIpv6Candidates, lanPortInUse, preview, core, pairingApproval, rotatePairing } = deps;
|
|
87
91
|
const route = (path, handler) => registerRoute({ kind: 'exact', path, handler: guarded(handler) })
|
|
92
|
+
// 中转配置那一组要**装配期已有的实例**(config store、sidecar 生命周期),不能靠模块级
|
|
93
|
+
// 单例去拿:同一进程里两份 relay.json 的真值只会打架。所以这里按装配层给的依赖建一次。
|
|
94
|
+
const relayControl = createRelayControlHandlers(deps.relayControl || {})
|
|
88
95
|
|
|
89
96
|
registerRoute({ kind: 'exact', path: '/api/mobile/v1/status', handler: guarded(async (_ctx, _req, res) => {
|
|
90
97
|
// 内存 `state` 是本进程唯一的真值。旧实现这里 `loadRelayState()` 重新读盘,
|
|
91
98
|
// 于是同一份状态出现两个来源:控制面改完内存立刻返回,而移动端读到的还是
|
|
92
99
|
// 上一次落盘的内容(多实例/慢盘时甚至永久不一致)。
|
|
100
|
+
//
|
|
101
|
+
// `relayUrl` 挂在这条上,是因为**这里每台设备每 30 秒必发一发**(它是判断设备在不在线
|
|
102
|
+
// 的心跳,与用户选的通道档位无关)。用户在插件里改了中转地址之后,手机手上还攥着旧
|
|
103
|
+
// 地址,而旧中转上没有这台电脑、只会回「那台不在线」——它能学到新地址的唯一机会,
|
|
104
|
+
// 是在还连得上宿主的那些时刻(局域网 / IPv6)从宿主嘴里听到。`/connection-info` 也有
|
|
105
|
+
// 这个字段,但它只在「先探到一条局域网或 IPv6 地址」时才会被问,而「只走公网」那一档
|
|
106
|
+
// 恰恰把这两条通道全排除了,于是那条路对她永远不会走。
|
|
107
|
+
//
|
|
108
|
+
// 所以这条不是「多带一个字段」,它是「改了地址之后,非局域网用户也能自己恢复」的那
|
|
109
|
+
// 一发。`enabled` 关掉时给 null,表示「这台机器没有中转」,与「地址是空的」区分开。
|
|
93
110
|
sendJson(res, 200, {
|
|
94
111
|
ok: true,
|
|
95
112
|
apiVersion: API_VERSION,
|
|
96
113
|
instanceId: state.instanceId || null,
|
|
97
114
|
name: state.name || null,
|
|
98
115
|
remoteEnabled: Boolean(state.enabled),
|
|
116
|
+
relayUrl: state.enabled ? (state.relayUrl || null) : null,
|
|
99
117
|
...hostMetadata(),
|
|
100
118
|
})
|
|
101
119
|
}) })
|
|
@@ -135,6 +153,8 @@ function registerMobileRoutes(deps) {
|
|
|
135
153
|
lanCandidates: safeLanCandidates,
|
|
136
154
|
ipv6Candidates: safeIpv6Candidates,
|
|
137
155
|
lanPort: lanPortInUse,
|
|
156
|
+
// 配对成功即换码 + 换票据(§5.1):谁截了二维码,那张图从这一刻起就没用了。
|
|
157
|
+
rotatePairing,
|
|
138
158
|
log: (...args) => console.warn('[dsh-mobile-plugin] [pair]', ...args),
|
|
139
159
|
}),
|
|
140
160
|
})
|
|
@@ -142,11 +162,29 @@ function registerMobileRoutes(deps) {
|
|
|
142
162
|
// 纯局域网配对(App 只扫到局域网二维码、完全不经中转)需要一个宿主自己回答的地址,
|
|
143
163
|
// 否则 App 拿不到 lanCandidates,换网卡后就再也找不到这台机器;中转在中间时这条
|
|
144
164
|
// 同样转发到这里,所以两条链路走的是同一份实现。
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
165
|
+
// 设备凭据吊销 —— **手机自己解绑**这条路(§3.3)。由宿主应答,中转只转发:
|
|
166
|
+
// 「哪台设备还能用」是宿主的判断。
|
|
167
|
+
//
|
|
168
|
+
// 旧实现回一句 `{ok:true}` 什么都不做:手机点了「删除设备」、本地删干净了,宿主那份
|
|
169
|
+
// 凭据还活着,那枚 token 在到期之前仍然能用。这里按**这次请求出示的那一枚**吊销,
|
|
170
|
+
// 不是按 deviceId —— 手机手上只有自己的凭据,让它报 deviceId 等于允许吊销别人。
|
|
171
|
+
route('/api/mobile/v1/device-credential/revoke', async (_ctx, req, res) => {
|
|
172
|
+
const presented = req.headers['x-dsh-device-credential']
|
|
173
|
+
? String(req.headers['x-dsh-device-credential'])
|
|
174
|
+
: (/^Bearer\s+([^\s]+)$/.exec(req.headers.authorization || '') || [])[1]
|
|
175
|
+
const store = credentialsEntry()
|
|
176
|
+
const instanceId = String(state.instanceId || '')
|
|
177
|
+
const revoked = presented ? store.revoke(presented, instanceId) : false
|
|
178
|
+
// 尽力而为的语义不变:手机那边失败也不阻断本地删除,所以这里始终回 ok,
|
|
179
|
+
// 但把「到底吊销了一张没有」如实带上 —— 排查「删了还能用」时这一格是唯一线索。
|
|
180
|
+
sendJson(res, 200, { ok: true, revoked })
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// 旧会话升级成 T1(§3.3):新 App 每次启动幂等调一次。判据、密钥归属与「为什么密钥
|
|
184
|
+
// 只能明文上行一次」都在 mobile/credential-upgrade.js 的文件头里。
|
|
185
|
+
route('/api/mobile/v1/device-credential/upgrade', async (_ctx, req, res) => {
|
|
186
|
+
// 必须 await:这条路要现读请求体(明文老会话的字节还挂在 socket 上,见 handler 文件头)。
|
|
187
|
+
await handleCredentialUpgrade(req, res, { instanceId: String(state.instanceId || ''), store: credentialsEntry() })
|
|
150
188
|
})
|
|
151
189
|
|
|
152
190
|
route('/api/mobile/v1/connection-info', async (_ctx, _req, res) => {
|
|
@@ -155,8 +193,10 @@ function registerMobileRoutes(deps) {
|
|
|
155
193
|
const port = typeof lanPortInUse === 'function' ? lanPortInUse() : state.lanPort
|
|
156
194
|
// 用户在面板上选过的「手机优先用这条」(设置项 `preferredLanAddress`)在这里同样兑现:
|
|
157
195
|
// 手机是按顺序挨个试的,所以只挪顺序、不删候选。选的那条不在了就退回宿主排序。
|
|
196
|
+
// 手填的直连地址再盖到最前面:那是用户明确指定的那条,`preferredLanAddress` 只调网卡候选
|
|
197
|
+
// 的顺序,不该把它压到后面。
|
|
158
198
|
const candidates = state.lanEnabled
|
|
159
|
-
? orderCandidates(safeLanCandidates(port), state.preferredLanAddress)
|
|
199
|
+
? withManualCandidate(orderCandidates(safeLanCandidates(port), state.preferredLanAddress), state.manualLanUrl)
|
|
160
200
|
: []
|
|
161
201
|
// IPv6 直连候选:单独一份、**不参与**上面的排序 —— 它和中转谁更快只有手机实测得出,
|
|
162
202
|
// 宿主无从判断对端走哪条路由。与局域网那份同一个开关管(那条监听挂在同一个代理上),
|
|
@@ -185,6 +225,10 @@ function registerMobileRoutes(deps) {
|
|
|
185
225
|
// 带 `?rev=` 条件刷新。它要替掉原生那边每 8 秒一发的完整清单(那份还得为 20 个
|
|
186
226
|
// 会话各读一次 history 补预览),见 mobile/monitor.js。
|
|
187
227
|
route('/api/mobile/v1/monitor', monitor.handleMonitor)
|
|
228
|
+
// iOS 推送令牌上报:App 把 APNs 令牌交给宿主,宿主经隧道帧转交中转落表。中转没配
|
|
229
|
+
// 密钥时那头静默忽略——这条路由照常回 ok,「推没推成」不是 App 要操心的事。
|
|
230
|
+
// 判据(要不要收这个令牌)与配对同一把锁:guarded 已核过设备身份。
|
|
231
|
+
route('/api/mobile/v1/push-token', pushNotifyRoute.createPushTokenHandler({ pushNotify: deps.pushNotify }))
|
|
188
232
|
route('/api/mobile/v1/session', sessions.handleSession)
|
|
189
233
|
route('/api/mobile/v1/session/messages', sessions.handleMessages)
|
|
190
234
|
route('/api/mobile/v1/session/attachment', sessions.handleSessionAttachment)
|
|
@@ -334,6 +378,33 @@ function registerMobileRoutes(deps) {
|
|
|
334
378
|
// 经 typertGateway 命名空间 pluginInventory 取,见 mobile/plugins.js。
|
|
335
379
|
route('/api/mobile/v1/plugins', plugins.handlePlugins)
|
|
336
380
|
|
|
381
|
+
// 这台设备上的**中转与连接配置**(见 mobile/relay-control.js)。
|
|
382
|
+
//
|
|
383
|
+
// 这些项从前只有一个入口:电脑上的当机面板(`/api/relay/*`,全部 localOnly)。于是
|
|
384
|
+
// 「手机上发现连不上、想改个中转地址」必须回电脑前坐下才能做,而那往往正是人不在电脑前
|
|
385
|
+
// 的时候。它们与装/卸插件不是一类东西:**不会让插件代码变**,最坏只是这台从网上找不到,
|
|
386
|
+
// 而重连的主动权在手机这一侧。所以能力面有意放开给已配对设备。
|
|
387
|
+
//
|
|
388
|
+
// 改地址 / 关遥控 / 吊销 / 重连 / 重启后台服务这五条会掐断承载本请求的那条隧道,全部
|
|
389
|
+
// 「先回包再动手」,结果靠重连后读状态判 —— 与下面 `/plugin/update/apply` 同一个手感。
|
|
390
|
+
route('/api/mobile/v1/relay/config', relayControl.handleRelayConfig)
|
|
391
|
+
route('/api/mobile/v1/relay/config/save', relayControl.handleRelayConfigSave)
|
|
392
|
+
route('/api/mobile/v1/relay/enabled', relayControl.handleRelayToggle)
|
|
393
|
+
route('/api/mobile/v1/relay/pairing-code', relayControl.handleRelayRegenerate)
|
|
394
|
+
route('/api/mobile/v1/relay/revoke-all', relayControl.handleRelayRevokeAll)
|
|
395
|
+
route('/api/mobile/v1/relay/reconnect', relayControl.handleRelayReconnect)
|
|
396
|
+
route('/api/mobile/v1/relay/restart-sidecar', relayControl.handleRelayRestartSidecar)
|
|
397
|
+
route('/api/mobile/v1/relay/check-port', relayControl.handleRelayCheckPort)
|
|
398
|
+
route('/api/mobile/v1/relay/probe', relayControl.handleRelayProbe)
|
|
399
|
+
route('/api/mobile/v1/relay/diagnostics', relayControl.handleRelayDiagnostics)
|
|
400
|
+
|
|
401
|
+
// 这台插件**自己**的版本现场与自动更新开关(见 mobile/plugin-self.js)。
|
|
402
|
+
// 只管 dsh-mobile-plugin 这一个包:动别的插件会把承载 App 连接的那条链一起带走,而手机
|
|
403
|
+
// 没有在电脑前的那双手。与下面那三条 `/plugin/update/*` 是同一份真源(外壳的
|
|
404
|
+
// dshMobilePluginRuntime),这里补的是设备页要的另外几格:自动更新开关与回退结果。
|
|
405
|
+
route('/api/mobile/v1/plugin/self', pluginSelf.handlePluginSelf)
|
|
406
|
+
route('/api/mobile/v1/plugin/self/auto', pluginSelf.handlePluginAuto)
|
|
407
|
+
|
|
337
408
|
// 插件自更新:APP 的「应用更新」页给这台宿主升级插件版本(见 mobile/plugin-update.js)。
|
|
338
409
|
// 与桌面控制面 `/api/relay/update/*` 同一套实现,差别只在入口:那三条 localOnly,手机
|
|
339
410
|
// 永远打不到,而手机上聊天时「有对话在跑」那道闸几乎总关着 —— 自动更新等于不跑。
|
|
@@ -342,6 +413,18 @@ function registerMobileRoutes(deps) {
|
|
|
342
413
|
route('/api/mobile/v1/plugin/update/check', pluginUpdate.handlePluginUpdateCheck)
|
|
343
414
|
route('/api/mobile/v1/plugin/update/apply', pluginUpdate.handlePluginUpdateApply)
|
|
344
415
|
|
|
416
|
+
// 扩展与当前这台宿主之间的三条(见 mobile/extensions.js)。
|
|
417
|
+
//
|
|
418
|
+
// 扩展的**产物与清单**不走这里 —— 那由 App 分发系统的公开接口提供,装与不装之前就要能读。
|
|
419
|
+
// 这三条只处理「宿主认账」:哪些卡被回写过、扩展自己改自己那张卡、以及让宿主替它办一件事。
|
|
420
|
+
//
|
|
421
|
+
// 能力面同样是有意收窄的:宿主动作由那份硬编码登记表预先枚举,**现在一张都没登记**,
|
|
422
|
+
// 所以第三条对任何动作名都回 403。将来放开时在登记表里逐条加,不在这里开透传口子 ——
|
|
423
|
+
// 移动 API 不暴露 shell / 系统设置 / 任意文件读写,这条也一样。
|
|
424
|
+
route('/api/mobile/v1/extension/cards', extensions.handleExtensionCards)
|
|
425
|
+
route('/api/mobile/v1/extension/card-state', extensions.handleExtensionCardState)
|
|
426
|
+
route('/api/mobile/v1/extension/action', extensions.handleExtensionAction)
|
|
427
|
+
|
|
345
428
|
// 分享文件的落地通道(App 把手机分享进来的文件分片传上来,见 mobile/uploads.js)。
|
|
346
429
|
// 手机端没有「把文件交给宿主」的路:图片能进 session.send 的 base64 内容块,其它格式
|
|
347
430
|
// 一律进不去。四条端点只做「收字节 → 落盘到 ~/.dsh/mobile-inbox/<日期>/<文件名>」,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 配对请求与回包的加解密(协议 v2 配对)。**宿主侧**。
|
|
5
|
+
*
|
|
6
|
+
* 密钥只有一个来源:二维码里那张一次性票据。
|
|
7
|
+
*
|
|
8
|
+
* pairKey = HKDF-SHA256(ikm = ticket, salt = instanceId, info = 'dsh-pair-v2', len = 32)
|
|
9
|
+
*
|
|
10
|
+
* 票据由宿主生成、写进二维码、绝不经过中转 —— 所以配对阶段不需要任何公钥操作
|
|
11
|
+
* (HPKE / X25519 在 §8.2 被实测否掉了)。
|
|
12
|
+
*
|
|
13
|
+
* 为什么回包也必须加密:里面有 `token`、`cookie`、`deviceCredential`,全是能直接冒用的
|
|
14
|
+
* 凭据。明文回包等于前面白做 —— 链路上的任何人只要老实转发,就顺手拿到这台设备未来
|
|
15
|
+
* 一年的通行证。
|
|
16
|
+
*
|
|
17
|
+
* 两条入口(宿主的主路由与局域网代理)共用这一份:它们在两个进程里,但字节口径必须一样。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const e2ee = require('../shared/e2ee');
|
|
21
|
+
|
|
22
|
+
/** 设备主密钥与标识的形状。两者都要进凭据库:验签要用 deviceKey 重算。 */
|
|
23
|
+
const DEVICE_KEY_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
24
|
+
const KEY_ID_RE = /^[0-9a-f]{16}$/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 解一份加密的配对请求。
|
|
28
|
+
*
|
|
29
|
+
* @param {object} input
|
|
30
|
+
* @param {string} input.ticket 宿主当前的配对票据(base64url 43 字符)
|
|
31
|
+
* @param {string} input.instanceId 宿主实例号
|
|
32
|
+
* @param {string} input.sealed 请求体里的 `sealed`(base64)
|
|
33
|
+
* @param {string} [input.claimedInstanceId] 请求体里自报的实例号
|
|
34
|
+
* @returns {{ ok: true, pairKey: Buffer, device: object, deviceKey: string, keyId: string }
|
|
35
|
+
* | { ok: false }}
|
|
36
|
+
*/
|
|
37
|
+
function openSealedPairRequest(input) {
|
|
38
|
+
const ticket = typeof input.ticket === 'string' ? input.ticket : '';
|
|
39
|
+
const instanceId = String(input.instanceId || '');
|
|
40
|
+
// 没有票据(旧状态文件、或者刚被换过)就无从解起。
|
|
41
|
+
if (!ticket || !instanceId) return { ok: false };
|
|
42
|
+
// 请求体里的实例号必须与本机一致:一张二维码只能配它自己那台机器。
|
|
43
|
+
const claimed = typeof input.claimedInstanceId === 'string' ? input.claimedInstanceId.trim() : '';
|
|
44
|
+
if (claimed && claimed !== instanceId) return { ok: false };
|
|
45
|
+
|
|
46
|
+
let pairKey;
|
|
47
|
+
let plain;
|
|
48
|
+
try {
|
|
49
|
+
pairKey = e2ee.derivePairKey(Buffer.from(ticket, 'base64url'), instanceId);
|
|
50
|
+
plain = e2ee.open(pairKey, Buffer.from(input.sealed, 'base64'), e2ee.pairAad(instanceId));
|
|
51
|
+
} catch {
|
|
52
|
+
return { ok: false };
|
|
53
|
+
}
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(Buffer.from(plain).toString('utf8'));
|
|
57
|
+
} catch {
|
|
58
|
+
return { ok: false };
|
|
59
|
+
}
|
|
60
|
+
if (!parsed || typeof parsed !== 'object') return { ok: false };
|
|
61
|
+
|
|
62
|
+
const deviceKey = typeof parsed.deviceKey === 'string' ? parsed.deviceKey : '';
|
|
63
|
+
const keyId = typeof parsed.keyId === 'string' ? parsed.keyId : '';
|
|
64
|
+
// 形状不对就是实现错了(这两个值要进凭据库并被拿去验签),当场拒掉,
|
|
65
|
+
// 别把一条坏记录写进去。
|
|
66
|
+
if (!DEVICE_KEY_RE.test(deviceKey)) return { ok: false };
|
|
67
|
+
if (!KEY_ID_RE.test(keyId)) return { ok: false };
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
pairKey,
|
|
72
|
+
device: parsed.device && typeof parsed.device === 'object' ? parsed.device : {},
|
|
73
|
+
deviceKey,
|
|
74
|
+
keyId,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 把配对回包封成 `{ ok, proto, sealed }`。
|
|
80
|
+
*
|
|
81
|
+
* @param {Buffer} pairKey
|
|
82
|
+
* @param {string} instanceId
|
|
83
|
+
* @param {object} payload 明文回包(token / cookie / deviceCredential / serverTime …)
|
|
84
|
+
* @returns {{ ok: true, proto: 2, sealed: string }}
|
|
85
|
+
*/
|
|
86
|
+
function sealPairResponse(pairKey, instanceId, payload) {
|
|
87
|
+
const sealed = e2ee.seal(pairKey, Buffer.from(JSON.stringify(payload), 'utf8'), e2ee.pairAad(instanceId));
|
|
88
|
+
return { ok: true, proto: 2, sealed: Buffer.from(sealed).toString('base64') };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { openSealedPairRequest, sealPairResponse, DEVICE_KEY_RE, KEY_ID_RE };
|