Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 16 additions & 11 deletions src/app/service/offscreen/event_page_manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@ afterEach(() => {
}
});

// fast 项目共享模块环境。切换平台时重载了 LoggerCore,也要重建它的单例,
// 否则随后运行的测试无法创建 Server 或发布 MessageQueue 消息。
const resetPlatformModules = async () => {
vi.resetModules();
const loggerModule = await import("../../logger/core.js");
const LoggerCore = loggerModule.default as unknown as typeof LoggerCoreType;
new LoggerCore({
level: "trace",
consoleLevel: "trace",
writer: new loggerModule.EmptyWriter(),
labels: { env: "test" },
});
};

const loadKeepAliveEnabledManager = async () => {
vi.stubGlobal("scheduler", {
postTask: vi.fn(),
Expand All @@ -52,22 +66,13 @@ const loadKeepAliveEnabledManager = async () => {
}
}
);
vi.resetModules();
const loggerModule = await import("../../logger/core.js");
const LoggerCore = loggerModule.default as unknown as typeof LoggerCoreType;
const logger = new LoggerCore({
level: "trace",
consoleLevel: "trace",
writer: new loggerModule.EmptyWriter(),
labels: { env: "test" },
});
logger.logger().debug("test start");
await resetPlatformModules();
return { ...(await import("./event_page_manager.js")), ...(await import("./keep_alive.js")), probeImages };
};

const loadChromeKeepAlive = async () => {
isFirefoxMock.mockReturnValue(false);
vi.resetModules();
await resetPlatformModules();
return await import("./keep_alive.js");
};

Expand Down
221 changes: 198 additions & 23 deletions src/app/service/offscreen/external-access-connect.test.ts

Large diffs are not rendered by default.

115 changes: 83 additions & 32 deletions src/app/service/offscreen/external-access-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
AuthMode,
AuthOkPayload,
AuthResponsePayload,
ExternalAccessRejectionReason,
WSEnvelope,
} from "../service_worker/external_access/types";

