Skip to content
Draft
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
2 changes: 1 addition & 1 deletion apps/extension/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@browser-skill/extension",
"version": "0.4.2",
"version": "0.4.3",
"private": true,
"type": "module",
"scripts": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ beforeEach(() => {
vi.mocked(readLocalPairingInfo)
.mockReset()
.mockResolvedValue({
policy: { skip: false, everPaired: false, legacyCompat: false },
policy: { skip: false },
devices: [],
});
vi.mocked(readSavedRemoteConnection)
Expand Down Expand Up @@ -323,7 +323,7 @@ it("pair: opens local pairing immediately and recognizes local links from either
});
function pairedInfo(skip = false) {
vi.mocked(readLocalPairingInfo).mockResolvedValue({
policy: { skip, everPaired: true, legacyCompat: false },
policy: { skip },
devices: [{ daemonId: v.daemon_id, deviceId: v.device_id, endpoint: "ws://127.0.0.1:52800/" }],
});
}
Expand Down
2 changes: 1 addition & 1 deletion apps/extension/src/entrypoints/popup/local-pairing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { LocalAuthStatus } from "@/transport/local-auth";
import { Switch } from "./switch";

export interface LocalPairingInfo {
policy: { skip: boolean; everPaired: boolean; legacyCompat: boolean };
policy: { skip: boolean };
devices: { daemonId: string; deviceId: string; endpoint: string }[];
}
export async function localPairingRequest(request: Record<string, unknown>) {
Expand Down
14 changes: 1 addition & 13 deletions apps/extension/src/lib/local-pairing-bridge.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import { LocalAuthError } from "@/transport/local-auth";
import {
initializeLocalMigration,
readLocalState,
setSkipLocalVerification,
} from "@/transport/local-credentials";
import { 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";
Expand All @@ -19,14 +15,6 @@ export function trustedPopup(sender: chrome.runtime.MessageSender): boolean {
);
}
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)) {
Expand Down
69 changes: 66 additions & 3 deletions apps/extension/src/transport/local-auth-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import WebSocket from "ws";
import { EXTENSION_VERSION, PROTOCOL_VERSION } from "./handshake";
import { authenticateLocal, createLocalAuthenticator, LocalAuthError } from "./local-auth";
import * as localCredentials from "./local-credentials";
import { initializeLocalMigration, readLocalState } from "./local-credentials";
import { readLocalState } from "./local-credentials";
import { verifyLocalPairing } from "./pairing-verifier";

// Opt-in process tests: BSK_TEST_BINARY is a current build; BSK_TEST_OLD_BINARY
Expand Down Expand Up @@ -64,7 +64,9 @@ async function start(binary: string) {
let port = 0;
await vi.waitFor(
async () => {
port = JSON.parse(await readFile(join(home, "daemon.json"), "utf8")).ws_port;
const info = JSON.parse(await readFile(join(home, "daemon.json"), "utf8"));
expect(info.pid).toBe(daemon!.pid);
port = info.ws_port;
expect(port).toBeGreaterThan(0);
},
{ timeout: 10_000 },
Expand Down Expand Up @@ -178,7 +180,7 @@ it.skipIf(!current)(
// survive durable cleanup as well as an ordinary credential read.
const clock = vi.spyOn(Date, "now").mockReturnValue(staged!.createdAt + 24 * 60 * 60_000);
try {
await initializeLocalMigration();
await localCredentials.setSkipLocalVerification(false);
const credentials = (await readLocalState()).credentials;
expect(credentials).toEqual(before);
const ws = await opened(url);
Expand Down Expand Up @@ -219,3 +221,64 @@ it.skipIf(!old)(
},
20_000,
);

it.skipIf(!current)(
"optional pairing: a rejected link preserves unpaired use and successful pairing verifies reconnects",
async () => {
const url = await start(current!);
const status = vi.fn();
const authenticate = createLocalAuthenticator({
instanceId: async () => instance,
origin,
status,
});
const unpaired = await opened(url);
await authenticate(browserSocket(unpaired), AbortSignal.timeout(5000));
expect(status).toHaveBeenLastCalledWith({ state: "unverified_local", reason: "unpaired" });
expect((await handshake(unpaired)).result).toBeDefined();
const link = await cli(current!, "daemon", "pair");
const badLink = link.replace(/[^.]+$/, "A".repeat(43));
await expect(
verifyLocalPairing(badLink, instance, origin, (address) => browserSocket(socket(address))),
).rejects.toBeInstanceOf(LocalAuthError);
expect(unpaired.readyState).toBe(WebSocket.OPEN);
expect((await readLocalState()).credentials).toHaveLength(0);
const report = JSON.parse(await cli(current!, "--json", "status"));
expect(report.browsers).toHaveLength(1);

await verifyLocalPairing(link, instance, origin, (address) => browserSocket(socket(address)));
const paired = await opened(url);
await authenticate(browserSocket(paired), AbortSignal.timeout(5000));
expect(status).toHaveBeenLastCalledWith(expect.objectContaining({ state: "verified" }));
expect((await handshake(paired)).result).toBeDefined();
},
20_000,
);
it.skipIf(!current || !old)(
"paired profiles refuse a pre-pairing daemon after an independent CLI downgrade",
async () => {
await start(current!);
const link = await cli(current!, "daemon", "pair");
await verifyLocalPairing(link, instance, origin, (address) => browserSocket(socket(address)));
const stopped = once(daemon!, "exit");
daemon!.kill();
await stopped;
const url = await start(old!);
const status = vi.fn();
const authenticate = createLocalAuthenticator({
instanceId: async () => instance,
origin,
status,
});
for (let attempt = 0; attempt < 2; attempt++) {
const ws = await opened(url);
await expect(
authenticate(browserSocket(ws), AbortSignal.timeout(5000)),
).rejects.toBeInstanceOf(LocalAuthError);
}
expect(status.mock.calls.some(([value]) => value.state === "unverified_local")).toBe(false);
expect((await readLocalState()).credentials[0].role).toBe("active");
expect(JSON.parse(await cli(old!, "--json", "status")).browsers).toHaveLength(0);
},
20_000,
);
35 changes: 10 additions & 25 deletions apps/extension/src/transport/local-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import v from "../../../../crates/bsk-protocol/tests/fixtures/local-auth-v1.json
import { openCredentialDatabase } from "./credential-db";
import {
discardStaged,
initializeLocalMigration,
parseLocalPairing,
promoteCandidate,
readLocalState,
Expand Down Expand Up @@ -46,7 +45,6 @@ it("L08c: key writes use strict durability, compare candidates, and promote atom
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" });
Expand Down Expand Up @@ -76,7 +74,6 @@ it("L08c/L14c: startup retains an expired replacement candidate until recovery c
vi.spyOn(Date, "now").mockReturnValue(saved.createdAt + 24 * 60 * 60_000);

expect((await readLocalState()).credentials).toEqual([active, saved]);
await initializeLocalMigration();
await setSkipLocalVerification(true);
expect((await readLocalState()).credentials).toEqual([active, saved]);
await expect(
Expand All @@ -94,29 +91,17 @@ it("L08c/L14c: startup retains an expired replacement candidate until recovery c
});
expect(recovered[0].pending).toBeUndefined();
});
it("L18: only eligible updates get migration compatibility; pairing never resurrects it", async () => {
await initializeLocalMigration({ reason: "install" }, "0.4.2");
expect((await readLocalState()).policy.legacyCompat).toBe(false);
await initializeLocalMigration({ reason: "update", previousVersion: "0.4.1" }, "0.4.2");
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.4.1" }, "0.4.2");
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.4.1" }, "0.4.2");
await setSkipLocalVerification(true);
await initializeLocalMigration(undefined, "0.4.3");
expect((await readLocalState()).policy).toEqual({
skip: true,
everPaired: false,
legacyCompat: false,
it("ignores obsolete migration fields while retaining the explicit skip preference", async () => {
const db = await openCredentialDatabase();
const tx = db.transaction("migration", "readwrite");
tx.objectStore("migration").put({ skip: true, everPaired: true, legacyCompat: true }, "policy");
await new Promise<void>((resolve) => {
tx.oncomplete = () => resolve();
});
db.close();
expect((await readLocalState()).policy).toEqual({ skip: true });
await setSkipLocalVerification(false);
expect((await readLocalState()).policy).toEqual({ skip: false });
});
it("upgrades the shared database while keeping the existing remote grant", async () => {
await new Promise<void>((resolve) => {
Expand Down
32 changes: 2 additions & 30 deletions apps/extension/src/transport/local-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { openCredentialDatabase } from "./credential-db";
import { decodeBytes } from "./local-auth-crypto";
import type { Rotation } from "./local-auth-protocol";

export const PAIRING_VERSION = "0.4.2";
export const MIGRATION_EXIT_VERSION = "0.4.3";
const STAGED_TTL_MS = 10 * 60_000;
export interface LocalCredential {
daemonId: string;
Expand All @@ -17,17 +15,14 @@ export interface LocalCredential {
}
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. */
* including strict persistence of candidates, promotion, and explicit skip preferences. */
async function access<T>(write: boolean, action: (state: LocalState) => T): Promise<T> {
const database = await openCredentialDatabase();
try {
Expand All @@ -49,7 +44,7 @@ async function access<T>(write: boolean, action: (state: LocalState) => T): Prom
try {
const state: LocalState = {
credentials: records.result ?? [],
policy: policy.result ?? defaultPolicy(),
policy: { skip: policy.result?.skip ?? false },
};
// A saved candidate may already be committed by the daemon even if
// its reply or our promotion was interrupted. Keep both keys until
Expand Down Expand Up @@ -174,8 +169,6 @@ export function promoteCandidate(
state.credentials = state.credentials.filter(
(c) => c.daemonId !== record.daemonId || c === record,
);
state.policy.everPaired = true;
state.policy.legacyCompat = false;
return record;
});
}
Expand All @@ -196,24 +189,3 @@ export function setSkipLocalVerification(skip: boolean): Promise<void> {
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<void> {
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;
});
}
2 changes: 0 additions & 2 deletions packages/i18n/src/locales/de-DE/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,6 @@
"already_paired": "Dieser Browser ist bereits gekoppelt. Zum Ersetzen bsk daemon pair --replace {{browser}} ausführen und den neuen Link einfügen.",
"replace_mismatch": "Dieser Browser ist {{instance}}; das Ziel ist {{browser}}. Keine Änderungen. Für ein neues Profil bsk daemon pair verwenden; ungenutzte Geräte mit bsk daemon revoke {{browser}} widerrufen.",
"skip": "Lokale Verifizierung wird übersprungen. Lokale Programme könnten sich als bsk ausgeben und diesen Browser steuern.",
"migration_old": "Diese Verbindung ist nicht verifiziert; bsk ist für die Kopplung zu alt. Mit bsk update aktualisieren, dann koppeln.",
"migration_pair": "Diese Verbindung ist nicht verifiziert. bsk wird bald eine Kopplung voraussetzen. Bitte jetzt einmalig koppeln.",
"server_unverified": "Verifizierung fehlgeschlagen. Ein anderes Programm könnte den Port belegen oder die Kopplung wurde zurückgesetzt. Mit bsk doctor prüfen.",
"daemon_too_old": "bsk ist für eine sichere Verbindung zu alt. Führen Sie bsk update aus und versuchen Sie es erneut.",
"busy": "Eine andere Verbindung verifiziert dieses Gerät. Bitte kurz warten."
Expand Down
2 changes: 0 additions & 2 deletions packages/i18n/src/locales/en-US/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,6 @@
"already_paired": "This browser is already paired with this bsk. To replace it, run bsk daemon pair --replace {{browser}} and paste the new link.",
"replace_mismatch": "This browser is {{instance}}; the replacement targets {{browser}}. No changes were made. Use bsk daemon pair for a new browser profile; revoke an unused device separately with bsk daemon revoke {{browser}}.",
"skip": "Local verification is skipped. Any local program could impersonate bsk and control this browser.",
"migration_old": "This connection is unverified and the local bsk is too old to pair. Run bsk update, then pair this browser.",
"migration_pair": "This connection is unverified. bsk will soon require pairing; complete it once now.",
"server_unverified": "The service failed verification. Another program may be using the port, or this bsk’s pairing may have been reset. Run bsk doctor to check.",
"daemon_too_old": "This bsk is too old for a secure connection. Run bsk update, then retry.",
"busy": "Another connection is verifying this device. Please retry shortly."
Expand Down
2 changes: 0 additions & 2 deletions packages/i18n/src/locales/es-ES/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,6 @@
"already_paired": "Este navegador ya está vinculado. Para reemplazarlo, ejecuta bsk daemon pair --replace {{browser}} y pega el nuevo enlace.",
"replace_mismatch": "Este navegador es {{instance}}; el destino es {{browser}}. No se realizaron cambios. Usa bsk daemon pair para un perfil nuevo y bsk daemon revoke {{browser}} para revocar un dispositivo sin uso.",
"skip": "Se ha omitido la verificación local. Cualquier programa local podría suplantar a bsk y controlar este navegador.",
"migration_old": "Esta conexión no está verificada y bsk es demasiado antiguo para vincularse. Ejecuta bsk update y luego vincula el navegador.",
"migration_pair": "Esta conexión no está verificada. Pronto bsk exigirá vinculación; complétala una vez ahora.",
"server_unverified": "El servicio no superó la verificación. Otro programa podría estar usando el puerto o se restableció la vinculación. Compruébalo con bsk doctor.",
"daemon_too_old": "bsk es demasiado antiguo para una conexión segura. Ejecuta bsk update y reintenta.",
"busy": "Otra conexión está verificando este dispositivo. Reintenta en un momento."
Expand Down
2 changes: 0 additions & 2 deletions packages/i18n/src/locales/fr-FR/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,6 @@
"already_paired": "Ce navigateur est déjà associé. Pour remplacer l’association, exécutez bsk daemon pair --replace {{browser}} et collez le nouveau lien.",
"replace_mismatch": "Ce navigateur est {{instance}} ; la cible est {{browser}}. Aucune modification. Pour un nouveau profil, utilisez bsk daemon pair ; révoquez un appareil inutilisé avec bsk daemon revoke {{browser}}.",
"skip": "La vérification locale est ignorée. Un programme local pourrait se faire passer pour bsk et contrôler ce navigateur.",
"migration_old": "Connexion non vérifiée : bsk est trop ancien pour l’association. Exécutez bsk update, puis associez le navigateur.",
"migration_pair": "Cette connexion n’est pas vérifiée. bsk exigera bientôt une association ; effectuez-la une fois maintenant.",
"server_unverified": "Échec de la vérification. Un autre programme utilise peut-être le port ou l’association a été réinitialisée. Vérifiez avec bsk doctor.",
"daemon_too_old": "bsk est trop ancien pour une connexion sécurisée. Exécutez bsk update et réessayez.",
"busy": "Une autre connexion vérifie cet appareil. Réessayez dans un instant."
Expand Down
2 changes: 0 additions & 2 deletions packages/i18n/src/locales/it-IT/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,6 @@
"already_paired": "Questo browser è già associato. Per sostituirlo, esegui bsk daemon pair --replace {{browser}} e incolla il nuovo link.",
"replace_mismatch": "Questo browser è {{instance}}; la destinazione è {{browser}}. Nessuna modifica. Usa bsk daemon pair per un nuovo profilo e bsk daemon revoke {{browser}} per revocare un dispositivo inutilizzato.",
"skip": "La verifica locale è saltata. Un programma locale potrebbe impersonare bsk e controllare questo browser.",
"migration_old": "Questa connessione non è verificata e bsk è troppo vecchio per l’associazione. Esegui bsk update, poi associa il browser.",
"migration_pair": "Questa connessione non è verificata. bsk richiederà presto l’associazione; completala una volta ora.",
"server_unverified": "Verifica del servizio fallita. Un altro programma potrebbe usare la porta o l’associazione è stata reimpostata. Controlla con bsk doctor.",
"daemon_too_old": "bsk è troppo vecchio per una connessione sicura. Esegui bsk update e riprova.",
"busy": "Un’altra connessione sta verificando questo dispositivo. Riprova tra poco."
Expand Down
Loading
Loading