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 75 76 const encoded = createPairingInvitation(relayUrl, token, "populated");77 const invitation = parsePairingInvitation(encoded);78 if (!invitation) throw new Error("invitation_generation_failed");7980 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));81 activeSocket = socket;82 const readyLocal = local;83 void runSourceSession(84 socket,85 token,86 relayUrl,87 readyLocal,88 ).finally(() => {89 readyLocal.privateKey.fill(0);90 readyLocal.publicKey.fill(0);91 clearState();92 });93 return { invitation: encoded };94 } catch (error) {95 local?.privateKey.fill(0);96 local?.publicKey.fill(0);97 clearState();98 throw error;99 }100}101102export function cancelRelayAdoptionSource(): void {103 activeSocket?.close();104 clearState();105}106107108109110111112113114115116117118export async function startRelayAdoptionReceiver(): Promise<{ invitation: string }> {119 if (inFlight) throw new Error("adoption_receiver_in_progress");120 inFlight = true;121 try {122 const settings = await getSyncSettings();123 const relayUrl = relayInForce(settings);124 const token = generatePairingRendezvousToken();125 126 127 const encoded = createPairingInvitation(relayUrl, token, "empty");128 const invitation = parsePairingInvitation(encoded);129 if (!invitation) throw new Error("invitation_generation_failed");130 131 132 133 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));134 activeSocket = socket;135 return { invitation: encoded };136 } catch (error) {137 clearState();138 throw error;139 }140}141142async function runSourceSession(143 socket: PairingSocket,144 expectedToken: string,145 relayUrl: string,146 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,147): Promise<void> {148 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);149 let noise: NoiseNKSession | undefined;150 try {151 const hello = decodeJson<{ type?: unknown; token?: unknown }>(152 await socket.receive(),153 );154 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||155 !constantTimeEqual(hello.token, expectedToken)) {156 throw new Error("invalid_invitation");157 }158 socket.send(encodeJson({159 type: "PEACH_HELLO_ACK",160 ephemeralPublicKeyHex: bytesToHex(local.publicKey),161 }));162163 const session = await noiseNKResponder(164 local.privateKey,165 local.publicKey,166 async (bytes) => socket.send(bytes),167 () => socket.receive(),168 );169 noise = session;170 const channel: PairingTransport = {171 async send(plaintext) { socket.send(session.encrypt(plaintext)); },172 async receive() { return session.decrypt(await socket.receive()); },173 };174 const request = await receiveFrame(channel);175 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");176177 const device = await grantAdoption(channel, request, {178 sourceIdentity: local.identity,179 pairState: {180 rendezvousSecretHex: bytesToHex(181 deriveRendezvousSecret(session.handshakeHash),182 ),183 relayUrl,184 },185 async rootKeyHex() {186 187 188 const value = getRootKeyHex();189 if (!value) throw new Error("vault_locked");190 return value;191 },192 async vaultId() {193 const value = getRootKeyHex();194 if (!value) throw new Error("vault_locked");195 const bytes = hexToBytes(value);196 try {197 return await computeVaultId(bytes);198 } finally {199 bytes.fill(0);200 }201 },202 async license() {203 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;204 },205 onConsent: (adoptRequest) => requestConsent(206 adoptRequest,207 derivePairingSAS(session.handshakeHash),208 ),209 upsertPairedDevice,210 });211 if (!device) {212 push("ADOPTION_SOURCE_RESULT", {213 status: "error",214 error: "adoption_rejected",215 });216 return;217 }218 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });219 220 221 222 223 void triggerSyncIfReachable({ reason: "manual", force: true });224 setTimeout(() => {225 void triggerSyncIfReachable({ reason: "manual", force: true });226 }, 1_000);227 } catch {228 push("ADOPTION_SOURCE_RESULT", {229 status: "error",230 error: "adoption_failed",231 });232 } finally {233 clearTimeout(timeout);234 noise?.close();235 socket.close();236 }237}238239240241242243244245function requestConsent(request: AdoptRequest, sas: string): Promise<boolean> {246 push("ADOPTION_SOURCE_SAS", {247 sas,248 receiverDeviceName: request.device_name,249 });250 return Promise.resolve(true);251}252253function clearState(): void {254 activeSocket = null;255 inFlight = false;256}257258function constantTimeEqual(a: string, b: string): boolean {259 if (a.length !== b.length) return false;260 let diff = 0;261 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);262 return diff === 0;263}264265function encodeJson(value: unknown): Uint8Array {266 return new TextEncoder().encode(JSON.stringify(value));267}268269function decodeJson<T>(value: Uint8Array): T {270 return JSON.parse(new TextDecoder().decode(value)) as T;271}272273function hexToBytes(hex: string): Uint8Array {274 const out = new Uint8Array(hex.length / 2);275 for (let i = 0; i < out.length; i++) {276 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);277 }278 return out;279}