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 { getSyncSettings, relayInForce } from "./sync-settings";44import { activateLicense, getLicenseKey, isProUser } from "./license";45import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";46import { deferAutoLockForOperation } from "./alarms";47import { computeVaultId } from "../shared/sync-identity";48import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";49import { storageRemove, storageSet } from "../shared/storage";50import { markLastSyncedAt, upsertPairedDevice, clearDeviceRevocation, hasAdoptionIdentityConflict } from "./paired-devices";51import { isValidPairSecret, setPairSecret } from "./pair-secrets";52import { createSyncVaultAdapter } from "./sync-vault-adapter";53import { triggerSyncIfReachable } from "./sync-trigger";54import { schedulePostAdoptionSync } from "./sync-alarm";5556const SOURCE_WINDOW_MS = 5 * 60 * 1000;5758function push(type: string, payload: unknown): void {59 try {60 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;61 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {62 (maybe as Promise<unknown>).catch(() => undefined);63 }64 } catch {65 66 }67}686970function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {71 return new Promise<T>((resolve, reject) => {72 const timer = setTimeout(() => reject(new Error("timeout")), ms);73 promise.then(74 (value) => {75 clearTimeout(timer);76 resolve(value);77 },78 (error) => {79 clearTimeout(timer);80 reject(error);81 },82 );83 });84}8586let inFlight = false;87let activeSocket: PairingSocket | null = null;8889909192const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();93const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();9495export function isRelayAdoptionSourceInFlight(): boolean {96 return inFlight;97}9899100101102103export async function startRelayAdoptionSource(): Promise<{104 invitation: string;105}> {106 if (inFlight) throw new Error("adoption_source_in_progress");107 if (!isUnlocked()) throw new Error("vault_locked");108 inFlight = true;109 const releaseAutoLock = deferAutoLockForOperation(SOURCE_WINDOW_MS);110111 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;112 try {113 const settings = await getSyncSettings();114 const relayUrl = relayInForce(settings);115 local = await getLocalPairingIdentity();116 const token = generatePairingRendezvousToken();117 118 119 const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));120 const invitation = parsePairingInvitation(encoded);121 if (!invitation) throw new Error("invitation_generation_failed");122123 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));124 activeSocket = socket;125 const readyLocal = local;126 void runSourceSession(127 socket,128 token,129 relayUrl,130 readyLocal,131 ).finally(() => {132 readyLocal.privateKey.fill(0);133 readyLocal.publicKey.fill(0);134 clearState();135 releaseAutoLock();136 });137 return { invitation: encoded };138 } catch (error) {139 local?.privateKey.fill(0);140 local?.publicKey.fill(0);141 clearState();142 releaseAutoLock();143 throw error;144 }145}146147export function cancelRelayAdoptionSource(): void {148 activeSocket?.close();149 clearState();150}151152153154155156157158159160161162163export async function startRelayAdoptionReceiver(164 password: string,165): Promise<{ invitation: string }> {166 if (inFlight) throw new Error("adoption_receiver_in_progress");167 if (typeof password !== "string" || password.length === 0) {168 throw new Error("ADOPT_INVALID_PASSWORD");169 }170 inFlight = true;171 try {172 const settings = await getSyncSettings();173 const relayUrl = relayInForce(settings);174 const token = generatePairingRendezvousToken();175 const ephemeral = generateKeyPair();176 const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));177 const invitation = parsePairingInvitation(encoded);178 if (!invitation) throw new Error("invitation_generation_failed");179 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));180 activeSocket = socket;181 void runReceiverSession(182 socket,183 token,184 relayUrl,185 password,186 ephemeral,187 ).finally(() => {188 ephemeral.secretKey.fill(0);189 ephemeral.publicKey.fill(0);190 clearState();191 });192 return { invitation: encoded };193 } catch (error) {194 clearState();195 throw error;196 }197}198199200201202203204205206export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {207 const pending = pendingSourceSasConfirmation.get(id);208 if (!pending) return false;209 pendingSourceSasConfirmation.delete(id);210 pending.resolve(ok);211 return true;212}213214export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {215 const pending = pendingReceiverSasConfirmation.get(id);216 if (!pending) return false;217 pendingReceiverSasConfirmation.delete(id);218 pending.resolve(ok && constantTimeEqual(pending.sas, sas));219 return true;220}221222async function runSourceSession(223 socket: PairingSocket,224 expectedToken: string,225 relayUrl: string,226 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,227): Promise<void> {228 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);229 let noise: NoiseNKSession | undefined;230 try {231 const hello = decodeJson<{ type?: unknown; token?: unknown }>(232 await socket.receive(),233 );234 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||235 !constantTimeEqual(hello.token, expectedToken)) {236 throw new Error("invalid_invitation");237 }238 socket.send(encodeJson({239 type: "PEACH_HELLO_ACK",240 ephemeralPublicKeyHex: bytesToHex(local.publicKey),241 }));242243 const session = await noiseNKResponder(244 local.privateKey,245 local.publicKey,246 async (bytes) => socket.send(bytes),247 () => socket.receive(),248 );249 noise = session;250 const channel: PairingTransport = {251 async send(plaintext) { socket.send(session.encrypt(plaintext)); },252 async receive() { return session.decrypt(await socket.receive()); },253 };254 const request = await receiveFrame(channel);255 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");256257 const device = await grantAdoption(channel, request, {258 sourceIdentity: local.identity,259 pairState: {260 rendezvousSecretHex: bytesToHex(261 deriveRendezvousSecret(session.handshakeHash),262 ),263 relayUrl,264 },265 async rootKeyHex() {266 267 268 const value = getRootKeyHex();269 if (!value) throw new Error("vault_locked");270 return value;271 },272 async vaultId() {273 const value = getRootKeyHex();274 if (!value) throw new Error("vault_locked");275 const bytes = hexToBytes(value);276 try {277 return await computeVaultId(bytes);278 } finally {279 bytes.fill(0);280 }281 },282 async license() {283 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;284 },285 async applyLicense(license) {286 await applyTransferredLicense(license);287 },288 onConsent: (adoptRequest) => requestConsent(289 adoptRequest,290 derivePairingSAS(session.handshakeHash),291 crypto.randomUUID(),292 ),293 upsertPairedDevice: storeAdoptedDevice,294 hasDeviceIdentityCollision: async (device) =>295 hasAdoptionIdentityConflict(device.deviceId, device.longTermPublicKeyHex ?? ""),296 rotateSourceIdentity: async () => {297 await rotateDeviceId();298 const masterKeyHex = getRootKeyHex();299 if (!masterKeyHex) throw new Error("identity_unavailable");300 const masterKey = hexToBytes(masterKeyHex);301 try {302 return await buildLocalIdentity(masterKey);303 } finally {304 masterKey.fill(0);305 }306 },307 });308 if (!device) {309 push("ADOPTION_SOURCE_RESULT", {310 status: "error",311 error: "adoption_rejected",312 });313 return;314 }315 316 317 318 if (request.snapshot_capable === true) {319 try {320 const adapter = createSyncVaultAdapter();321 const entryManifest = await adapter.buildEntryManifest();322 const entryIds = Object.keys(entryManifest);323 const entries =324 entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};325 const tombstones = await adapter.buildTombstoneManifest();326 let duressSlot: string | null = null;327 try {328 duressSlot = await exportDuressSlotForAdoption();329 } catch {330 331 }332 await sendVaultSnapshot(channel, entries, tombstones, duressSlot);333334 const applied = await withTimeout(receiveFrame(channel), 15_000);335 if (isAdoptSnapshotApplied(applied)) {336 console.info(337 `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,338 );339 }340341 if (adapter.buildOutgoingDeviceManifest) {342 const manifest = await adapter.buildOutgoingDeviceManifest();343 if (manifest) {344 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });345 await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);346 }347 }348 } catch (snapshotError) {349 if (350 snapshotError instanceof Error &&351 snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"352 ) {353 throw snapshotError;354 }355 console.info(356 "[Peach] inline snapshot send failed (receiver will use sync pipeline):",357 snapshotError,358 );359 }360 }361 push("ADOPTION_SOURCE_RESULT", { status: "complete" });362 363 364 365 void triggerSyncIfReachable({ reason: "manual", force: true });366 schedulePostAdoptionSync();367 } catch (error) {368 push("ADOPTION_SOURCE_RESULT", {369 status: "error",370 error: error instanceof Error ? error.message : "adoption_failed",371 });372 } finally {373 clearTimeout(timeout);374 noise?.close();375 socket.close();376 pendingSourceSasConfirmation.clear();377 }378}379380381382383384385async function runReceiverSession(386 socket: PairingSocket,387 expectedToken: string,388 relayUrl: string,389 password: string,390 invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },391): Promise<void> {392 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);393 394 const ephemeral = invitationEphemeral;395 const ephemeralPrivate = ephemeral.secretKey;396 const ephemeralPublic = ephemeral.publicKey;397 let noise: NoiseNKSession | undefined;398 let createdVault = false;399 let protocolError: string | undefined;400 try {401 const hello = decodeJson<{ type?: unknown; token?: unknown }>(402 await socket.receive(),403 );404 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||405 !constantTimeEqual(hello.token, expectedToken)) {406 throw new Error("invalid_invitation");407 }408 socket.send(encodeJson({409 type: "PEACH_HELLO_ACK",410 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),411 }));412413 const session = await noiseNKResponder(414 ephemeralPrivate,415 ephemeralPublic,416 async (bytes) => socket.send(bytes),417 () => socket.receive(),418 );419 noise = session;420 const channel: PairingTransport = {421 async send(plaintext) { socket.send(session.encrypt(plaintext)); },422 async receive() { return session.decrypt(await socket.receive()); },423 };424425 const deviceName = await getDeviceName();426427 const adoptionResult = await requestAdoption(channel, {428 deviceName,429 generateDeviceId: () => crypto.randomUUID(),430 async confirmSas() {431 const sas = derivePairingSAS(session.handshakeHash);432 const id = crypto.randomUUID();433 return waitForReceiverSasConfirmation(id, sas);434 },435 async createVaultFromRootKey(rootKeyHex) {436 437 438 439 440 441 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);442 createdVault = true;443 return { vaultId };444 },445 async applyLicense(license) {446 447 448 await applyTransferredLicense(license);449 },450 async license() {451 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;452 },453 async deriveLocalIdentity() {454 455 const masterKeyHex = getRootKeyHex();456 if (!masterKeyHex) throw new Error("identity_unavailable");457 const masterKey = hexToBytes(masterKeyHex);458 try {459 return await buildLocalIdentity(masterKey);460 } finally {461 masterKey.fill(0);462 }463 },464 async rotateLocalIdentity() {465 await rotateDeviceId();466 const masterKeyHex = getRootKeyHex();467 if (!masterKeyHex) throw new Error("identity_unavailable");468 const masterKey = hexToBytes(masterKeyHex);469 try {470 return await buildLocalIdentity(masterKey);471 } finally {472 masterKey.fill(0);473 }474 },475 async upsertPairedDevice(paired) {476 477 await storeAdoptedDevice(paired);478 },479 async hasSourceIdentityCollision(source) {480 return hasAdoptionIdentityConflict(source.device_id, source.long_term_public_key_hex);481 },482 pairState: {483 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),484 relayUrl,485 },486 onError(error) {487 protocolError = error;488 },489 });490491 if (!adoptionResult) {492 if (createdVault) {493 await rollbackAdoptedVault();494 createdVault = false;495 }496 push("ADOPTION_SOURCE_RESULT", {497 status: "error",498 error: protocolError ?? "adoption_rejected",499 });500 return;501 }502 const { device, snapshotCapable } = adoptionResult;503 504 505 506 507 508 let snapshotApplied = false;509 if (snapshotCapable) try {510 const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);511 const adapter = createSyncVaultAdapter();512 await adapter.applyEntries(snapshot.entries);513 await adapter.applyTombstones(snapshot.tombstones);514 515 516 if (snapshot.duressSlot) {517 try {518 await installAdoptedDuressSlot(snapshot.duressSlot);519 } catch (duressError) {520 console.info(521 "[Peach] adopted duress slot install failed (non-fatal):",522 duressError,523 );524 }525 }526 await sendFrame(channel, {527 type: "ADOPT_SNAPSHOT_APPLIED",528 entries_received: Object.keys(snapshot.entries).length,529 tombstones_received: Object.keys(snapshot.tombstones).length,530 });531532 533 534 try {535 const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);536 if (537 isAdoptDeviceManifestFrame(manifestFrame) &&538 adapter.applyIncomingDeviceManifest &&539 typeof device.longTermPublicKeyHex === "string" &&540 /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)541 ) {542 await adapter.applyIncomingDeviceManifest(543 manifestFrame.manifest,544 hexToBytes(device.longTermPublicKeyHex),545 );546 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });547 }548 } catch {549 550 }551552 await markLastSyncedAt(device.deviceId, Date.now());553 snapshotApplied = true;554 } catch (snapshotError) {555 console.info(556 "[Peach] inline snapshot failed, falling back to sync pipeline:",557 snapshotError,558 );559 }560561 562 563 564 565 566 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(567 () => undefined,568 );569 push("ADOPTION_SOURCE_RESULT", { status: "complete" });570 createdVault = false;571572 if (snapshotApplied) {573 574 575 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);576 } else {577 578 579 void triggerSyncIfReachable({ reason: "manual", force: true });580 schedulePostAdoptionSync();581 }582 } catch (error) {583 if (createdVault) {584 try {585 await rollbackAdoptedVault();586 createdVault = false;587 } catch (rollbackError) {588 console.error("[Peach] rollback failed:", rollbackError);589 }590 }591 console.error("[Peach] receiver session failed:", error);592 push("ADOPTION_SOURCE_RESULT", {593 status: "error",594 error: error instanceof Error ? error.message : "adoption_failed",595 });596 } finally {597 clearTimeout(timeout);598 noise?.close();599 socket.close();600 ephemeralPrivate.fill(0);601 ephemeralPublic.fill(0);602 }603}604605606607608609610async function applyTransferredLicense(license: string): Promise<void> {611 const activated = await activateLicense(license);612 if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {613 throw new Error("license_transfer_failed");614 }615}616617618619620621622623624625626627function waitForReceiverSasConfirmation(628 id: string,629 sas: string,630): Promise<boolean> {631 return new Promise<boolean>((resolve) => {632 const timer = setTimeout(() => {633 pendingReceiverSasConfirmation.delete(id);634 resolve(false);635 }, SOURCE_WINDOW_MS);636 pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {637 clearTimeout(timer);638 resolve(ok);639 } });640 push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });641 });642}643644function waitForSourceSasConfirmation(645 id: string,646 request: AdoptRequest,647 sas: string,648): Promise<boolean> {649 return new Promise<boolean>((resolve) => {650 const timer = setTimeout(() => {651 pendingSourceSasConfirmation.delete(id);652 resolve(false);653 }, SOURCE_WINDOW_MS);654 pendingSourceSasConfirmation.set(id, {655 resolve: (ok) => {656 clearTimeout(timer);657 resolve(ok);658 },659 });660 push("ADOPTION_SOURCE_SAS", {661 id,662 sas,663 receiverDeviceName: request.device_name,664 });665 });666}667668669670671672673674function requestConsent(675 request: AdoptRequest,676 sas: string,677 id: string,678): Promise<boolean> {679 return waitForSourceSasConfirmation(id, request, sas);680}681682function clearState(): void {683 activeSocket = null;684 inFlight = false;685 pendingSourceSasConfirmation.clear();686 pendingReceiverSasConfirmation.clear();687}688689function constantTimeEqual(a: string, b: string): boolean {690 if (a.length !== b.length) return false;691 let diff = 0;692 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);693 return diff === 0;694}695696function encodeJson(value: unknown): Uint8Array {697 return new TextEncoder().encode(JSON.stringify(value));698}699700function decodeJson<T>(value: Uint8Array): T {701 return JSON.parse(new TextDecoder().decode(value)) as T;702}703704705706707708709710async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {711 712 713 714 await clearDeviceRevocation(paired.deviceId);715 const secret = paired.rendezvousSecretHex;716 const pub = { ...paired };717 delete pub.rendezvousSecretHex;718 await upsertPairedDevice(pub);719 if (!isValidPairSecret(secret)) return;720 const masterKeyHex = getRootKeyHex();721 if (!masterKeyHex) return;722 const masterKey = hexToBytes(masterKeyHex);723 try {724 await setPairSecret(paired.deviceId, secret, masterKey);725 } finally {726 masterKey.fill(0);727 }728}729730function hexToBytes(hex: string): Uint8Array {731 const out = new Uint8Array(hex.length / 2);732 for (let i = 0; i < out.length; i++) {733 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);734 }735 return out;736}