Expand Down Expand Up @@ -41,6 +42,7 @@ export type ExternalAccessAuth = { mode: "session"; key: string } | { mode: "pai
export interface ExternalAccessConnectParam {
url: string;
auth: ExternalAccessAuth;
instanceId: string;
}

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

// HMAC-SHA-256(key, utf8(context || firstHex || secondHex)) → 小写 hex。
export async function computeHandshakeHmac(
keyBytes: Bytes,
context: string,
Expand Down Expand Up @@ -150,18 +151,20 @@ export async function decryptLongTermKey(encKeyBytes: Bytes, ivB64: string, ciph
// 收到 $session.authenticate 后,按当前模式计算响应,并带出握手参数以验证 $session.authenticated。
async function buildAuthResponse(
auth: ExternalAccessAuth,
instanceId: string,
nonceD: string
): Promise<{ payload: AuthResponsePayload; verifyKey: Bytes; nonceE: string; enc?: Bytes }> {
const nonceE = randomNonceHex();
const mode: AuthMode = auth.mode;
const peer: AuthResponsePayload["peer"] = { kind: "scriptcat", instanceId };
if (auth.mode === "session") {
const keyBytes = hexToBytes(auth.key);
const hmac = await computeHandshakeHmac(keyBytes, CRYPTO.context.sessionExt, nonceD, nonceE);
return { payload: { mode, nonceE, hmac }, verifyKey: keyBytes, nonceE };
const hmac = await computeHandshakeHmac(keyBytes, CRYPTO.context.scriptcatSessionExt + instanceId, nonceD, nonceE);
return { payload: { mode, nonceE, hmac, peer }, verifyKey: keyBytes, nonceE };
}
const { mac, enc } = await derivePairingKeys(auth.code);
const hmac = await computeHandshakeHmac(mac, CRYPTO.context.pairExt, nonceD, nonceE);
return { payload: { mode, nonceE, hmac }, verifyKey: mac, nonceE, enc };
const hmac = await computeHandshakeHmac(mac, CRYPTO.context.scriptcatPairExt + instanceId, nonceD, nonceE);
return { payload: { mode, nonceE, hmac, peer }, verifyKey: mac, nonceE, enc };
}

export class ExternalAccessConnect {
Expand All @@ -172,6 +175,10 @@ export class ExternalAccessConnect {
private epoch = 0;
private currentParams: ExternalAccessConnectParam | null = null;
private handshakeComplete = false;
private opened = false;
private capabilitiesId?: string;
private pairedKey?: string;
private conflict = false;
// 已发出认证响应、等待 $session.authenticated 校验期间保留的握手素材。
private pendingVerify: { verifyKey: Bytes; nonceD: string; nonceE: string; mode: AuthMode; enc?: Bytes } | null =
null;
Expand Down Expand Up @@ -205,7 +212,6 @@ export class ExternalAccessConnect {
private startSession(params: ExternalAccessConnectParam): void {
this.dispose();
this.currentParams = params;
this.epoch++;
this.connect(this.epoch);
}

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

this.ws.onopen = () => this.handleOpen(sessionEpoch);
this.ws.onmessage = (ev) => void this.handleMessage(ev, sessionEpoch);
this.ws.onclose = () => this.handleClose(sessionEpoch);
// daemon 可连续发 authenticated 与 hello。按线上的顺序完成异步密码校验,再处理后续帧和关闭事件。
let frames: Promise<void> | undefined;
this.ws.onmessage = (ev) => {
const processing = (frames ?? Promise.resolve())
.then(() => this.handleMessage(ev, sessionEpoch))
.catch((error: unknown) => {
this.logger.warn("Failed to process WebSocket frame", Logger.E(error));
if (sessionEpoch === this.epoch) this.ws?.close();
});
frames = processing;
void processing.then(() => {
if (frames === processing) frames = undefined;
});
};
this.ws.onclose = ({ code }) => {
if (frames) void frames.then(() => this.handleClose(sessionEpoch, code));
else this.handleClose(sessionEpoch, code);
};
this.ws.onerror = (ev) => this.handleError(ev, sessionEpoch);
} catch (e) {
this.logger.error("WebSocket creation failed", Logger.E(e));
this.handleError(e, sessionEpoch);
this.handleClose(sessionEpoch, 1006);
}
}

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

if (!envelope.method && envelope.id && envelope.id === this.capabilitiesId) {
this.capabilitiesId = undefined;
if (envelope.error) {
this.conflict = envelope.error.data?.code === "CONFLICT";
this.ws?.close();
} else {
const key = this.pairedKey;
if (key && this.currentParams) this.currentParams = { ...this.currentParams, auth: { mode: "session", key } };
this.pairedKey = undefined;
this.reconnectDelay = CONFIG.BASE_RECONNECT_DELAY;
this.clearTimer("authTimeoutTimer");
await this.relay.established(key);
}
return;
}

switch (envelope.method) {
case "$session.ping":
if (envelope.id) this.rawSend({ jsonrpc: "2.0", id: envelope.id, result: {} });
Expand All @@ -281,12 +320,12 @@ export class ExternalAccessConnect {
}

private async handleHandshakeMessage(envelope: WSEnvelope, sessionEpoch: number): Promise<void> {
const auth = this.currentParams?.auth;
if (!auth) return;
if (!this.currentParams) return;
const { auth, instanceId } = this.currentParams;

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

private async verifyAuthOk(payload: AuthOkPayload, sessionEpoch: number): Promise<void> {
const pending = this.pendingVerify;
if (!pending) {
if (!pending || !this.currentParams) {
this.ws?.close();
return;
}
const context = pending.mode === "session" ? CRYPTO.context.sessionDaemon : CRYPTO.context.pairDaemon;
const expected = await computeHandshakeHmac(pending.verifyKey, context, pending.nonceE, pending.nonceD);
const context =
pending.mode === "session" ? CRYPTO.context.scriptcatSessionDaemon : CRYPTO.context.scriptcatPairDaemon;
const expected = await computeHandshakeHmac(
pending.verifyKey,
context + this.currentParams.instanceId,
pending.nonceE,
pending.nonceD
);
if (sessionEpoch !== this.epoch) return;
if (!constantTimeEqualHex(expected, payload.hmac || "")) {
this.logger.warn("$session.authenticated HMAC mismatch, closing");
this.ws?.close();
return;
}

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

this.pendingVerify = null;
this.handshakeComplete = true;
this.clearTimer("authTimeoutTimer");
this.reconnectDelay = CONFIG.BASE_RECONNECT_DELAY;
this.logger.info("Auth handshake complete");
}

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

Expand All @@ -360,16 +403,19 @@ export class ExternalAccessConnect {
}
}

private handleClose(sessionEpoch: number): void {
if (sessionEpoch !== this.epoch) return;
this.clearTimer("connectTimeoutTimer");
this.clearTimer("authTimeoutTimer");
private handleClose(sessionEpoch: number, code: number): void {
if (sessionEpoch !== this.epoch || !this.currentParams) return;
const reason: ExternalAccessRejectionReason | undefined = this.conflict
? "name-taken"
: this.opened && !this.handshakeComplete && code === 1008
? "refused"
: undefined;
const mode = this.currentParams.auth.mode;
this.ws = null;
this.handshakeComplete = false;
this.pendingVerify = null;
this.logger.debug("WebSocket connection closed");
void this.relay.disconnected();
this.scheduleReconnect();
this.dispose();
if (reason) this.currentParams = null;
void this.relay.disconnected(reason, mode);
if (!reason) this.scheduleReconnect();
}

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

private scheduleReconnect(): void {
Expand All @@ -402,6 +448,11 @@ export class ExternalAccessConnect {
}

private dispose(): void {
this.epoch++;
this.opened = false;
this.capabilitiesId = undefined;
this.pairedKey = undefined;
this.conflict = false;
this.clearTimer("reconnectTimer");
this.clearTimer("connectTimeoutTimer");
this.clearTimer("authTimeoutTimer");
Expand Down
20 changes: 12 additions & 8 deletions src/app/service/service_worker/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import { encodeRValue, type TKeyValuePair } from "@App/pkg/utils/message_value";
import { type TSetValuesParams } from "./value";
import type { LocalBackupExport } from "./synchronize";
import type { ExternalAccessUIService } from "./external_access/service";
import type { WSEnvelope } from "./external_access/types";
import type { AuthMode, ExternalAccessRejectionReason, WSEnvelope } from "./external_access/types";
import type {
NetworkRuleMutationResult,
NetworkRuleCreateInput,
Expand Down Expand Up @@ -662,13 +662,17 @@ export class ExternalAccessClient extends Client {
super(msgSender, "serviceWorker/externalAccess");
}

getBridgeStatus(): Promise<ReturnType<ExternalAccessUIService["getStatus"]>> {
getBridgeStatus(): ReturnType<ExternalAccessUIService["getStatus"]> {
return this.doThrow("status");
}

// Enrollment (接入): dial the daemon with the one-time code the user read from `sctl connect`.
enroll(code: string) {
return this.do("enroll", code);
enroll(code: string, name: string): ReturnType<ExternalAccessUIService["enroll"]> {
return this.doThrow("enroll", { code, name });
}

rename(name: string): ReturnType<ExternalAccessUIService["rename"]> {
return this.doThrow("rename", name);
}

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

paired(key: string): Promise<void> {
return this.do("paired", { key });
established(key?: string): Promise<void> {
return this.do("established", { key });
}

disconnected(): Promise<void> {
return this.do("disconnected");
disconnected(reason: ExternalAccessRejectionReason | undefined, mode: AuthMode): Promise<void> {
return this.do("disconnected", { reason, mode });
}
}
Loading
Loading