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, isAdoptDeviceManifestFrame, isAdoptRequest, isAdoptSnapshotApplied, receiveFrame, receiveVaultSnapshot, requestAdoption, sendFrame, sendVaultSnapshot, type AdoptRequest, } from "../shared/vault-adopt"; import { derivePairingSAS, type PairingTransport, } from "../shared/pairing-handshake"; import type { PairedDevice } from "../shared/sync-types"; 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, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault"; import { computeVaultId } from "../shared/sync-identity"; import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync"; import { storageRemove, storageSet } from "../shared/storage"; import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices"; import { isValidPairSecret, setPairSecret } from "./pair-secrets"; import { createSyncVaultAdapter } from "./sync-vault-adapter"; import { triggerSyncIfReachable } from "./sync-trigger"; import { schedulePostAdoptionSync } from "./sync-alarm"; 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. } } /** Race a promise against a wall-clock deadline; rejects with "timeout". */ function withTimeout(promise: Promise, ms: number): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("timeout")), ms); promise.then( (value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); }, ); }); } 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", bytesToHex(local.publicKey)); 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 ephemeral = generateKeyPair(); const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey)); 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, ephemeral, ).finally(() => { ephemeral.secretKey.fill(0); ephemeral.publicKey.fill(0); 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; }, async applyLicense(license) { await applyTransferredLicense(license); }, onConsent: (adoptRequest) => requestConsent( adoptRequest, derivePairingSAS(session.handshakeHash), crypto.randomUUID(), ), upsertPairedDevice: storeAdoptedDevice, }); if (!device) { push("ADOPTION_SOURCE_RESULT", { status: "error", error: "adoption_rejected", }); return; } // If the receiver advertised inline-snapshot support, send the vault now // over the same authenticated channel. Best-effort: any failure leaves the // receiver to fall back to the sync pipeline triggered below. if (request.snapshot_capable === true) { try { const adapter = createSyncVaultAdapter(); const entryManifest = await adapter.buildEntryManifest(); const entryIds = Object.keys(entryManifest); const entries = entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {}; const tombstones = await adapter.buildTombstoneManifest(); let duressSlot: string | null = null; try { duressSlot = await exportDuressSlotForAdoption(); } catch { // Duress omission must not fail the whole snapshot. } await sendVaultSnapshot(channel, entries, tombstones, duressSlot); const applied = await withTimeout(receiveFrame(channel), 15_000); if (isAdoptSnapshotApplied(applied)) { console.info( `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`, ); } if (adapter.buildOutgoingDeviceManifest) { const manifest = await adapter.buildOutgoingDeviceManifest(); if (manifest) { await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest }); await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined); } } } catch (snapshotError) { if ( snapshotError instanceof Error && snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE" ) { throw snapshotError; } console.info( "[Peach] inline snapshot send failed (receiver will use sync pipeline):", snapshotError, ); } } push("ADOPTION_SOURCE_RESULT", { status: "complete", device }); // The Android receiver starts its relay listener as soon as its source // record is durable. Immediate best-effort trigger; alarm-based retries // cover MV3 service-worker death (setTimeout does not survive it). void triggerSyncIfReachable({ reason: "manual", force: true }); schedulePostAdoptionSync(); } catch (error) { push("ADOPTION_SOURCE_RESULT", { status: "error", error: error instanceof Error ? error.message : "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, invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array }, ): Promise { const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS); // The invitation binds the responder static so the initiator can verify the ACK. const ephemeral = invitationEphemeral; const ephemeralPrivate = ephemeral.secretKey; const ephemeralPublic = ephemeral.publicKey; let noise: NoiseNKSession | undefined; let createdVault = false; let protocolError: string | 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(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 adoptionResult = 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. // Still show the locally derived code for visual verification. async confirmSas() { push("ADOPTION_SOURCE_SAS", { sas: derivePairingSAS(session.handshakeHash), displayOnly: true, }); 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 applyTransferredLicense(license); }, async license() { return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined; }, 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 storeAdoptedDevice(paired); }, pairState: { rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)), relayUrl, }, onError(error) { protocolError = error; }, }); if (!adoptionResult) { if (createdVault) { await rollbackAdoptedVault(); createdVault = false; } push("ADOPTION_SOURCE_RESULT", { status: "error", error: protocolError ?? "adoption_rejected", }); return; } const { device, snapshotCapable } = adoptionResult; // The pairing channel is still open and authenticated — try to pull the // vault snapshot inline so the popup transitions immediately instead of // waiting on a second sync round. Skip the wait entirely when the source // did not echo snapshot_capable: true (pre-C source, or explicit opt-out); // fall through to the existing sync pipeline below. let snapshotApplied = false; if (snapshotCapable) try { const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000); const adapter = createSyncVaultAdapter(); await adapter.applyEntries(snapshot.entries); await adapter.applyTombstones(snapshot.tombstones); // Duress install is isolated: a failure here must NOT demote an // otherwise-successful entry snapshot to the sync fallback. if (snapshot.duressSlot) { try { await installAdoptedDuressSlot(snapshot.duressSlot); } catch (duressError) { console.info( "[Peach] adopted duress slot install failed (non-fatal):", duressError, ); } } await sendFrame(channel, { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: Object.keys(snapshot.entries).length, tombstones_received: Object.keys(snapshot.tombstones).length, }); // Optional device graph. Non-fatal and isolated so a manifest timeout or // unsupported source cannot demote an otherwise-successful entry snapshot. try { const manifestFrame = await withTimeout(receiveFrame(channel), 5_000); if ( isAdoptDeviceManifestFrame(manifestFrame) && adapter.applyIncomingDeviceManifest && typeof device.longTermPublicKeyHex === "string" && /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ) { await adapter.applyIncomingDeviceManifest( manifestFrame.manifest, hexToBytes(device.longTermPublicKeyHex), ); await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" }); } } catch { // The graph arrives via incremental sync instead. } await markLastSyncedAt(device.deviceId, Date.now()); snapshotApplied = true; } catch (snapshotError) { console.info( "[Peach] inline snapshot failed, falling back to sync pipeline:", snapshotError, ); } // Adoption has committed identity and vault-key material, but entries have // not necessarily crossed yet. Persist the exact source row before telling // the popup pairing is complete so a popup close/reopen cannot reveal the // temporarily empty receiver vault. The popup clears this only after that // row records a successful sync. await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch( () => undefined, ); push("ADOPTION_SOURCE_RESULT", { status: "complete", device }); createdVault = false; if (snapshotApplied) { // The snapshot populated the vault and marked the source synced, so the // pending marker's job is done — clear it instead of making the popup wait. await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined); } else { // Fallback: the existing sync pipeline fills the vault. Immediate // best-effort trigger plus alarm-based retries that survive SW death. void triggerSyncIfReachable({ reason: "manual", force: true }); schedulePostAdoptionSync(); } } 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); } } /** * A transferred entitlement is committed only when activation succeeds and * the stored value can immediately be re-read as Pro. activateLicense returns * a normal Free result for invalid input, so callers must not ignore it. */ async function applyTransferredLicense(license: string): Promise { const activated = await activateLicense(license); if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) { throw new Error("license_transfer_failed"); } } /** * 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; } /** * PB-26-213: an adopted device's secret bypasses the public registry into * the encrypted pair-secret record. The registry row stays secretless; when * the vault is somehow locked here the row still lands and the next unlock * cannot recover this secret — re-pair is the fail-closed path. */ async function storeAdoptedDevice(paired: PairedDevice): Promise { const secret = paired.rendezvousSecretHex; const pub = { ...paired }; delete pub.rendezvousSecretHex; await upsertPairedDevice(pub); if (!isValidPairSecret(secret)) return; const masterKeyHex = getRootKeyHex(); if (!masterKeyHex) return; const masterKey = hexToBytes(masterKeyHex); try { await setPairSecret(paired.deviceId, secret, masterKey); } finally { masterKey.fill(0); } } 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; }