Skip to content

Commit 1e5d41d

Browse files
authored
✨ 外部接入:以独立实例身份连接 sctl,被拒绝时停止重连并说明原因,支持命名与改名 (#1821)
* ✨ 外部接入:以实例身份连接 sctl(稳定的实例 ID 与名称、实例绑定的握手 HMAC、能力声明自报信息) * ✨ 外部接入:被 sctl 拒绝时停止重连并给出原因,支持接入时命名与改名 * ✨ 外部接入页:显示与修改实例名称、接入时命名、被 sctl 拒绝时说明原因 * 🐛 外部接入:能力声明在 Firefox 与 Safari 上也自报浏览器版本 getBrowserVersion() 只认 UA 中的 Chrome/ 标记,Firefox 与 Safari 上 productVersion 总被省略;改为按产品读取 Firefox/ 与 Version/ 标记,Chromium 系不变。 顺带修正随本功能过期的注释与测试标题(clientId、paired、接入提示),并去掉 en-US 文案中会原样显示的反引号。 * 🐛 外部接入:配对中 SW 重启不再丢弃新密钥,本地拦下保留名 scriptcat,接入请求失败时给出提示 - onEstablished 改以 disabled 状态而非内存里的 active 判断是否接收:SW 在配对握手期间重启后,daemon 已登记的新密钥仍会落盘并转为已连接;stop() 之后迟到的通知仍被忽略 - 实例名称规则收敛为 config 中唯一的 isValidExternalAccessName,页面与控制器共用,并拒绝 sctl 保留给旧版 ScriptCat 的 scriptcat(否则接入会在认证后被拒、白白消耗一次性配对码),各语言提示同步说明 - 接入请求本身失败(doThrow 拒绝)时页面提示失败,不再产生未处理的 Promise 拒绝 - 能力声明只对来自 UA、可被改写的浏览器版本截断到 64 字节;名称与扩展版本均为受控输入,删去不可达的 UTF-8 截断 - Web Locks 测试替身从生产目录移到 tests/mockWebLocks.ts;连接测试改经 Group 下发能力声明、以 hello 转发观察握手完成、以重连后的会话模式握手验证密钥切换,不再读私有字段 * 🐛 外部接入:接入时名称冲突后恢复接入前的实例名称 * 💄 外部接入页:手机宽度下配对码不再撑出对话框,状态徽标不再被挤成竖排
1 parent acec65c commit 1e5d41d

26 files changed

Lines changed: 1526 additions & 232 deletions

‎src/app/service/offscreen/event_page_manager.test.ts‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ afterEach(() => {
3434
}
3535
});
3636

37+
// fast 项目共享模块环境。切换平台时重载了 LoggerCore,也要重建它的单例,
38+
// 否则随后运行的测试无法创建 Server 或发布 MessageQueue 消息。
39+
const resetPlatformModules = async () => {
40+
vi.resetModules();
41+
const loggerModule = await import("../../logger/core.js");
42+
const LoggerCore = loggerModule.default as unknown as typeof LoggerCoreType;
43+
new LoggerCore({
44+
level: "trace",
45+
consoleLevel: "trace",
46+
writer: new loggerModule.EmptyWriter(),
47+
labels: { env: "test" },
48+
});
49+
};
50+
3751
const loadKeepAliveEnabledManager = async () => {
3852
vi.stubGlobal("scheduler", {
3953
postTask: vi.fn(),
@@ -52,22 +66,13 @@ const loadKeepAliveEnabledManager = async () => {
5266
}
5367
}
5468
);
55-
vi.resetModules();
56-
const loggerModule = await import("../../logger/core.js");
57-
const LoggerCore = loggerModule.default as unknown as typeof LoggerCoreType;
58-
const logger = new LoggerCore({
59-
level: "trace",
60-
consoleLevel: "trace",
61-
writer: new loggerModule.EmptyWriter(),
62-
labels: { env: "test" },
63-
});
64-
logger.logger().debug("test start");
69+
await resetPlatformModules();
6570
return { ...(await import("./event_page_manager.js")), ...(await import("./keep_alive.js")), probeImages };
6671
};
6772

