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 { buildLocalIdentity, getDeviceName, getLocalPairingIdentity } from "./device-identity";38import { getSyncSettings, relayInForce } from "./sync-settings";39import { activateLicense, getLicenseKey, isProUser } from "./license";40import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";41import { deferAutoLockForOperation } from "./alarms";42import { computeVaultId } from "../shared/sync-identity";43import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";44import { storageRemove, storageSet } from "../shared/storage";45import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices";46import { isValidPairSecret, setPairSecret } from "./pair-secrets";47import { createSyncVaultAdapter } from "./sync-vault-adapter";48import { triggerSyncIfReachable } from "./sync-trigger";49import { schedulePostAdoptionSync } from "./sync-alarm";5051const SOURCE_WINDOW_MS = 5 * 60 * 1000;5253function push(type: string, payload: unknown): void {54 try {55 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;56 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {57 (maybe as Promise<unknown>).catch(() => undefined);58 }59 } catch {60 61 }62}636465function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {66 return new Promise<T>((resolve, reject) => {67 const timer = setTimeout(() => reject(new Error("timeout")), ms);68 promise.then(69 (value) => {70 clearTimeout(timer);71 resolve(value);72 },73 (error) => {74 clearTimeout(timer);75 reject(error);76 },77 );78 });79}8081let inFlight = false;82let activeSocket: PairingSocket | null = null;8384858687const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();88const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();8990export function isRelayAdoptionSourceInFlight(): boolean {91 return inFlight;92}939495969798export async function startRelayAdoptionSource(): Promise<{99 invitation: string;100}> {101 if (inFlight) throw new Error("adoption_source_in_progress");102 if (!isUnlocked()) throw new Error("vault_locked");103 inFlight = true;104 const releaseAutoLock = deferAutoLockForOperation(SOURCE_WINDOW_MS);105106 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;107 try {108 const settings = await getSyncSettings();109 const relayUrl = relayInForce(settings);110 local = await getLocalPairingIdentity();111 const token = generatePairingRendezvousToken();112 113 114 const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));115 const invitation = parsePairingInvitation(encoded);116 if (!invitation) throw new Error("invitation_generation_failed");117118 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));119 activeSocket = socket;120 const readyLocal = local;121 void runSourceSession(122 socket,123 token,124 relayUrl,125 readyLocal,126 ).finally(() => {127 readyLocal.privateKey.fill(0);128 readyLocal.publicKey.fill(0);129 clearState();130 releaseAutoLock();131 });132 return { invitation: encoded };133 } catch (error) {134 local?.privateKey.fill(0);135 local?.publicKey.fill(0);136 clearState();137 releaseAutoLock();138 throw error;139 }140}141142export function cancelRelayAdoptionSource(): void {143 activeSocket?.close();144 clearState();145}146147148149150151152153154155156157158export async function startRelayAdoptionReceiver(159 password: string,160): Promise<{ invitation: string }> {161 if (inFlight) throw new Error("adoption_receiver_in_progress");162 if (typeof password !== "string" || password.length === 0) {163 throw new Error("ADOPT_INVALID_PASSWORD");164 }165 inFlight = true;166 try {167 const settings = await getSyncSettings();168 const relayUrl = relayInForce(settings);169 const token = generatePairingRendezvousToken();170 const ephemeral = generateKeyPair();171 const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));172 const invitation = parsePairingInvitation(encoded);173 if (!invitation) throw new Error("invitation_generation_failed");174 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));175 activeSocket = socket;176 void runReceiverSession(177 socket,178 token,179 relayUrl,180 password,181 ephemeral,182 ).finally(() => {183 ephemeral.secretKey.fill(0);184 ephemeral.publicKey.fill(0);185 clearState();186 });187 return { invitation: encoded };188 } catch (error) {189 clearState();190 throw error;191 }192}193194195196197198199200201export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {202 const pending = pendingSourceSasConfirmation.get(id);203 if (!pending) return false;204 pendingSourceSasConfirmation.delete(id);205 pending.resolve(ok);206 return true;207}208209export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {210 const pending = pendingReceiverSasConfirmation.get(id);211 if (!pending) return false;212 pendingReceiverSasConfirmation.delete(id);213 pending.resolve(ok && constantTimeEqual(pending.sas, sas));214 return true;215}216217async function runSourceSession(218 socket: PairingSocket,219 expectedToken: string,220 relayUrl: string,221 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,222): Promise<void> {223 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);224 let noise: NoiseNKSession | undefined;225 try {226 const hello = decodeJson<{ type?: unknown; token?: unknown }>(227 await socket.receive(),228 );229 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||230 !constantTimeEqual(hello.token, expectedToken)) {231 throw new Error("invalid_invitation");232 }233 socket.send(encodeJson({234 type: "PEACH_HELLO_ACK",235 ephemeralPublicKeyHex: bytesToHex(local.publicKey),236 }));237238 const session = await noiseNKResponder(239 local.privateKey,240 local.publicKey,241 async (bytes) => socket.send(bytes),242 () => socket.receive(),243 );244 noise = session;245 const channel: PairingTransport = {246 async send(plaintext) { socket.send(session.encrypt(plaintext)); },247 async receive() { return session.decrypt(await socket.receive()); },248 };249 const request = await receiveFrame(channel);250 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");251252 const device = await grantAdoption(channel, request, {253 sourceIdentity: local.identity,254 pairState: {255 rendezvousSecretHex: bytesToHex(256 deriveRendezvousSecret(session.handshakeHash),257 ),258 relayUrl,259 },260 async rootKeyHex() {261 262 263 const value = getRootKeyHex();264 if (!value) throw new Error("vault_locked");265 return value;266 },267 async vaultId() {268 const value = getRootKeyHex();269 if (!value) throw new Error("vault_locked");270 const bytes = hexToBytes(value);271 try {272 return await computeVaultId(bytes);273 } finally {274 bytes.fill(0);275 }276 },277 async license() {278 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;279 },280 async applyLicense(license) {281 await applyTransferredLicense(license);282 },283 onConsent: (adoptRequest) => requestConsent(284 adoptRequest,285 derivePairingSAS(session.handshakeHash),286 crypto.randomUUID(),287 ),288 upsertPairedDevice: storeAdoptedDevice,289 });290 if (!device) {291 push("ADOPTION_SOURCE_RESULT", {292 status: "error",293 error: "adoption_rejected",294 });295 return;296 }297 298 299 300 if (request.snapshot_capable === true) {301 try {302 const adapter = createSyncVaultAdapter();303 const entryManifest = await adapter.buildEntryManifest();304 const entryIds = Object.keys(entryManifest);305 const entries =306 entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};307 const tombstones = await adapter.buildTombstoneManifest();308 let duressSlot: string | null = null;309 try {310 duressSlot = await exportDuressSlotForAdoption();311 } catch {312 313 }314 await sendVaultSnapshot(channel, entries, tombstones, duressSlot);315316 const applied = await withTimeout(receiveFrame(channel), 15_000);317 if (isAdoptSnapshotApplied(applied)) {318 console.info(319 `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,320 );321 }322323 if (adapter.buildOutgoingDeviceManifest) {324 const manifest = await adapter.buildOutgoingDeviceManifest();325 if (manifest) {326 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });327 await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);328 }329 }330 } catch (snapshotError) {331 if (332 snapshotError instanceof Error &&333 snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"334 ) {335 throw snapshotError;336 }337 console.info(338 "[Peach] inline snapshot send failed (receiver will use sync pipeline):",339 snapshotError,340 );341 }342 }343 push("ADOPTION_SOURCE_RESULT", { status: "complete" });344 345 346 347 void triggerSyncIfReachable({ reason: "manual", force: true });348 schedulePostAdoptionSync();349 } catch (error) {350 push("ADOPTION_SOURCE_RESULT", {351 status: "error",352 error: error instanceof Error ? error.message : "adoption_failed",353 });354 } finally {355 clearTimeout(timeout);356 noise?.close();357 socket.close();358 pendingSourceSasConfirmation.clear();359 }360}361362363364365366367async function runReceiverSession(368 socket: PairingSocket,369 expectedToken: string,370 relayUrl: string,371 password: string,372 invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },373): Promise<void> {374 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);375 376 const ephemeral = invitationEphemeral;377 const ephemeralPrivate = ephemeral.secretKey;378 const ephemeralPublic = ephemeral.publicKey;379 let noise: NoiseNKSession | undefined;380 let createdVault = false;381 let protocolError: string | undefined;382 try {383 const hello = decodeJson<{ type?: unknown; token?: unknown }>(384 await socket.receive(),385 );386 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||387 !constantTimeEqual(hello.token, expectedToken)) {388 throw new Error("invalid_invitation");389 }390 socket.send(encodeJson({391 type: "PEACH_HELLO_ACK",392 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),393 }));394395 const session = await noiseNKResponder(396 ephemeralPrivate,397 ephemeralPublic,398 async (bytes) => socket.send(bytes),399 () => socket.receive(),400 );401 noise = session;402 const channel: PairingTransport = {403 async send(plaintext) { socket.send(session.encrypt(plaintext)); },404 async receive() { return session.decrypt(await socket.receive()); },405 };406407 const deviceName = await getDeviceName();408409 const adoptionResult = await requestAdoption(channel, {410 deviceName,411 generateDeviceId: () => crypto.randomUUID(),412 async confirmSas() {413 const sas = derivePairingSAS(session.handshakeHash);414 const id = crypto.randomUUID();415 return waitForReceiverSasConfirmation(id, sas);416 },417 async createVaultFromRootKey(rootKeyHex) {418 419 420 421 422 423 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);424 createdVault = true;425 return { vaultId };426 },427 async applyLicense(license) {428 429 430 await applyTransferredLicense(license);431 },432 async license() {433 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;434 },435 async deriveLocalIdentity() {436 437 const masterKeyHex = getRootKeyHex();438 if (!masterKeyHex) throw new Error("identity_unavailable");439 const masterKey = hexToBytes(masterKeyHex);440 try {441 return await buildLocalIdentity(masterKey);442 } finally {443 masterKey.fill(0);444 }445 },446 async upsertPairedDevice(paired) {447 448 await storeAdoptedDevice(paired);449 },450 pairState: {451 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),452 relayUrl,453 },454 onError(error) {455 protocolError = error;456 },457 });458459 if (!adoptionResult) {460 if (createdVault) {461 await rollbackAdoptedVault();462 createdVault = false;463 }464 push("ADOPTION_SOURCE_RESULT", {465 status: "error",466 error: protocolError ?? "adoption_rejected",467 });468 return;469 }470 const { device, snapshotCapable } = adoptionResult;471 472 473 474 475 476 let snapshotApplied = false;477 if (snapshotCapable) try {478 const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);479 const adapter = createSyncVaultAdapter();480 await adapter.applyEntries(snapshot.entries);481 await adapter.applyTombstones(snapshot.tombstones);482 483 484 if (snapshot.duressSlot) {485 try {486 await installAdoptedDuressSlot(snapshot.duressSlot);487 } catch (duressError) {488 console.info(489 "[Peach] adopted duress slot install failed (non-fatal):",490 duressError,491 );492 }493 }494 await sendFrame(channel, {495 type: "ADOPT_SNAPSHOT_APPLIED",496 entries_received: Object.keys(snapshot.entries).length,497 tombstones_received: Object.keys(snapshot.tombstones).length,498 });499500 501 502 try {503 const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);504 if (505 isAdoptDeviceManifestFrame(manifestFrame) &&506 adapter.applyIncomingDeviceManifest &&507 typeof device.longTermPublicKeyHex === "string" &&508 /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)509 ) {510 await adapter.applyIncomingDeviceManifest(511 manifestFrame.manifest,512 hexToBytes(device.longTermPublicKeyHex),513 );514 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });515 }516 } catch {517 518 }519520 await markLastSyncedAt(device.deviceId, Date.now());521 snapshotApplied = true;522 } catch (snapshotError) {523 console.info(524 "[Peach] inline snapshot failed, falling back to sync pipeline:",525 snapshotError,526 );527 }528529 530 531 532 533 534 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(535 () => undefined,536 );537 push("ADOPTION_SOURCE_RESULT", { status: "complete" });538 createdVault = false;539540 if (snapshotApplied) {541 542 543 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);544 } else {545 546 547 void triggerSyncIfReachable({ reason: "manual", force: true });548 schedulePostAdoptionSync();549 }550 } catch (error) {551 if (createdVault) {552 try {553 await rollbackAdoptedVault();554 createdVault = false;555 } catch (rollbackError) {556 console.error("[Peach] rollback failed:", rollbackError);557 }558 }559 console.error("[Peach] receiver session failed:", error);560 push("ADOPTION_SOURCE_RESULT", {561 status: "error",562 error: error instanceof Error ? error.message : "adoption_failed",563 });564 } finally {565 clearTimeout(timeout);566 noise?.close();567 socket.close();568 ephemeralPrivate.fill(0);569 ephemeralPublic.fill(0);570 }571}572573574575576577578async function applyTransferredLicense(license: string): Promise<void> {579 const activated = await activateLicense(license);580 if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {581 throw new Error("license_transfer_failed");582 }583}584585586587588589590591592593594595function waitForReceiverSasConfirmation(596 id: string,597 sas: string,598): Promise<boolean> {599 return new Promise<boolean>((resolve) => {600 const timer = setTimeout(() => {601 pendingReceiverSasConfirmation.delete(id);602 resolve(false);603 }, SOURCE_WINDOW_MS);604 pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {605 clearTimeout(timer);606 resolve(ok);607 } });608 push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });609 });610}611612function waitForSourceSasConfirmation(613 id: string,614 request: AdoptRequest,615 sas: string,616): Promise<boolean> {617 return new Promise<boolean>((resolve) => {618 const timer = setTimeout(() => {619 pendingSourceSasConfirmation.delete(id);620 resolve(false);621 }, SOURCE_WINDOW_MS);622 pendingSourceSasConfirmation.set(id, {623 resolve: (ok) => {624 clearTimeout(timer);625 resolve(ok);626 },627 });628 push("ADOPTION_SOURCE_SAS", {629 id,630 sas,631 receiverDeviceName: request.device_name,632 });633 });634}635636637638639640641642function requestConsent(643 request: AdoptRequest,644 sas: string,645 id: string,646): Promise<boolean> {647 return waitForSourceSasConfirmation(id, request, sas);648}649650function clearState(): void {651 activeSocket = null;652 inFlight = false;653 pendingSourceSasConfirmation.clear();654 pendingReceiverSasConfirmation.clear();655}656657function constantTimeEqual(a: string, b: string): boolean {658 if (a.length !== b.length) return false;659 let diff = 0;660 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);661 return diff === 0;662}663664function encodeJson(value: unknown): Uint8Array {665 return new TextEncoder().encode(JSON.stringify(value));666}667668function decodeJson<T>(value: Uint8Array): T {669 return JSON.parse(new TextDecoder().decode(value)) as T;670}671672673674675676677678async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {679 const secret = paired.rendezvousSecretHex;680 const pub = { ...paired };681 delete pub.rendezvousSecretHex;682 await upsertPairedDevice(pub);683 if (!isValidPairSecret(secret)) return;684 const masterKeyHex = getRootKeyHex();685 if (!masterKeyHex) return;686 const masterKey = hexToBytes(masterKeyHex);687 try {688 await setPairSecret(paired.deviceId, secret, masterKey);689 } finally {690 masterKey.fill(0);691 }692}693694function hexToBytes(hex: string): Uint8Array {695 const out = new Uint8Array(hex.length / 2);696 for (let i = 0; i < out.length; i++) {697 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);698 }699 return out;700}