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 { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";37import { storageSet } from "../shared/storage";38import { upsertPairedDevice } from "./paired-devices";39import { triggerSyncIfReachable } from "./sync-trigger";4041const SOURCE_WINDOW_MS = 5 * 60 * 1000;4243function push(type: string, payload: unknown): void {44 try {45 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;46 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {47 (maybe as Promise<unknown>).catch(() => undefined);48 }49 } catch {50 51 }52}5354let inFlight = false;55let activeSocket: PairingSocket | null = null;5657585960const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();6162export function isRelayAdoptionSourceInFlight(): boolean {63 return inFlight;64}656667686970export async function startRelayAdoptionSource(): Promise<{71 invitation: string;72}> {73 if (inFlight) throw new Error("adoption_source_in_progress");74 if (!isUnlocked()) throw new Error("vault_locked");75 inFlight = true;7677 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;78 try {79 const settings = await getSyncSettings();80 const relayUrl = relayInForce(settings);81 local = await getLocalPairingIdentity();82 const token = generatePairingRendezvousToken();83 84 85 const encoded = createPairingInvitation(relayUrl, token, "populated");86 const invitation = parsePairingInvitation(encoded);87 if (!invitation) throw new Error("invitation_generation_failed");8889 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));90 activeSocket = socket;91 const readyLocal = local;92 void runSourceSession(93 socket,94 token,95 relayUrl,96 readyLocal,97 ).finally(() => {98 readyLocal.privateKey.fill(0);99 readyLocal.publicKey.fill(0);100 clearState();101 });102 return { invitation: encoded };103 } catch (error) {104 local?.privateKey.fill(0);105 local?.publicKey.fill(0);106 clearState();107 throw error;108 }109}110111export function cancelRelayAdoptionSource(): void {112 activeSocket?.close();113 clearState();114}115116117118119120121122123124125126127export async function startRelayAdoptionReceiver(128 password: string,129): Promise<{ invitation: string }> {130 if (inFlight) throw new Error("adoption_receiver_in_progress");131 if (typeof password !== "string" || password.length === 0) {132 throw new Error("ADOPT_INVALID_PASSWORD");133 }134 inFlight = true;135 try {136 const settings = await getSyncSettings();137 const relayUrl = relayInForce(settings);138 const token = generatePairingRendezvousToken();139 const encoded = createPairingInvitation(relayUrl, token, "empty");140 const invitation = parsePairingInvitation(encoded);141 if (!invitation) throw new Error("invitation_generation_failed");142 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));143 activeSocket = socket;144 void runReceiverSession(145 socket,146 token,147 relayUrl,148 password,149 ).finally(clearState);150 return { invitation: encoded };151 } catch (error) {152 clearState();153 throw error;154 }155}156157158159160161162163164export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {165 const pending = pendingSourceSasConfirmation.get(id);166 if (!pending) return false;167 pendingSourceSasConfirmation.delete(id);168 pending.resolve(ok);169 return true;170}171172async function runSourceSession(173 socket: PairingSocket,174 expectedToken: string,175 relayUrl: string,176 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,177): Promise<void> {178 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);179 let noise: NoiseNKSession | undefined;180 try {181 const hello = decodeJson<{ type?: unknown; token?: unknown }>(182 await socket.receive(),183 );184 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||185 !constantTimeEqual(hello.token, expectedToken)) {186 throw new Error("invalid_invitation");187 }188 socket.send(encodeJson({189 type: "PEACH_HELLO_ACK",190 ephemeralPublicKeyHex: bytesToHex(local.publicKey),191 }));192193 const session = await noiseNKResponder(194 local.privateKey,195 local.publicKey,196 async (bytes) => socket.send(bytes),197 () => socket.receive(),198 );199 noise = session;200 const channel: PairingTransport = {201 async send(plaintext) { socket.send(session.encrypt(plaintext)); },202 async receive() { return session.decrypt(await socket.receive()); },203 };204 const request = await receiveFrame(channel);205 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");206207 const device = await grantAdoption(channel, request, {208 sourceIdentity: local.identity,209 pairState: {210 rendezvousSecretHex: bytesToHex(211 deriveRendezvousSecret(session.handshakeHash),212 ),213 relayUrl,214 },215 async rootKeyHex() {216 217 218 const value = getRootKeyHex();219 if (!value) throw new Error("vault_locked");220 return value;221 },222 async vaultId() {223 const value = getRootKeyHex();224 if (!value) throw new Error("vault_locked");225 const bytes = hexToBytes(value);226 try {227 return await computeVaultId(bytes);228 } finally {229 bytes.fill(0);230 }231 },232 async license() {233 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;234 },235 async applyLicense(license) {236 await applyTransferredLicense(license);237 },238 onConsent: (adoptRequest) => requestConsent(239 adoptRequest,240 derivePairingSAS(session.handshakeHash),241 crypto.randomUUID(),242 ),243 upsertPairedDevice,244 });245 if (!device) {246 push("ADOPTION_SOURCE_RESULT", {247 status: "error",248 error: "adoption_rejected",249 });250 return;251 }252 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });253 254 255 256 257 void triggerSyncIfReachable({ reason: "manual", force: true });258 setTimeout(() => {259 void triggerSyncIfReachable({ reason: "manual", force: true });260 }, 1_000);261 } catch (error) {262 push("ADOPTION_SOURCE_RESULT", {263 status: "error",264 error: error instanceof Error ? error.message : "adoption_failed",265 });266 } finally {267 clearTimeout(timeout);268 noise?.close();269 socket.close();270 pendingSourceSasConfirmation.clear();271 }272}273274275276277278279async function runReceiverSession(280 socket: PairingSocket,281 expectedToken: string,282 relayUrl: string,283 password: string,284): Promise<void> {285 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);286 287 const ephemeral = generateKeyPair();288 const ephemeralPrivate = ephemeral.secretKey;289 const ephemeralPublic = ephemeral.publicKey;290 let noise: NoiseNKSession | undefined;291 let createdVault = false;292 let protocolError: string | undefined;293 try {294 const hello = decodeJson<{ type?: unknown; token?: unknown }>(295 await socket.receive(),296 );297 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||298 !constantTimeEqual(hello.token, expectedToken)) {299 throw new Error("invalid_invitation");300 }301 socket.send(encodeJson({302 type: "PEACH_HELLO_ACK",303 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),304 }));305306 const session = await noiseNKResponder(307 ephemeralPrivate,308 ephemeralPublic,309 async (bytes) => socket.send(bytes),310 () => socket.receive(),311 );312 noise = session;313 const channel: PairingTransport = {314 async send(plaintext) { socket.send(session.encrypt(plaintext)); },315 async receive() { return session.decrypt(await socket.receive()); },316 };317318 const deviceName = await getDeviceName();319320 const device = await requestAdoption(channel, {321 deviceName,322 generateDeviceId: () => crypto.randomUUID(),323 324 325 326 327 328 329 async confirmSas() {330 push("ADOPTION_SOURCE_SAS", {331 sas: derivePairingSAS(session.handshakeHash),332 displayOnly: true,333 });334 return true;335 },336 async createVaultFromRootKey(rootKeyHex) {337 338 339 340 341 342 const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);343 createdVault = true;344 return { vaultId };345 },346 async applyLicense(license) {347 348 349 await applyTransferredLicense(license);350 },351 async license() {352 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;353 },354 async deriveLocalIdentity() {355 356 const masterKeyHex = getRootKeyHex();357 if (!masterKeyHex) throw new Error("identity_unavailable");358 const masterKey = hexToBytes(masterKeyHex);359 try {360 return await buildLocalIdentity(masterKey);361 } finally {362 masterKey.fill(0);363 }364 },365 async upsertPairedDevice(paired) {366 367 await upsertPairedDevice(paired);368 },369 pairState: {370 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),371 relayUrl,372 },373 onError(error) {374 protocolError = error;375 },376 });377378 if (!device) {379 if (createdVault) {380 await rollbackAdoptedVault();381 createdVault = false;382 }383 push("ADOPTION_SOURCE_RESULT", {384 status: "error",385 error: protocolError ?? "adoption_rejected",386 });387 return;388 }389 390 391 392 393 394 await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(395 () => undefined,396 );397 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });398 createdVault = false;399 void triggerSyncIfReachable({ reason: "manual", force: true });400 } catch (error) {401 if (createdVault) {402 try {403 await rollbackAdoptedVault();404 createdVault = false;405 } catch (rollbackError) {406 console.error("[Peach] rollback failed:", rollbackError);407 }408 }409 console.error("[Peach] receiver session failed:", error);410 push("ADOPTION_SOURCE_RESULT", {411 status: "error",412 error: error instanceof Error ? error.message : "adoption_failed",413 });414 } finally {415 clearTimeout(timeout);416 noise?.close();417 socket.close();418 ephemeralPrivate.fill(0);419 ephemeralPublic.fill(0);420 }421}422423424425426427428async function applyTransferredLicense(license: string): Promise<void> {429 const activated = await activateLicense(license);430 if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {431 throw new Error("license_transfer_failed");432 }433}434435436437438439440441442443444445function waitForSourceSasConfirmation(446 id: string,447 request: AdoptRequest,448 sas: string,449): Promise<boolean> {450 return new Promise<boolean>((resolve) => {451 const timer = setTimeout(() => {452 pendingSourceSasConfirmation.delete(id);453 resolve(false);454 }, SOURCE_WINDOW_MS);455 pendingSourceSasConfirmation.set(id, {456 resolve: (ok) => {457 clearTimeout(timer);458 resolve(ok);459 },460 });461 push("ADOPTION_SOURCE_SAS", {462 id,463 sas,464 receiverDeviceName: request.device_name,465 });466 });467}468469470471472473474475function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {476 return waitForSourceSasConfirmation(id, request, sas);477}478479function clearState(): void {480 activeSocket = null;481 inFlight = false;482 pendingSourceSasConfirmation.clear();483}484485function constantTimeEqual(a: string, b: string): boolean {486 if (a.length !== b.length) return false;487 let diff = 0;488 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);489 return diff === 0;490}491492function encodeJson(value: unknown): Uint8Array {493 return new TextEncoder().encode(JSON.stringify(value));494}495496function decodeJson<T>(value: Uint8Array): T {497 return JSON.parse(new TextDecoder().decode(value)) as T;498}499500function hexToBytes(hex: string): Uint8Array {501 const out = new Uint8Array(hex.length / 2);502 for (let i = 0; i < out.length; i++) {503 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);504 }505 return out;506}