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 } 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;5253export function isRelayAdoptionSourceInFlight(): boolean {54 return inFlight;55}565758596061export async function startRelayAdoptionSource(): Promise<{62 invitation: string;63}> {64 if (inFlight) throw new Error("adoption_source_in_progress");65 if (!isUnlocked()) throw new Error("vault_locked");66 inFlight = true;6768 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;69 try {70 const settings = await getSyncSettings();71 const relayUrl = relayInForce(settings);72 local = await getLocalPairingIdentity();73 const token = generatePairingRendezvousToken();74 const encoded = createPairingInvitation(relayUrl, token);75 const invitation = parsePairingInvitation(encoded);76 if (!invitation) throw new Error("invitation_generation_failed");7778 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));79 activeSocket = socket;80 const readyLocal = local;81 void runSourceSession(82 socket,83 token,84 relayUrl,85 readyLocal,86 ).finally(() => {87 readyLocal.privateKey.fill(0);88 readyLocal.publicKey.fill(0);89 clearState();90 });91 return { invitation: encoded };92 } catch (error) {93 local?.privateKey.fill(0);94 local?.publicKey.fill(0);95 clearState();96 throw error;97 }98}99100export function cancelRelayAdoptionSource(): void {101 activeSocket?.close();102 clearState();103}104105106107108109110111112113114115116export async function startRelayAdoptionReceiver(): Promise<{ invitation: string }> {117 if (inFlight) throw new Error("adoption_receiver_in_progress");118 inFlight = true;119 try {120 const settings = await getSyncSettings();121 const relayUrl = relayInForce(settings);122 const token = generatePairingRendezvousToken();123 const encoded = createPairingInvitation(relayUrl, token);124 const invitation = parsePairingInvitation(encoded);125 if (!invitation) throw new Error("invitation_generation_failed");126 127 128 129 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));130 activeSocket = socket;131 return { invitation: encoded };132 } catch (error) {133 clearState();134 throw error;135 }136}137138async function runSourceSession(139 socket: PairingSocket,140 expectedToken: string,141 relayUrl: string,142 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,143): Promise<void> {144 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);145 let noise: NoiseNKSession | undefined;146 try {147 const hello = decodeJson<{ type?: unknown; token?: unknown }>(148 await socket.receive(),149 );150 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||151 !constantTimeEqual(hello.token, expectedToken)) {152 throw new Error("invalid_invitation");153 }154 socket.send(encodeJson({155 type: "PEACH_HELLO_ACK",156 ephemeralPublicKeyHex: bytesToHex(local.publicKey),157 }));158159 const session = await noiseNKResponder(160 local.privateKey,161 local.publicKey,162 async (bytes) => socket.send(bytes),163 () => socket.receive(),164 );165 noise = session;166 const channel: PairingTransport = {167 async send(plaintext) { socket.send(session.encrypt(plaintext)); },168 async receive() { return session.decrypt(await socket.receive()); },169 };170 const request = await receiveFrame(channel);171 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");172173 const device = await grantAdoption(channel, request, {174 sourceIdentity: local.identity,175 pairState: {176 rendezvousSecretHex: bytesToHex(177 deriveRendezvousSecret(session.handshakeHash),178 ),179 relayUrl,180 },181 async rootKeyHex() {182 183 184 const value = getRootKeyHex();185 if (!value) throw new Error("vault_locked");186 return value;187 },188 async vaultId() {189 const value = getRootKeyHex();190 if (!value) throw new Error("vault_locked");191 const bytes = hexToBytes(value);192 try {193 return await computeVaultId(bytes);194 } finally {195 bytes.fill(0);196 }197 },198 async license() {199 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;200 },201 onConsent: (adoptRequest) => requestConsent(202 adoptRequest,203 derivePairingSAS(session.handshakeHash),204 ),205 upsertPairedDevice,206 });207 if (!device) {208 push("ADOPTION_SOURCE_RESULT", {209 status: "error",210 error: "adoption_rejected",211 });212 return;213 }214 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });215 216 217 218 219 void triggerSyncIfReachable({ reason: "manual", force: true });220 setTimeout(() => {221 void triggerSyncIfReachable({ reason: "manual", force: true });222 }, 1_000);223 } catch {224 push("ADOPTION_SOURCE_RESULT", {225 status: "error",226 error: "adoption_failed",227 });228 } finally {229 clearTimeout(timeout);230 noise?.close();231 socket.close();232 }233}234235236237238239240241function requestConsent(request: AdoptRequest, sas: string): Promise<boolean> {242 push("ADOPTION_SOURCE_SAS", {243 sas,244 receiverDeviceName: request.device_name,245 });246 return Promise.resolve(true);247}248249function clearState(): void {250 activeSocket = null;251 inFlight = false;252}253254function constantTimeEqual(a: string, b: string): boolean {255 if (a.length !== b.length) return false;256 let diff = 0;257 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);258 return diff === 0;259}260261function encodeJson(value: unknown): Uint8Array {262 return new TextEncoder().encode(JSON.stringify(value));263}264265function decodeJson<T>(value: Uint8Array): T {266 return JSON.parse(new TextDecoder().decode(value)) as T;267}268269function hexToBytes(hex: string): Uint8Array {270 const out = new Uint8Array(hex.length / 2);271 for (let i = 0; i < out.length; i++) {272 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);273 }274 return out;275}