import { generateKeyPair } from "@stablelib/x25519"; import { createPairingInvitation, pairingInvitationUrl, parsePairingInvitation, } from "../shared/pairing-invitation"; import { openRelayPairingSocket, type PairingSocket, } from "../shared/pairing-dial"; import { noiseNKResponder, type NoiseNKSession, } from "../shared/noise-js"; import { grantAdoption, isAdoptRequest, receiveFrame, requestAdoption, type AdoptRequest, } from "../shared/vault-adopt"; import { derivePairingSAS, type PairingTransport, } from "../shared/pairing-handshake"; import { bytesToHex, deriveRendezvousSecret, generatePairingRendezvousToken, } from "../shared/rendezvous"; import { buildLocalIdentity, getDeviceName, getLocalPairingIdentity } from "./device-identity"; import { getSyncSettings, relayInForce } from "./sync-settings"; import { activateLicense, getLicenseKey, isProUser } from "./license"; import { createVaultFromRootKey, getRootKeyHex, isUnlocked, rollbackAdoptedVault } from "./vault"; import { computeVaultId } from "../shared/sync-identity"; import { upsertPairedDevice } from "./paired-devices"; import { triggerSyncIfReachable } from "./sync-trigger"; const SOURCE_WINDOW_MS = 5 * 60 * 1000; function push(type: string, payload: unknown): void { try { const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown; if (maybe && typeof (maybe as Promise).catch === "function") { (maybe as Promise).catch(() => undefined); } } catch { // The popup may have closed. The pending approval fails closed on cleanup. } } let inFlight = false; let activeSocket: PairingSocket | null = null; // Outstanding source-side SAS confirmations, keyed by id. A map (vs the // receiver's single slot) makes a stale decision from a previous source // session impossible to land on the current one even if both run on the // same background worker. const pendingSourceSasConfirmation = new Map void }>(); export function isRelayAdoptionSourceInFlight(): boolean { return inFlight; } /** * Occupy the non-buffering room before returning the invitation to the UI. * All long-term key material remains in the background service worker. */ export async function startRelayAdoptionSource(): Promise<{ invitation: string; }> { if (inFlight) throw new Error("adoption_source_in_progress"); if (!isUnlocked()) throw new Error("vault_locked"); inFlight = true; let local: Awaited> | null = null; try { const settings = await getSyncSettings(); const relayUrl = relayInForce(settings); local = await getLocalPairingIdentity(); const token = generatePairingRendezvousToken(); // Source side has the vault — caller checked isUnlocked — so the phone // can read the invitation and know it should receive the vault. const encoded = createPairingInvitation(relayUrl, token, "populated"); const invitation = parsePairingInvitation(encoded); if (!invitation) throw new Error("invitation_generation_failed"); const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation)); activeSocket = socket; const readyLocal = local; void runSourceSession( socket, token, relayUrl, readyLocal, ).finally(() => { readyLocal.privateKey.fill(0); readyLocal.publicKey.fill(0); clearState(); }); return { invitation: encoded }; } catch (error) { local?.privateKey.fill(0); local?.publicKey.fill(0); clearState(); throw error; } } export function cancelRelayAdoptionSource(): void { activeSocket?.close(); clearState(); } /** * Onboarding pairing request — the empty extension shows a QR and the populated * phone scans it. The invitation carries `vaultStatus: "empty"`, which the * phone reads to switch into source mode and `grantAdoption` over the * established Noise channel. The receiver's `runReceiverSession` accepts the * root key and creates a local vault wrapped with the popup-supplied password. * * `password` is the local-only password the user picked on the "Set a password * for this device" screen; it wraps the adopted root key at rest and has no * relationship to the source device's password. */ export async function startRelayAdoptionReceiver( password: string, ): Promise<{ invitation: string }> { if (inFlight) throw new Error("adoption_receiver_in_progress"); if (typeof password !== "string" || password.length === 0) { throw new Error("ADOPT_INVALID_PASSWORD"); } inFlight = true; try { const settings = await getSyncSettings(); const relayUrl = relayInForce(settings); const token = generatePairingRendezvousToken(); const encoded = createPairingInvitation(relayUrl, token, "empty"); const invitation = parsePairingInvitation(encoded); if (!invitation) throw new Error("invitation_generation_failed"); const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation)); activeSocket = socket; void runReceiverSession( socket, token, relayUrl, password, ).finally(clearState); return { invitation: encoded }; } catch (error) { clearState(); throw error; } } /** * Resolves the source-side SAS confirmation by id. Called by the message * handler when the popup taps Confirm/Cancel on the source's SAS screen. * Returns true if the id matched an in-flight confirmation. Unknown ids * (already resolved, timed out, or never existed) are a no-op so a late * popup click cannot crash anything. */ export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean { const pending = pendingSourceSasConfirmation.get(id); if (!pending) return false; pendingSourceSasConfirmation.delete(id); pending.resolve(ok); return true; } async function runSourceSession( socket: PairingSocket, expectedToken: string, relayUrl: string, local: Awaited>, ): Promise { const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS); let noise: NoiseNKSession | undefined; try { const hello = decodeJson<{ type?: unknown; token?: unknown }>( await socket.receive(), ); if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" || !constantTimeEqual(hello.token, expectedToken)) { throw new Error("invalid_invitation"); } socket.send(encodeJson({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: bytesToHex(local.publicKey), })); const session = await noiseNKResponder( local.privateKey, local.publicKey, async (bytes) => socket.send(bytes), () => socket.receive(), ); noise = session; const channel: PairingTransport = { async send(plaintext) { socket.send(session.encrypt(plaintext)); }, async receive() { return session.decrypt(await socket.receive()); }, }; const request = await receiveFrame(channel); if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST"); const device = await grantAdoption(channel, request, { sourceIdentity: local.identity, pairState: { rendezvousSecretHex: bytesToHex( deriveRendezvousSecret(session.handshakeHash), ), relayUrl, }, async rootKeyHex() { // grantAdoption calls this only after local password approval and the // receiver's encrypted ADOPT_CONFIRM(true). const value = getRootKeyHex(); if (!value) throw new Error("vault_locked"); return value; }, async vaultId() { const value = getRootKeyHex(); if (!value) throw new Error("vault_locked"); const bytes = hexToBytes(value); try { return await computeVaultId(bytes); } finally { bytes.fill(0); } }, async license() { return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined; }, onConsent: (adoptRequest) => requestConsent( adoptRequest, derivePairingSAS(session.handshakeHash), crypto.randomUUID(), ), upsertPairedDevice, }); if (!device) { push("ADOPTION_SOURCE_RESULT", { status: "error", error: "adoption_rejected", }); return; } push("ADOPTION_SOURCE_RESULT", { status: "complete", device }); // The Android receiver starts its relay listener as soon as its source // record is durable. Try once now and once after service startup settles so // the newly adopted empty vault receives its first sync without another // user action. Both calls are best-effort and contain no user data. void triggerSyncIfReachable({ reason: "manual", force: true }); setTimeout(() => { void triggerSyncIfReachable({ reason: "manual", force: true }); }, 1_000); } catch { push("ADOPTION_SOURCE_RESULT", { status: "error", error: "adoption_failed", }); } finally { clearTimeout(timeout); noise?.close(); socket.close(); pendingSourceSasConfirmation.clear(); } } /** * Browser receives the vault from the phone. The protocol is the mirror of * the source side: phone is the Noise initiator and the source of truth for * the SAS gate, browser is the responder that creates the vault locally. */ async function runReceiverSession( socket: PairingSocket, expectedToken: string, relayUrl: string, password: string, ): Promise { const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS); // Ephemeral keypair for the NK responder half. Never persisted; zeroed on exit. const ephemeral = generateKeyPair(); const ephemeralPrivate = ephemeral.secretKey; const ephemeralPublic = ephemeral.publicKey; let noise: NoiseNKSession | undefined; let createdVault = false; try { const hello = decodeJson<{ type?: unknown; token?: unknown }>( await socket.receive(), ); if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" || !constantTimeEqual(hello.token, expectedToken)) { throw new Error("invalid_invitation"); } socket.send(encodeJson({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: bytesToHex(ephemeralPublic), })); const session = await noiseNKResponder( ephemeralPrivate, ephemeralPublic, async (bytes) => socket.send(bytes), () => socket.receive(), ); noise = session; const channel: PairingTransport = { async send(plaintext) { socket.send(session.encrypt(plaintext)); }, async receive() { return session.decrypt(await socket.receive()); }, }; const deviceName = await getDeviceName(); const device = await requestAdoption(channel, { deviceName, generateDeviceId: () => crypto.randomUUID(), // Auto-accept the SAS: both sides derive it from the same Noise // handshake hash, so the codes match by construction. Requiring the user // to confirm on both sides creates a race where the phone can time out // waiting for ADOPT_CONFIRM before the browser's popup is reached. The // phone's onConsent is the single user gate; this side just proceeds. async confirmSas() { return true; }, async createVaultFromRootKey(rootKeyHex) { // Direct call, not chrome.runtime.sendMessage — a service worker // calling sendMessage does not reach its own listener (returns // undefined), and the receiver already lives in the same SW context // as the vault module. Round-tripping through the message bus was // the root cause of "create_vault_no_response". const { vaultId } = await createVaultFromRootKey(password, rootKeyHex); createdVault = true; return { vaultId }; }, async applyLicense(license) { // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self // issue as createVaultFromRootKey. SW can't reach its own listener. await activateLicense(license); }, async deriveLocalIdentity() { // Direct call for the same reason as applyLicense above. const masterKeyHex = getRootKeyHex(); if (!masterKeyHex) throw new Error("identity_unavailable"); const masterKey = hexToBytes(masterKeyHex); try { return await buildLocalIdentity(masterKey); } finally { masterKey.fill(0); } }, async upsertPairedDevice(paired) { // Direct call for the same reason as applyLicense above. await upsertPairedDevice(paired); }, pairState: { rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)), relayUrl, }, onError(error) { push("ADOPTION_SOURCE_RESULT", { status: "error", error }); }, }); if (!device) { if (createdVault) { await rollbackAdoptedVault(); createdVault = false; } push("ADOPTION_SOURCE_RESULT", { status: "error", error: "adoption_rejected", }); return; } push("ADOPTION_SOURCE_RESULT", { status: "complete", device }); createdVault = false; void triggerSyncIfReachable({ reason: "manual", force: true }); } catch (error) { if (createdVault) { try { await rollbackAdoptedVault(); createdVault = false; } catch (rollbackError) { console.error("[Peach] rollback failed:", rollbackError); } } console.error("[Peach] receiver session failed:", error); push("ADOPTION_SOURCE_RESULT", { status: "error", error: error instanceof Error ? error.message : "adoption_failed", }); } finally { clearTimeout(timeout); noise?.close(); socket.close(); ephemeralPrivate.fill(0); ephemeralPublic.fill(0); } } /** * Push the SAS to the popup, then block until the popup responds via the * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on * timeout or cancel so the adoption aborts before any vault state changes. * * The pending deferred MUST be installed before the push: a popup-side * auto-approver (test mocks, future automation) can synchronously resolve * the deferred the moment it sees the SAS push, and a missing slot would * drop that approval on the floor. */ function waitForSourceSasConfirmation( id: string, request: AdoptRequest, sas: string, ): Promise { return new Promise((resolve) => { const timer = setTimeout(() => { pendingSourceSasConfirmation.delete(id); resolve(false); }, SOURCE_WINDOW_MS); pendingSourceSasConfirmation.set(id, { resolve: (ok) => { clearTimeout(timer); resolve(ok); }, }); push("ADOPTION_SOURCE_SAS", { id, sas, receiverDeviceName: request.device_name, }); }); } /** * Opening Add Device in an unlocked extension is the source-side authorization. * Announce the SAS for comparison, then let the scanning device's explicit * confirmation be the single commit gate. grantAdoption still refuses to send * the root key until the receiver returns ADOPT_CONFIRM(true). */ function requestConsent(request: AdoptRequest, sas: string, id: string): Promise { return waitForSourceSasConfirmation(id, request, sas); } function clearState(): void { activeSocket = null; inFlight = false; pendingSourceSasConfirmation.clear(); } function constantTimeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0; } function encodeJson(value: unknown): Uint8Array { return new TextEncoder().encode(JSON.stringify(value)); } function decodeJson(value: Uint8Array): T { return JSON.parse(new TextDecoder().decode(value)) as T; } function hexToBytes(hex: string): Uint8Array { const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) { out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); } return out; }