1import {2 createPairingInvitation,3 pairingInvitationUrl,4 parsePairingInvitation,5} from "../shared/pairing-invitation";6import {7 openRelayPairingSocket,8 type PairingSocket,9} from "../shared/pairing-dial";10import {11 noiseNKResponder,12 type NoiseNKSession,13} from "../shared/noise-js";14import {15 grantAdoption,16 isAdoptRequest,17 receiveFrame,18 type AdoptRequest,19} from "../shared/vault-adopt";20import {21 derivePairingSAS,22 type PairingTransport,23} from "../shared/pairing-handshake";24import {25 bytesToHex,26 deriveRendezvousSecret,27 generatePairingRendezvousToken,28} from "../shared/rendezvous";29import { getLocalPairingIdentity } from "./device-identity";30import { getSyncSettings, relayInForce } from "./sync-settings";31import { getLicenseKey, isProUser } from "./license";32import { getRootKeyHex, isUnlocked, verifyMainPassword } from "./vault";33import { computeVaultId } from "../shared/sync-identity";34import { upsertPairedDevice } from "./paired-devices";35import { triggerSyncIfReachable } from "./sync-trigger";3637const SOURCE_WINDOW_MS = 5 * 60 * 1000;3839function push(type: string, payload: unknown): void {40 try {41 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;42 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {43 (maybe as Promise<unknown>).catch(() => undefined);44 }45 } catch {46 47 }48}4950let inFlight = false;51let activeSocket: PairingSocket | null = null;52let pendingConsent: ((approved: boolean) => void) | null = null;5354export function isRelayAdoptionSourceInFlight(): boolean {55 return inFlight;56}575859606162export async function startRelayAdoptionSource(): Promise<{63 invitation: string;64}> {65 if (inFlight) throw new Error("adoption_source_in_progress");66 if (!isUnlocked()) throw new Error("vault_locked");67 inFlight = true;6869 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;70 try {71 const settings = await getSyncSettings();72 const relayUrl = relayInForce(settings);73 local = await getLocalPairingIdentity();74 const token = generatePairingRendezvousToken();75 const encoded = createPairingInvitation(relayUrl, token);76 const invitation = parsePairingInvitation(encoded);77 if (!invitation) throw new Error("invitation_generation_failed");7879 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));80 activeSocket = socket;81 const readyLocal = local;82 void runSourceSession(83 socket,84 token,85 relayUrl,86 readyLocal,87 ).finally(() => {88 readyLocal.privateKey.fill(0);89 readyLocal.publicKey.fill(0);90 clearState();91 });92 return { invitation: encoded };93 } catch (error) {94 local?.privateKey.fill(0);95 local?.publicKey.fill(0);96 clearState();97 throw error;98 }99}100101102103104export async function confirmRelayAdoptionSource(105 password: string | null,106): Promise<{ valid: boolean }> {107 const pending = pendingConsent;108 if (!pending) return { valid: false };109 if (password === null) {110 pendingConsent = null;111 pending(false);112 return { valid: true };113 }114 const valid = await verifyMainPassword(password);115 if (valid) {116 pendingConsent = null;117 pending(true);118 }119 return { valid };120}121122export function cancelRelayAdoptionSource(): void {123 pendingConsent?.(false);124 pendingConsent = null;125 activeSocket?.close();126 clearState();127}128129async function runSourceSession(130 socket: PairingSocket,131 expectedToken: string,132 relayUrl: string,133 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,134): Promise<void> {135 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);136 let noise: NoiseNKSession | undefined;137 try {138 const hello = decodeJson<{ type?: unknown; token?: unknown }>(139 await socket.receive(),140 );141 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||142 !constantTimeEqual(hello.token, expectedToken)) {143 throw new Error("invalid_invitation");144 }145 socket.send(encodeJson({146 type: "PEACH_HELLO_ACK",147 ephemeralPublicKeyHex: bytesToHex(local.publicKey),148 }));149150 const session = await noiseNKResponder(151 local.privateKey,152 local.publicKey,153 async (bytes) => socket.send(bytes),154 () => socket.receive(),155 );156 noise = session;157 const channel: PairingTransport = {158 async send(plaintext) { socket.send(session.encrypt(plaintext)); },159 async receive() { return session.decrypt(await socket.receive()); },160 };161 const request = await receiveFrame(channel);162 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");163164 const device = await grantAdoption(channel, request, {165 sourceIdentity: local.identity,166 pairState: {167 rendezvousSecretHex: bytesToHex(168 deriveRendezvousSecret(session.handshakeHash),169 ),170 relayUrl,171 },172 async rootKeyHex() {173 174 175 const value = getRootKeyHex();176 if (!value) throw new Error("vault_locked");177 return value;178 },179 async vaultId() {180 const value = getRootKeyHex();181 if (!value) throw new Error("vault_locked");182 const bytes = hexToBytes(value);183 try {184 return await computeVaultId(bytes);185 } finally {186 bytes.fill(0);187 }188 },189 async license() {190 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;191 },192 onConsent: (adoptRequest) => requestConsent(193 adoptRequest,194 derivePairingSAS(session.handshakeHash),195 ),196 upsertPairedDevice,197 });198 if (!device) {199 push("ADOPTION_SOURCE_RESULT", {200 status: "error",201 error: "adoption_rejected",202 });203 return;204 }205 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });206 207 208 209 210 void triggerSyncIfReachable({ reason: "manual", force: true });211 setTimeout(() => {212 void triggerSyncIfReachable({ reason: "manual", force: true });213 }, 1_000);214 } catch {215 push("ADOPTION_SOURCE_RESULT", {216 status: "error",217 error: "adoption_failed",218 });219 } finally {220 clearTimeout(timeout);221 noise?.close();222 socket.close();223 }224}225226function requestConsent(request: AdoptRequest, sas: string): Promise<boolean> {227 push("ADOPTION_SOURCE_SAS", {228 sas,229 receiverDeviceName: request.device_name,230 });231 return new Promise<boolean>((resolve) => {232 pendingConsent = resolve;233 });234}235236function clearState(): void {237 pendingConsent?.(false);238 pendingConsent = null;239 activeSocket = null;240 inFlight = false;241}242243function constantTimeEqual(a: string, b: string): boolean {244 if (a.length !== b.length) return false;245 let diff = 0;246 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);247 return diff === 0;248}249250function encodeJson(value: unknown): Uint8Array {251 return new TextEncoder().encode(JSON.stringify(value));252}253254function decodeJson<T>(value: Uint8Array): T {255 return JSON.parse(new TextDecoder().decode(value)) as T;256}257258function hexToBytes(hex: string): Uint8Array {259 const out = new Uint8Array(hex.length / 2);260 for (let i = 0; i < out.length; i++) {261 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);262 }263 return out;264}