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 {32 bytesToHex,33 deriveRendezvousSecret,34 generatePairingRendezvousToken,35} from "../shared/rendezvous";36import { buildLocalIdentity, getDeviceName, getLocalPairingIdentity } from "./device-identity";37import { getSyncSettings, relayInForce } from "./sync-settings";38import { activateLicense, getLicenseKey, isProUser } from "./license";39import { createVaultFromRootKey, getRootKeyHex, isUnlocked, rollbackAdoptedVault } from "./vault";40import { computeVaultId } from "../shared/sync-identity";41import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";42import { storageRemove, storageSet } from "../shared/storage";43import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices";44import { createSyncVaultAdapter } from "./sync-vault-adapter";45import { triggerSyncIfReachable } from "./sync-trigger";4647const SOURCE_WINDOW_MS = 5 * 60 * 1000;4849function push(type: string, payload: unknown): void {50 try {51 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;52 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {53 (maybe as Promise<unknown>).catch(() => undefined);54 }55 } catch {56 57 }58}596061function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {62 return new Promise<T>((resolve, reject) => {63 const timer = setTimeout(() => reject(new Error("timeout")), ms);64 promise.then(65 (value) => {66 clearTimeout(timer);67 resolve(value);68 },69 (error) => {70 clearTimeout(timer);71 reject(error);72 },73 );74 });75}7677let inFlight = false;78let activeSocket: PairingSocket | null = null;7980818283const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();8485export function isRelayAdoptionSourceInFlight(): boolean {86 return inFlight;87}888990919293export async function startRelayAdoptionSource(): Promise<{94 invitation: string;95}> {96 if (inFlight) throw new Error("adoption_source_in_progress");97 if (!isUnlocked()) throw new Error("vault_locked");98 inFlight = true;99100 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;101 try {102 const settings = await getSyncSettings();103 const relayUrl = relayInForce(settings);104 local = await getLocalPairingIdentity();105 const token = generatePairingRendezvousToken();106 107 108 const encoded = createPairingInvitation(relayUrl, token, "populated");109 const invitation = parsePairingInvitation(encoded);110 if (!invitation) throw new Error("invitation_generation_failed");111112 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));113 activeSocket = socket;114 const readyLocal = local;115 void runSourceSession(116 socket,117 token,118 relayUrl,119 readyLocal,120 ).finally(() => {121 readyLocal.privateKey.fill(0);122 readyLocal.publicKey.fill(0);123 clearState();124 });125 return { invitation: encoded };126 } catch (error) {127 local?.privateKey.fill(0);128 local?.publicKey.fill(0);129 clearState();130 throw error;131 }132}133134export function cancelRelayAdoptionSource(): void {135 activeSocket?.close();136 clearState();137}138139140141142143144145146147148149150export async function startRelayAdoptionReceiver(151 password: string,152): Promise<{ invitation: string }> {153 if (inFlight) throw new Error("adoption_receiver_in_progress");154 if (typeof password !== "string" || password.length === 0) {155 throw new Error("ADOPT_INVALID_PASSWORD");156 }157 inFlight = true;158 try {159 const settings = await getSyncSettings();160 const relayUrl = relayInForce(settings);161 const token = generatePairingRendezvousToken();162 const encoded = createPairingInvitation(relayUrl, token, "empty");163 const invitation = parsePairingInvitation(encoded);164 if (!invitation) throw new Error("invitation_generation_failed");165 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));166 activeSocket = socket;167 void runReceiverSession(168 socket,169 token,170 relayUrl,171 password,172 ).finally(clearState);173 return { invitation: encoded };174 } catch (error) {175 clearState();176 throw error;177 }178}179180181182183184185186187export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {188 const pending = pendingSourceSasConfirmation.get(id);189 if (!pending) return false;190 pendingSourceSasConfirmation.delete(id);191 pending.resolve(ok);192 return true;193}194195async function runSourceSession(196 socket: PairingSocket,197 expectedToken: string,198 relayUrl: string,199 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,200): Promise<void> {201 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);202 let noise: NoiseNKSession | undefined;203 try {204 const hello = decodeJson<{ type?: unknown; token?: unknown }>(205 await socket.receive(),206 );207 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||208 !constantTimeEqual(hello.token, expectedToken)) {209 throw new Error("invalid_invitation");210 }211 socket.send(encodeJson({212 type: "PEACH_HELLO_ACK",213 ephemeralPublicKeyHex: bytesToHex(local.publicKey),214 }));215216 const session = await noiseNKResponder(217 local.privateKey,218 local.publicKey,219 async (bytes) => socket.send(bytes),220 () => socket.receive(),221 );222 noise = session;223 const channel: PairingTransport = {224 async send(plaintext) { socket.send(session.encrypt(plaintext)); },225 async receive() { return session.decrypt(await socket.receive()); },226 };227 const request = await receiveFrame(channel);228 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");229230 const device = await grantAdoption(channel, request, {231 sourceIdentity: local.identity,232 pairState: {233 rendezvousSecretHex: bytesToHex(234 deriveRendezvousSecret(session.handshakeHash),235 ),236 relayUrl,237 },238 async rootKeyHex() {239 240 241 const value = getRootKeyHex();242 if (!value) throw new Error("vault_locked");243 return value;244 },245 async vaultId() {246 const value = getRootKeyHex();247 if (!value) throw new Error("vault_locked");248 const bytes = hexToBytes(value);249 try {250 return await computeVaultId(bytes);251 } finally {252 bytes.fill(0);253 }254 },255 async license() {256 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;257 },258 async applyLicense(license) {259 await applyTransferredLicense(license);260 },261 onConsent: (adoptRequest) => requestConsent(262 adoptRequest,263 derivePairingSAS(session.handshakeHash),264 crypto.randomUUID(),265 ),266 upsertPairedDevice,267 });268 if (!device) {269 push("ADOPTION_SOURCE_RESULT", {270 status: "error",271 error: "adoption_rejected",272 });273 return;274 }275 276 277 278 if (request.snapshot_capable === true) {279 try {280 const adapter = createSyncVaultAdapter();281 const entryManifest = await adapter.buildEntryManifest();282 const entryIds = Object.keys(entryManifest);283 const entries =284 entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};285 const tombstones = await adapter.buildTombstoneManifest();286 await sendVaultSnapshot(channel, entries, tombstones);287288 const applied = await withTimeout(receiveFrame(channel), 15_000);289 if (isAdoptSnapshotApplied(applied)) {290 console.info(291 `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,292 );293 }294295 if (adapter.buildOutgoingDeviceManifest) {296 const manifest = await adapter.buildOutgoingDeviceManifest();297 if (manifest) {298 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });299 await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);300 }301 }302 } catch (snapshotError) {303 console.info(304 "[Peach] inline snapshot send failed (receiver will use sync pipeline):",305 snapshotError,306 );307 }308 }309 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });310 311 312 313 314 void triggerSyncIfReachable({ reason: "manual", force: true });315 setTimeout(() => {316 void triggerSyncIfReachable({ reason: "manual", force: true });317 }, 1_000);318 } catch (error) {319 push("ADOPTION_SOURCE_RESULT", {320 status: "error",321 error: error instanceof Error ? error.message : "adoption_failed",322 });323 } finally {324 clearTimeout(timeout);325 noise?.close();326 socket.close();327 pendingSourceSasConfirmation.clear();328 }329}330331332333334335336async function runReceiverSession(337 socket: PairingSocket,338 expectedToken: string,339 relayUrl: string,340 password: string,341): Promise<void> {342 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);343 344 const ephemeral = generateKeyPair();345 const ephemeralPrivate = ephemeral.secretKey;346 const ephemeralPublic = ephemeral.publicKey;347 let noise: NoiseNKSession | undefined;348 let createdVault = false;349 let protocolError: string | undefined;350 try {351 const hello = decodeJson<{ type?: unknown; token?: unknown }>(352 await socket.receive(),353 );354 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||355 !constantTimeEqual(hello.token, expectedToken)) {356 throw new Error("invalid_invitation");357 }358 socket.send(encodeJson({359 type: "PEACH_HELLO_ACK",360 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),361 }));362363 const session = await noiseNKResponder(364 ephemeralPrivate,365 ephemeralPublic,366 async (bytes) => socket.send(bytes),367 () => socket.receive(),368 );369 noise = session;370 const channel: PairingTransport = {371 async send(plaintext) { socket.send(session.encrypt(plaintext)); },372 async receive() { return session.decrypt(await socket.receive()); },373 };374375 const deviceName = await getDeviceName();376377 const device = await requestAdoption(channel, {378 deviceName,379 generateDeviceId: () => crypto.randomUUID(),380 381 382 383 384 385 386 async confirmSas() {387 push("ADOPTION_SOURCE_SAS", {388 sas: derivePairingSAS(session.handshakeHash),389 displayOnly: true,390 });391 return true;392 },393 async createVaultFromRootKey(rootKeyHex) {394 395 396 397 398 399 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);400 createdVault = true;401 return { vaultId };402 },403 async applyLicense(license) {404 405 406 await applyTransferredLicense(license);407 },408 async license() {409 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;410 },411 async deriveLocalIdentity() {412 413 const masterKeyHex = getRootKeyHex();414 if (!masterKeyHex) throw new Error("identity_unavailable");415 const masterKey = hexToBytes(masterKeyHex);416 try {417 return await buildLocalIdentity(masterKey);418 } finally {419 masterKey.fill(0);420 }421 },422 async upsertPairedDevice(paired) {423 424 await upsertPairedDevice(paired);425 },426 pairState: {427 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),428 relayUrl,429 },430 onError(error) {431 protocolError = error;432 },433 });434435 if (!device) {436 if (createdVault) {437 await rollbackAdoptedVault();438 createdVault = false;439 }440 push("ADOPTION_SOURCE_RESULT", {441 status: "error",442 error: protocolError ?? "adoption_rejected",443 });444 return;445 }446 447 448 449 450 let snapshotApplied = false;451 try {452 const snapshot = await withTimeout(receiveVaultSnapshot(channel), 15_000);453 const adapter = createSyncVaultAdapter();454 await adapter.applyEntries(snapshot.entries);455 await adapter.applyTombstones(snapshot.tombstones);456 await sendFrame(channel, {457 type: "ADOPT_SNAPSHOT_APPLIED",458 entries_received: Object.keys(snapshot.entries).length,459 tombstones_received: Object.keys(snapshot.tombstones).length,460 });461462 463 464 try {465 const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);466 if (467 isAdoptDeviceManifestFrame(manifestFrame) &&468 adapter.applyIncomingDeviceManifest &&469 typeof device.longTermPublicKeyHex === "string" &&470 /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)471 ) {472 await adapter.applyIncomingDeviceManifest(473 manifestFrame.manifest,474 hexToBytes(device.longTermPublicKeyHex),475 );476 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });477 }478 } catch {479 480 }481482 await markLastSyncedAt(device.deviceId, Date.now());483 snapshotApplied = true;484 } catch (snapshotError) {485 console.info(486 "[Peach] inline snapshot failed, falling back to sync pipeline:",487 snapshotError,488 );489 }490491 492 493 494 495 496 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(497 () => undefined,498 );499 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });500 createdVault = false;501502 if (snapshotApplied) {503 504 505 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);506 } else {507 508 void triggerSyncIfReachable({ reason: "manual", force: true });509 }510 } catch (error) {511 if (createdVault) {512 try {513 await rollbackAdoptedVault();514 createdVault = false;515 } catch (rollbackError) {516 console.error("[Peach] rollback failed:", rollbackError);517 }518 }519 console.error("[Peach] receiver session failed:", error);520 push("ADOPTION_SOURCE_RESULT", {521 status: "error",522 error: error instanceof Error ? error.message : "adoption_failed",523 });524 } finally {525 clearTimeout(timeout);526 noise?.close();527 socket.close();528 ephemeralPrivate.fill(0);529 ephemeralPublic.fill(0);530 }531}532533534535536537538async function applyTransferredLicense(license: string): Promise<void> {539 const activated = await activateLicense(license);540 if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {541 throw new Error("license_transfer_failed");542 }543}544545546547548549550551552553554555function waitForSourceSasConfirmation(556 id: string,557 request: AdoptRequest,558 sas: string,559): Promise<boolean> {560 return new Promise<boolean>((resolve) => {561 const timer = setTimeout(() => {562 pendingSourceSasConfirmation.delete(id);563 resolve(false);564 }, SOURCE_WINDOW_MS);565 pendingSourceSasConfirmation.set(id, {566 resolve: (ok) => {567 clearTimeout(timer);568 resolve(ok);569 },570 });571 push("ADOPTION_SOURCE_SAS", {572 id,573 sas,574 receiverDeviceName: request.device_name,575 });576 });577}578579580581582583584585function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {586 return waitForSourceSasConfirmation(id, request, sas);587}588589function clearState(): void {590 activeSocket = null;591 inFlight = false;592 pendingSourceSasConfirmation.clear();593}594595function constantTimeEqual(a: string, b: string): boolean {596 if (a.length !== b.length) return false;597 let diff = 0;598 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);599 return diff === 0;600}601602function encodeJson(value: unknown): Uint8Array {603 return new TextEncoder().encode(JSON.stringify(value));604}605606function decodeJson<T>(value: Uint8Array): T {607 return JSON.parse(new TextDecoder().decode(value)) as T;608}609610function hexToBytes(hex: string): Uint8Array {611 const out = new Uint8Array(hex.length / 2);612 for (let i = 0; i < out.length; i++) {613 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);614 }615 return out;616}