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 isAdoptRequest,18 receiveFrame,19 requestAdoption,20 type AdoptRequest,21} from "../shared/vault-adopt";22import {23 derivePairingSAS,24 type PairingTransport,25} from "../shared/pairing-handshake";26import {27 bytesToHex,28 deriveRendezvousSecret,29 generatePairingRendezvousToken,30} from "../shared/rendezvous";31import { buildLocalIdentity, getDeviceName, getLocalPairingIdentity } from "./device-identity";32import { getSyncSettings, relayInForce } from "./sync-settings";33import { activateLicense, getLicenseKey, isProUser } from "./license";34import { createVaultFromRootKey, getRootKeyHex, isUnlocked, rollbackAdoptedVault } from "./vault";35import { computeVaultId } from "../shared/sync-identity";36import { upsertPairedDevice } from "./paired-devices";37import { triggerSyncIfReachable } from "./sync-trigger";3839const SOURCE_WINDOW_MS = 5 * 60 * 1000;4041function push(type: string, payload: unknown): void {42 try {43 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;44 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {45 (maybe as Promise<unknown>).catch(() => undefined);46 }47 } catch {48 49 }50}5152let inFlight = false;53let activeSocket: PairingSocket | null = null;5455565758const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();5960export function isRelayAdoptionSourceInFlight(): boolean {61 return inFlight;62}636465666768export async function startRelayAdoptionSource(): Promise<{69 invitation: string;70}> {71 if (inFlight) throw new Error("adoption_source_in_progress");72 if (!isUnlocked()) throw new Error("vault_locked");73 inFlight = true;7475 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;76 try {77 const settings = await getSyncSettings();78 const relayUrl = relayInForce(settings);79 local = await getLocalPairingIdentity();80 const token = generatePairingRendezvousToken();81 82 83 const encoded = createPairingInvitation(relayUrl, token, "populated");84 const invitation = parsePairingInvitation(encoded);85 if (!invitation) throw new Error("invitation_generation_failed");8687 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));88 activeSocket = socket;89 const readyLocal = local;90 void runSourceSession(91 socket,92 token,93 relayUrl,94 readyLocal,95 ).finally(() => {96 readyLocal.privateKey.fill(0);97 readyLocal.publicKey.fill(0);98 clearState();99 });100 return { invitation: encoded };101 } catch (error) {102 local?.privateKey.fill(0);103 local?.publicKey.fill(0);104 clearState();105 throw error;106 }107}108109export function cancelRelayAdoptionSource(): void {110 activeSocket?.close();111 clearState();112}113114115116117118119120121122123124125export async function startRelayAdoptionReceiver(126 password: string,127): Promise<{ invitation: string }> {128 if (inFlight) throw new Error("adoption_receiver_in_progress");129 if (typeof password !== "string" || password.length === 0) {130 throw new Error("ADOPT_INVALID_PASSWORD");131 }132 inFlight = true;133 try {134 const settings = await getSyncSettings();135 const relayUrl = relayInForce(settings);136 const token = generatePairingRendezvousToken();137 const encoded = createPairingInvitation(relayUrl, token, "empty");138 const invitation = parsePairingInvitation(encoded);139 if (!invitation) throw new Error("invitation_generation_failed");140 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));141 activeSocket = socket;142 void runReceiverSession(143 socket,144 token,145 relayUrl,146 password,147 ).finally(clearState);148 return { invitation: encoded };149 } catch (error) {150 clearState();151 throw error;152 }153}154155156157158159160161162export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {163 const pending = pendingSourceSasConfirmation.get(id);164 if (!pending) return false;165 pendingSourceSasConfirmation.delete(id);166 pending.resolve(ok);167 return true;168}169170async function runSourceSession(171 socket: PairingSocket,172 expectedToken: string,173 relayUrl: string,174 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,175): Promise<void> {176 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);177 let noise: NoiseNKSession | undefined;178 try {179 const hello = decodeJson<{ type?: unknown; token?: unknown }>(180 await socket.receive(),181 );182 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||183 !constantTimeEqual(hello.token, expectedToken)) {184 throw new Error("invalid_invitation");185 }186 socket.send(encodeJson({187 type: "PEACH_HELLO_ACK",188 ephemeralPublicKeyHex: bytesToHex(local.publicKey),189 }));190191 const session = await noiseNKResponder(192 local.privateKey,193 local.publicKey,194 async (bytes) => socket.send(bytes),195 () => socket.receive(),196 );197 noise = session;198 const channel: PairingTransport = {199 async send(plaintext) { socket.send(session.encrypt(plaintext)); },200 async receive() { return session.decrypt(await socket.receive()); },201 };202 const request = await receiveFrame(channel);203 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");204205 const device = await grantAdoption(channel, request, {206 sourceIdentity: local.identity,207 pairState: {208 rendezvousSecretHex: bytesToHex(209 deriveRendezvousSecret(session.handshakeHash),210 ),211 relayUrl,212 },213 async rootKeyHex() {214 215 216 const value = getRootKeyHex();217 if (!value) throw new Error("vault_locked");218 return value;219 },220 async vaultId() {221 const value = getRootKeyHex();222 if (!value) throw new Error("vault_locked");223 const bytes = hexToBytes(value);224 try {225 return await computeVaultId(bytes);226 } finally {227 bytes.fill(0);228 }229 },230 async license() {231 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;232 },233 async applyLicense(license) {234 await applyTransferredLicense(license);235 },236 onConsent: (adoptRequest) => requestConsent(237 adoptRequest,238 derivePairingSAS(session.handshakeHash),239 crypto.randomUUID(),240 ),241 upsertPairedDevice,242 });243 if (!device) {244 push("ADOPTION_SOURCE_RESULT", {245 status: "error",246 error: "adoption_rejected",247 });248 return;249 }250 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });251 252 253 254 255 void triggerSyncIfReachable({ reason: "manual", force: true });256 setTimeout(() => {257 void triggerSyncIfReachable({ reason: "manual", force: true });258 }, 1_000);259 } catch (error) {260 push("ADOPTION_SOURCE_RESULT", {261 status: "error",262 error: error instanceof Error ? error.message : "adoption_failed",263 });264 } finally {265 clearTimeout(timeout);266 noise?.close();267 socket.close();268 pendingSourceSasConfirmation.clear();269 }270}271272273274275276277async function runReceiverSession(278 socket: PairingSocket,279 expectedToken: string,280 relayUrl: string,281 password: string,282): Promise<void> {283 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);284 285 const ephemeral = generateKeyPair();286 const ephemeralPrivate = ephemeral.secretKey;287 const ephemeralPublic = ephemeral.publicKey;288 let noise: NoiseNKSession | undefined;289 let createdVault = false;290 let protocolError: string | undefined;291 try {292 const hello = decodeJson<{ type?: unknown; token?: unknown }>(293 await socket.receive(),294 );295 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||296 !constantTimeEqual(hello.token, expectedToken)) {297 throw new Error("invalid_invitation");298 }299 socket.send(encodeJson({300 type: "PEACH_HELLO_ACK",301 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),302 }));303304 const session = await noiseNKResponder(305 ephemeralPrivate,306 ephemeralPublic,307 async (bytes) => socket.send(bytes),308 () => socket.receive(),309 );310 noise = session;311 const channel: PairingTransport = {312 async send(plaintext) { socket.send(session.encrypt(plaintext)); },313 async receive() { return session.decrypt(await socket.receive()); },314 };315316 const deviceName = await getDeviceName();317318 const device = await requestAdoption(channel, {319 deviceName,320 generateDeviceId: () => crypto.randomUUID(),321 322 323 324 325 326 327 async confirmSas() {328 push("ADOPTION_SOURCE_SAS", {329 sas: derivePairingSAS(session.handshakeHash),330 displayOnly: true,331 });332 return true;333 },334 async createVaultFromRootKey(rootKeyHex) {335 336 337 338 339 340 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);341 createdVault = true;342 return { vaultId };343 },344 async applyLicense(license) {345 346 347 await applyTransferredLicense(license);348 },349 async license() {350 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;351 },352 async deriveLocalIdentity() {353 354 const masterKeyHex = getRootKeyHex();355 if (!masterKeyHex) throw new Error("identity_unavailable");356 const masterKey = hexToBytes(masterKeyHex);357 try {358 return await buildLocalIdentity(masterKey);359 } finally {360 masterKey.fill(0);361 }362 },363 async upsertPairedDevice(paired) {364 365 await upsertPairedDevice(paired);366 },367 pairState: {368 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),369 relayUrl,370 },371 onError(error) {372 protocolError = error;373 },374 });375376 if (!device) {377 if (createdVault) {378 await rollbackAdoptedVault();379 createdVault = false;380 }381 push("ADOPTION_SOURCE_RESULT", {382 status: "error",383 error: protocolError ?? "adoption_rejected",384 });385 return;386 }387 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });388 createdVault = false;389 void triggerSyncIfReachable({ reason: "manual", force: true });390 } catch (error) {391 if (createdVault) {392 try {393 await rollbackAdoptedVault();394 createdVault = false;395 } catch (rollbackError) {396 console.error("[Peach] rollback failed:", rollbackError);397 }398 }399 console.error("[Peach] receiver session failed:", error);400 push("ADOPTION_SOURCE_RESULT", {401 status: "error",402 error: error instanceof Error ? error.message : "adoption_failed",403 });404 } finally {405 clearTimeout(timeout);406 noise?.close();407 socket.close();408 ephemeralPrivate.fill(0);409 ephemeralPublic.fill(0);410 }411}412413414415416417418async function applyTransferredLicense(license: string): Promise<void> {419 const activated = await activateLicense(license);420 if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {421 throw new Error("license_transfer_failed");422 }423}424425426427428429430431432433434435function waitForSourceSasConfirmation(436 id: string,437 request: AdoptRequest,438 sas: string,439): Promise<boolean> {440 return new Promise<boolean>((resolve) => {441 const timer = setTimeout(() => {442 pendingSourceSasConfirmation.delete(id);443 resolve(false);444 }, SOURCE_WINDOW_MS);445 pendingSourceSasConfirmation.set(id, {446 resolve: (ok) => {447 clearTimeout(timer);448 resolve(ok);449 },450 });451 push("ADOPTION_SOURCE_SAS", {452 id,453 sas,454 receiverDeviceName: request.device_name,455 });456 });457}458459460461462463464465function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {466 return waitForSourceSasConfirmation(id, request, sas);467}468469function clearState(): void {470 activeSocket = null;471 inFlight = false;472 pendingSourceSasConfirmation.clear();473}474475function constantTimeEqual(a: string, b: string): boolean {476 if (a.length !== b.length) return false;477 let diff = 0;478 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);479 return diff === 0;480}481482function encodeJson(value: unknown): Uint8Array {483 return new TextEncoder().encode(JSON.stringify(value));484}485486function decodeJson<T>(value: Uint8Array): T {487 return JSON.parse(new TextDecoder().decode(value)) as T;488}489490function hexToBytes(hex: string): Uint8Array {491 const out = new Uint8Array(hex.length / 2);492 for (let i = 0; i < out.length; i++) {493 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);494 }495 return out;496}