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");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 encoded = createPairingInvitation(relayUrl, token, "empty");164 const invitation = parsePairingInvitation(encoded);165 if (!invitation) throw new Error("invitation_generation_failed");166 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));167 activeSocket = socket;168 void runReceiverSession(169 socket,170 token,171 relayUrl,172 password,173 ).finally(clearState);174 return { invitation: encoded };175 } catch (error) {176 clearState();177 throw error;178 }179}180181182183184185186187188export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {189 const pending = pendingSourceSasConfirmation.get(id);190 if (!pending) return false;191 pendingSourceSasConfirmation.delete(id);192 pending.resolve(ok);193 return true;194}195196async function runSourceSession(197 socket: PairingSocket,198 expectedToken: string,199 relayUrl: string,200 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,201): Promise<void> {202 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);203 let noise: NoiseNKSession | undefined;204 try {205 const hello = decodeJson<{ type?: unknown; token?: unknown }>(206 await socket.receive(),207 );208 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||209 !constantTimeEqual(hello.token, expectedToken)) {210 throw new Error("invalid_invitation");211 }212 socket.send(encodeJson({213 type: "PEACH_HELLO_ACK",214 ephemeralPublicKeyHex: bytesToHex(local.publicKey),215 }));216217 const session = await noiseNKResponder(218 local.privateKey,219 local.publicKey,220 async (bytes) => socket.send(bytes),221 () => socket.receive(),222 );223 noise = session;224 const channel: PairingTransport = {225 async send(plaintext) { socket.send(session.encrypt(plaintext)); },226 async receive() { return session.decrypt(await socket.receive()); },227 };228 const request = await receiveFrame(channel);229 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");230231 const device = await grantAdoption(channel, request, {232 sourceIdentity: local.identity,233 pairState: {234 rendezvousSecretHex: bytesToHex(235 deriveRendezvousSecret(session.handshakeHash),236 ),237 relayUrl,238 },239 async rootKeyHex() {240 241 242 const value = getRootKeyHex();243 if (!value) throw new Error("vault_locked");244 return value;245 },246 async vaultId() {247 const value = getRootKeyHex();248 if (!value) throw new Error("vault_locked");249 const bytes = hexToBytes(value);250 try {251 return await computeVaultId(bytes);252 } finally {253 bytes.fill(0);254 }255 },256 async license() {257 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;258 },259 async applyLicense(license) {260 await applyTransferredLicense(license);261 },262 onConsent: (adoptRequest) => requestConsent(263 adoptRequest,264 derivePairingSAS(session.handshakeHash),265 crypto.randomUUID(),266 ),267 upsertPairedDevice,268 });269 if (!device) {270 push("ADOPTION_SOURCE_RESULT", {271 status: "error",272 error: "adoption_rejected",273 });274 return;275 }276 277 278 279 if (request.snapshot_capable === true) {280 try {281 const adapter = createSyncVaultAdapter();282 const entryManifest = await adapter.buildEntryManifest();283 const entryIds = Object.keys(entryManifest);284 const entries =285 entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};286 const tombstones = await adapter.buildTombstoneManifest();287 let duressSlot: string | null = null;288 try {289 duressSlot = await exportDuressSlotForAdoption();290 } catch {291 292 }293 await sendVaultSnapshot(channel, entries, tombstones, duressSlot);294295 const applied = await withTimeout(receiveFrame(channel), 15_000);296 if (isAdoptSnapshotApplied(applied)) {297 console.info(298 `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,299 );300 }301302 if (adapter.buildOutgoingDeviceManifest) {303 const manifest = await adapter.buildOutgoingDeviceManifest();304 if (manifest) {305 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });306 await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);307 }308 }309 } catch (snapshotError) {310 if (311 snapshotError instanceof Error &&312 snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"313 ) {314 throw snapshotError;315 }316 console.info(317 "[Peach] inline snapshot send failed (receiver will use sync pipeline):",318 snapshotError,319 );320 }321 }322 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });323 324 325 326 void triggerSyncIfReachable({ reason: "manual", force: true });327 schedulePostAdoptionSync();328 } catch (error) {329 push("ADOPTION_SOURCE_RESULT", {330 status: "error",331 error: error instanceof Error ? error.message : "adoption_failed",332 });333 } finally {334 clearTimeout(timeout);335 noise?.close();336 socket.close();337 pendingSourceSasConfirmation.clear();338 }339}340341342343344345346async function runReceiverSession(347 socket: PairingSocket,348 expectedToken: string,349 relayUrl: string,350 password: string,351): Promise<void> {352 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);353 354 const ephemeral = generateKeyPair();355 const ephemeralPrivate = ephemeral.secretKey;356 const ephemeralPublic = ephemeral.publicKey;357 let noise: NoiseNKSession | undefined;358 let createdVault = false;359 let protocolError: string | undefined;360 try {361 const hello = decodeJson<{ type?: unknown; token?: unknown }>(362 await socket.receive(),363 );364 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||365 !constantTimeEqual(hello.token, expectedToken)) {366 throw new Error("invalid_invitation");367 }368 socket.send(encodeJson({369 type: "PEACH_HELLO_ACK",370 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),371 }));372373 const session = await noiseNKResponder(374 ephemeralPrivate,375 ephemeralPublic,376 async (bytes) => socket.send(bytes),377 () => socket.receive(),378 );379 noise = session;380 const channel: PairingTransport = {381 async send(plaintext) { socket.send(session.encrypt(plaintext)); },382 async receive() { return session.decrypt(await socket.receive()); },383 };384385 const deviceName = await getDeviceName();386387 const adoptionResult = await requestAdoption(channel, {388 deviceName,389 generateDeviceId: () => crypto.randomUUID(),390 391 392 393 394 395 396 async confirmSas() {397 push("ADOPTION_SOURCE_SAS", {398 sas: derivePairingSAS(session.handshakeHash),399 displayOnly: true,400 });401 return true;402 },403 async createVaultFromRootKey(rootKeyHex) {404 405 406 407 408 409 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);410 createdVault = true;411 return { vaultId };412 },413 async applyLicense(license) {414 415 416 await applyTransferredLicense(license);417 },418 async license() {419 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;420 },421 async deriveLocalIdentity() {422 423 const masterKeyHex = getRootKeyHex();424 if (!masterKeyHex) throw new Error("identity_unavailable");425 const masterKey = hexToBytes(masterKeyHex);426 try {427 return await buildLocalIdentity(masterKey);428 } finally {429 masterKey.fill(0);430 }431 },432 async upsertPairedDevice(paired) {433 434 await upsertPairedDevice(paired);435 },436 pairState: {437 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),438 relayUrl,439 },440 onError(error) {441 protocolError = error;442 },443 });444445 if (!adoptionResult) {446 if (createdVault) {447 await rollbackAdoptedVault();448 createdVault = false;449 }450 push("ADOPTION_SOURCE_RESULT", {451 status: "error",452 error: protocolError ?? "adoption_rejected",453 });454 return;455 }456 const { device, snapshotCapable } = adoptionResult;457 458 459 460 461 462 let snapshotApplied = false;463 if (snapshotCapable) try {464 const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);465 const adapter = createSyncVaultAdapter();466 await adapter.applyEntries(snapshot.entries);467 await adapter.applyTombstones(snapshot.tombstones);468 469 470 if (snapshot.duressSlot) {471 try {472 await installAdoptedDuressSlot(snapshot.duressSlot);473 } catch (duressError) {474 console.info(475 "[Peach] adopted duress slot install failed (non-fatal):",476 duressError,477 );478 }479 }480 await sendFrame(channel, {481 type: "ADOPT_SNAPSHOT_APPLIED",482 entries_received: Object.keys(snapshot.entries).length,483 tombstones_received: Object.keys(snapshot.tombstones).length,484 });485486 487 488 try {489 const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);490 if (491 isAdoptDeviceManifestFrame(manifestFrame) &&492 adapter.applyIncomingDeviceManifest &&493 typeof device.longTermPublicKeyHex === "string" &&494 /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)495 ) {496 await adapter.applyIncomingDeviceManifest(497 manifestFrame.manifest,498 hexToBytes(device.longTermPublicKeyHex),499 );500 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });501 }502 } catch {503 504 }505506 await markLastSyncedAt(device.deviceId, Date.now());507 snapshotApplied = true;508 } catch (snapshotError) {509 console.info(510 "[Peach] inline snapshot failed, falling back to sync pipeline:",511 snapshotError,512 );513 }514515 516 517 518 519 520 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(521 () => undefined,522 );523 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });524 createdVault = false;525526 if (snapshotApplied) {527 528 529 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);530 } else {531 532 533 void triggerSyncIfReachable({ reason: "manual", force: true });534 schedulePostAdoptionSync();535 }536 } catch (error) {537 if (createdVault) {538 try {539 await rollbackAdoptedVault();540 createdVault = false;541 } catch (rollbackError) {542 console.error("[Peach] rollback failed:", rollbackError);543 }544 }545 console.error("[Peach] receiver session failed:", error);546 push("ADOPTION_SOURCE_RESULT", {547 status: "error",548 error: error instanceof Error ? error.message : "adoption_failed",549 });550 } finally {551 clearTimeout(timeout);552 noise?.close();553 socket.close();554 ephemeralPrivate.fill(0);555 ephemeralPublic.fill(0);556 }557}558559560561562563564async function applyTransferredLicense(license: string): Promise<void> {565 const activated = await activateLicense(license);566 if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {567 throw new Error("license_transfer_failed");568 }569}570571572573574575576577578579580581function waitForSourceSasConfirmation(582 id: string,583 request: AdoptRequest,584 sas: string,585): Promise<boolean> {586 return new Promise<boolean>((resolve) => {587 const timer = setTimeout(() => {588 pendingSourceSasConfirmation.delete(id);589 resolve(false);590 }, SOURCE_WINDOW_MS);591 pendingSourceSasConfirmation.set(id, {592 resolve: (ok) => {593 clearTimeout(timer);594 resolve(ok);595 },596 });597 push("ADOPTION_SOURCE_SAS", {598 id,599 sas,600 receiverDeviceName: request.device_name,601 });602 });603}604605606607608609610611function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {612 return waitForSourceSasConfirmation(id, request, sas);613}614615function clearState(): void {616 activeSocket = null;617 inFlight = false;618 pendingSourceSasConfirmation.clear();619}620621function constantTimeEqual(a: string, b: string): boolean {622 if (a.length !== b.length) return false;623 let diff = 0;624 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);625 return diff === 0;626}627628function encodeJson(value: unknown): Uint8Array {629 return new TextEncoder().encode(JSON.stringify(value));630}631632function decodeJson<T>(value: Uint8Array): T {633 return JSON.parse(new TextDecoder().decode(value)) as T;634}635636function hexToBytes(hex: string): Uint8Array {637 const out = new Uint8Array(hex.length / 2);638 for (let i = 0; i < out.length; i++) {639 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);640 }641 return out;642}