1import { generateKeyPair } from "@stablelib/x25519";2import {3 createPairingInvitation,4 pairingInvitationUrl,5 parsePairingInvitation,6} from "../shared/pairing-invitation";7import {8 openRelayPairingSocket,9 type PairingSocket,10} from "../shared/pairing-dial";11import {12 noiseNKResponder,13 type NoiseNKSession,14} from "../shared/noise-js";15import {16 grantAdoption,17 isAdoptDeviceManifestFrame,18 isAdoptRequest,19 isAdoptSnapshotApplied,20 receiveFrame,21 receiveVaultSnapshot,22 sendFrame,23 sendVaultSnapshot,24 type AdoptionSourceDeps,25} from "../shared/vault-adopt";26import {27 grantAdoptionV2,28 isAdoptRequestV2,29 requestAdoptionV2,30 type AdoptionV2ReceiverDeps,31 type AdoptionV2SourceDeps,32} from "../shared/adoption-protocol";33import {34 derivePairingSAS,35 type PairingTransport,36} from "../shared/pairing-handshake";37import type { PairedDevice } from "../shared/sync-types";38import {39 bytesToHex,40 deriveRendezvousSecret,41 generatePairingRendezvousToken,42} from "../shared/rendezvous";43import {44 buildLocalIdentity,45 getDeviceName,46 getLocalPairingIdentity,47 preparePreVaultIdentity,48 readPreVaultIdentity,49 rotatePreVaultIdentity,50 rotateDeviceId,51} from "./device-identity";52import {53 hasAdoptionIdentityConflict,54 markLastSyncedAt,55 upsertPairedDeviceAndClearRevocation,56} from "./paired-devices";57import { getSyncSettings, relayInForce } from "./sync-settings";58import { activateLicense, getLicenseKey, isProUser } from "./license";59import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";60import { deferAutoLockForOperation } from "./alarms";61import { computeVaultId } from "../shared/sync-identity";62import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";63import { storageRemove, storageSet } from "../shared/storage";64import {65 persistPendingAdoptionHolder,66 persistPendingAdoptionIdentity,67 readPendingAdoptionHolder,68 removePendingAdoptionHolder,69 completePendingAdoptionIdentity,70} from "./pending-adoption-holder";71import { isValidPairSecret, setPairSecret } from "./pair-secrets";72import { createSyncVaultAdapter } from "./sync-vault-adapter";73import { triggerSyncIfReachable } from "./sync-trigger";74import { schedulePostAdoptionSync } from "./sync-alarm";7576const SOURCE_WINDOW_MS = 5 * 60 * 1000;7778function push(type: string, payload: unknown): void {79 try {80 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;81 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {82 (maybe as Promise<unknown>).catch(() => undefined);83 }84 } catch {85 86 }87}888990function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {91 return new Promise<T>((resolve, reject) => {92 const timer = setTimeout(() => reject(new Error("timeout")), ms);93 promise.then(94 (value) => {95 clearTimeout(timer);96 resolve(value);97 },98 (error) => {99 clearTimeout(timer);100 reject(error);101 },102 );103 });104}105106let inFlight = false;107let activeSocket: PairingSocket | null = null;108109110111112const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();113const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();114115export function isRelayAdoptionSourceInFlight(): boolean {116 return inFlight;117}118119120121122123export async function startRelayAdoptionSource(): Promise<{124 invitation: string;125}> {126 if (inFlight) throw new Error("adoption_source_in_progress");127 if (!isUnlocked()) throw new Error("vault_locked");128 inFlight = true;129 const releaseAutoLock = deferAutoLockForOperation(SOURCE_WINDOW_MS);130131 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;132 try {133 const settings = await getSyncSettings();134 const relayUrl = relayInForce(settings);135 local = await getLocalPairingIdentity();136 const token = generatePairingRendezvousToken();137 138 139 const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));140 const invitation = parsePairingInvitation(encoded);141 if (!invitation) throw new Error("invitation_generation_failed");142143 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));144 activeSocket = socket;145 const readyLocal = local;146 void runSourceSession(147 socket,148 token,149 relayUrl,150 readyLocal,151 ).finally(() => {152 readyLocal.privateKey.fill(0);153 readyLocal.publicKey.fill(0);154 clearState();155 releaseAutoLock();156 });157 return { invitation: encoded };158 } catch (error) {159 local?.privateKey.fill(0);160 local?.publicKey.fill(0);161 clearState();162 releaseAutoLock();163 throw error;164 }165}166167export function cancelRelayAdoptionSource(): void {168 activeSocket?.close();169 clearState();170}171172173174175176177178179180181182183export async function startRelayAdoptionReceiver(184 password: string,185): Promise<{ invitation: string }> {186 if (inFlight) throw new Error("adoption_receiver_in_progress");187 if (typeof password !== "string" || password.length === 0) {188 throw new Error("ADOPT_INVALID_PASSWORD");189 }190 inFlight = true;191 try {192 await preparePreVaultIdentity(password);193 const settings = await getSyncSettings();194 const relayUrl = relayInForce(settings);195 const token = generatePairingRendezvousToken();196 const ephemeral = generateKeyPair();197 const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));198 const invitation = parsePairingInvitation(encoded);199 if (!invitation) throw new Error("invitation_generation_failed");200 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));201 activeSocket = socket;202 void runReceiverSession(203 socket,204 token,205 relayUrl,206 password,207 ephemeral,208 ).finally(() => {209 ephemeral.secretKey.fill(0);210 ephemeral.publicKey.fill(0);211 clearState();212 });213 return { invitation: encoded };214 } catch (error) {215 clearState();216 throw error;217 }218}219220221222223224225226227export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {228 const pending = pendingSourceSasConfirmation.get(id);229 if (!pending) return false;230 pendingSourceSasConfirmation.delete(id);231 pending.resolve(ok);232 return true;233}234235export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {236 const pending = pendingReceiverSasConfirmation.get(id);237 if (!pending) return false;238 pendingReceiverSasConfirmation.delete(id);239 pending.resolve(ok && constantTimeEqual(pending.sas, sas));240 return true;241}242243async function runSourceSession(244 socket: PairingSocket,245 expectedToken: string,246 relayUrl: string,247 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,248): Promise<void> {249 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);250 let noise: NoiseNKSession | undefined;251 try {252 const hello = decodeJson<{ type?: unknown; token?: unknown }>(253 await socket.receive(),254 );255 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||256 !constantTimeEqual(hello.token, expectedToken)) {257 throw new Error("invalid_invitation");258 }259 socket.send(encodeJson({260 type: "PEACH_HELLO_ACK",261 ephemeralPublicKeyHex: bytesToHex(local.publicKey),262 }));263264 const session = await noiseNKResponder(265 local.privateKey,266 local.publicKey,267 async (bytes) => socket.send(bytes),268 () => socket.receive(),269 );270 noise = session;271 const channel: PairingTransport = {272 async send(plaintext) { socket.send(session.encrypt(plaintext)); },273 async receive() { return session.decrypt(await socket.receive()); },274 };275 const request = await receiveFrame(channel);276 if (!isAdoptRequest(request) && !isAdoptRequestV2(request)) {277 throw new Error("expected_ADOPT_REQUEST");278 }279280 const sourceDeps: AdoptionSourceDeps & AdoptionV2SourceDeps = {281 sourceIdentity: local.identity,282 pairState: {283 rendezvousSecretHex: bytesToHex(284 deriveRendezvousSecret(session.handshakeHash),285 ),286 relayUrl,287 },288 async rootKeyHex() {289 290 291 const value = getRootKeyHex();292 if (!value) throw new Error("vault_locked");293 return value;294 },295 async vaultId() {296 const value = getRootKeyHex();297 if (!value) throw new Error("vault_locked");298 const bytes = hexToBytes(value);299 try {300 return await computeVaultId(bytes);301 } finally {302 bytes.fill(0);303 }304 },305 async license() {306 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;307 },308 async applyLicense(license) {309 await applyTransferredLicense(license);310 },311 onConsent: (adoptRequest) => requestConsent(312 adoptRequest,313 derivePairingSAS(session.handshakeHash),314 crypto.randomUUID(),315 ),316 persistPendingHolder: persistPendingAdoptionHolder,317 clearPendingHolder: async (adoptRequest) => {318 const current = await readPendingAdoptionHolder();319 if (current?.request.device_id === adoptRequest.device_id) {320 await removePendingAdoptionHolder();321 }322 },323 async upsertPairedDevice(paired) {324 await storeAdoptedDevice(paired);325 },326 hasDeviceIdentityCollision: async (device) =>327 hasAdoptionIdentityConflict(device.deviceId, device.longTermPublicKeyHex ?? ""),328 rotateSourceIdentity: async () => {329 await rotateDeviceId();330 return await buildLocalIdentity();331 },332 persistPendingIdentity: persistPendingAdoptionIdentity,333 async completePendingIdentity(paired: PairedDevice) {334 await completePendingAdoptionIdentity(paired);335 },336 snapshotCapable: () => true,337 };338 const device = isAdoptRequestV2(request)339 ? await grantAdoptionV2(channel, request, sourceDeps)340 : await grantAdoption(channel, request, sourceDeps);341 if (!device) {342 push("ADOPTION_SOURCE_RESULT", {343 status: "error",344 error: "adoption_rejected",345 });346 return;347 }348 349 350 351 if (request.snapshot_capable === true) {352 try {353 const adapter = createSyncVaultAdapter();354 const entryManifest = await adapter.buildEntryManifest();355 const entryIds = Object.keys(entryManifest);356 const entries =357 entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};358 const tombstones = await adapter.buildTombstoneManifest();359 let duressSlot: string | null = null;360 try {361 duressSlot = await exportDuressSlotForAdoption();362 } catch {363 364 }365 await sendVaultSnapshot(channel, entries, tombstones, duressSlot);366367 const applied = await withTimeout(receiveFrame(channel), 15_000);368 if (isAdoptSnapshotApplied(applied)) {369 console.info(370 `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,371 );372 }373374 if (adapter.buildOutgoingDeviceManifest) {375 const manifest = await adapter.buildOutgoingDeviceManifest();376 if (manifest) {377 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });378 await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);379 }380 }381 } catch (snapshotError) {382 if (383 snapshotError instanceof Error &&384 snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"385 ) {386 throw snapshotError;387 }388 console.info(389 "[Peach] inline snapshot send failed (receiver will use sync pipeline):",390 snapshotError,391 );392 }393 }394 push("ADOPTION_SOURCE_RESULT", { status: "complete" });395 396 397 398 void triggerSyncIfReachable({ reason: "manual", force: true });399 schedulePostAdoptionSync();400 } catch (error) {401 push("ADOPTION_SOURCE_RESULT", {402 status: "error",403 error: error instanceof Error ? error.message : "adoption_failed",404 });405 } finally {406 clearTimeout(timeout);407 noise?.close();408 socket.close();409 pendingSourceSasConfirmation.clear();410 }411}412413414415416417418async function runReceiverSession(419 socket: PairingSocket,420 expectedToken: string,421 relayUrl: string,422 password: string,423 invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },424): Promise<void> {425 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);426 427 const ephemeral = invitationEphemeral;428 const ephemeralPrivate = ephemeral.secretKey;429 const ephemeralPublic = ephemeral.publicKey;430 let noise: NoiseNKSession | undefined;431 let createdVault = false;432 let protocolError: string | undefined;433 try {434 const hello = decodeJson<{ type?: unknown; token?: unknown }>(435 await socket.receive(),436 );437 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||438 !constantTimeEqual(hello.token, expectedToken)) {439 throw new Error("invalid_invitation");440 }441 socket.send(encodeJson({442 type: "PEACH_HELLO_ACK",443 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),444 }));445446 const session = await noiseNKResponder(447 ephemeralPrivate,448 ephemeralPublic,449 async (bytes) => socket.send(bytes),450 () => socket.receive(),451 );452 noise = session;453 const channel: PairingTransport = {454 async send(plaintext) { socket.send(session.encrypt(plaintext)); },455 async receive() { return session.decrypt(await socket.receive()); },456 };457458 const deviceName = await getDeviceName();459460 const receiverDeps: AdoptionV2ReceiverDeps = {461 deviceName,462 async confirmSas() {463 const sas = derivePairingSAS(session.handshakeHash);464 const id = crypto.randomUUID();465 return waitForReceiverSasConfirmation(id, sas);466 },467 async createVaultFromRootKey(rootKeyHex) {468 469 470 471 472 473 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);474 createdVault = true;475 return { vaultId };476 },477 async applyLicense(license) {478 479 480 await applyTransferredLicense(license);481 },482 async license() {483 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;484 },485 async rotateLocalIdentity() {486 return await rotatePreVaultIdentity(password);487 },488 async hasSourceIdentityCollision(source) {489 return hasAdoptionIdentityConflict(source.device_id, source.long_term_public_key_hex);490 },491 pairState: {492 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),493 relayUrl,494 },495 onError(error) {496 protocolError = error;497 },498 async localIdentity() {499 return await readPreVaultIdentity(password);500 },501 async validateSource() {},502 async upsertPairedDeviceAndClearRevocation(paired: PairedDevice) {503 await storeAdoptedDevice(paired);504 },505 };506 const adoptionResult = await requestAdoptionV2(channel, receiverDeps);507508 if (!adoptionResult) {509 if (createdVault) {510 await rollbackAdoptedVault();511 createdVault = false;512 }513 push("ADOPTION_SOURCE_RESULT", {514 status: "error",515 error: protocolError ?? "adoption_rejected",516 });517 return;518 }519 const { device, snapshotCapable } = adoptionResult;520 521 522 523 524 525 let snapshotApplied = false;526 if (snapshotCapable) try {527 const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);528 const adapter = createSyncVaultAdapter();529 await adapter.applyEntries(snapshot.entries);530 await adapter.applyTombstones(snapshot.tombstones);531 532 533 if (snapshot.duressSlot) {534 try {535 await installAdoptedDuressSlot(snapshot.duressSlot);536 } catch (duressError) {537 console.info(538 "[Peach] adopted duress slot install failed (non-fatal):",539 duressError,540 );541 }542 }543 await sendFrame(channel, {544 type: "ADOPT_SNAPSHOT_APPLIED",545 entries_received: Object.keys(snapshot.entries).length,546 tombstones_received: Object.keys(snapshot.tombstones).length,547 });548549 550 551 try {552 const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);553 if (554 isAdoptDeviceManifestFrame(manifestFrame) &&555 adapter.applyIncomingDeviceManifest &&556 typeof device.longTermPublicKeyHex === "string" &&557 /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)558 ) {559 await adapter.applyIncomingDeviceManifest(560 manifestFrame.manifest,561 hexToBytes(device.longTermPublicKeyHex),562 );563 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });564 }565 } catch {566 567 }568569 await markLastSyncedAt(device.deviceId, Date.now());570 snapshotApplied = true;571 } catch (snapshotError) {572 console.info(573 "[Peach] inline snapshot failed, falling back to sync pipeline:",574 snapshotError,575 );576 }577578 579 580 581 582 583 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(584 () => undefined,585 );586 push("ADOPTION_SOURCE_RESULT", { status: "complete" });587 createdVault = false;588589 if (snapshotApplied) {590 591 592 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);593 } else {594 595 596 void triggerSyncIfReachable({ reason: "manual", force: true });597 schedulePostAdoptionSync();598 }599 } catch (error) {600 if (createdVault) {601 try {602 await rollbackAdoptedVault();603 createdVault = false;604 } catch (rollbackError) {605 console.error("[Peach] rollback failed:", rollbackError);606 }607 }608 console.error("[Peach] receiver session failed:", error);609 push("ADOPTION_SOURCE_RESULT", {610 status: "error",611 error: error instanceof Error ? error.message : "adoption_failed",612 });613 } finally {614 clearTimeout(timeout);615 noise?.close();616 socket.close();617 ephemeralPrivate.fill(0);618 ephemeralPublic.fill(0);619 }620}621622623624625626627async function applyTransferredLicense(license: string): Promise<void> {628 const activated = await activateLicense(license);629 if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {630 throw new Error("license_transfer_failed");631 }632}633634635636637638639640641642643644function waitForReceiverSasConfirmation(645 id: string,646 sas: string,647): Promise<boolean> {648 return new Promise<boolean>((resolve) => {649 const timer = setTimeout(() => {650 pendingReceiverSasConfirmation.delete(id);651 resolve(false);652 }, SOURCE_WINDOW_MS);653 pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {654 clearTimeout(timer);655 resolve(ok);656 } });657 push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });658 });659}660661function waitForSourceSasConfirmation(662 id: string,663 request: { device_name: string },664 sas: string,665): Promise<boolean> {666 return new Promise<boolean>((resolve) => {667 const timer = setTimeout(() => {668 pendingSourceSasConfirmation.delete(id);669 resolve(false);670 }, SOURCE_WINDOW_MS);671 pendingSourceSasConfirmation.set(id, {672 resolve: (ok) => {673 clearTimeout(timer);674 resolve(ok);675 },676 });677 push("ADOPTION_SOURCE_SAS", {678 id,679 sas,680 receiverDeviceName: request.device_name,681 });682 });683}684685686687688689690691function requestConsent(692 request: { device_name: string },693 sas: string,694 id: string,695): Promise<boolean> {696 return waitForSourceSasConfirmation(id, request, sas);697}698699function clearState(): void {700 activeSocket = null;701 inFlight = false;702 pendingSourceSasConfirmation.clear();703 pendingReceiverSasConfirmation.clear();704}705706function constantTimeEqual(a: string, b: string): boolean {707 if (a.length !== b.length) return false;708 let diff = 0;709 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);710 return diff === 0;711}712713function encodeJson(value: unknown): Uint8Array {714 return new TextEncoder().encode(JSON.stringify(value));715}716717function decodeJson<T>(value: Uint8Array): T {718 return JSON.parse(new TextDecoder().decode(value)) as T;719}720721722723724725726727async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {728 const secret = paired.rendezvousSecretHex;729 const pub = { ...paired };730 delete pub.rendezvousSecretHex;731 732 733 await upsertPairedDeviceAndClearRevocation(pub);734 if (!isValidPairSecret(secret)) return;735 const masterKeyHex = getRootKeyHex();736 if (!masterKeyHex) return;737 const masterKey = hexToBytes(masterKeyHex);738 try {739 await setPairSecret(paired.deviceId, secret, masterKey);740 } finally {741 masterKey.fill(0);742 }743}744745function hexToBytes(hex: string): Uint8Array {746 const out = new Uint8Array(hex.length / 2);747 for (let i = 0; i < out.length; i++) {748 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);749 }750 return out;751}