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, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, 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";46import { schedulePostAdoptionSync } from "./sync-alarm";4748const SOURCE_WINDOW_MS = 5 * 60 * 1000;4950function push(type: string, payload: unknown): void {51 try {52 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;53 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {54 (maybe as Promise<unknown>).catch(() => undefined);55 }56 } catch {57 58 }59}606162function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {63 return new Promise<T>((resolve, reject) => {64 const timer = setTimeout(() => reject(new Error("timeout")), ms);65 promise.then(66 (value) => {67 clearTimeout(timer);68 resolve(value);69 },70 (error) => {71 clearTimeout(timer);72 reject(error);73 },74 );75 });76}7778let inFlight = false;79let activeSocket: PairingSocket | null = null;8081828384const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();8586export function isRelayAdoptionSourceInFlight(): boolean {87 return inFlight;88}899091929394export async function startRelayAdoptionSource(): Promise<{95 invitation: string;96}> {97 if (inFlight) throw new Error("adoption_source_in_progress");98 if (!isUnlocked()) throw new Error("vault_locked");99 inFlight = true;100101 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;102 try {103 const settings = await getSyncSettings();104 const relayUrl = relayInForce(settings);105 local = await getLocalPairingIdentity();106 const token = generatePairingRendezvousToken();107 108 109 const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));110 const invitation = parsePairingInvitation(encoded);111 if (!invitation) throw new Error("invitation_generation_failed");112113 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));114 activeSocket = socket;115 const readyLocal = local;116 void runSourceSession(117 socket,118 token,119 relayUrl,120 readyLocal,121 ).finally(() => {122 readyLocal.privateKey.fill(0);123 readyLocal.publicKey.fill(0);124 clearState();125 });126 return { invitation: encoded };127 } catch (error) {128 local?.privateKey.fill(0);129 local?.publicKey.fill(0);130 clearState();131 throw error;132 }133}134135export function cancelRelayAdoptionSource(): void {136 activeSocket?.close();137 clearState();138}139140141142143144145146147148149150151export async function startRelayAdoptionReceiver(152 password: string,153): Promise<{ invitation: string }> {154 if (inFlight) throw new Error("adoption_receiver_in_progress");155 if (typeof password !== "string" || password.length === 0) {156 throw new Error("ADOPT_INVALID_PASSWORD");157 }158 inFlight = true;159 try {160 const settings = await getSyncSettings();161 const relayUrl = relayInForce(settings);162 const token = generatePairingRendezvousToken();163 const ephemeral = generateKeyPair();164 const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));165 const invitation = parsePairingInvitation(encoded);166 if (!invitation) throw new Error("invitation_generation_failed");167 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));168 activeSocket = socket;169 void runReceiverSession(170 socket,171 token,172 relayUrl,173 password,174 ephemeral,175 ).finally(() => {176 ephemeral.secretKey.fill(0);177 ephemeral.publicKey.fill(0);178 clearState();179 });180 return { invitation: encoded };181 } catch (error) {182 clearState();183 throw error;184 }185}186187188189190191192193194export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {195 const pending = pendingSourceSasConfirmation.get(id);196 if (!pending) return false;197 pendingSourceSasConfirmation.delete(id);198 pending.resolve(ok);199 return true;200}201202async function runSourceSession(203 socket: PairingSocket,204 expectedToken: string,205 relayUrl: string,206 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,207): Promise<void> {208 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);209 let noise: NoiseNKSession | undefined;210 try {211 const hello = decodeJson<{ type?: unknown; token?: unknown }>(212 await socket.receive(),213 );214 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||215 !constantTimeEqual(hello.token, expectedToken)) {216 throw new Error("invalid_invitation");217 }218 socket.send(encodeJson({219 type: "PEACH_HELLO_ACK",220 ephemeralPublicKeyHex: bytesToHex(local.publicKey),221 }));222223 const session = await noiseNKResponder(224 local.privateKey,225 local.publicKey,226 async (bytes) => socket.send(bytes),227 () => socket.receive(),228 );229 noise = session;230 const channel: PairingTransport = {231 async send(plaintext) { socket.send(session.encrypt(plaintext)); },232 async receive() { return session.decrypt(await socket.receive()); },233 };234 const request = await receiveFrame(channel);235 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");236237 const device = await grantAdoption(channel, request, {238 sourceIdentity: local.identity,239 pairState: {240 rendezvousSecretHex: bytesToHex(241 deriveRendezvousSecret(session.handshakeHash),242 ),243 relayUrl,244 },245 async rootKeyHex() {246 247 248 const value = getRootKeyHex();249 if (!value) throw new Error("vault_locked");250 return value;251 },252 async vaultId() {253 const value = getRootKeyHex();254 if (!value) throw new Error("vault_locked");255 const bytes = hexToBytes(value);256 try {257 return await computeVaultId(bytes);258 } finally {259 bytes.fill(0);260 }261 },262 async license() {263 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;264 },265 async applyLicense(license) {266 await applyTransferredLicense(license);267 },268 onConsent: (adoptRequest) => requestConsent(269 adoptRequest,270 derivePairingSAS(session.handshakeHash),271 crypto.randomUUID(),272 ),273 upsertPairedDevice,274 });275 if (!device) {276 push("ADOPTION_SOURCE_RESULT", {277 status: "error",278 error: "adoption_rejected",279 });280 return;281 }282 283 284 285 if (request.snapshot_capable === true) {286 try {287 const adapter = createSyncVaultAdapter();288 const entryManifest = await adapter.buildEntryManifest();289 const entryIds = Object.keys(entryManifest);290 const entries =291 entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};292 const tombstones = await adapter.buildTombstoneManifest();293 let duressSlot: string | null = null;294 try {295 duressSlot = await exportDuressSlotForAdoption();296 } catch {297 298 }299 await sendVaultSnapshot(channel, entries, tombstones, duressSlot);300301 const applied = await withTimeout(receiveFrame(channel), 15_000);302 if (isAdoptSnapshotApplied(applied)) {303 console.info(304 `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,305 );306 }307308 if (adapter.buildOutgoingDeviceManifest) {309 const manifest = await adapter.buildOutgoingDeviceManifest();310 if (manifest) {311 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });312 await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);313 }314 }315 } catch (snapshotError) {316 if (317 snapshotError instanceof Error &&318 snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"319 ) {320 throw snapshotError;321 }322 console.info(323 "[Peach] inline snapshot send failed (receiver will use sync pipeline):",324 snapshotError,325 );326 }327 }328 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });329 330 331 332 void triggerSyncIfReachable({ reason: "manual", force: true });333 schedulePostAdoptionSync();334 } catch (error) {335 push("ADOPTION_SOURCE_RESULT", {336 status: "error",337 error: error instanceof Error ? error.message : "adoption_failed",338 });339 } finally {340 clearTimeout(timeout);341 noise?.close();342 socket.close();343 pendingSourceSasConfirmation.clear();344 }345}346347348349350351352async function runReceiverSession(353 socket: PairingSocket,354 expectedToken: string,355 relayUrl: string,356 password: string,357 invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },358): Promise<void> {359 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);360 361 const ephemeral = invitationEphemeral;362 const ephemeralPrivate = ephemeral.secretKey;363 const ephemeralPublic = ephemeral.publicKey;364 let noise: NoiseNKSession | undefined;365 let createdVault = false;366 let protocolError: string | undefined;367 try {368 const hello = decodeJson<{ type?: unknown; token?: unknown }>(369 await socket.receive(),370 );371 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||372 !constantTimeEqual(hello.token, expectedToken)) {373 throw new Error("invalid_invitation");374 }375 socket.send(encodeJson({376 type: "PEACH_HELLO_ACK",377 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),378 }));379380 const session = await noiseNKResponder(381 ephemeralPrivate,382 ephemeralPublic,383 async (bytes) => socket.send(bytes),384 () => socket.receive(),385 );386 noise = session;387 const channel: PairingTransport = {388 async send(plaintext) { socket.send(session.encrypt(plaintext)); },389 async receive() { return session.decrypt(await socket.receive()); },390 };391392 const deviceName = await getDeviceName();393394 const adoptionResult = await requestAdoption(channel, {395 deviceName,396 generateDeviceId: () => crypto.randomUUID(),397 398 399 400 401 402 403 async confirmSas() {404 push("ADOPTION_SOURCE_SAS", {405 sas: derivePairingSAS(session.handshakeHash),406 displayOnly: true,407 });408 return true;409 },410 async createVaultFromRootKey(rootKeyHex) {411 412 413 414 415 416 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);417 createdVault = true;418 return { vaultId };419 },420 async applyLicense(license) {421 422 423 await applyTransferredLicense(license);424 },425 async license() {426 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;427 },428 async deriveLocalIdentity() {429 430 const masterKeyHex = getRootKeyHex();431 if (!masterKeyHex) throw new Error("identity_unavailable");432 const masterKey = hexToBytes(masterKeyHex);433 try {434 return await buildLocalIdentity(masterKey);435 } finally {436 masterKey.fill(0);437 }438 },439 async upsertPairedDevice(paired) {440 441 await upsertPairedDevice(paired);442 },443 pairState: {444 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),445 relayUrl,446 },447 onError(error) {448 protocolError = error;449 },450 });451452 if (!adoptionResult) {453 if (createdVault) {454 await rollbackAdoptedVault();455 createdVault = false;456 }457 push("ADOPTION_SOURCE_RESULT", {458 status: "error",459 error: protocolError ?? "adoption_rejected",460 });461 return;462 }463 const { device, snapshotCapable } = adoptionResult;464 465 466 467 468 469 let snapshotApplied = false;470 if (snapshotCapable) try {471 const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);472 const adapter = createSyncVaultAdapter();473 await adapter.applyEntries(snapshot.entries);474 await adapter.applyTombstones(snapshot.tombstones);475 476 477 if (snapshot.duressSlot) {478 try {479 await installAdoptedDuressSlot(snapshot.duressSlot);480 } catch (duressError) {481 console.info(482 "[Peach] adopted duress slot install failed (non-fatal):",483 duressError,484 );485 }486 }487 await sendFrame(channel, {488 type: "ADOPT_SNAPSHOT_APPLIED",489 entries_received: Object.keys(snapshot.entries).length,490 tombstones_received: Object.keys(snapshot.tombstones).length,491 });492493 494 495 try {496 const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);497 if (498 isAdoptDeviceManifestFrame(manifestFrame) &&499 adapter.applyIncomingDeviceManifest &&500 typeof device.longTermPublicKeyHex === "string" &&501 /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)502 ) {503 await adapter.applyIncomingDeviceManifest(504 manifestFrame.manifest,505 hexToBytes(device.longTermPublicKeyHex),506 );507 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });508 }509 } catch {510 511 }512513 await markLastSyncedAt(device.deviceId, Date.now());514 snapshotApplied = true;515 } catch (snapshotError) {516 console.info(517 "[Peach] inline snapshot failed, falling back to sync pipeline:",518 snapshotError,519 );520 }521522 523 524 525 526 527 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(528 () => undefined,529 );530 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });531 createdVault = false;532533 if (snapshotApplied) {534 535 536 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);537 } else {538 539 540 void triggerSyncIfReachable({ reason: "manual", force: true });541 schedulePostAdoptionSync();542 }543 } catch (error) {544 if (createdVault) {545 try {546 await rollbackAdoptedVault();547 createdVault = false;548 } catch (rollbackError) {549 console.error("[Peach] rollback failed:", rollbackError);550 }551 }552 console.error("[Peach] receiver session failed:", error);553 push("ADOPTION_SOURCE_RESULT", {554 status: "error",555 error: error instanceof Error ? error.message : "adoption_failed",556 });557 } finally {558 clearTimeout(timeout);559 noise?.close();560 socket.close();561 ephemeralPrivate.fill(0);562 ephemeralPublic.fill(0);563 }564}565566567568569570571async function applyTransferredLicense(license: string): Promise<void> {572 const activated = await activateLicense(license);573 if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {574 throw new Error("license_transfer_failed");575 }576}577578579580581582583584585586587588function waitForSourceSasConfirmation(589 id: string,590 request: AdoptRequest,591 sas: string,592): Promise<boolean> {593 return new Promise<boolean>((resolve) => {594 const timer = setTimeout(() => {595 pendingSourceSasConfirmation.delete(id);596 resolve(false);597 }, SOURCE_WINDOW_MS);598 pendingSourceSasConfirmation.set(id, {599 resolve: (ok) => {600 clearTimeout(timer);601 resolve(ok);602 },603 });604 push("ADOPTION_SOURCE_SAS", {605 id,606 sas,607 receiverDeviceName: request.device_name,608 });609 });610}611612613614615616617618function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {619 return waitForSourceSasConfirmation(id, request, sas);620}621622function clearState(): void {623 activeSocket = null;624 inFlight = false;625 pendingSourceSasConfirmation.clear();626}627628function constantTimeEqual(a: string, b: string): boolean {629 if (a.length !== b.length) return false;630 let diff = 0;631 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);632 return diff === 0;633}634635function encodeJson(value: unknown): Uint8Array {636 return new TextEncoder().encode(JSON.stringify(value));637}638639function decodeJson<T>(value: Uint8Array): T {640 return JSON.parse(new TextDecoder().decode(value)) as T;641}642643function hexToBytes(hex: string): Uint8Array {644 const out = new Uint8Array(hex.length / 2);645 for (let i = 0; i < out.length; i++) {646 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);647 }648 return out;649}