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 { computeVaultId } from "../shared/sync-identity";42import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";43import { storageRemove, storageSet } from "../shared/storage";44import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices";45import { isValidPairSecret, setPairSecret } from "./pair-secrets";46import { createSyncVaultAdapter } from "./sync-vault-adapter";47import { triggerSyncIfReachable } from "./sync-trigger";48import { schedulePostAdoptionSync } from "./sync-alarm";4950const SOURCE_WINDOW_MS = 5 * 60 * 1000;5152function push(type: string, payload: unknown): void {53 try {54 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;55 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {56 (maybe as Promise<unknown>).catch(() => undefined);57 }58 } catch {59 60 }61}626364function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {65 return new Promise<T>((resolve, reject) => {66 const timer = setTimeout(() => reject(new Error("timeout")), ms);67 promise.then(68 (value) => {69 clearTimeout(timer);70 resolve(value);71 },72 (error) => {73 clearTimeout(timer);74 reject(error);75 },76 );77 });78}7980let inFlight = false;81let activeSocket: PairingSocket | null = null;8283848586const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();87const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();8889export function isRelayAdoptionSourceInFlight(): boolean {90 return inFlight;91}929394959697export async function startRelayAdoptionSource(): Promise<{98 invitation: string;99}> {100 if (inFlight) throw new Error("adoption_source_in_progress");101 if (!isUnlocked()) throw new Error("vault_locked");102 inFlight = true;103104 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;105 try {106 const settings = await getSyncSettings();107 const relayUrl = relayInForce(settings);108 local = await getLocalPairingIdentity();109 const token = generatePairingRendezvousToken();110 111 112 const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));113 const invitation = parsePairingInvitation(encoded);114 if (!invitation) throw new Error("invitation_generation_failed");115116 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));117 activeSocket = socket;118 const readyLocal = local;119 void runSourceSession(120 socket,121 token,122 relayUrl,123 readyLocal,124 ).finally(() => {125 readyLocal.privateKey.fill(0);126 readyLocal.publicKey.fill(0);127 clearState();128 });129 return { invitation: encoded };130 } catch (error) {131 local?.privateKey.fill(0);132 local?.publicKey.fill(0);133 clearState();134 throw error;135 }136}137138export function cancelRelayAdoptionSource(): void {139 activeSocket?.close();140 clearState();141}142143144145146147148149150151152153154export async function startRelayAdoptionReceiver(155 password: string,156): Promise<{ invitation: string }> {157 if (inFlight) throw new Error("adoption_receiver_in_progress");158 if (typeof password !== "string" || password.length === 0) {159 throw new Error("ADOPT_INVALID_PASSWORD");160 }161 inFlight = true;162 try {163 const settings = await getSyncSettings();164 const relayUrl = relayInForce(settings);165 const token = generatePairingRendezvousToken();166 const ephemeral = generateKeyPair();167 const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));168 const invitation = parsePairingInvitation(encoded);169 if (!invitation) throw new Error("invitation_generation_failed");170 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));171 activeSocket = socket;172 void runReceiverSession(173 socket,174 token,175 relayUrl,176 password,177 ephemeral,178 ).finally(() => {179 ephemeral.secretKey.fill(0);180 ephemeral.publicKey.fill(0);181 clearState();182 });183 return { invitation: encoded };184 } catch (error) {185 clearState();186 throw error;187 }188}189190191192193194195196197export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {198 const pending = pendingSourceSasConfirmation.get(id);199 if (!pending) return false;200 pendingSourceSasConfirmation.delete(id);201 pending.resolve(ok);202 return true;203}204205export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {206 const pending = pendingReceiverSasConfirmation.get(id);207 if (!pending) return false;208 pendingReceiverSasConfirmation.delete(id);209 pending.resolve(ok && constantTimeEqual(pending.sas, sas));210 return true;211}212213async function runSourceSession(214 socket: PairingSocket,215 expectedToken: string,216 relayUrl: string,217 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,218): Promise<void> {219 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);220 let noise: NoiseNKSession | undefined;221 try {222 const hello = decodeJson<{ type?: unknown; token?: unknown }>(223 await socket.receive(),224 );225 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||226 !constantTimeEqual(hello.token, expectedToken)) {227 throw new Error("invalid_invitation");228 }229 socket.send(encodeJson({230 type: "PEACH_HELLO_ACK",231 ephemeralPublicKeyHex: bytesToHex(local.publicKey),232 }));233234 const session = await noiseNKResponder(235 local.privateKey,236 local.publicKey,237 async (bytes) => socket.send(bytes),238 () => socket.receive(),239 );240 noise = session;241 const channel: PairingTransport = {242 async send(plaintext) { socket.send(session.encrypt(plaintext)); },243 async receive() { return session.decrypt(await socket.receive()); },244 };245 const request = await receiveFrame(channel);246 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");247248 const device = await grantAdoption(channel, request, {249 sourceIdentity: local.identity,250 pairState: {251 rendezvousSecretHex: bytesToHex(252 deriveRendezvousSecret(session.handshakeHash),253 ),254 relayUrl,255 },256 async rootKeyHex() {257 258 259 const value = getRootKeyHex();260 if (!value) throw new Error("vault_locked");261 return value;262 },263 async vaultId() {264 const value = getRootKeyHex();265 if (!value) throw new Error("vault_locked");266 const bytes = hexToBytes(value);267 try {268 return await computeVaultId(bytes);269 } finally {270 bytes.fill(0);271 }272 },273 async license() {274 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;275 },276 async applyLicense(license) {277 await applyTransferredLicense(license);278 },279 onConsent: (adoptRequest) => requestConsent(280 adoptRequest,281 derivePairingSAS(session.handshakeHash),282 crypto.randomUUID(),283 session.handshakeHash,284 ),285 upsertPairedDevice: storeAdoptedDevice,286 });287 if (!device) {288 push("ADOPTION_SOURCE_RESULT", {289 status: "error",290 error: "adoption_rejected",291 });292 return;293 }294 295 296 297 if (request.snapshot_capable === true) {298 try {299 const adapter = createSyncVaultAdapter();300 const entryManifest = await adapter.buildEntryManifest();301 const entryIds = Object.keys(entryManifest);302 const entries =303 entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};304 const tombstones = await adapter.buildTombstoneManifest();305 let duressSlot: string | null = null;306 try {307 duressSlot = await exportDuressSlotForAdoption();308 } catch {309 310 }311 await sendVaultSnapshot(channel, entries, tombstones, duressSlot);312313 const applied = await withTimeout(receiveFrame(channel), 15_000);314 if (isAdoptSnapshotApplied(applied)) {315 console.info(316 `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,317 );318 }319320 if (adapter.buildOutgoingDeviceManifest) {321 const manifest = await adapter.buildOutgoingDeviceManifest();322 if (manifest) {323 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });324 await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);325 }326 }327 } catch (snapshotError) {328 if (329 snapshotError instanceof Error &&330 snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"331 ) {332 throw snapshotError;333 }334 console.info(335 "[Peach] inline snapshot send failed (receiver will use sync pipeline):",336 snapshotError,337 );338 }339 }340 push("ADOPTION_SOURCE_RESULT", { status: "complete" });341 342 343 344 void triggerSyncIfReachable({ reason: "manual", force: true });345 schedulePostAdoptionSync();346 } catch (error) {347 push("ADOPTION_SOURCE_RESULT", {348 status: "error",349 error: error instanceof Error ? error.message : "adoption_failed",350 });351 } finally {352 clearTimeout(timeout);353 noise?.close();354 socket.close();355 pendingSourceSasConfirmation.clear();356 }357}358359360361362363364async function runReceiverSession(365 socket: PairingSocket,366 expectedToken: string,367 relayUrl: string,368 password: string,369 invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },370): Promise<void> {371 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);372 373 const ephemeral = invitationEphemeral;374 const ephemeralPrivate = ephemeral.secretKey;375 const ephemeralPublic = ephemeral.publicKey;376 let noise: NoiseNKSession | undefined;377 let createdVault = false;378 let protocolError: string | undefined;379 try {380 const hello = decodeJson<{ type?: unknown; token?: unknown }>(381 await socket.receive(),382 );383 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||384 !constantTimeEqual(hello.token, expectedToken)) {385 throw new Error("invalid_invitation");386 }387 socket.send(encodeJson({388 type: "PEACH_HELLO_ACK",389 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),390 }));391392 const session = await noiseNKResponder(393 ephemeralPrivate,394 ephemeralPublic,395 async (bytes) => socket.send(bytes),396 () => socket.receive(),397 );398 noise = session;399 const channel: PairingTransport = {400 async send(plaintext) { socket.send(session.encrypt(plaintext)); },401 async receive() { return session.decrypt(await socket.receive()); },402 };403404 const deviceName = await getDeviceName();405406 const adoptionResult = await requestAdoption(channel, {407 deviceName,408 generateDeviceId: () => crypto.randomUUID(),409 async confirmSas() {410 const sas = derivePairingSAS(session.handshakeHash);411 const id = crypto.randomUUID();412 return waitForReceiverSasConfirmation(id, sas, session.handshakeHash);413 },414 async createVaultFromRootKey(rootKeyHex) {415 416 417 418 419 420 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);421 createdVault = true;422 return { vaultId };423 },424 async applyLicense(license) {425 426 427 await applyTransferredLicense(license);428 },429 async license() {430 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;431 },432 async deriveLocalIdentity() {433 434 const masterKeyHex = getRootKeyHex();435 if (!masterKeyHex) throw new Error("identity_unavailable");436 const masterKey = hexToBytes(masterKeyHex);437 try {438 return await buildLocalIdentity(masterKey);439 } finally {440 masterKey.fill(0);441 }442 },443 async upsertPairedDevice(paired) {444 445 await storeAdoptedDevice(paired);446 },447 pairState: {448 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),449 relayUrl,450 },451 onError(error) {452 protocolError = error;453 },454 });455456 if (!adoptionResult) {457 if (createdVault) {458 await rollbackAdoptedVault();459 createdVault = false;460 }461 push("ADOPTION_SOURCE_RESULT", {462 status: "error",463 error: protocolError ?? "adoption_rejected",464 });465 return;466 }467 const { device, snapshotCapable } = adoptionResult;468 469 470 471 472 473 let snapshotApplied = false;474 if (snapshotCapable) try {475 const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);476 const adapter = createSyncVaultAdapter();477 await adapter.applyEntries(snapshot.entries);478 await adapter.applyTombstones(snapshot.tombstones);479 480 481 if (snapshot.duressSlot) {482 try {483 await installAdoptedDuressSlot(snapshot.duressSlot);484 } catch (duressError) {485 console.info(486 "[Peach] adopted duress slot install failed (non-fatal):",487 duressError,488 );489 }490 }491 await sendFrame(channel, {492 type: "ADOPT_SNAPSHOT_APPLIED",493 entries_received: Object.keys(snapshot.entries).length,494 tombstones_received: Object.keys(snapshot.tombstones).length,495 });496497 498 499 try {500 const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);501 if (502 isAdoptDeviceManifestFrame(manifestFrame) &&503 adapter.applyIncomingDeviceManifest &&504 typeof device.longTermPublicKeyHex === "string" &&505 /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)506 ) {507 await adapter.applyIncomingDeviceManifest(508 manifestFrame.manifest,509 hexToBytes(device.longTermPublicKeyHex),510 );511 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });512 }513 } catch {514 515 }516517 await markLastSyncedAt(device.deviceId, Date.now());518 snapshotApplied = true;519 } catch (snapshotError) {520 console.info(521 "[Peach] inline snapshot failed, falling back to sync pipeline:",522 snapshotError,523 );524 }525526 527 528 529 530 531 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(532 () => undefined,533 );534 push("ADOPTION_SOURCE_RESULT", { status: "complete" });535 createdVault = false;536537 if (snapshotApplied) {538 539 540 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);541 } else {542 543 544 void triggerSyncIfReachable({ reason: "manual", force: true });545 schedulePostAdoptionSync();546 }547 } catch (error) {548 if (createdVault) {549 try {550 await rollbackAdoptedVault();551 createdVault = false;552 } catch (rollbackError) {553 console.error("[Peach] rollback failed:", rollbackError);554 }555 }556 console.error("[Peach] receiver session failed:", error);557 push("ADOPTION_SOURCE_RESULT", {558 status: "error",559 error: error instanceof Error ? error.message : "adoption_failed",560 });561 } finally {562 clearTimeout(timeout);563 noise?.close();564 socket.close();565 ephemeralPrivate.fill(0);566 ephemeralPublic.fill(0);567 }568}569570571572573574575async function applyTransferredLicense(license: string): Promise<void> {576 const activated = await activateLicense(license);577 if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {578 throw new Error("license_transfer_failed");579 }580}581582583584585586587588589590591592function pairingSessionFingerprint(handshakeHash: Uint8Array): string {593 return bytesToHex(handshakeHash).slice(0, 12).match(/.{4}/g)?.join(" ") ?? "";594}595596function waitForReceiverSasConfirmation(597 id: string,598 sas: string,599 handshakeHash: Uint8Array,600): Promise<boolean> {601 return new Promise<boolean>((resolve) => {602 const timer = setTimeout(() => {603 pendingReceiverSasConfirmation.delete(id);604 resolve(false);605 }, SOURCE_WINDOW_MS);606 pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {607 clearTimeout(timer);608 resolve(ok);609 } });610 push("ADOPTION_SOURCE_SAS", {611 id,612 sas,613 displayOnly: false,614 peerFingerprint: pairingSessionFingerprint(handshakeHash),615 });616 });617}618619function waitForSourceSasConfirmation(620 id: string,621 request: AdoptRequest,622 sas: string,623 handshakeHash: Uint8Array,624): Promise<boolean> {625 return new Promise<boolean>((resolve) => {626 const timer = setTimeout(() => {627 pendingSourceSasConfirmation.delete(id);628 resolve(false);629 }, SOURCE_WINDOW_MS);630 pendingSourceSasConfirmation.set(id, {631 resolve: (ok) => {632 clearTimeout(timer);633 resolve(ok);634 },635 });636 push("ADOPTION_SOURCE_SAS", {637 id,638 sas,639 receiverDeviceName: request.device_name,640 peerFingerprint: pairingSessionFingerprint(handshakeHash),641 });642 });643}644645646647648649650651function requestConsent(652 request: AdoptRequest,653 sas: string,654 id: string,655 handshakeHash: Uint8Array,656): Promise<boolean> {657 return waitForSourceSasConfirmation(id, request, sas, handshakeHash);658}659660function clearState(): void {661 activeSocket = null;662 inFlight = false;663 pendingSourceSasConfirmation.clear();664 pendingReceiverSasConfirmation.clear();665}666667function constantTimeEqual(a: string, b: string): boolean {668 if (a.length !== b.length) return false;669 let diff = 0;670 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);671 return diff === 0;672}673674function encodeJson(value: unknown): Uint8Array {675 return new TextEncoder().encode(JSON.stringify(value));676}677678function decodeJson<T>(value: Uint8Array): T {679 return JSON.parse(new TextDecoder().decode(value)) as T;680}681682683684685686687688async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {689 const secret = paired.rendezvousSecretHex;690 const pub = { ...paired };691 delete pub.rendezvousSecretHex;692 await upsertPairedDevice(pub);693 if (!isValidPairSecret(secret)) return;694 const masterKeyHex = getRootKeyHex();695 if (!masterKeyHex) return;696 const masterKey = hexToBytes(masterKeyHex);697 try {698 await setPairSecret(paired.deviceId, secret, masterKey);699 } finally {700 masterKey.fill(0);701 }702}703704function hexToBytes(hex: string): Uint8Array {705 const out = new Uint8Array(hex.length / 2);706 for (let i = 0; i < out.length; i++) {707 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);708 }709 return out;710}