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";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 await sendVaultSnapshot(channel, entries, tombstones);288289 const applied = await withTimeout(receiveFrame(channel), 15_000);290 if (isAdoptSnapshotApplied(applied)) {291 console.info(292 `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,293 );294 }295296 if (adapter.buildOutgoingDeviceManifest) {297 const manifest = await adapter.buildOutgoingDeviceManifest();298 if (manifest) {299 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });300 await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);301 }302 }303 } catch (snapshotError) {304 console.info(305 "[Peach] inline snapshot send failed (receiver will use sync pipeline):",306 snapshotError,307 );308 }309 }310 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });311 312 313 314 void triggerSyncIfReachable({ reason: "manual", force: true });315 schedulePostAdoptionSync();316 } catch (error) {317 push("ADOPTION_SOURCE_RESULT", {318 status: "error",319 error: error instanceof Error ? error.message : "adoption_failed",320 });321 } finally {322 clearTimeout(timeout);323 noise?.close();324 socket.close();325 pendingSourceSasConfirmation.clear();326 }327}328329330331332333334async function runReceiverSession(335 socket: PairingSocket,336 expectedToken: string,337 relayUrl: string,338 password: string,339): Promise<void> {340 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);341 342 const ephemeral = generateKeyPair();343 const ephemeralPrivate = ephemeral.secretKey;344 const ephemeralPublic = ephemeral.publicKey;345 let noise: NoiseNKSession | undefined;346 let createdVault = false;347 let protocolError: string | undefined;348 try {349 const hello = decodeJson<{ type?: unknown; token?: unknown }>(350 await socket.receive(),351 );352 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||353 !constantTimeEqual(hello.token, expectedToken)) {354 throw new Error("invalid_invitation");355 }356 socket.send(encodeJson({357 type: "PEACH_HELLO_ACK",358 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),359 }));360361 const session = await noiseNKResponder(362 ephemeralPrivate,363 ephemeralPublic,364 async (bytes) => socket.send(bytes),365 () => socket.receive(),366 );367 noise = session;368 const channel: PairingTransport = {369 async send(plaintext) { socket.send(session.encrypt(plaintext)); },370 async receive() { return session.decrypt(await socket.receive()); },371 };372373 const deviceName = await getDeviceName();374375 const device = await requestAdoption(channel, {376 deviceName,377 generateDeviceId: () => crypto.randomUUID(),378 379 380 381 382 383 384 async confirmSas() {385 push("ADOPTION_SOURCE_SAS", {386 sas: derivePairingSAS(session.handshakeHash),387 displayOnly: true,388 });389 return true;390 },391 async createVaultFromRootKey(rootKeyHex) {392 393 394 395 396 397 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);398 createdVault = true;399 return { vaultId };400 },401 async applyLicense(license) {402 403 404 await applyTransferredLicense(license);405 },406 async license() {407 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;408 },409 async deriveLocalIdentity() {410 411 const masterKeyHex = getRootKeyHex();412 if (!masterKeyHex) throw new Error("identity_unavailable");413 const masterKey = hexToBytes(masterKeyHex);414 try {415 return await buildLocalIdentity(masterKey);416 } finally {417 masterKey.fill(0);418 }419 },420 async upsertPairedDevice(paired) {421 422 await upsertPairedDevice(paired);423 },424 pairState: {425 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),426 relayUrl,427 },428 onError(error) {429 protocolError = error;430 },431 });432433 if (!device) {434 if (createdVault) {435 await rollbackAdoptedVault();436 createdVault = false;437 }438 push("ADOPTION_SOURCE_RESULT", {439 status: "error",440 error: protocolError ?? "adoption_rejected",441 });442 return;443 }444 445 446 447 448 let snapshotApplied = false;449 try {450 const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);451 const adapter = createSyncVaultAdapter();452 await adapter.applyEntries(snapshot.entries);453 await adapter.applyTombstones(snapshot.tombstones);454 await sendFrame(channel, {455 type: "ADOPT_SNAPSHOT_APPLIED",456 entries_received: Object.keys(snapshot.entries).length,457 tombstones_received: Object.keys(snapshot.tombstones).length,458 });459460 461 462 try {463 const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);464 if (465 isAdoptDeviceManifestFrame(manifestFrame) &&466 adapter.applyIncomingDeviceManifest &&467 typeof device.longTermPublicKeyHex === "string" &&468 /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)469 ) {470 await adapter.applyIncomingDeviceManifest(471 manifestFrame.manifest,472 hexToBytes(device.longTermPublicKeyHex),473 );474 await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });475 }476 } catch {477 478 }479480 await markLastSyncedAt(device.deviceId, Date.now());481 snapshotApplied = true;482 } catch (snapshotError) {483 console.info(484 "[Peach] inline snapshot failed, falling back to sync pipeline:",485 snapshotError,486 );487 }488489 490 491 492 493 494 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(495 () => undefined,496 );497 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });498 createdVault = false;499500 if (snapshotApplied) {501 502 503 await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);504 } else {505 506 507 void triggerSyncIfReachable({ reason: "manual", force: true });508 schedulePostAdoptionSync();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}