From 32dff12d2d928e642ec19db0944dfa5b79d23c44 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Tue, 13 Oct 2026 21:38:46 +0800 Subject: [PATCH] feat(extension): authenticate local daemons and persist pairing credentials --- apps/extension/package.json | 6 +- .../__tests__/background-overlays.test.ts | 7 +- apps/extension/src/entrypoints/background.ts | 21 ++ .../src/lib/connection-controller.ts | 13 + .../src/lib/local-pairing-bridge.test.ts | 14 + .../extension/src/lib/local-pairing-bridge.ts | 82 ++++++ .../__tests__/remote-authorization.test.ts | 17 ++ .../__tests__/remote-storage.test.ts | 8 + .../transport/__tests__/ws-transport.test.ts | 56 ++++ apps/extension/src/transport/credential-db.ts | 24 ++ .../src/transport/local-auth-daemon.test.ts | 163 +++++++++++ .../src/transport/local-auth.test.ts | 277 ++++++++++++++++++ apps/extension/src/transport/local-auth.ts | 243 +++++++++++++++ .../src/transport/local-credentials.test.ts | 122 ++++++++ .../src/transport/local-credentials.ts | 217 ++++++++++++++ .../src/transport/pairing-verifier.ts | 92 ++++++ .../src/transport/remote-authorization.ts | 50 +++- .../extension/src/transport/remote-storage.ts | 30 +- apps/extension/src/transport/ws-transport.ts | 117 +++++++- .../src/video/capture.browser.test.ts | 71 ++++- apps/extension/src/video/pairing-fixture.ts | 34 +++ pnpm-lock.yaml | 69 +++-- 22 files changed, 1650 insertions(+), 83 deletions(-) create mode 100644 apps/extension/src/lib/local-pairing-bridge.test.ts create mode 100644 apps/extension/src/lib/local-pairing-bridge.ts create mode 100644 apps/extension/src/transport/credential-db.ts create mode 100644 apps/extension/src/transport/local-auth-daemon.test.ts create mode 100644 apps/extension/src/transport/local-auth.test.ts create mode 100644 apps/extension/src/transport/local-auth.ts create mode 100644 apps/extension/src/transport/local-credentials.test.ts create mode 100644 apps/extension/src/transport/local-credentials.ts create mode 100644 apps/extension/src/transport/pairing-verifier.ts create mode 100644 apps/extension/src/video/pairing-fixture.ts diff --git a/apps/extension/package.json b/apps/extension/package.json index 49fa99d37..a85b54b03 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,6 +1,6 @@ { "name": "@browser-skill/extension", - "version": "0.3.2", + "version": "0.4.0", "private": true, "type": "module", "scripts": { @@ -27,14 +27,18 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/chrome": "^0.0.287", + "@types/node": "^22.20.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/ws": "^8.18.2", "@vitejs/plugin-react": "^6.0.0", "@wxt-dev/module-react": "^1.1.3", + "fake-indexeddb": "^6.2.5", "happy-dom": "^15.11.7", "tailwindcss": "^4.3.0", "typescript": "^5.8.0", "vitest": "^4.1.6", + "ws": "^8.22.0", "wxt": "^0.20.0" } } diff --git a/apps/extension/src/entrypoints/__tests__/background-overlays.test.ts b/apps/extension/src/entrypoints/__tests__/background-overlays.test.ts index f7eac3409..e5a6bbf1f 100644 --- a/apps/extension/src/entrypoints/__tests__/background-overlays.test.ts +++ b/apps/extension/src/entrypoints/__tests__/background-overlays.test.ts @@ -29,6 +29,7 @@ vi.mock("@/lib/heartbeat"); vi.mock("@/lib/instance-id"); vi.mock("@/lib/interaction-preferences"); vi.mock("@/lib/keepalive"); +vi.mock("@/lib/local-pairing-bridge"); vi.mock("@/lib/recording/frame-coordinator"); vi.mock("@/lib/sessions-live-flag"); vi.mock("@/long-screenshot/background"); @@ -85,7 +86,11 @@ async function fixture() { }, debugger: { onDetach: event() }, webNavigation: { onBeforeNavigate: event(), onCompleted: event(), onErrorOccurred: event() }, - runtime: { onMessage: event(), onConnect: event() }, + runtime: { + onMessage: event(), + onConnect: event(), + getURL: (path: string) => `chrome-extension://test/${path}`, + }, notifications: { onClicked: event(), onButtonClicked: event() }, }); vi.mocked(watchDaemonConnection).mockReturnValue({ ready: Promise.resolve(), dispose() {} }); diff --git a/apps/extension/src/entrypoints/background.ts b/apps/extension/src/entrypoints/background.ts index 999a5f25e..8011b32fd 100644 --- a/apps/extension/src/entrypoints/background.ts +++ b/apps/extension/src/entrypoints/background.ts @@ -10,11 +10,13 @@ import { watchDaemonConnection } from "@/lib/daemon-connection-preference"; import { startHeartbeat } from "@/lib/heartbeat"; import { getConnectionEnabled, + getOrCreateInstanceId, setConnectionEnabled as persistConnectionEnabled, setLabel, } from "@/lib/instance-id"; import { interactionPolicy, interactionPreferences } from "@/lib/interaction-preferences"; import { startKeepalive } from "@/lib/keepalive"; +import { attachLocalPairing } from "@/lib/local-pairing-bridge"; import { OVERLAY_AGENT_STATE, OVERLAY_AUTOMATION_BYPASS, @@ -55,6 +57,7 @@ import { import { cdpBlockedUrlReason, chromeTabsApi } from "@/tools/shared"; import { chromeTabMutationApi } from "@/tools/tabs"; import { detectBrowserMeta } from "@/transport/handshake"; +import { createLocalAuthenticator } from "@/transport/local-auth"; import { watchRemoteAuthorization } from "@/transport/remote-authorization"; import { type RemoteEndpoint, remoteSocket } from "@/transport/remote-endpoint"; import type { Transport } from "@/transport/transport"; @@ -69,8 +72,25 @@ export default defineBackground(() => { let remoteEndpoint: RemoteEndpoint | null = null; let connectionPreferenceValid = false; let requestedConnection: { key: string; remote: boolean } | null = null; + const authenticateLocal = createLocalAuthenticator({ + instanceId: () => getOrCreateInstanceId(), + origin: chrome.runtime.getURL("").replace(/\/$/, ""), + status: (status) => controller.setLocalAuth(status), + }); const transport = new WSTransport({ url: __BSK_DAEMON_WS_URL__, + onAuthenticationError: () => { + const state = controller.snapshot().localAuth?.state; + if (!remoteEndpoint && (state === "verifying" || state === "verified")) + controller.setLocalAuth({ state: "server_unverified" }); + }, + authenticate: (socket, signal) => { + if (remoteEndpoint) { + controller.setLocalAuth(null); + return; + } + return authenticateLocal(socket, signal); + }, webSocketFactory: (url) => { if (!connectionPreferenceValid) throw new Error("Connection settings are unavailable"); const socket = remoteSocket(url, remoteEndpoint); @@ -160,6 +180,7 @@ export default defineBackground(() => { const popupSnapshotRefreshers = new Set<() => void>(); const controlModes = new Map(); + attachLocalPairing(controller, transport); watchRemoteAuthorization(); const daemonPort = watchDaemonConnection( (url, remote) => { diff --git a/apps/extension/src/lib/connection-controller.ts b/apps/extension/src/lib/connection-controller.ts index 013b71524..c55b4e75f 100644 --- a/apps/extension/src/lib/connection-controller.ts +++ b/apps/extension/src/lib/connection-controller.ts @@ -4,6 +4,7 @@ import { PROTOCOL_VERSION, performHandshake, } from "../transport/handshake"; +import type { LocalAuthStatus } from "../transport/local-auth"; import type { Transport } from "../transport/transport"; import type { ConnectionState, HandshakeResult } from "../transport/types"; import { getLabel, getOrCreateInstanceId } from "./instance-id"; @@ -19,6 +20,7 @@ export interface SnapshotInfo { handshake: HandshakeResult | null; lastError: string | null; connectionEnabled: boolean; + localAuth?: LocalAuthStatus | null; } type Listener = (s: SnapshotInfo) => void; @@ -48,6 +50,16 @@ export class ConnectionController { this.auditEnabled = enabled; } private lastError: string | null = null; + private localAuth: LocalAuthStatus | null = null; + + setLocalAuth(status: LocalAuthStatus | null): void { + this.localAuth = status; + this.fire(); + } + + async retryConnection(): Promise { + if (this.ready && this.connectionEnabled) await this.teardown(false); + } private connectionEnabled = true; private listeners = new Set(); private lifecycleHooks: ConnectionLifecycleHooks = {}; @@ -86,6 +98,7 @@ export class ConnectionController { handshake: this.handshake, lastError: this.lastError, connectionEnabled: this.connectionEnabled, + localAuth: this.localAuth, }; } diff --git a/apps/extension/src/lib/local-pairing-bridge.test.ts b/apps/extension/src/lib/local-pairing-bridge.test.ts new file mode 100644 index 000000000..746f31d44 --- /dev/null +++ b/apps/extension/src/lib/local-pairing-bridge.test.ts @@ -0,0 +1,14 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { trustedPopup } from "./local-pairing-bridge"; + +afterEach(() => vi.unstubAllGlobals()); +it("accepts only this extension's popup, never a content script or tab", () => { + vi.stubGlobal("chrome", { + runtime: { id: "test", getURL: (path: string) => `chrome-extension://test/${path}` }, + }); + const sender = { id: "test", url: "chrome-extension://test/popup.html" }; + expect(trustedPopup(sender)).toBe(true); + expect(trustedPopup({ ...sender, tab: { id: 1 } as chrome.tabs.Tab })).toBe(false); + expect(trustedPopup({ ...sender, id: "other" })).toBe(false); + expect(trustedPopup({ ...sender, url: "https://example.test/popup.html" })).toBe(false); +}); diff --git a/apps/extension/src/lib/local-pairing-bridge.ts b/apps/extension/src/lib/local-pairing-bridge.ts new file mode 100644 index 000000000..a20423571 --- /dev/null +++ b/apps/extension/src/lib/local-pairing-bridge.ts @@ -0,0 +1,82 @@ +import { LocalAuthError } from "@/transport/local-auth"; +import { + initializeLocalMigration, + readLocalState, + setSkipLocalVerification, +} from "@/transport/local-credentials"; +import { verifyLocalPairing } from "@/transport/pairing-verifier"; +import { writeRemoteConnection } from "@/transport/remote-storage"; +import type { WSTransport } from "@/transport/ws-transport"; +import type { ConnectionController } from "./connection-controller"; +import { getOrCreateInstanceId, setDaemonPort } from "./instance-id"; + +export const LOCAL_PAIRING_MESSAGE = "bsk-local-pairing"; +export function trustedPopup(sender: chrome.runtime.MessageSender): boolean { + return ( + sender.id === chrome.runtime.id && + sender.url === chrome.runtime.getURL("popup.html") && + !sender.tab + ); +} +export function attachLocalPairing(controller: ConnectionController, transport: WSTransport): void { + const initialize = () => + initializeLocalMigration().catch(() => controller.setLocalAuth({ state: "server_unverified" })); + void initialize(); + chrome.runtime.onInstalled.addListener((details) => { + void initializeLocalMigration(details).catch(() => + controller.setLocalAuth({ state: "server_unverified" }), + ); + }); + chrome.runtime.onMessage.addListener((message, sender, reply) => { + if (message?.kind !== LOCAL_PAIRING_MESSAGE) return false; + if (!trustedPopup(sender)) { + reply({ error: "Invalid pairing request" }); + return false; + } + const action = async () => { + switch (message.action) { + case "info": { + const state = await readLocalState(); + return { + policy: state.policy, + devices: state.credentials + .filter((c) => c.role === "active") + .map((c) => ({ daemonId: c.daemonId, deviceId: c.deviceId, endpoint: c.endpoint })), + }; + } + case "pair": { + if (typeof message.link !== "string") throw new Error("Invalid pairing request"); + const result = await verifyLocalPairing( + message.link, + await getOrCreateInstanceId(), + chrome.runtime.getURL("").replace(/\/$/, ""), + ); + await setDaemonPort(Number(new URL(result.endpoint).port || "80")); + await writeRemoteConnection(null); + transport.resetAuthenticationBackoff(); + await controller.retryConnection(); + return { ok: true }; + } + case "skip": + if (typeof message.value !== "boolean") throw new Error("Invalid pairing request"); + await setSkipLocalVerification(message.value); + transport.resetAuthenticationBackoff(); + await controller.retryConnection(); + return { ok: true }; + case "retry": + transport.resetAuthenticationBackoff(); + await controller.retryConnection(); + return { ok: true }; + default: + throw new Error("Invalid pairing request"); + } + }; + void action().then(reply, (error) => + reply({ + error: error instanceof LocalAuthError ? error.status.state : "pairing_failed", + ...(error instanceof LocalAuthError ? { browserId: error.status.browserId } : {}), + }), + ); + return true; + }); +} diff --git a/apps/extension/src/transport/__tests__/remote-authorization.test.ts b/apps/extension/src/transport/__tests__/remote-authorization.test.ts index 674d3a7c1..09c5d60bf 100644 --- a/apps/extension/src/transport/__tests__/remote-authorization.test.ts +++ b/apps/extension/src/transport/__tests__/remote-authorization.test.ts @@ -175,3 +175,20 @@ it("only schedules renewal alarms while a remote connection is selected", async await vi.waitFor(() => expect(chrome.alarms.clear).toHaveBeenCalledTimes(2)); watcher.dispose(); }); + +it("L20: a renewal failure cannot switch a newly paired local connection back to remote", async () => { + let reject!: (reason: Error) => void; + vi.mocked(fetch).mockImplementationOnce( + () => + new Promise((_resolve, fail) => { + reject = fail; + }), + ); + const renewing = renewRemoteAuthorization(); + const failed = expect(renewing).rejects.toThrow("offline"); + await vi.waitFor(() => expect(fetch).toHaveBeenCalled()); + values[REMOTE_ENDPOINT_KEY] = null; + reject(new Error("offline")); + await failed; + expect(values[REMOTE_ENDPOINT_KEY]).toBeNull(); +}); diff --git a/apps/extension/src/transport/__tests__/remote-storage.test.ts b/apps/extension/src/transport/__tests__/remote-storage.test.ts index 969b3db9b..5cbb96ce0 100644 --- a/apps/extension/src/transport/__tests__/remote-storage.test.ts +++ b/apps/extension/src/transport/__tests__/remote-storage.test.ts @@ -121,3 +121,11 @@ it("migrates a legacy grant before restoring ordinary settings access", async () expect(values.bsk_remote_endpoint).toBeUndefined(); expect(chrome.storage.local.setAccessLevel).toHaveBeenCalledOnce(); }); + +it("L20: keeps a remote authorization when switching to local", async () => { + availableDatabase(); + await storage.writeRemoteConnection(endpoint); + await storage.writeRemoteConnection(null); + expect(await storage.readRemoteConnection()).toBeNull(); + expect(await storage.readSavedRemoteConnection()).toEqual(endpoint); +}); diff --git a/apps/extension/src/transport/__tests__/ws-transport.test.ts b/apps/extension/src/transport/__tests__/ws-transport.test.ts index 63a9055fa..e10ff8899 100644 --- a/apps/extension/src/transport/__tests__/ws-transport.test.ts +++ b/apps/extension/src/transport/__tests__/ws-transport.test.ts @@ -317,3 +317,59 @@ describe("WSTransport", () => { expect(t.state).toBe("connected"); }); }); + +it("authenticates before connected, rejects early sends, and closes a second final frame", async () => { + let complete!: () => void; + const gate = new Promise((resolve) => { + complete = resolve; + }); + const t = new WSTransport({ + url: "ws://127.0.0.1:52800", + authenticate: () => gate, + webSocketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const handler = vi.fn(); + t.onMessage(handler); + const pending = t.connect(); + lastSocket().open(); + expect(t.state).toBe("connecting"); + expect(() => t.send({ id: "early", method: "system.handshake" })).toThrow(); + lastSocket().receive({ id: "early", result: {} }); + expect(handler).not.toHaveBeenCalled(); + complete(); + await pending; + expect(t.state).toBe("connected"); + lastSocket().receive({ + type: "local.auth.ready", + v: 1, + rotation: null, + commit: null, + } as unknown as ProtocolFrame); + expect(handler).not.toHaveBeenCalled(); + expect(t.state).toBe("disconnected"); + await t.disconnect(); +}); +it("auth failures back off for 30 seconds and explicit retry resets the delay", async () => { + vi.useFakeTimers(); + const authenticate = vi.fn().mockRejectedValue(new Error("server_unverified")); + const t = new WSTransport({ + url: "ws://127.0.0.1:52800", + authenticate, + webSocketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const pending = t.connect(); + lastSocket().open(); + await expect(pending).rejects.toThrow("server_unverified"); + const count = FakeSocket.instances.length; + await vi.advanceTimersByTimeAsync(29_999); + expect(FakeSocket.instances).toHaveLength(count); + await expect(t.connect()).rejects.toThrow("backoff"); + t.resetAuthenticationBackoff(); + authenticate.mockResolvedValue(undefined); + const retried = t.connect(); + lastSocket().open(); + await retried; + expect(t.state).toBe("connected"); + await t.disconnect(); + vi.useRealTimers(); +}); diff --git a/apps/extension/src/transport/credential-db.ts b/apps/extension/src/transport/credential-db.ts new file mode 100644 index 000000000..869389e4f --- /dev/null +++ b/apps/extension/src/transport/credential-db.ts @@ -0,0 +1,24 @@ +/** Extension-origin IndexedDB is shared by local and remote credentials. */ +export function openCredentialDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open("bsk-remote-authorization", 2); + request.onupgradeneeded = () => { + for (const name of ["connection", "local", "migration"]) { + if (!request.result.objectStoreNames.contains(name)) request.result.createObjectStore(name); + } + }; + request.onerror = () => reject(new Error("Credential storage is unavailable")); + let blocked = false; + request.onblocked = () => { + blocked = true; + reject(new Error("Credential storage is blocked")); + }; + request.onsuccess = () => { + if (blocked) request.result.close(); + else { + request.result.onversionchange = () => request.result.close(); + resolve(request.result); + } + }; + }); +} diff --git a/apps/extension/src/transport/local-auth-daemon.test.ts b/apps/extension/src/transport/local-auth-daemon.test.ts new file mode 100644 index 000000000..6a4158ee8 --- /dev/null +++ b/apps/extension/src/transport/local-auth-daemon.test.ts @@ -0,0 +1,163 @@ +// @vitest-environment node +/// +import { type ChildProcess, execFile, spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { IDBFactory } from "fake-indexeddb"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import WebSocket from "ws"; +import { authenticateLocal, createLocalAuthenticator, LegacyReconnect } from "./local-auth"; +import { initializeLocalMigration, readLocalState } from "./local-credentials"; +import { verifyLocalPairing } from "./pairing-verifier"; + +// Opt-in process tests: BSK_TEST_BINARY is a current build; BSK_TEST_OLD_BINARY +// is a released/pre-pairing executable. Each owns an isolated home and port. +const current = process.env.BSK_TEST_BINARY; +const old = process.env.BSK_TEST_OLD_BINARY; +const origin = "chrome-extension://abcdefghijklmnopabcdefghijklmnop"; +const instance = "a1b2c3d4"; +let home: string; +let daemon: ChildProcess | undefined; +const sockets: WebSocket[] = []; +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), "bsk-auth-")); + vi.stubGlobal("indexedDB", new IDBFactory()); +}); +afterEach(async () => { + for (const socket of sockets.splice(0)) socket.terminate(); + if (daemon && daemon.exitCode === null) { + const exited = once(daemon, "exit"); + daemon.kill(); + await exited; + } + daemon = undefined; + await rm(home, { recursive: true, force: true }); + vi.unstubAllGlobals(); +}); +function environment() { + const env: NodeJS.ProcessEnv = { + ...process.env, + BSK_HOME: home, + BSK_AUTO_UPDATE: "off", + BSK_BROWSER_WAIT_MS: "0", + }; + delete env.BSK_DAEMONIZED; + delete env.BSK_DAEMON_REPLACES_PID; + return env; +} +async function cli(binary: string, ...args: string[]) { + return ( + await promisify(execFile)(binary, args, { env: environment(), timeout: 10_000 }) + ).stdout.trim(); +} +async function start(binary: string) { + daemon = spawn(binary, ["daemon", "start", "--foreground", "--port", "0"], { + env: environment(), + stdio: "ignore", + }); + let port = 0; + await vi.waitFor( + async () => { + port = JSON.parse(await readFile(join(home, "daemon.json"), "utf8")).ws_port; + expect(port).toBeGreaterThan(0); + }, + { timeout: 10_000 }, + ); + return `ws://127.0.0.1:${port}/`; +} +function socket(url: string): WebSocket { + const ws = new WebSocket(url, { origin }); + sockets.push(ws); + return ws; +} +async function opened(url: string) { + const ws = socket(url); + await once(ws, "open"); + return ws; +} +function browserSocket(ws: WebSocket) { + return ws as unknown as globalThis.WebSocket; +} +async function handshake(ws: WebSocket) { + const response = once(ws, "message"); + ws.send( + JSON.stringify({ + id: "handshake", + method: "system.handshake", + params: { + client: "browser-skill-extension", + version: "0.4.0", + protocol_version: "1.3", + instance_id: instance, + browser: { name: "Chrome", version: "130" }, + label: "integration", + }, + }), + ); + return JSON.parse(String((await response)[0])); +} +it.skipIf(!current)( + "real daemon: verifier activation, reconnect, rotation, business handshake, revoke", + async () => { + await cli(current!, "daemon", "trust-local", "off"); + const url = await start(current!); + const link = await cli(current!, "daemon", "pair"); + await verifyLocalPairing(link, instance, origin, (address) => browserSocket(socket(address))); + let ws = await opened(url); + await authenticateLocal( + browserSocket(ws), + AbortSignal.timeout(5000), + (await readLocalState()).credentials, + instance, + origin, + ); + expect((await handshake(ws)).result).toBeDefined(); + await cli(current!, "daemon", "rotate", instance); + const closed = once(ws, "close"); + ws.close(); + await closed; + ws = await opened(url); + await authenticateLocal( + browserSocket(ws), + AbortSignal.timeout(5000), + (await readLocalState()).credentials, + instance, + origin, + ); + expect((await readLocalState()).credentials[0].gen).toBe(2); + expect((await handshake(ws)).result).toBeDefined(); + const revoked = once(ws, "close"); + await cli(current!, "daemon", "revoke", instance); + await revoked; + const devices = JSON.parse(await cli(current!, "daemon", "devices")); + expect(devices[0].status).toBe("revoked"); + }, + 20_000, +); +it.skipIf(!old)( + "L19: a real pre-pairing daemon only receives a legacy handshake after local migration authorization", + async () => { + const url = await start(old!); + const authenticate = createLocalAuthenticator({ + instanceId: async () => instance, + origin, + status: vi.fn(), + }); + let ws = await opened(url); + await expect( + authenticate(browserSocket(ws), AbortSignal.timeout(5000)), + ).rejects.not.toBeInstanceOf(LegacyReconnect); + await initializeLocalMigration({ reason: "update", previousVersion: "0.3.2" }, "0.4.0"); + ws = await opened(url); + await expect(authenticate(browserSocket(ws), AbortSignal.timeout(5000))).rejects.toBeInstanceOf( + LegacyReconnect, + ); + ws = await opened(url); + await authenticate(browserSocket(ws), AbortSignal.timeout(5000)); + expect((await handshake(ws)).result).toBeDefined(); + }, + 20_000, +); diff --git a/apps/extension/src/transport/local-auth.test.ts b/apps/extension/src/transport/local-auth.test.ts new file mode 100644 index 000000000..d34c8f2b3 --- /dev/null +++ b/apps/extension/src/transport/local-auth.test.ts @@ -0,0 +1,277 @@ +import { IDBFactory } from "fake-indexeddb"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import v from "../../../../crates/bsk-protocol/tests/fixtures/local-auth-v1.json"; +import { authenticateLocal, createLocalAuthenticator, LegacyReconnect } from "./local-auth"; +import { prove, transcript } from "./local-auth-crypto"; +import type { LocalCredential } from "./local-credentials"; +import * as credentials from "./local-credentials"; +import { verifyLocalPairing } from "./pairing-verifier"; + +const link = `ws://127.0.0.1:52800/#bsk-local.v1.${v.daemon_id}.${v.device_id}.${v.key}`; +const origin = "chrome-extension://abcdefghijklmnopabcdefghijklmnop"; +const instanceId = "a1b2c3d4"; +class Socket extends EventTarget { + readyState = 1; + sent: Record[] = []; + handler: (frame: any) => void | Promise = () => {}; + send(raw: string) { + const value = JSON.parse(raw); + this.sent.push(value); + void Promise.resolve(this.handler(value)).catch(() => this.close()); + } + receive(value: unknown) { + this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify(value) })); + } + close() { + this.readyState = 3; + this.dispatchEvent(new Event("close")); + } + ws() { + return this as unknown as WebSocket; + } +} +beforeEach(() => vi.stubGlobal("indexedDB", new IDBFactory())); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +function auth(socket: Socket, records: LocalCredential[]) { + return authenticateLocal(socket.ws(), new AbortController().signal, records, instanceId, origin); +} +function server( + socket: Socket, + record: LocalCredential, + mode: "rotate" | "recover" | "normal" | "reject" | "bad" = "rotate", +) { + let t: Uint8Array; + let candidate: string; + socket.handler = async (message) => { + if (message.method === "system.ping") { + const gen = mode === "recover" ? record.pending!.gen : record.gen; + const key = mode === "recover" ? record.pending!.key : record.key; + t = transcript({ + daemon_id: record.daemonId, + device_id: record.deviceId, + gen, + client_nonce: message.params.local_auth.client_nonce, + instance_id: instanceId, + origin, + server_nonce: v.server_nonce, + }); + candidate = mode === "recover" ? record.pending!.key : await prove(key, "rotate", t, v.txn); + socket.receive({ + id: message.id, + result: { + local_auth: { + type: "challenge", + daemon_id: record.daemonId, + device_id: record.deviceId, + gen, + server_nonce: v.server_nonce, + server_proof: mode === "bad" ? v.client : await prove(key, "server", t), + }, + }, + }); + } else if (message.type === "local.auth.prove") { + if (mode === "reject") + socket.receive({ + type: "local.auth.rejected", + v: 1, + reason: "already_paired", + browser_id: instanceId, + }); + else + socket.receive({ + type: "local.auth.ready", + v: 1, + commit: + mode === "recover" + ? { + txn: v.txn, + gen: record.pending!.gen, + proof: await prove(candidate, "committed", t, v.txn), + } + : null, + rotation: mode === "rotate" ? { txn: v.txn, gen: record.gen + 1 } : null, + }); + } else if (message.type === "local.auth.rotate_confirm") { + socket.receive({ + type: "local.auth.committed", + v: 1, + gen: record.gen + 1, + proof: await prove(candidate, "committed", t, v.txn), + }); + } + }; +} +it("L03: never proves or sends a business handshake to an unverified server", async () => { + const record = credentials.parseLocalPairing(link); + const socket = new Socket(); + server(socket, record, "bad"); + await expect(auth(socket, [record])).rejects.toThrow("server_unverified"); + expect(socket.sent).toHaveLength(1); +}); +it.each([ + "old", + "hint", + "unknown", + "close", +])("L04: fresh profiles cannot downgrade on %s", async (kind) => { + const socket = new Socket(); + socket.handler = (request) => { + if (kind === "close") { + socket.close(); + return; + } + socket.receive( + kind === "old" + ? { id: request.id, error: { message: "first frame must be system.handshake" } } + : { + id: request.id, + result: { + local_auth: { + type: "pairing_required", + daemon_id: kind === "unknown" ? v.txn : v.daemon_id, + legacy_accepted: true, + }, + }, + }, + ); + }; + const authenticate = createLocalAuthenticator({ + instanceId: async () => instanceId, + origin, + status: vi.fn(), + }); + await expect(authenticate(socket.ws(), new AbortController().signal)).rejects.not.toBeInstanceOf( + LegacyReconnect, + ); + expect(socket.sent).toHaveLength(1); +}); +it("L17/L18: only persisted migration can request one legacy reconnect; skip sends no hello", async () => { + await credentials.initializeLocalMigration( + { reason: "update", previousVersion: "0.3.2" }, + "0.4.0", + ); + const authenticate = createLocalAuthenticator({ + instanceId: async () => instanceId, + origin, + status: vi.fn(), + }); + const socket = new Socket(); + socket.handler = () => socket.close(); + await expect(authenticate(socket.ws(), new AbortController().signal)).rejects.toBeInstanceOf( + LegacyReconnect, + ); + const fallback = new Socket(); + await authenticate(fallback.ws(), new AbortController().signal); + expect(fallback.sent).toHaveLength(0); + await credentials.setSkipLocalVerification(true); + const skipped = new Socket(); + await authenticate(skipped.ws(), new AbortController().signal); + expect(skipped.sent).toHaveLength(0); +}); +it("L08: activation durably saves and promotes the candidate before returning", async () => { + const record = credentials.parseLocalPairing(link); + await credentials.stageCredential(record); + const socket = new Socket(); + server(socket, record); + expect(await auth(socket, [record])).toMatchObject({ state: "verified" }); + expect((await credentials.readLocalState()).credentials[0]).toMatchObject({ + role: "active", + gen: 1, + }); + expect(socket.sent.map((frame) => frame.type ?? frame.method)).toEqual([ + "system.ping", + "local.auth.prove", + "local.auth.rotate_confirm", + ]); +}); +it("L08a/L08b: recovery verifies the saved key against the new transcript before promotion", async () => { + let record = credentials.parseLocalPairing(link); + await credentials.stageCredential(record); + record = await credentials.saveCandidate(record, { txn: v.txn, gen: 1, key: v.candidate }); + const socket = new Socket(); + server(socket, record, "recover"); + await auth(socket, [record]); + expect((await credentials.readLocalState()).credentials[0].key).toBe(v.candidate); + expect(socket.sent).toHaveLength(2); +}); +it("L08c: confirmation waits for the strict transaction completion", async () => { + const record = credentials.parseLocalPairing(link); + await credentials.stageCredential(record); + const original = credentials.saveCandidate; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const save = vi.spyOn(credentials, "saveCandidate").mockImplementation(async (...args) => { + const value = await original(...args); + await gate; + return value; + }); + const socket = new Socket(); + server(socket, record); + const result = auth(socket, [record]); + await vi.waitFor(() => expect(save).toHaveBeenCalledOnce()); + expect(socket.sent.some((frame) => frame.type === "local.auth.rotate_confirm")).toBe(false); + release(); + await result; +}); +it("L13: rejected staged verification preserves an active pairing and never sends business frames", async () => { + let record = credentials.parseLocalPairing(link); + await credentials.stageCredential(record); + record = await credentials.saveCandidate(record, { txn: v.txn, gen: 1, key: v.candidate }); + record = await credentials.promoteCandidate(record, { txn: v.txn, gen: 1 }); + const replacementLink = link.replace(v.device_id, v.txn); + const socket = new Socket(); + server(socket, credentials.parseLocalPairing(replacementLink), "reject"); + const result = verifyLocalPairing(replacementLink, instanceId, origin, () => { + queueMicrotask(() => socket.dispatchEvent(new Event("open"))); + return socket.ws(); + }); + await expect(result).rejects.toThrow("already_paired"); + expect((await credentials.readLocalState()).credentials).toEqual([record]); + expect(socket.sent.some((frame) => frame.method === "system.handshake")).toBe(false); +}); + +it.each([ + false, + true, +])("a concurrent activation still requires a fresh server proof (bad=%s)", async (bad) => { + const first = new Socket(); + const next = new Socket(); + let active: LocalCredential; + first.handler = async (request) => { + // The main socket commits after the verifier captured generation zero. + let record = (await credentials.readLocalState()).credentials[0]; + record = await credentials.saveCandidate(record, { txn: v.txn, gen: 1, key: v.candidate }); + active = await credentials.promoteCandidate(record, { txn: v.txn, gen: 1 }); + server(next, active, bad ? "bad" : "normal"); + first.receive({ + id: request.id, + result: { + local_auth: { + type: "pairing_required", + daemon_id: v.daemon_id, + legacy_accepted: false, + }, + }, + }); + }; + const sockets = [first, next]; + const result = verifyLocalPairing(link, instanceId, origin, () => { + const socket = sockets.shift()!; + queueMicrotask(() => socket.dispatchEvent(new Event("open"))); + return socket.ws(); + }); + if (bad) await expect(result).rejects.toThrow("server_unverified"); + else expect(await result).toMatchObject({ state: "verified" }); + expect(sockets).toHaveLength(0); + expect((await credentials.readLocalState()).credentials).toEqual([active!]); + expect(next.sent[0].params).toMatchObject({ local_auth: { credentials: [{ gen: 1 }] } }); + if (bad) expect(next.sent).toHaveLength(1); + expect([...first.sent, ...next.sent].some((frame) => frame.method === "system.handshake")).toBe( + false, + ); +}); diff --git a/apps/extension/src/transport/local-auth.ts b/apps/extension/src/transport/local-auth.ts new file mode 100644 index 000000000..ae46e9b74 --- /dev/null +++ b/apps/extension/src/transport/local-auth.ts @@ -0,0 +1,243 @@ +import { prove, randomBytes, transcript, verify } from "./local-auth-crypto"; +import { parseAuthJson, parseAuthMessage, parseChallenge } from "./local-auth-protocol"; +import { + type LocalCredential, + promoteCandidate, + readLocalState, + saveCandidate, +} from "./local-credentials"; + +export type LocalAuthCode = + | "verifying" + | "verified" + | "pairing_required" + | "already_paired" + | "replace_mismatch" + | "server_unverified" + | "daemon_too_old" + | "unverified_local" + | "busy"; +export interface LocalAuthStatus { + state: LocalAuthCode; + daemonId?: string; + deviceId?: string; + browserId?: string; + reason?: "skip" | "migration_old" | "migration_pair"; +} +export class LocalAuthError extends Error { + constructor(readonly status: LocalAuthStatus) { + super(status.state); + } +} +/** Only a decision from persisted local policy can request an old handshake. */ +export class LegacyReconnect extends Error {} + +class AuthChannel { + private queue: string[] = []; + private waiting?: { resolve: (value: string) => void; reject: (error: Error) => void }; + private failure?: Error; + constructor( + private socket: WebSocket, + private signal: AbortSignal, + ) { + socket.addEventListener("message", this.message); + socket.addEventListener("close", this.closed); + signal.addEventListener("abort", this.aborted, { once: true }); + if (signal.aborted) this.aborted(); + } + private fail(error: Error) { + this.failure ??= error; + this.waiting?.reject(this.failure); + this.waiting = undefined; + } + private message = (event: MessageEvent) => { + if ( + typeof event.data !== "string" || + new TextEncoder().encode(event.data).length > 4096 || + this.queue.length >= 4 + ) { + this.fail(new LocalAuthError({ state: "server_unverified" })); + return; + } + if (this.waiting) { + this.waiting.resolve(event.data); + this.waiting = undefined; + } else this.queue.push(event.data); + }; + private closed = () => this.fail(new LocalAuthError({ state: "daemon_too_old" })); + private aborted = () => this.fail(new LocalAuthError({ state: "server_unverified" })); + next(): Promise { + if (this.failure) return Promise.reject(this.failure); + const next = this.queue.shift(); + if (next !== undefined) return Promise.resolve(next); + return new Promise((resolve, reject) => { + this.waiting = { resolve, reject }; + }); + } + send(value: unknown) { + if (this.failure) throw this.failure; + if (this.signal.aborted || this.socket.readyState !== 1 /* OPEN */) + throw new LocalAuthError({ state: "server_unverified" }); + this.socket.send(JSON.stringify(value)); + } + finish() { + if (this.failure) throw this.failure; + if (this.queue.length) throw new LocalAuthError({ state: "server_unverified" }); + } + dispose() { + this.socket.removeEventListener("message", this.message); + this.socket.removeEventListener("close", this.closed); + this.signal.removeEventListener("abort", this.aborted); + } +} + +export async function authenticateLocal( + socket: WebSocket, + signal: AbortSignal, + credentials: LocalCredential[], + instanceId: string, + origin: string, +): Promise { + const channel = new AuthChannel(socket, signal); + try { + const nonce = randomBytes(32); + const id = crypto.randomUUID(); + channel.send({ + id, + method: "system.ping", + params: { + local_auth: { + v: 1, + client_nonce: nonce, + instance_id: instanceId, + credentials: credentials.map((c) => ({ + role: c.role, + device_id: c.deviceId, + gen: c.gen, + pending: c.pending ? { txn: c.pending.txn, gen: c.pending.gen } : null, + })), + }, + }, + }); + const reply = parseAuthJson(await channel.next()) as { + id?: unknown; + result?: { local_auth?: unknown }; + }; + if (!reply || reply.id !== id || !reply.result?.local_auth) + throw new LocalAuthError({ state: "daemon_too_old" }); + if ( + Object.keys(reply).some((key) => key !== "id" && key !== "result") || + Object.keys(reply.result).some((key) => key !== "local_auth") + ) + throw new LocalAuthError({ state: "server_unverified" }); + const challenge = parseChallenge(reply.result.local_auth); + if (challenge.type === "pairing_required") + throw new LocalAuthError({ state: "pairing_required", daemonId: challenge.daemon_id }); + if (challenge.type === "busy") throw new LocalAuthError({ state: "busy" }); + let credential = credentials.find( + (c) => + c.daemonId === challenge.daemon_id && + c.deviceId === challenge.device_id && + (c.gen === challenge.gen || c.pending?.gen === challenge.gen), + ); + if (!credential) throw new LocalAuthError({ state: "server_unverified" }); + const key = credential.pending?.gen === challenge.gen ? credential.pending.key : credential.key; + const t = transcript({ ...challenge, instance_id: instanceId, origin, client_nonce: nonce }); + if (!(await verify(key, "server", t, challenge.server_proof))) + throw new LocalAuthError({ state: "server_unverified" }); + channel.send({ type: "local.auth.prove", v: 1, client_proof: await prove(key, "client", t) }); + const ready = parseAuthMessage(await channel.next()); + if (ready.type === "local.auth.rejected") + throw new LocalAuthError({ state: ready.reason, browserId: ready.browser_id }); + if (ready.type !== "local.auth.ready") throw new LocalAuthError({ state: "server_unverified" }); + if (ready.commit) { + const pending = credential.pending; + if ( + !pending || + challenge.gen !== pending.gen || + ready.commit.gen !== pending.gen || + ready.commit.txn !== pending.txn || + !(await verify(pending.key, "committed", t, ready.commit.proof, pending.txn)) + ) + throw new LocalAuthError({ state: "server_unverified" }); + // Recovery uses the saved candidate and this connection's transcript. + credential = await promoteCandidate(credential, pending); + } else if (ready.rotation) { + if (challenge.gen !== credential.gen || ready.rotation.gen !== credential.gen + 1) + throw new LocalAuthError({ state: "server_unverified" }); + const rotation = ready.rotation; + const candidate = await prove(key, "rotate", t, rotation.txn); + credential = await saveCandidate(credential, { ...rotation, key: candidate }); + channel.send({ + type: "local.auth.rotate_confirm", + v: 1, + txn: rotation.txn, + proof: await prove(candidate, "confirm", t, rotation.txn), + }); + const committed = parseAuthMessage(await channel.next()); + if ( + committed.type !== "local.auth.committed" || + committed.gen !== rotation.gen || + !(await verify(candidate, "committed", t, committed.proof, rotation.txn)) + ) + throw new LocalAuthError({ state: "server_unverified" }); + credential = await promoteCandidate(credential, rotation); + } else if (credential.role !== "active" || challenge.gen !== credential.gen) { + throw new LocalAuthError({ state: "server_unverified" }); + } + channel.finish(); + return { state: "verified", daemonId: credential.daemonId, deviceId: credential.deviceId }; + } catch (error) { + if (error instanceof LocalAuthError) throw error; + throw new LocalAuthError({ state: "server_unverified" }); + } finally { + channel.dispose(); + } +} + +export function createLocalAuthenticator(options: { + instanceId: () => Promise; + origin: string; + status: (status: LocalAuthStatus) => void; +}) { + let legacyNext = false; + let legacyReason: "migration_old" | "migration_pair" = "migration_old"; + return async (socket: WebSocket, signal: AbortSignal): Promise => { + const { credentials, policy } = await readLocalState(); + if (signal.aborted) throw new LocalAuthError({ state: "server_unverified" }); + const allowLegacy = + policy.legacyCompat && !policy.everPaired && !credentials.some((c) => c.role === "active"); + if (policy.skip || (legacyNext && allowLegacy)) { + legacyNext = false; + options.status({ state: "unverified_local", reason: policy.skip ? "skip" : legacyReason }); + return; + } + legacyNext = false; + options.status({ state: "verifying" }); + try { + const status = await authenticateLocal( + socket, + signal, + credentials, + await options.instanceId(), + options.origin, + ); + options.status(status); + } catch (error) { + if (signal.aborted) throw error; + // Neither a peer's error text nor its legacy_accepted flag grants fallback. + if (allowLegacy) { + legacyReason = + error instanceof LocalAuthError && error.status.state === "pairing_required" + ? "migration_pair" + : "migration_old"; + legacyNext = true; + throw new LegacyReconnect(); + } + options.status( + error instanceof LocalAuthError ? error.status : { state: "server_unverified" }, + ); + throw error; + } + }; +} diff --git a/apps/extension/src/transport/local-credentials.test.ts b/apps/extension/src/transport/local-credentials.test.ts new file mode 100644 index 000000000..59843b802 --- /dev/null +++ b/apps/extension/src/transport/local-credentials.test.ts @@ -0,0 +1,122 @@ +import { IDBDatabase, IDBFactory } from "fake-indexeddb"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import v from "../../../../crates/bsk-protocol/tests/fixtures/local-auth-v1.json"; +import { openCredentialDatabase } from "./credential-db"; +import { + discardStaged, + initializeLocalMigration, + parseLocalPairing, + promoteCandidate, + readLocalState, + saveCandidate, + setSkipLocalVerification, + stageCredential, +} from "./local-credentials"; + +export const link = `ws://127.0.0.1:52800/#bsk-local.v1.${v.daemon_id}.${v.device_id}.${v.key}`; +beforeEach(() => vi.stubGlobal("indexedDB", new IDBFactory())); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +it("L16: rejects remote credentials and non-loopback local endpoints without exposing secrets", () => { + expect(parseLocalPairing(link).endpoint).toBe("ws://127.0.0.1:52800/"); + for (const bad of [ + link.replace("127.0.0.1", "example.com"), + link.replace("ws:", "wss:"), + link.replace("/#", "/extension#"), + link.replace("/#", "/?key=1#"), + link.replace("127.0.0.1", "user@127.0.0.1"), + link.replace("#bsk-local.v1.", "#"), + `${link}=`, + link.replace(".v1.", ".v2."), + ]) + expect(() => parseLocalPairing(bad)).toThrow("Invalid local pairing link"); +}); +it("L08c: key writes use strict durability, compare candidates, and promote atomically", async () => { + const transactions = vi.spyOn(IDBDatabase.prototype, "transaction"); + const credential = parseLocalPairing(link); + await stageCredential(credential); + const candidate = { txn: v.txn, gen: 1, key: v.candidate }; + const saved = await saveCandidate(credential, candidate); + await expect(saveCandidate(credential, candidate)).rejects.toThrow("changed"); + expect((await readLocalState()).credentials[0].gen).toBe(0); + await promoteCandidate(saved, candidate); + const state = await readLocalState(); + expect(state.credentials[0]).toMatchObject({ role: "active", gen: 1, key: v.candidate }); + expect(state.credentials[0].pending).toBeUndefined(); + expect(state.policy).toMatchObject({ everPaired: true, legacyCompat: false }); + const writes = transactions.mock.calls.filter((call) => call[1] === "readwrite"); + expect(writes.length).toBeGreaterThan(2); + for (const call of writes) expect(call[2]).toEqual({ durability: "strict" }); +}); +it("L14c/L21: staged writes and expiry preserve active credentials", async () => { + let active = parseLocalPairing(link); + await stageCredential(active); + active = await saveCandidate(active, { txn: v.txn, gen: 1, key: v.candidate }); + await promoteCandidate(active, { txn: v.txn, gen: 1 }); + const staged = { + ...parseLocalPairing(link), + deviceId: v.txn, + createdAt: Date.now() - 11 * 60_000, + }; + await stageCredential(staged); + expect((await readLocalState()).credentials).toHaveLength(1); + await discardStaged(staged); + expect((await readLocalState()).credentials[0].key).toBe(v.candidate); +}); +it("L18: only eligible updates get migration compatibility; pairing never resurrects it", async () => { + await initializeLocalMigration({ reason: "install" }, "0.4.0"); + expect((await readLocalState()).policy.legacyCompat).toBe(false); + await initializeLocalMigration({ reason: "update", previousVersion: "0.3.2" }, "0.4.0"); + expect((await readLocalState()).policy.legacyCompat).toBe(true); + let credential = parseLocalPairing(link); + await stageCredential(credential); + credential = await saveCandidate(credential, { txn: v.txn, gen: 1, key: v.candidate }); + await promoteCandidate(credential, { txn: v.txn, gen: 1 }); + await initializeLocalMigration({ reason: "update", previousVersion: "0.3.2" }, "0.4.0"); + expect((await readLocalState()).policy.legacyCompat).toBe(false); + await setSkipLocalVerification(true); + expect((await readLocalState()).credentials).toHaveLength(1); +}); +it("L18: the exit version clears compatibility and never clears explicit skipping", async () => { + await initializeLocalMigration({ reason: "update", previousVersion: "0.3.2" }, "0.4.0"); + await setSkipLocalVerification(true); + await initializeLocalMigration(undefined, "0.4.1"); + expect((await readLocalState()).policy).toEqual({ + skip: true, + everPaired: false, + legacyCompat: false, + }); +}); +it("upgrades the shared database while keeping the existing remote grant", async () => { + await new Promise((resolve) => { + const old = indexedDB.open("bsk-remote-authorization", 1); + old.onupgradeneeded = () => + old.result.createObjectStore("connection").put({ token: "private" }, "endpoint"); + old.onsuccess = () => { + old.result.close(); + resolve(); + }; + }); + const db = await openCredentialDatabase(); + expect([...db.objectStoreNames]).toEqual(["connection", "local", "migration"]); + const request = db.transaction("connection").objectStore("connection").get("endpoint"); + await new Promise((resolve) => { + request.onsuccess = () => resolve(); + }); + expect(request.result).toEqual({ token: "private" }); + db.close(); +}); + +it("L21: eight daemon limit never evicts an existing active pairing", async () => { + const { encodeBytes } = await import("./local-auth-crypto"); + const record = parseLocalPairing(link); + for (let index = 0; index < 8; index++) + await stageCredential({ ...record, daemonId: encodeBytes(new Uint8Array(16).fill(index)) }); + await expect( + stageCredential({ ...record, daemonId: encodeBytes(new Uint8Array(16).fill(8)) }), + ).rejects.toThrow("limit"); + expect((await readLocalState()).credentials).toHaveLength(8); +}); diff --git a/apps/extension/src/transport/local-credentials.ts b/apps/extension/src/transport/local-credentials.ts new file mode 100644 index 000000000..24d40315e --- /dev/null +++ b/apps/extension/src/transport/local-credentials.ts @@ -0,0 +1,217 @@ +import { openCredentialDatabase } from "./credential-db"; +import { decodeBytes } from "./local-auth-crypto"; +import type { Rotation } from "./local-auth-protocol"; + +export const PAIRING_VERSION = "0.4.0"; +export const MIGRATION_EXIT_VERSION = "0.4.1"; +const STAGED_TTL_MS = 10 * 60_000; +export interface LocalCredential { + daemonId: string; + deviceId: string; + key: string; + gen: number; + role: "active" | "staged"; + endpoint: string; + createdAt: number; + pending?: Rotation & { key: string }; +} +export interface LocalPolicy { + skip: boolean; + everPaired: boolean; + legacyCompat: boolean; +} +export interface LocalState { + credentials: LocalCredential[]; + policy: LocalPolicy; +} +const defaultPolicy = (): LocalPolicy => ({ skip: false, everPaired: false, legacyCompat: false }); + +/** No asynchronous work inside an IDB transaction. Resolve only on complete, + * including strict persistence of candidates, promotion, and migration state. */ +async function access(write: boolean, action: (state: LocalState) => T): Promise { + const database = await openCredentialDatabase(); + try { + return await new Promise((resolve, reject) => { + const tx = database.transaction( + ["local", "migration"], + write ? "readwrite" : "readonly", + write ? { durability: "strict" } : undefined, + ); + const local = tx.objectStore("local"); + const migration = tx.objectStore("migration"); + const records = local.get("credentials"); + const policy = migration.get("policy"); + let remaining = 2; + let result: T; + let failure: unknown; + const loaded = () => { + if (--remaining) return; + try { + const state: LocalState = { + credentials: records.result ?? [], + policy: policy.result ?? defaultPolicy(), + }; + // Expired staged records never displace the still-valid active entry. + state.credentials = state.credentials.filter( + (c) => c.role === "active" || c.createdAt + STAGED_TTL_MS > Date.now(), + ); + result = action(state); + if (write) { + local.put(state.credentials, "credentials"); + migration.put(state.policy, "policy"); + } + } catch (error) { + failure = error; + tx.abort(); + } + }; + records.onsuccess = loaded; + policy.onsuccess = loaded; + tx.oncomplete = () => resolve(result); + tx.onabort = () => reject(failure ?? new Error("Credential transaction failed")); + tx.onerror = () => { + /* onabort settles the transaction */ + }; + }); + } finally { + database.close(); + } +} + +export function parseLocalPairing(input: string): LocalCredential { + try { + const raw = input.trim(); + const authority = /^ws:\/\/127\.0\.0\.1:(\d{1,5})\/#/.exec(raw); + if (!authority || Number(authority[1]) < 1 || Number(authority[1]) > 65535) throw new Error(); + const url = new URL(raw); + if ( + url.protocol !== "ws:" || + url.hostname !== "127.0.0.1" || + url.username || + url.password || + url.search || + url.pathname !== "/" + ) + throw new Error(); + const parts = url.hash.slice(1).split("."); + if (parts.length !== 5 || parts[0] !== "bsk-local" || parts[1] !== "v1") throw new Error(); + const [, , daemonId, deviceId, key] = parts; + decodeBytes(daemonId, 16); + decodeBytes(deviceId, 16); + decodeBytes(key, 32); + url.hash = ""; + return { + daemonId, + deviceId, + key, + gen: 0, + role: "staged", + endpoint: `ws://127.0.0.1:${Number(authority[1])}/`, + createdAt: Date.now(), + }; + } catch { + throw new Error("Invalid local pairing link"); + } +} +export const readLocalState = (): Promise => access(false, (state) => state); +export function stageCredential(credential: LocalCredential): Promise { + return access(true, (state) => { + const same = state.credentials.filter((c) => c.daemonId === credential.daemonId); + const existing = same.find((c) => c.deviceId === credential.deviceId); + if (existing?.role === "active") throw new Error("Pairing link already stored"); + if (existing) return existing; + if (same.some((c) => c.role === "staged" && c.pending)) + throw new Error("Previous pairing needs recovery"); + if (!same.length && new Set(state.credentials.map((c) => c.daemonId)).size >= 8) + throw new Error("Local pairing limit reached"); + state.credentials = state.credentials.filter( + (c) => c.daemonId !== credential.daemonId || c.role !== "staged", + ); + state.credentials.push(credential); + return credential; + }); +} +function current(state: LocalState, expected: LocalCredential): LocalCredential { + const record = state.credentials.find( + (c) => + c.daemonId === expected.daemonId && + c.deviceId === expected.deviceId && + c.role === expected.role, + ); + if (!record || record.gen !== expected.gen || record.pending?.txn !== expected.pending?.txn) + throw new Error("Local credential changed"); + return record; +} +export function saveCandidate( + expected: LocalCredential, + pending: Rotation & { key: string }, +): Promise { + return access(true, (state) => { + const record = current(state, expected); + if (pending.gen !== record.gen + 1) throw new Error("Invalid key generation"); + record.pending = pending; + return record; + }); +} +export function promoteCandidate( + expected: LocalCredential, + rotation: Rotation, +): Promise { + return access(true, (state) => { + const record = current(state, expected); + if ( + !record.pending || + record.pending.txn !== rotation.txn || + record.pending.gen !== rotation.gen + ) + throw new Error("Local candidate changed"); + record.gen = record.pending.gen; + record.key = record.pending.key; + delete record.pending; + record.role = "active"; + state.credentials = state.credentials.filter( + (c) => c.daemonId !== record.daemonId || c === record, + ); + state.policy.everPaired = true; + state.policy.legacyCompat = false; + return record; + }); +} +export function discardStaged(expected: LocalCredential): Promise { + return access(true, (state) => { + state.credentials = state.credentials.filter( + (c) => + !( + c.daemonId === expected.daemonId && + c.deviceId === expected.deviceId && + c.role === "staged" + ), + ); + }); +} +export function setSkipLocalVerification(skip: boolean): Promise { + return access(true, (state) => { + state.policy.skip = skip; + }); +} +function before(version: string, bound: string): boolean { + const a = version.split(".").map(Number); + const b = bound.split(".").map(Number); + if (a.length !== 3 || a.some((part) => !Number.isSafeInteger(part) || part < 0)) return false; + return a[0] < b[0] || (a[0] === b[0] && (a[1] < b[1] || (a[1] === b[1] && a[2] < b[2]))); +} +export function initializeLocalMigration( + details?: { reason: string; previousVersion?: string }, + version = __BSK_EXT_VERSION__, +): Promise { + return access(true, ({ policy }) => { + if (!before(version, MIGRATION_EXIT_VERSION) || policy.everPaired) policy.legacyCompat = false; + else if (details?.reason === "install") policy.legacyCompat = false; + else if ( + details?.reason === "update" && + details.previousVersion && + before(details.previousVersion, PAIRING_VERSION) + ) + policy.legacyCompat = true; + }); +} diff --git a/apps/extension/src/transport/pairing-verifier.ts b/apps/extension/src/transport/pairing-verifier.ts new file mode 100644 index 000000000..67e3a5b26 --- /dev/null +++ b/apps/extension/src/transport/pairing-verifier.ts @@ -0,0 +1,92 @@ +import { authenticateLocal, LocalAuthError, type LocalAuthStatus } from "./local-auth"; +import { + discardStaged, + parseLocalPairing, + readLocalState, + stageCredential, +} from "./local-credentials"; + +let running = false; +/** A verifier owns no business transport and cannot disturb an active session. */ +export async function verifyLocalPairing( + link: string, + instanceId: string, + origin: string, + factory: (url: string) => WebSocket = (url) => new WebSocket(url), +): Promise { + if (running) throw new LocalAuthError({ state: "busy" }); + let credential = parseLocalPairing(link); + running = true; + let socket: WebSocket | undefined; + let staged = false; + const abort = new AbortController(); + const timer = setTimeout(() => abort.abort(), 10_000); + try { + credential = await stageCredential(credential); + staged = true; + for (let attempt = 0; ; attempt++) { + if (abort.signal.aborted) throw new LocalAuthError({ state: "server_unverified" }); + socket = factory(credential.endpoint); + const connection = socket; + await new Promise((resolve, reject) => { + const dispose = () => { + connection.removeEventListener("open", opened); + connection.removeEventListener("close", failed); + abort.signal.removeEventListener("abort", failed); + }; + const opened = () => { + dispose(); + resolve(); + }; + const failed = () => { + dispose(); + reject(new LocalAuthError({ state: "server_unverified" })); + }; + connection.addEventListener("open", opened); + connection.addEventListener("close", failed); + abort.signal.addEventListener("abort", failed, { once: true }); + }); + try { + const status = await authenticateLocal( + connection, + abort.signal, + [credential], + instanceId, + origin, + ); + return { ...status, endpoint: credential.endpoint }; + } catch (error) { + const latest = (await readLocalState()).credentials.find( + (c) => c.deviceId === credential.deviceId && c.daemonId === credential.daemonId, + ); + // The main connection may activate or recover this same staged record + // while the verifier opens. Verify its advanced key on a fresh socket; + // a concurrent commit is never accepted without another server proof. + if ( + attempt === 0 && + latest && + (latest.gen > credential.gen || + (latest.pending && latest.pending.txn !== credential.pending?.txn)) + ) { + credential = latest; + socket.close(); + continue; + } + throw error; + } + } + } catch (error) { + // Once confirmation may have reached the daemon, retain the candidate for + // recovery. Deleting it on a lost commit reply would strand a replacement. + const stored = (await readLocalState()).credentials.find( + (c) => c.deviceId === credential.deviceId && c.daemonId === credential.daemonId, + ); + if (staged && !stored?.pending) await discardStaged(credential); + throw error; + } finally { + clearTimeout(timer); + abort.abort(); + socket?.close(); + running = false; + } +} diff --git a/apps/extension/src/transport/remote-authorization.ts b/apps/extension/src/transport/remote-authorization.ts index 777922679..7dde8c6b9 100644 --- a/apps/extension/src/transport/remote-authorization.ts +++ b/apps/extension/src/transport/remote-authorization.ts @@ -1,9 +1,15 @@ -import { parseRemoteEndpoint, type RemoteEndpoint, readRemoteEndpoint } from "./remote-endpoint"; +import { + parseRemoteEndpoint, + type RemoteEndpoint, + readRemoteEndpoint, + remoteAuthorizationStatus, +} from "./remote-endpoint"; import { initializeRemoteStorage, REMOTE_CONNECTION_MODE, REMOTE_CONNECTION_REVISION, readRemoteConnection, + readSavedRemoteConnection, writeRemoteConnection, } from "./remote-storage"; @@ -84,6 +90,15 @@ export function updateRemoteConnection(pairing: string | null): Promise { + return serialized(async () => { + const endpoint = await readSavedRemoteConnection(); + if (!endpoint || ["expired", "rejected"].includes(remoteAuthorizationStatus(endpoint))) + throw new Error("Remote authorization has expired; pair again"); + await writeRemoteConnection(endpoint); + return endpoint.url; + }); +} let renewing = false; /** Persist the candidate before rotating. A lost HTTP response can be retried * using the same old/new pair after service-worker or server restart. */ @@ -120,24 +135,28 @@ async function renewRemote(): Promise { renewalAttemptAt: new Date(Date.now()).toISOString(), }; await writeRemoteConnection(pending); + const stillSelected = async () => { + const latest = await readRemoteConnection(); + return ( + latest?.url === endpoint.url && + latest.token === endpoint.token && + latest.deviceId === endpoint.deviceId && + latest.pendingToken === nextToken + ); + }; let updated: RemoteEndpoint; try { updated = await authorize(endpoint, "renew", nextToken); } catch (error) { - await writeRemoteConnection({ - ...pending, - renewalFailure: error instanceof AuthorizationRejected ? "rejected" : "unavailable", - }); + if (await stillSelected()) + await writeRemoteConnection({ + ...pending, + renewalFailure: error instanceof AuthorizationRejected ? "rejected" : "unavailable", + }); throw error; } - const latest = await readRemoteConnection(); // A settings change during the request must not resurrect an old connection. - if ( - latest?.url === endpoint.url && - latest.token === endpoint.token && - latest.deviceId === endpoint.deviceId && - latest.pendingToken === nextToken - ) { + if (await stillSelected()) { await writeRemoteConnection(updated); } } @@ -148,12 +167,15 @@ export function watchRemoteAuthorization() { if ( sender.url !== chrome.runtime.getURL("popup.html") || sender.id !== chrome.runtime.id || - (message.pairing !== null && typeof message.pairing !== "string") + sender.tab !== undefined || + (message.resume !== true && message.pairing !== null && typeof message.pairing !== "string") ) { reply({ error: "Invalid authorization request" }); return false; } - void updateRemoteConnection(message.pairing).then( + void ( + message.resume === true ? resumeRemoteConnection() : updateRemoteConnection(message.pairing) + ).then( (url) => reply({ url }), () => reply({ error: "Unable to pair; copy a new pairing link and try again" }), ); diff --git a/apps/extension/src/transport/remote-storage.ts b/apps/extension/src/transport/remote-storage.ts index 19681128f..213a1daaf 100644 --- a/apps/extension/src/transport/remote-storage.ts +++ b/apps/extension/src/transport/remote-storage.ts @@ -1,31 +1,14 @@ +import { openCredentialDatabase } from "./credential-db"; import { REMOTE_ENDPOINT_KEY, type RemoteEndpoint, readRemoteEndpoint } from "./remote-endpoint"; /** Only this non-secret revision is exposed through chrome.storage.local. */ export const REMOTE_CONNECTION_REVISION = "bsk_remote_connection_revision"; export const REMOTE_CONNECTION_MODE = "bsk_connection_mode"; -const DATABASE = "bsk-remote-authorization"; const STORE = "connection"; const MIGRATING = "bsk_remote_storage_migrating"; -function openDatabase(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DATABASE, 1); - request.onupgradeneeded = () => request.result.createObjectStore(STORE); - request.onerror = () => reject(request.error); - let blocked = false; - request.onblocked = () => { - blocked = true; - reject(new Error("Remote authorization storage is blocked")); - }; - request.onsuccess = () => { - if (blocked) request.result.close(); - else resolve(request.result); - }; - }); -} - async function storedEndpoint(write?: { value: RemoteEndpoint | null }): Promise { - const database = await openDatabase(); + const database = await openCredentialDatabase(); try { return await new Promise((resolve, reject) => { const transaction = database.transaction(STORE, write ? "readwrite" : "readonly"); @@ -81,8 +64,7 @@ export async function writeRemoteConnection(endpoint: RemoteEndpoint | null): Pr [REMOTE_CONNECTION_MODE]: "local", [REMOTE_CONNECTION_REVISION]: crypto.randomUUID(), }); - // A stale private record cannot become active while local mode is selected. - await storedEndpoint({ value: null }).catch(() => {}); + // Keep the private grant so switching back before expiry needs no new link. } } @@ -107,3 +89,9 @@ export function initializeRemoteStorage(): Promise { }); return initialization; } + +/** Read inactive remote authorization without selecting it or renewing it. */ +export async function readSavedRemoteConnection(): Promise { + await initializeRemoteStorage(); + return readRemoteEndpoint(await storedEndpoint()); +} diff --git a/apps/extension/src/transport/ws-transport.ts b/apps/extension/src/transport/ws-transport.ts index 2248dd0bf..8c7e9c6b4 100644 --- a/apps/extension/src/transport/ws-transport.ts +++ b/apps/extension/src/transport/ws-transport.ts @@ -1,3 +1,4 @@ +import { LegacyReconnect } from "./local-auth"; import type { ConnectionStateHandler, Disposable, FrameHandler, Transport } from "./transport"; import type { ConnectionState, ProtocolFrame } from "./types"; @@ -18,6 +19,8 @@ export interface WSTransportOptions { * Inject a fake WebSocket constructor in tests; defaults to `globalThis.WebSocket`. */ webSocketFactory?: WebSocketFactory; + authenticate?: (socket: WebSocket, signal: AbortSignal) => Promise | void; + onAuthenticationError?: () => void; } interface CloseLikeEvent { @@ -33,15 +36,22 @@ interface MessageLikeEvent { * WebSocket-backed implementation of {@link Transport} (design §4.7). * * Lifecycle: - * - `connect()` opens a socket and resolves once it transitions to OPEN. + * - `connect()` resolves after OPEN and the optional authentication prelude. * - Inbound text frames are parsed as JSON and dispatched to message handlers. - * - Outbound frames are JSON-serialised and sent only when the socket is OPEN. + * - Outbound frames are JSON-serialised and sent only after authentication succeeds. * - Unexpected closes trigger an exponential-backoff reconnect loop * (1s, 2s, 4s, …, capped at 5s) until `disconnect()` is called. */ export class WSTransport implements Transport { private url: string; private readonly factory: WebSocketFactory; + private readonly authenticate?: WSTransportOptions["authenticate"]; + private readonly onAuthenticationError?: () => void; + private authentication: AbortController | null = null; + private authenticationTimer: ReturnType | null = null; + private authFailures = 0; + private authBackoffUntil = 0; + private legacyReconnect = false; private readonly initialDelayMs: number; private readonly maxDelayMs: number; @@ -60,6 +70,8 @@ export class WSTransport implements Transport { constructor(options: WSTransportOptions) { this.url = options.url; + this.authenticate = options.authenticate; + this.onAuthenticationError = options.onAuthenticationError; this.factory = options.webSocketFactory ?? ((url: string) => new WebSocket(url)); this.initialDelayMs = options.reconnect?.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS; this.maxDelayMs = options.reconnect?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS; @@ -76,9 +88,15 @@ export class WSTransport implements Transport { return true; } + resetAuthenticationBackoff(): void { + this.authBackoffUntil = 0; + this.authFailures = 0; + } + connect(): Promise { if (this.currentState === "connected") return Promise.resolve(); + if (Date.now() < this.authBackoffUntil) return Promise.reject(new Error("local_auth_backoff")); this.explicitlyClosed = false; if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); @@ -109,6 +127,7 @@ export class WSTransport implements Transport { this.reconnectTimer = null; } const socket = this.socket; + this.endAuthentication(); this.socket = null; this.socketGeneration += 1; if (socket) { @@ -132,7 +151,11 @@ export class WSTransport implements Transport { } send(msg: ProtocolFrame): void { - if (!this.socket || this.socket.readyState !== 1 /* OPEN */) { + if ( + this.currentState !== "connected" || + !this.socket || + this.socket.readyState !== 1 /* OPEN */ + ) { throw new Error("[WSTransport] cannot send while not connected"); } this.socket.send(JSON.stringify(msg)); @@ -176,17 +199,56 @@ export class WSTransport implements Transport { socket.addEventListener("open", () => { if (!this.isCurrentSocket(socket, generation)) return; - this.reconnectAttempt = 0; - this.setState("connected"); - const resolve = this.resolveConnect; - this.resolveConnect = null; - this.rejectConnect = null; - this.connectingPromise = null; - resolve?.(); + const connected = () => { + if (!this.isCurrentSocket(socket, generation)) return; + if (socket.readyState !== 1) { + failed(new Error("Connection closed during authentication")); + return; + } + this.endAuthentication(); + this.resetAuthenticationBackoff(); + this.reconnectAttempt = 0; + this.setState("connected"); + const resolve = this.resolveConnect; + this.resolveConnect = null; + this.rejectConnect = null; + this.connectingPromise = null; + resolve?.(); + }; + const failed = (error: unknown) => { + if (!this.isCurrentSocket(socket, generation)) return; + this.legacyReconnect = error instanceof LegacyReconnect; + if (!this.legacyReconnect) this.onAuthenticationError?.(); + if (!this.legacyReconnect) + this.authBackoffUntil = Date.now() + Math.min(30_000 * 2 ** this.authFailures++, 300_000); + this.endAuthentication(); + const reject = this.rejectConnect; + this.resolveConnect = null; + this.rejectConnect = null; + this.connectingPromise = null; + reject?.(error instanceof Error ? error : new Error("Local authentication failed")); + socket.close(); + this.handleClose(socket, generation, {}); + }; + if (!this.authenticate) { + connected(); + return; + } + const abort = new AbortController(); + this.authentication = abort; + this.authenticationTimer = setTimeout( + () => failed(new Error("Local authentication timed out")), + 5_000, + ); + try { + Promise.resolve(this.authenticate(socket, abort.signal)).then(connected, failed); + } catch (error) { + failed(error); + } }); socket.addEventListener("message", (ev: MessageEvent) => { - if (!this.isCurrentSocket(socket, generation)) return; + if (!this.isCurrentSocket(socket, generation) || this.currentState !== "connected") return; this.handleInbound((ev as unknown as MessageLikeEvent).data); }); @@ -208,6 +270,20 @@ export class WSTransport implements Transport { } catch { return; } + // No authentication frame is a business message, including a second final. + if ( + parsed && + typeof parsed === "object" && + "type" in parsed && + typeof parsed.type === "string" && + parsed.type.startsWith("local.auth.") + ) { + this.onAuthenticationError?.(); + this.authFailures += 1; + this.authBackoffUntil = Date.now() + 30_000; + this.socket?.close(); + return; + } for (const h of this.messageHandlers) { try { h(parsed); @@ -219,6 +295,10 @@ export class WSTransport implements Transport { private handleClose(socket: WebSocket, generation: number, _ev: CloseLikeEvent): void { if (!this.isCurrentSocket(socket, generation)) return; + // The authenticator must observe close before deciding whether persisted + // migration policy permits a new legacy socket. Its total timeout bounds it. + if (this.authentication) return; + this.endAuthentication(); this.socket = null; if (this.explicitlyClosed) { this.setState("disconnected"); @@ -230,7 +310,13 @@ export class WSTransport implements Transport { private scheduleReconnect(): void { if (this.reconnectTimer) return; - const delay = Math.min(this.initialDelayMs * 2 ** this.reconnectAttempt, this.maxDelayMs); + const delay = this.legacyReconnect + ? 0 + : Math.max( + this.authBackoffUntil - Date.now(), + Math.min(this.initialDelayMs * 2 ** this.reconnectAttempt, this.maxDelayMs), + ); + this.legacyReconnect = false; this.reconnectAttempt += 1; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; @@ -241,6 +327,13 @@ export class WSTransport implements Transport { }, delay); } + private endAuthentication(): void { + this.authentication?.abort(); + this.authentication = null; + if (this.authenticationTimer) clearTimeout(this.authenticationTimer); + this.authenticationTimer = null; + } + private isCurrentSocket(socket: WebSocket, generation: number): boolean { return this.socket === socket && this.socketGeneration === generation; } diff --git a/apps/extension/src/video/capture.browser.test.ts b/apps/extension/src/video/capture.browser.test.ts index 9b2eac56f..14cf58645 100644 --- a/apps/extension/src/video/capture.browser.test.ts +++ b/apps/extension/src/video/capture.browser.test.ts @@ -9,8 +9,10 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; +import { STORAGE_KEYS } from "@/lib/instance-id"; import { attachVideoBackground, type BrowserSend as Send } from "./browser-fixture"; import { createDshVideoFixture } from "./dsh-fixture"; +import { buildPairingFixture } from "./pairing-fixture"; const run = promisify(execFile); @@ -125,9 +127,12 @@ describe.skipIf(!process.env.BSK_VIDEO_CHROME || !process.env.BSK_VIDEO_BSK)( const backgroundPath = path.join(extension, "background.js"); const background = await readFile(backgroundPath, "utf8"); expect(background).toContain("ws://127.0.0.1:52800"); + // Provision before the first connection attempt so this fixture never + // races automatic authentication or inherits its failure backoff. await writeFile( backgroundPath, - background.replaceAll("ws://127.0.0.1:52800", `ws://127.0.0.1:${port}`), + `chrome.storage.local.set(${JSON.stringify({ [STORAGE_KEYS.CONNECTION_ENABLED]: false })}) + .then(() => { ${background.replaceAll("ws://127.0.0.1:52800", `ws://127.0.0.1:${port}`)}\n });`, ); // Only the fixture delays production content startup. onCompleted can // resume capture before its listener exists, and the video query arrives @@ -169,6 +174,7 @@ describe.skipIf(!process.env.BSK_VIDEO_CHROME || !process.env.BSK_VIDEO_BSK)( import.meta.url, ).href ); + const pairingSource = await buildPairingFixture(directory, manifest.version); const attachedPages: { parent: string; session: string; prerender: boolean }[] = []; await withChrome( { @@ -195,17 +201,6 @@ describe.skipIf(!process.env.BSK_VIDEO_CHROME || !process.env.BSK_VIDEO_BSK)( }, }, async (send: Send) => { - await expect - .poll(async () => (await cli("browsers")).length, { timeout: 15_000 }) - .toBe(1); - const task = await cli("session", "start"); - const session = task.session_id; - await cli("navigate", "--session", session, `http://127.0.0.1:${address.port}/red`); - const started = await cli("video", "start", "--session", session, "--duration", "30s"); - const id = started.recording.recording_id; - expect(started.recording.state).toBe("recording"); - await new Promise((resolve) => setTimeout(resolve, 1100)); - await cli("navigate", "--session", session, `http://127.0.0.1:${address.port}/blue`); const workerSession = await attachVideoBackground(send, manifest.name); const origin = workerSession.origin; const evaluate = async (sessionId: string, expression: string): Promise => { @@ -217,6 +212,58 @@ describe.skipIf(!process.env.BSK_VIDEO_CHROME || !process.env.BSK_VIDEO_BSK)( if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); return result.result.value; }; + // Use the normal connection controls after provisioning the profile. + const controls = await send<{ targetId: string }>("Target.createTarget", { + url: `${origin}/popup.html`, + }); + const controlSession = await send<{ sessionId: string }>("Target.attachToTarget", { + targetId: controls.targetId, + flatten: true, + }); + await expect + .poll( + () => evaluate(controlSession.sessionId, "Boolean(globalThis.chrome?.runtime?.id)"), + { timeout: 10_000 }, + ) + .toBe(true); + const setConnectionEnabled = (enabled: boolean) => + evaluate( + controlSession.sessionId, + `new Promise(resolve => { + const port = chrome.runtime.connect({name:'popup'}); + port.onMessage.addListener(message => { + if(message.kind==='snapshot' && message.data.connectionEnabled===${enabled} && + message.data.state==='${enabled ? "connected" : "disconnected"}') { + port.disconnect(); resolve(true); + } + }); + port.postMessage({kind:'set_connection_enabled',value:${enabled}}); + })`, + ); + const { stdout: pairingOutput } = await run(executable, ["daemon", "pair"], { + env, + timeout: 10_000, + }); + // Run the production verifier in the isolated extension origin; the + // normal transport then reconnects using its committed credential. + const paired = await evaluate<{ state: string }>( + workerSession.sessionId, + `${pairingSource};PairingFixture.pair(${JSON.stringify(pairingOutput.trim())})`, + ); + expect(paired.state).toBe("verified"); + await setConnectionEnabled(true); + await send("Target.closeTarget", { targetId: controls.targetId }); + await expect + .poll(async () => (await cli("browsers")).length, { timeout: 15_000 }) + .toBe(1); + const task = await cli("session", "start"); + const session = task.session_id; + await cli("navigate", "--session", session, `http://127.0.0.1:${address.port}/red`); + const started = await cli("video", "start", "--session", session, "--duration", "30s"); + const id = started.recording.recording_id; + expect(started.recording.state).toBe("recording"); + await new Promise((resolve) => setTimeout(resolve, 1100)); + await cli("navigate", "--session", session, `http://127.0.0.1:${address.port}/blue`); await expect .poll( () => diff --git a/apps/extension/src/video/pairing-fixture.ts b/apps/extension/src/video/pairing-fixture.ts new file mode 100644 index 000000000..7ee8a6523 --- /dev/null +++ b/apps/extension/src/video/pairing-fixture.ts @@ -0,0 +1,34 @@ +import { writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; + +/** Bundle a test entry only; the installed background remains unchanged. */ +export async function buildPairingFixture(directory: string, version: string): Promise { + const input = path.join(directory, "pairing-entry.js"); + await writeFile( + input, + `import { verifyLocalPairing } from '@/transport/pairing-verifier'; + import { getOrCreateInstanceId } from '@/lib/instance-id'; + export async function pair(link) { + return verifyLocalPairing(link, await getOrCreateInstanceId(), + chrome.runtime.getURL('').replace(/\\/$/, '')); + }`, + ); + const require = createRequire(import.meta.url); + const { build } = await import(require.resolve("vite", { paths: [require.resolve("vitest")] })); + const bundle = await build({ + configFile: false, + logLevel: "error", + resolve: { alias: { "@": path.resolve("src") } }, + define: { + __BSK_EXT_VERSION__: JSON.stringify(version), + __BSK_DAEMON_WS_URL__: JSON.stringify("ws://127.0.0.1:52800"), + }, + build: { + write: false, + lib: { entry: input, formats: ["iife"], name: "PairingFixture" }, + }, + }); + const result = Array.isArray(bundle) ? bundle[0] : bundle; + return result.output[0].code; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 318e66af1..c2e42287e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.0(vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0)) + version: 4.3.0(vite@8.0.13(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -62,18 +62,27 @@ importers: '@types/chrome': specifier: ^0.0.287 version: 0.0.287 + '@types/node': + specifier: ^22.20.1 + version: 22.20.1 '@types/react': specifier: ^19.0.0 version: 19.2.14 '@types/react-dom': specifier: ^19.0.0 version: 19.2.3(@types/react@19.2.14) + '@types/ws': + specifier: ^8.18.2 + version: 8.18.2 '@vitejs/plugin-react': specifier: ^6.0.0 - version: 6.0.2(vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0)) + version: 6.0.2(vite@8.0.13(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)) '@wxt-dev/module-react': specifier: ^1.1.3 - version: 1.2.2(vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0))(wxt@0.20.26(@types/node@25.8.0)(jiti@2.7.0)(rolldown@1.2.4)(rollup@4.60.4)) + version: 1.2.2(vite@8.0.13(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0))(wxt@0.20.26(@types/node@22.20.1)(jiti@2.7.0)(rolldown@1.2.4)(rollup@4.60.4)) + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 happy-dom: specifier: ^15.11.7 version: 15.11.7 @@ -85,10 +94,13 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.6 - version: 4.1.6(@types/node@25.8.0)(happy-dom@15.11.7)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0)) + version: 4.1.6(@types/node@22.20.1)(happy-dom@15.11.7)(vite@8.0.13(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)) + ws: + specifier: ^8.22.0 + version: 8.22.0 wxt: specifier: ^0.20.0 - version: 0.20.26(@types/node@25.8.0)(jiti@2.7.0)(rolldown@1.2.4)(rollup@4.60.4) + version: 0.20.26(@types/node@22.20.1)(jiti@2.7.0)(rolldown@1.2.4)(rollup@4.60.4) packages/dsh-plugin-browserskill: devDependencies: @@ -1833,6 +1845,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.18.2': + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} + '@ungap/structured-clone@1.4.0': resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} @@ -2507,6 +2522,10 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -4153,8 +4172,8 @@ packages: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - ws@8.21.3: - resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -4396,7 +4415,7 @@ snapshots: '@deepseek-ai/dsh-timeout': 0.1.5-rc.3(@deepseek-ai/cordis@4.0.2) '@deepseek-ai/dsh-typert-protocol': 0.1.5-rc.3(@deepseek-ai/cordis@4.0.2) '@deepseek-ai/schemastery': 3.18.2 - ws: 8.21.3 + ws: 8.22.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -5388,12 +5407,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.0(vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0))': + '@tailwindcss/vite@4.3.0(vite@8.0.13(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0))': dependencies: '@tailwindcss/node': 4.3.0 '@tailwindcss/oxide': 4.3.0 tailwindcss: 4.3.0 - vite: 8.0.13(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0) + vite: 8.0.13(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0) '@testing-library/dom@10.4.1': dependencies: @@ -5518,12 +5537,16 @@ snapshots: '@types/unist@3.0.3': {} + '@types/ws@8.18.2': + dependencies: + '@types/node': 22.20.1 + '@ungap/structured-clone@1.4.0': {} - '@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0))': + '@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.0.13(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0) + vite: 8.0.13(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0) '@vitest/expect@4.1.6': dependencies: @@ -5589,11 +5612,11 @@ snapshots: '@types/filesystem': 0.0.36 '@types/har-format': 1.2.16 - '@wxt-dev/module-react@1.2.2(vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0))(wxt@0.20.26(@types/node@25.8.0)(jiti@2.7.0)(rolldown@1.2.4)(rollup@4.60.4))': + '@wxt-dev/module-react@1.2.2(vite@8.0.13(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0))(wxt@0.20.26(@types/node@22.20.1)(jiti@2.7.0)(rolldown@1.2.4)(rollup@4.60.4))': dependencies: - '@vitejs/plugin-react': 6.0.2(vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0)) - vite: 8.0.13(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0) - wxt: 0.20.26(@types/node@25.8.0)(jiti@2.7.0)(rolldown@1.2.4)(rollup@4.60.4) + '@vitejs/plugin-react': 6.0.2(vite@8.0.13(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)) + vite: 8.0.13(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0) + wxt: 0.20.26(@types/node@22.20.1)(jiti@2.7.0)(rolldown@1.2.4)(rollup@4.60.4) transitivePeerDependencies: - '@rolldown/plugin-babel' - babel-plugin-react-compiler @@ -6106,6 +6129,8 @@ snapshots: exsolve@1.0.8: {} + fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -7801,13 +7826,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@6.0.0(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0): + vite-node@6.0.0(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0): dependencies: cac: 7.0.0 es-module-lexer: 2.1.0 obug: 2.1.1 pathe: 2.0.3 - vite: 8.0.13(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0) + vite: 8.0.13(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -7993,14 +8018,14 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 - ws@8.21.3: {} + ws@8.22.0: {} wsl-utils@0.3.1: dependencies: is-wsl: 3.1.1 powershell-utils: 0.1.0 - wxt@0.20.26(@types/node@25.8.0)(jiti@2.7.0)(rolldown@1.2.4)(rollup@4.60.4): + wxt@0.20.26(@types/node@22.20.1)(jiti@2.7.0)(rolldown@1.2.4)(rollup@4.60.4): dependencies: '@1natsu/wait-element': 4.2.0 '@aklinker1/rollup-plugin-visualizer': 5.12.0(rollup@4.60.4) @@ -8041,8 +8066,8 @@ snapshots: scule: 1.3.0 tinyglobby: 0.2.16 unimport: 6.3.0(rolldown@1.2.4) - vite: 8.0.13(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0) - vite-node: 6.0.0(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0) + vite: 8.0.13(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0) + vite-node: 6.0.0(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0) web-ext-run: 0.2.4 transitivePeerDependencies: - '@types/node'