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 { getDeviceName, getLocalPairingIdentity } from "./device-identity";32import { getSyncSettings, relayInForce } from "./sync-settings";33import { getLicenseKey, isProUser } from "./license";34import { getRootKeyHex, isUnlocked } 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;4041424344const RECEIVER_SAS_TIMEOUT_MS = 5 * 60 * 1000;4546function push(type: string, payload: unknown): void {47 try {48 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;49 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {50 (maybe as Promise<unknown>).catch(() => undefined);51 }52 } catch {53 54 }55}5657let inFlight = false;58let activeSocket: PairingSocket | null = null;59606162let pendingSasConfirmation: {63 id: string;64 resolve: (ok: boolean) => void;65} | null = null;6667686970const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();7172export function isRelayAdoptionSourceInFlight(): boolean {73 return inFlight;74}757677787980export async function startRelayAdoptionSource(): Promise<{81 invitation: string;82}> {83 if (inFlight) throw new Error("adoption_source_in_progress");84 if (!isUnlocked()) throw new Error("vault_locked");85 inFlight = true;8687 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;88 try {89 const settings = await getSyncSettings();90 const relayUrl = relayInForce(settings);91 local = await getLocalPairingIdentity();92 const token = generatePairingRendezvousToken();93 94 95 const encoded = createPairingInvitation(relayUrl, token, "populated");96 const invitation = parsePairingInvitation(encoded);97 if (!invitation) throw new Error("invitation_generation_failed");9899 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));100 activeSocket = socket;101 const readyLocal = local;102 void runSourceSession(103 socket,104 token,105 relayUrl,106 readyLocal,107 ).finally(() => {108 readyLocal.privateKey.fill(0);109 readyLocal.publicKey.fill(0);110 clearState();111 });112 return { invitation: encoded };113 } catch (error) {114 local?.privateKey.fill(0);115 local?.publicKey.fill(0);116 clearState();117 throw error;118 }119}120121export function cancelRelayAdoptionSource(): void {122 activeSocket?.close();123 clearState();124}125126127128129130131132133134135136137export async function startRelayAdoptionReceiver(138 password: string,139): Promise<{ invitation: string }> {140 if (inFlight) throw new Error("adoption_receiver_in_progress");141 if (typeof password !== "string" || password.length === 0) {142 throw new Error("ADOPT_INVALID_PASSWORD");143 }144 inFlight = true;145 try {146 const settings = await getSyncSettings();147 const relayUrl = relayInForce(settings);148 const token = generatePairingRendezvousToken();149 const encoded = createPairingInvitation(relayUrl, token, "empty");150 const invitation = parsePairingInvitation(encoded);151 if (!invitation) throw new Error("invitation_generation_failed");152 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));153 activeSocket = socket;154 void runReceiverSession(155 socket,156 token,157 relayUrl,158 password,159 ).finally(clearState);160 return { invitation: encoded };161 } catch (error) {162 clearState();163 throw error;164 }165}166167168169170171172173174export function resolveReceiverSasConfirmation(id: string, ok: boolean): boolean {175 if (!pendingSasConfirmation || pendingSasConfirmation.id !== id) return false;176 pendingSasConfirmation.resolve(ok);177 pendingSasConfirmation = null;178 return true;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 onConsent: (adoptRequest) => requestConsent(260 adoptRequest,261 derivePairingSAS(session.handshakeHash),262 crypto.randomUUID(),263 ),264 upsertPairedDevice,265 });266 if (!device) {267 push("ADOPTION_SOURCE_RESULT", {268 status: "error",269 error: "adoption_rejected",270 });271 return;272 }273 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });274 275 276 277 278 void triggerSyncIfReachable({ reason: "manual", force: true });279 setTimeout(() => {280 void triggerSyncIfReachable({ reason: "manual", force: true });281 }, 1_000);282 } catch {283 push("ADOPTION_SOURCE_RESULT", {284 status: "error",285 error: "adoption_failed",286 });287 } finally {288 clearTimeout(timeout);289 noise?.close();290 socket.close();291 pendingSourceSasConfirmation.clear();292 }293}294295296297298299300async function runReceiverSession(301 socket: PairingSocket,302 expectedToken: string,303 relayUrl: string,304 password: string,305): Promise<void> {306 const timeout = setTimeout(() => socket.close(), RECEIVER_SAS_TIMEOUT_MS);307 308 const ephemeral = generateKeyPair();309 const ephemeralPrivate = ephemeral.secretKey;310 const ephemeralPublic = ephemeral.publicKey;311 let noise: NoiseNKSession | undefined;312 try {313 const hello = decodeJson<{ type?: unknown; token?: unknown }>(314 await socket.receive(),315 );316 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||317 !constantTimeEqual(hello.token, expectedToken)) {318 throw new Error("invalid_invitation");319 }320 socket.send(encodeJson({321 type: "PEACH_HELLO_ACK",322 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),323 }));324325 const session = await noiseNKResponder(326 ephemeralPrivate,327 ephemeralPublic,328 async (bytes) => socket.send(bytes),329 () => socket.receive(),330 );331 noise = session;332 const channel: PairingTransport = {333 async send(plaintext) { socket.send(session.encrypt(plaintext)); },334 async receive() { return session.decrypt(await socket.receive()); },335 };336337 const sas = derivePairingSAS(session.handshakeHash);338 const deviceName = await getDeviceName();339 const sasId = bytesToHex(session.handshakeHash).slice(0, 16);340341 const device = await requestAdoption(channel, {342 deviceName,343 generateDeviceId: () => crypto.randomUUID(),344 async confirmSas() {345 return await waitForSasConfirmation(sasId, deviceName, sas);346 },347 async createVaultFromRootKey(rootKeyHex) {348 const response = (await chrome.runtime.sendMessage({349 type: "CREATE_VAULT_FROM_ROOT_KEY",350 payload: { password, rootKeyHex },351 })) as { ok: boolean; data?: { vaultId?: string }; error?: string };352 if (!response.ok) {353 throw new Error(response.error ?? "create_vault_failed");354 }355 return { vaultId: response.data?.vaultId ?? "" };356 },357 async applyLicense(license) {358 const response = (await chrome.runtime.sendMessage({359 type: "ACTIVATE_LICENSE",360 payload: { key: license },361 })) as { ok: boolean; error?: string };362 if (!response.ok) throw new Error(response.error ?? "license_activation_failed");363 },364 async deriveLocalIdentity() {365 const response = (await chrome.runtime.sendMessage({366 type: "GET_LOCAL_IDENTITY",367 })) as { ok: boolean; data?: Record<string, unknown>; error?: string };368 if (!response.ok) throw new Error(response.error ?? "identity_unavailable");369 return response.data as never;370 },371 async upsertPairedDevice(paired) {372 const response = (await chrome.runtime.sendMessage({373 type: "COMPLETE_PAIRING",374 payload: { paired },375 })) as { ok: boolean; error?: string };376 if (!response.ok) throw new Error(response.error ?? "complete_pairing_failed");377 },378 pairState: {379 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),380 relayUrl,381 },382 onError(error) {383 push("ADOPTION_SOURCE_RESULT", { status: "error", error });384 },385 });386387 if (!device) {388 push("ADOPTION_SOURCE_RESULT", {389 status: "error",390 error: "adoption_rejected",391 });392 return;393 }394 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });395 void triggerSyncIfReachable({ reason: "manual", force: true });396 } catch (error) {397 console.error("[Peach] receiver session failed:", error);398 push("ADOPTION_SOURCE_RESULT", {399 status: "error",400 error: error instanceof Error ? error.message : "adoption_failed",401 });402 } finally {403 clearTimeout(timeout);404 noise?.close();405 socket.close();406 ephemeralPrivate.fill(0);407 ephemeralPublic.fill(0);408 409 if (pendingSasConfirmation) pendingSasConfirmation = null;410 }411}412413414415416417418419420421422423function waitForSasConfirmation(424 id: string,425 receiverDeviceName: string,426 sas: string,427): Promise<boolean> {428 return new Promise<boolean>((resolve) => {429 const timer = setTimeout(() => {430 if (pendingSasConfirmation?.id === id) pendingSasConfirmation = null;431 resolve(false);432 }, RECEIVER_SAS_TIMEOUT_MS);433 pendingSasConfirmation = {434 id,435 resolve: (ok) => {436 clearTimeout(timer);437 resolve(ok);438 },439 };440 push("ADOPTION_SOURCE_SAS", { id, sas, receiverDeviceName });441 });442}443444445446447448449450451452453454function waitForSourceSasConfirmation(455 id: string,456 request: AdoptRequest,457 sas: string,458): Promise<boolean> {459 return new Promise<boolean>((resolve) => {460 const timer = setTimeout(() => {461 pendingSourceSasConfirmation.delete(id);462 resolve(false);463 }, RECEIVER_SAS_TIMEOUT_MS);464 pendingSourceSasConfirmation.set(id, {465 resolve: (ok) => {466 clearTimeout(timer);467 resolve(ok);468 },469 });470 push("ADOPTION_SOURCE_SAS", {471 id,472 sas,473 receiverDeviceName: request.device_name,474 });475 });476}477478479480481482483484function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {485 return waitForSourceSasConfirmation(id, request, sas);486}487488function clearState(): void {489 activeSocket = null;490 inFlight = false;491 pendingSourceSasConfirmation.clear();492}493494function constantTimeEqual(a: string, b: string): boolean {495 if (a.length !== b.length) return false;496 let diff = 0;497 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);498 return diff === 0;499}500501function encodeJson(value: unknown): Uint8Array {502 return new TextEncoder().encode(JSON.stringify(value));503}504505function decodeJson<T>(value: Uint8Array): T {506 return JSON.parse(new TextDecoder().decode(value)) as T;507}508509function hexToBytes(hex: string): Uint8Array {510 const out = new Uint8Array(hex.length / 2);511 for (let i = 0; i < out.length; i++) {512 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);513 }514 return out;515}