6873
const loadChromeKeepAlive = async () => {
6974
isFirefoxMock.mockReturnValue(false);
70-
vi.resetModules();
75+
await resetPlatformModules();
7176
return await import("./keep_alive.js");
7277
};
7378

‎src/app/service/offscreen/external-access-connect.test.ts‎

Lines changed: 198 additions & 23 deletions
Large diffs are not rendered by default.

‎src/app/service/offscreen/external-access-connect.ts‎

Lines changed: 83 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
AuthMode,
1111
AuthOkPayload,
1212
AuthResponsePayload,
13+
ExternalAccessRejectionReason,
1314
WSEnvelope,
1415
} from "../service_worker/external_access/types";
1516

@@ -41,6 +42,7 @@ export type ExternalAccessAuth = { mode: "session"; key: string } | { mode: "pai
4142
export interface ExternalAccessConnectParam {
4243
url: string;
4344
auth: ExternalAccessAuth;
45+
instanceId: string;
4446
}
4547

4648
// ---------------------------------------------------------------------------------------------
@@ -105,7 +107,6 @@ export function normalizePairingCode(code: string): string {
105107
.replace(/[IL]/g, "1");
106108
}
107109

108-
// HMAC-SHA-256(key, utf8(context || firstHex || secondHex)) → 小写 hex。
109110
export async function computeHandshakeHmac(
110111
keyBytes: Bytes,
111112
context: string,
@@ -150,18 +151,20 @@ export async function decryptLongTermKey(encKeyBytes: Bytes, ivB64: string, ciph
150151
// 收到 $session.authenticate 后,按当前模式计算响应,并带出握手参数以验证 $session.authenticated。
151152
async function buildAuthResponse(
152153
auth: ExternalAccessAuth,
154+
instanceId: string,
153155
nonceD: string
154156
): Promise<{ payload: AuthResponsePayload; verifyKey: Bytes; nonceE: string; enc?: Bytes }> {
155157
const nonceE = randomNonceHex();
156158
const mode: AuthMode = auth.mode;
159+
const peer: AuthResponsePayload["peer"] = { kind: "scriptcat", instanceId };
157160
if (auth.mode === "session") {
158161
const keyBytes = hexToBytes(auth.key);
159-
const hmac = await computeHandshakeHmac(keyBytes, CRYPTO.context.sessionExt, nonceD, nonceE);
160-
return { payload: { mode, nonceE, hmac }, verifyKey: keyBytes, nonceE };
162+
const hmac = await computeHandshakeHmac(keyBytes, CRYPTO.context.scriptcatSessionExt + instanceId, nonceD, nonceE);
163+
return { payload: { mode, nonceE, hmac, peer }, verifyKey: keyBytes, nonceE };
161164
}
162165
const { mac, enc } = await derivePairingKeys(auth.code);
163-
const hmac = await computeHandshakeHmac(mac, CRYPTO.context.pairExt, nonceD, nonceE);
164-
return { payload: { mode, nonceE, hmac }, verifyKey: mac, nonceE, enc };
166+
const hmac = await computeHandshakeHmac(mac, CRYPTO.context.scriptcatPairExt + instanceId, nonceD, nonceE);
167+
return { payload: { mode, nonceE, hmac, peer }, verifyKey: mac, nonceE, enc };
165168
}
166169

167170
export class ExternalAccessConnect {
@@ -172,6 +175,10 @@ export class ExternalAccessConnect {
172175
private epoch = 0;
173176
private currentParams: ExternalAccessConnectParam | null = null;
174177
private handshakeComplete = false;
178+
private opened = false;
179+
private capabilitiesId?: string;
180+
private pairedKey?: string;
181+
private conflict = false;
175182
// 已发出认证响应、等待 $session.authenticated 校验期间保留的握手素材。
176183
private pendingVerify: { verifyKey: Bytes; nonceD: string; nonceE: string; mode: AuthMode; enc?: Bytes } | null =
177184
null;
@@ -205,7 +212,6 @@ export class ExternalAccessConnect {
205212
private startSession(params: ExternalAccessConnectParam): void {
206213
this.dispose();
207214
this.currentParams = params;
208-
this.epoch++;
209215
this.connect(this.epoch);
210216
}
211217

@@ -224,22 +230,39 @@ export class ExternalAccessConnect {
224230
}, CONFIG.CONNECT_TIMEOUT);
225231

226232
this.ws.onopen = () => this.handleOpen(sessionEpoch);
227-
this.ws.onmessage = (ev) => void this.handleMessage(ev, sessionEpoch);
228-
this.ws.onclose = () => this.handleClose(sessionEpoch);
233+
// daemon 可连续发 authenticated 与 hello。按线上的顺序完成异步密码校验,再处理后续帧和关闭事件。
234+
let frames: Promise<void> | undefined;
235+
this.ws.onmessage = (ev) => {
236+
const processing = (frames ?? Promise.resolve())
237+
.then(() => this.handleMessage(ev, sessionEpoch))
238+
.catch((error: unknown) => {
239+
this.logger.warn("Failed to process WebSocket frame", Logger.E(error));
240+
if (sessionEpoch === this.epoch) this.ws?.close();
241+
});
242+
frames = processing;
243+
void processing.then(() => {
244+
if (frames === processing) frames = undefined;
245+
});
246+
};
247+
this.ws.onclose = ({ code }) => {
248+
if (frames) void frames.then(() => this.handleClose(sessionEpoch, code));
249+
else this.handleClose(sessionEpoch, code);
250+
};
229251
this.ws.onerror = (ev) => this.handleError(ev, sessionEpoch);
230252
} catch (e) {
231253
this.logger.error("WebSocket creation failed", Logger.E(e));
232-
this.handleError(e, sessionEpoch);
254+
this.handleClose(sessionEpoch, 1006);
233255
}
234256
}
235257

236258
private handleOpen(sessionEpoch: number): void {
237259
if (sessionEpoch !== this.epoch) return;
260+
this.opened = true;
238261
this.logger.info("WebSocket connected, awaiting $session.authenticate");
239262
this.clearTimer("connectTimeoutTimer");
240-
// 握手必须在 authTimeoutMs 内完成,否则断开重连(PROTOCOL §3)。
263+
// 认证与能力声明必须在 authTimeoutMs 内完成,否则接入/改名调用可能永不返回。
241264
this.authTimeoutTimer = setTimeout(() => {
242-
if (sessionEpoch === this.epoch && !this.handshakeComplete) {
265+
if (sessionEpoch === this.epoch) {
243266
this.logger.warn("Auth handshake timeout");
244267
this.ws?.close();
245268
}
@@ -264,6 +287,22 @@ export class ExternalAccessConnect {
264287
return;
265288
}
266289

290+
if (!envelope.method && envelope.id && envelope.id === this.capabilitiesId) {
291+
this.capabilitiesId = undefined;
292+
if (envelope.error) {
293+
this.conflict = envelope.error.data?.code === "CONFLICT";
294+
this.ws?.close();
295+
} else {
296+
const key = this.pairedKey;
297+
if (key && this.currentParams) this.currentParams = { ...this.currentParams, auth: { mode: "session", key } };
298+
this.pairedKey = undefined;
299+
this.reconnectDelay = CONFIG.BASE_RECONNECT_DELAY;
300+
this.clearTimer("authTimeoutTimer");
301+
await this.relay.established(key);
302+
}
303+
return;
304+
}
305+
267306
switch (envelope.method) {
268307
case "$session.ping":
269308
if (envelope.id) this.rawSend({ jsonrpc: "2.0", id: envelope.id, result: {} });
@@ -281,12 +320,12 @@ export class ExternalAccessConnect {
281320
}
282321

283322
private async handleHandshakeMessage(envelope: WSEnvelope, sessionEpoch: number): Promise<void> {
284-
const auth = this.currentParams?.auth;
285-
if (!auth) return;
323+
if (!this.currentParams) return;
324+
const { auth, instanceId } = this.currentParams;
286325

287326
if (envelope.method === "$session.authenticate" && envelope.id) {
288327
const { nonceD } = envelope.params as AuthChallengePayload;
289-
const built = await buildAuthResponse(auth, nonceD);
328+
const built = await buildAuthResponse(auth, instanceId, nonceD);
290329
if (sessionEpoch !== this.epoch) return;
291330
// 保存验证素材,供随后的 $session.authenticated 校验使用。
292331
this.pendingVerify = {
@@ -311,20 +350,26 @@ export class ExternalAccessConnect {
311350

312351
private async verifyAuthOk(payload: AuthOkPayload, sessionEpoch: number): Promise<void> {
313352
const pending = this.pendingVerify;
314-
if (!pending) {
353+
if (!pending || !this.currentParams) {
315354
this.ws?.close();
316355
return;
317356
}
318-
const context = pending.mode === "session" ? CRYPTO.context.sessionDaemon : CRYPTO.context.pairDaemon;
319-
const expected = await computeHandshakeHmac(pending.verifyKey, context, pending.nonceE, pending.nonceD);
357+
const context =
358+
pending.mode === "session" ? CRYPTO.context.scriptcatSessionDaemon : CRYPTO.context.scriptcatPairDaemon;
359+
const expected = await computeHandshakeHmac(
360+
pending.verifyKey,
361+
context + this.currentParams.instanceId,
362+
pending.nonceE,
363+
pending.nonceD
364+
);
320365
if (sessionEpoch !== this.epoch) return;
321366
if (!constantTimeEqualHex(expected, payload.hmac || "")) {
322367
this.logger.warn("$session.authenticated HMAC mismatch, closing");
323368
this.ws?.close();
324369
return;
325370
}
326371

327-
// 配对模式:解密 daemon 下发的长期密钥 K,持久化交给 SW,并把自身切到会话模式供后续重连。
372+
// daemon 在接受名称之前已消费配对码并下发密钥;名称冲突时这把密钥不能落盘。
328373
if (pending.mode === "pairing") {
329374
if (!pending.enc || !payload.key) {
330375
this.logger.warn("pairing $session.authenticated missing key, closing");
@@ -333,14 +378,11 @@ export class ExternalAccessConnect {
333378
}
334379
const key = await decryptLongTermKey(pending.enc, payload.key.iv, payload.key.ciphertext);
335380
if (sessionEpoch !== this.epoch) return;
336-
if (this.currentParams) this.currentParams = { ...this.currentParams, auth: { mode: "session", key } };
337-
void this.relay.paired(key);
381+
this.pairedKey = key;
338382
}
339383

340384
this.pendingVerify = null;
341385
this.handshakeComplete = true;
342-
this.clearTimer("authTimeoutTimer");
343-
this.reconnectDelay = CONFIG.BASE_RECONNECT_DELAY;
344386
this.logger.info("Auth handshake complete");
345387
}
346388

@@ -351,6 +393,7 @@ export class ExternalAccessConnect {
351393
this.logger.warn("Dropped outbound JSON-RPC message before handshake", { method: envelope.method });
352394
return;
353395
}
396+
if (envelope.method === "$session.capabilities") this.capabilitiesId = envelope.id;
354397
this.rawSend(envelope);
355398
}
356399

@@ -360,16 +403,19 @@ export class ExternalAccessConnect {
360403
}
361404
}
362405

363-
private handleClose(sessionEpoch: number): void {
364-
if (sessionEpoch !== this.epoch) return;
365-
this.clearTimer("connectTimeoutTimer");
366-
this.clearTimer("authTimeoutTimer");
406+
private handleClose(sessionEpoch: number, code: number): void {
407+
if (sessionEpoch !== this.epoch || !this.currentParams) return;
408+
const reason: ExternalAccessRejectionReason | undefined = this.conflict
409+
? "name-taken"
410+
: this.opened && !this.handshakeComplete && code === 1008
411+
? "refused"
412+
: undefined;
413+
const mode = this.currentParams.auth.mode;
367414
this.ws = null;
368-
this.handshakeComplete = false;
369-
this.pendingVerify = null;
370-
this.logger.debug("WebSocket connection closed");
371-
void this.relay.disconnected();
372-
this.scheduleReconnect();
415+
this.dispose();
416+
if (reason) this.currentParams = null;
417+
void this.relay.disconnected(reason, mode);
418+
if (!reason) this.scheduleReconnect();
373419
}
374420

375421
private handleError(ev: Event | Error | unknown, sessionEpoch: number): void {
@@ -378,7 +424,7 @@ export class ExternalAccessConnect {
378424
event: ev instanceof Event ? ev.type : undefined,
379425
error: ev instanceof Error ? ev.message : String(ev),
380426
});
381-
this.scheduleReconnect();
427+
// 浏览器会紧接着发 close,只有 close 才能区分网络错误与 1008 拒绝。
382428
}
383429

384430
private scheduleReconnect(): void {
@@ -402,6 +448,11 @@ export class ExternalAccessConnect {
402448
}
403449

404450
private dispose(): void {
451+
this.epoch++;
452+
this.opened = false;
453+
this.capabilitiesId = undefined;
454+
this.pairedKey = undefined;
455+
this.conflict = false;
405456
this.clearTimer("reconnectTimer");
406457
this.clearTimer("connectTimeoutTimer");
407458
this.clearTimer("authTimeoutTimer");

‎src/app/service/service_worker/client.ts‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import { encodeRValue, type TKeyValuePair } from "@App/pkg/utils/message_value";
4747
import { type TSetValuesParams } from "./value";
4848
import type { LocalBackupExport } from "./synchronize";
4949
import type { ExternalAccessUIService } from "./external_access/service";
50-
import type { WSEnvelope } from "./external_access/types";
50+
import type { AuthMode, ExternalAccessRejectionReason, WSEnvelope } from "./external_access/types";
5151
import type {
5252
NetworkRuleMutationResult,
5353
NetworkRuleCreateInput,
@@ -662,13 +662,17 @@ export class ExternalAccessClient extends Client {
662662
super(msgSender, "serviceWorker/externalAccess");
663663
}
664664

665-
getBridgeStatus(): Promise<ReturnType<ExternalAccessUIService["getStatus"]>> {
665+
getBridgeStatus(): ReturnType<ExternalAccessUIService["getStatus"]> {
666666
return this.doThrow("status");
667667
}
668668

669669
// Enrollment (接入): dial the daemon with the one-time code the user read from `sctl connect`.
670-
enroll(code: string) {
671-
return this.do("enroll", code);
670+
enroll(code: string, name: string): ReturnType<ExternalAccessUIService["enroll"]> {
671+
return this.doThrow("enroll", { code, name });
672+
}
673+
674+
rename(name: string): ReturnType<ExternalAccessUIService["rename"]> {
675+
return this.doThrow("rename", name);
672676
}
673677

674678
getOperation(operationId: string): ReturnType<ExternalAccessUIService["getOperation"]> {
@@ -714,11 +718,11 @@ export class ExternalAccessConnectRelayClient extends Client {
714718
return this.do("envelope", envelope);
715719
}
716720

717-
paired(key: string): Promise<void> {
718-
return this.do("paired", { key });
721+
established(key?: string): Promise<void> {
722+
return this.do("established", { key });
719723
}
720724

721-
disconnected(): Promise<void> {
722-
return this.do("disconnected");
725+
disconnected(reason: ExternalAccessRejectionReason | undefined, mode: AuthMode): Promise<void> {
726+
return this.do("disconnected", { reason, mode });
723727
}
724728
}

0 commit comments

Comments
 (0)