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 requestAdoption,23 sendFrame,24 sendVaultSnapshot,25 type AdoptRequest,26} from "../shared/vault-adopt";27import {28 derivePairingSAS,29 type PairingTransport,30} from "../shared/pairing-handshake";31import type { PairedDevice } from "../shared/sync-types";32import {33 bytesToHex,34 deriveRendezvousSecret,35 generatePairingRendezvousToken,36} from "../shared/rendezvous";37import {38 buildLocalIdentity,39 getDeviceName,40 getLocalPairingIdentity,41 rotateDeviceId,42} from "./device-identity";43import {44 hasAdoptionIdentityConflict,45 markLastSyncedAt,46 upsertPairedDeviceAndClearRevocation,47} from "./paired-devices";48import { getSyncSettings, relayInForce } from "./sync-settings";49import { activateLicense, getLicenseKey, isProUser } from "./license";50import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";51import { deferAutoLockForOperation } from "./alarms";52import { computeVaultId } from "../shared/sync-identity";53import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";54import { storageRemove, storageSet } from "../shared/storage";55import {56 persistPendingAdoptionHolder,57 readPendingAdoptionHolder,58 removePendingAdoptionHolder,59} from "./pending-adoption-holder";60import { isValidPairSecret, setPairSecret } from "./pair-secrets";61import { createSyncVaultAdapter } from "./sync-vault-adapter";62import { triggerSyncIfReachable } from "./sync-trigger";63import { schedulePostAdoptionSync } from "./sync-alarm";6465const SOURCE_WINDOW_MS = 5 * 60 * 1000;6667function push(type: string, payload: unknown): void {68 try {69 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;70 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {71 (maybe as Promise<unknown>).catch(() => undefined);72 }73 } catch {74 75 }76}777879function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {80 return new Promise<T>((resolve, reject) => {81 const timer = setTimeout(() => reject(new Error("timeout")), ms);82 promise.then(83 (value) => {84 clearTimeout(timer);85 resolve(value);86 },87 (error) => {88 clearTimeout(timer);89 reject(error);90 },91 );92 });93}9495let inFlight = false;96let activeSocket: PairingSocket | null = null;979899100101const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();102const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();103104export function isRelayAdoptionSourceInFlight(): boolean {105 return inFlight;106}107108109110111112export async function startRelayAdoptionSource(): Promise<{113 invitation: string;114}> {115 if (inFlight) throw new Error("adoption_source_in_progress");116 if (!isUnlocked()) throw new Error("vault_locked");117 inFlight = true;118 const releaseAutoLock = deferAutoLockForOperation(SOURCE_WINDOW_MS);119120 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;121 try {122 const settings = await getSyncSettings();123 const relayUrl = relayInForce(settings);124 local = await getLocalPairingIdentity();125 const token = generatePairingRendezvousToken();126 127 128 const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));129 const invitation = parsePairingInvitation(encoded);130 if (!invitation) throw new Error("invitation_generation_failed");131132 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));133 activeSocket = socket;134 const readyLocal = local;135 void runSourceSession(136 socket,137 token,138 relayUrl,139 readyLocal,140 ).finally(() => {141 readyLocal.privateKey.fill(0);142 readyLocal.publicKey.fill(0);143 clearState();144 releaseAutoLock();145 });146 return { invitation: encoded };147 } catch (error) {148 local?.privateKey.fill(0);149 local?.publicKey.fill(0);150 clearState();151 releaseAutoLock();152 throw error;153 }154}155156export function cancelRelayAdoptionSource(): void {157 activeSocket?.close();158 clearState();159}160161162163164165166167168169170171172export async function startRelayAdoptionReceiver(173 password: string,174): Promise<{ invitation: string }> {175 if (inFlight) throw new Error("adoption_receiver_in_progress");176 if (typeof password !== "string" || password.length === 0) {177 throw new Error("ADOPT_INVALID_PASSWORD");178 }179 inFlight = true;180 try {181 const settings = await getSyncSettings();182 const relayUrl = relayInForce(settings);183 const token = generatePairingRendezvousToken();184 const ephemeral = generateKeyPair();185 const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));186 const invitation = parsePairingInvitation(encoded);187 if (!invitation) throw new Error("invitation_generation_failed");188 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));189 activeSocket = socket;190 void runReceiverSession(191 socket,192 token,193 relayUrl,194 password,195 ephemeral,196 ).finally(() => {197 ephemeral.secretKey.fill(0);198 ephemeral.publicKey.fill(0);199 clearState();200 });201 return { invitation: encoded };202 } catch (error) {203 clearState();204 throw error;205 }206}207208209210211212213214215export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {216 const pending = pendingSourceSasConfirmation.get(id);217 if (!pending) return false;218 pendingSourceSasConfirmation.delete(id);219 pending.resolve(ok);220 return true;221}222223export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {224 const pending = pendingReceiverSasConfirmation.get(id);225 if (!pending) return false;226 pendingReceiverSasConfirmation.delete(id);227 pending.resolve(ok && constantTimeEqual(pending.sas, sas));228 return true;229}230231async function runSourceSession(232 socket: PairingSocket,233 expectedToken: string,234 relayUrl: string,235 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,236): Promise<void> {237 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);238 let noise: NoiseNKSession | undefined;239 try {240 const hello = decodeJson<{ type?: unknown; token?: unknown }>(241 await socket.receive(),242 );243 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||244 !constantTimeEqual(hello.token, expectedToken)) {245 throw new Error("invalid_invitation");246 }247 socket.send(encodeJson({248 type: "PEACH_HELLO_ACK",249 ephemeralPublicKeyHex: bytesToHex(local.publicKey),250 }));251252 const session = await noiseNKResponder(253 local.privateKey,254 local.publicKey,255 async (bytes) => socket.send(bytes),256 () => socket.receive(),257 );258 noise = session;259 const channel: PairingTransport = {260 async send(plaintext) { socket.send(session.encrypt(plaintext)); },261 async receive() { return session.decrypt(await socket.receive()); },262 };263 const request = await receiveFrame(channel);264 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");265266 const device = await grantAdoption(channel, request, {267 sourceIdentity: local.identity,268 pairState: {269 rendezvousSecretHex: bytesToHex(270 deriveRendezvousSecret(session.handshakeHash),271 ),272 relayUrl,273 },274 async rootKeyHex() {275 276 277 const value = getRootKeyHex();278 if (!value) throw new Error("vault_locked");279 return value;280 },281 async vaultId() {282 const value = getRootKeyHex();283 if (!value) throw new Error("vault_locked");284 const bytes = hexToBytes(value);285 try {286 return await computeVaultId(bytes);287 } finally {288 bytes.fill(0);289 }290 },291 async license() {292 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;293 },294 async applyLicense(license) {295 await applyTransferredLicense(license);296 },297 onConsent: (adoptRequest) => requestConsent(298 adoptRequest,299 derivePairingSAS(session.handshakeHash),300 crypto.randomUUID(),301 ),302 persistPendingHolder: persistPendingAdoptionHolder,303 clearPendingHolder: async (adoptRequest) => {304 const current = await readPendingAdoptionHolder();305 if (current?.request.device_id === adoptRequest.device_id) {306 await removePendingAdoptionHolder();307 }308 },309 async upsertPairedDevice(paired) {310 await storeAdoptedDevice(paired);311 },312 hasDeviceIdentityCollision: async (device) =>313 hasAdoptionIdentityConflict(device.deviceId, device.longTermPublicKeyHex ?? ""),314 rotateSourceIdentity: async () => {315 await rotateDeviceId();316 const masterKeyHex = getRootKeyHex();317 if (!masterKeyHex) throw new Error("identity_unavailable");318 const masterKey = hexToBytes(masterKeyHex);319 try {320 return await buildLocalIdentity(masterKey);321 } finally {322 masterKey.fill(0);323 }324 },325 });326 if (!device) {327 push("ADOPTION_SOURCE_RESULT", {328 status: "error",329 error: "adoption_rejected",330 });331 return;332 }333 334 335 336 if (request.snapshot_capable === true) {337 try {338 const adapter = createSyncVaultAdapter();339 const entryManifest = await adapter.buildEntryManifest();340 const entryIds = Object.keys(entryManifest);341 const entries =342 entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};343 const tombstones = await adapter.buildTombstoneManifest();344 let duressSlot: string | null = null;345 try {346 duressSlot = await exportDuressSlotForAdoption();347 } catch {348 349 }350 await sendVaultSnapshot(channel, entries, tombstones, duressSlot);351352 const applied = await withTimeout(receiveFrame(channel), 15_000);353 if (isAdoptSnapshotApplied(applied)) {354 console.info(355 `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,356 );357 }358359 if (adapter.buildOutgoingDeviceManifest) {360 const manifest = await adapter.buildOutgoingDeviceManifest();361 if (manifest) {362 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });363 await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);364 }365 }366 } catch (snapshotError) {367 if (368 snapshotError instanceof Error &&369 snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"370 ) {371 throw snapshotError;372 }373 console.info(374 "[Peach] inline snapshot send failed (receiver will use sync pipeline):",375 snapshotError,376 );377 }378 }379 push("ADOPTION_SOURCE_RESULT", { status: "complete" });380 381 382 383 void triggerSyncIfReachable({ reason: "manual", force: true });384 schedulePostAdoptionSync();385 } catch (error) {386 push("ADOPTION_SOURCE_RESULT", {387 status: "error",388 error: error instanceof Error ? error.message : "adoption_failed",389 });390 } finally {391 clearTimeout(timeout);392 noise?.close();393 socket.close();394 pendingSourceSasConfirmation.clear();395 }396}397398399400401402403async function runReceiverSession(404 socket: PairingSocket,405 expectedToken: string,406 relayUrl: string,407 password: string,408 invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },409): Promise<void> {410 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);411 412 const ephemeral = invitationEphemeral;413 const ephemeralPrivate = ephemeral.secretKey;414 const ephemeralPublic = ephemeral.publicKey;415 let noise: NoiseNKSession | undefined;416 let createdVault = false;417 let protocolError: string | undefined;418 try {419 const hello = decodeJson<{ type?: unknown; token?: unknown }>(420 await socket.receive(),421 );422 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||423 !constantTimeEqual(hello.token, expectedToken)) {424 throw new Error("invalid_invitation");425 }426 socket.send(encodeJson({427 type: "PEACH_HELLO_ACK",428 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),429 }));430431 const session = await noiseNKResponder(432 ephemeralPrivate,433 ephemeralPublic,434 async (bytes) => socket.send(bytes),435 () => socket.receive(),436 );437 noise = session;438 const channel: PairingTransport = {439 async send(plaintext) { socket.send(session.encrypt(plaintext)); },440 async receive() { return session.decrypt(await socket.receive()); },441 };442443 const deviceName = await getDeviceName();444445 const adoptionResult = await requestAdoption(channel, {446 deviceName,447 generateDeviceId: () => crypto.randomUUID(),448 async confirmSas() {449 const sas = derivePairingSAS(session.handshakeHash);450 const id = crypto.randomUUID();451 return waitForReceiverSasConfirmation(id, sas);452 },453 async createVaultFromRootKey(rootKeyHex) {454 455 456 457 458 459 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);460 createdVault = true;461 return { vaultId };462 },463 async applyLicense(license) {464 465 466 await applyTransferredLicense(license);467 },468 async license() {469 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;470 },471 async deriveLocalIdentity() {472 473 const masterKeyHex = getRootKeyHex();474 if (!masterKeyHex) throw new Error("identity_unavailable");475 const masterKey = hexToBytes(masterKeyHex);476 try {477 return await buildLocalIdentity(masterKey);478 } finally {479 masterKey.fill(0);480 }481 },482 async rotateLocalIdentity() {483 await rotateDeviceId();484 const masterKeyHex = getRootKeyHex();485 if (!masterKeyHex) throw new Error("identity_unavailable");486 const masterKey = hexToBytes(masterKeyHex);487 try {488 return await buildLocalIdentity(masterKey);489 } finally {490 masterKey.fill(0);491 }492 },493 async upsertPairedDevice(paired) {494 495 await storeAdoptedDevice(paired);496 },497 async hasSourceIdentityCollision(source) {498 return hasAdoptionIdentityConflict(source.device_id, source.long_term_public_key_hex);499 },500 pairState: {501 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),502 relayUrl,503 },504 onError(error) {505 protocolError = error;506 },507 });508509 if (!adoptionResult) {510 if (createdVault) {511 await rollbackAdoptedVault();512 createdVault = false;513 }514 push("ADOPTION_SOURCE_RESULT", {515 status: "error",516 error: protocolError ?? "adoption_rejected",517 });518 return;519 }520 const { device, snapshotCapable } = adoptionResult;521 522 523 524 525 526 let snapshotApplied = false;527 if (snapshotCapable) try {528 const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);529 const adapter = createSyncVaultAdapter();530 await adapter.applyEntries(snapshot.entries);531 await adapter.applyTombstones(snapshot.tombstones);532 533 534 if (snapshot.duressSlot) {535 try {536 await installAdoptedDuressSlot(snapshot.duressSlot);537 } catch (duressError) {538 console.info(539 "[Peach] adopted duress slot install failed (non-fatal):",540 duressError,541 );542 }543 }544 await sendFrame(channel, {545 type: "ADOPT_SNAPSHOT_APPLIED",546 entries_received: Object.keys(snapshot.entries).length,547 tombstones_received: Object.keys(snapshot.tombstones).length,548 });549550 551 552 try {553 const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);554 if (555 isAdoptDeviceManifestFrame(manifestFrame) &&556 adapter.applyIncomingDeviceManifest &&557 typeof device.longTermPublicKeyHex === "string" &&558 /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)559 ) {560 await adapter.applyIncomingDeviceManifest(561 manifestFrame.manifest,562 hexToBytes(device.longTermPublicKeyHex),563 );564 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });565 }566 } catch {567 568 }569570 await markLastSyncedAt(device.deviceId, Date.now());571 snapshotApplied = true;572 } catch (snapshotError) {573 console.info(574 "[Peach] inline snapshot failed, falling back to sync pipeline:",575 snapshotError,576 );577 }578579 580 581 582 583 584 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(585 () => undefined,586 );587 push("ADOPTION_SOURCE_RESULT", { status: "complete" });588 createdVault = false;589590 if (snapshotApplied) {591 592 593 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);594 } else {595 596 597 void triggerSyncIfReachable({ reason: "manual", force: true });598 schedulePostAdoptionSync();599 }600 } catch (error) {601 if (createdVault) {602 try {603 await rollbackAdoptedVault();604 createdVault = false;605 } catch (rollbackError) {606 console.error("[Peach] rollback failed:", rollbackError);607 }608 }609 console.error("[Peach] receiver session failed:", error);610 push("ADOPTION_SOURCE_RESULT", {611 status: "error",612 error: error instanceof Error ? error.message : "adoption_failed",613 });614 } finally {615 clearTimeout(timeout);616 noise?.close();617 socket.close();618 ephemeralPrivate.fill(0);619 ephemeralPublic.fill(0);620 }621}622623624625626627628async function applyTransferredLicense(license: string): Promise<void> {629 const activated = await activateLicense(license);630 if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {631 throw new Error("license_transfer_failed");632 }633}634635636637638639640641642643644645function waitForReceiverSasConfirmation(646 id: string,647 sas: string,648): Promise<boolean> {649 return new Promise<boolean>((resolve) => {650 const timer = setTimeout(() => {651 pendingReceiverSasConfirmation.delete(id);652 resolve(false);653 }, SOURCE_WINDOW_MS);654 pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {655 clearTimeout(timer);656 resolve(ok);657 } });658 push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });659 });660}661662function waitForSourceSasConfirmation(663 id: string,664 request: AdoptRequest,665 sas: string,666): Promise<boolean> {667 return new Promise<boolean>((resolve) => {668 const timer = setTimeout(() => {669 pendingSourceSasConfirmation.delete(id);670 resolve(false);671 }, SOURCE_WINDOW_MS);672 pendingSourceSasConfirmation.set(id, {673 resolve: (ok) => {674 clearTimeout(timer);675 resolve(ok);676 },677 });678 push("ADOPTION_SOURCE_SAS", {679 id,680 sas,681 receiverDeviceName: request.device_name,682 });683 });684}685686687688689690691692function requestConsent(693 request: AdoptRequest,694 sas: string,695 id: string,696): Promise<boolean> {697 return waitForSourceSasConfirmation(id, request, sas);698}699700function clearState(): void {701 activeSocket = null;702 inFlight = false;703 pendingSourceSasConfirmation.clear();704 pendingReceiverSasConfirmation.clear();705}706707function constantTimeEqual(a: string, b: string): boolean {708 if (a.length !== b.length) return false;709 let diff = 0;710 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);711 return diff === 0;712}713714function encodeJson(value: unknown): Uint8Array {715 return new TextEncoder().encode(JSON.stringify(value));716}717718function decodeJson<T>(value: Uint8Array): T {719 return JSON.parse(new TextDecoder().decode(value)) as T;720}721722723724725726727728async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {729 const secret = paired.rendezvousSecretHex;730 const pub = { ...paired };731 delete pub.rendezvousSecretHex;732 733 734 await upsertPairedDeviceAndClearRevocation(pub);735 if (!isValidPairSecret(secret)) return;736 const masterKeyHex = getRootKeyHex();737 if (!masterKeyHex) return;738 const masterKey = hexToBytes(masterKeyHex);739 try {740 await setPairSecret(paired.deviceId, secret, masterKey);741 } finally {742 masterKey.fill(0);743 }744}745746function hexToBytes(hex: string): Uint8Array {747 const out = new Uint8Array(hex.length / 2);748 for (let i = 0; i < out.length; i++) {749 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);750 }751 return out;752}