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 } 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 if (settings.mode === "off") throw new Error("sync_disabled");73 local = await getLocalPairingIdentity();74 const relayUrl = settings.relayUrl;75 const token = generatePairingRendezvousToken();76 const encoded = createPairingInvitation(relayUrl, token);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}101102103104105export async function confirmRelayAdoptionSource(106 password: string | null,107): Promise<{ valid: boolean }> {108 const pending = pendingConsent;109 if (!pending) return { valid: false };110 if (password === null) {111 pendingConsent = null;112 pending(false);113 return { valid: true };114 }115 const valid = await verifyMainPassword(password);116 if (valid) {117 pendingConsent = null;118 pending(true);119 }120 return { valid };121}122123export function cancelRelayAdoptionSource(): void {124 pendingConsent?.(false);125 pendingConsent = null;126 activeSocket?.close();127 clearState();128}129130async function runSourceSession(131 socket: PairingSocket,132 expectedToken: string,133 relayUrl: string,134 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,135): Promise<void> {136 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);137 let noise: NoiseNKSession | undefined;138 try {139 const hello = decodeJson<{ type?: unknown; token?: unknown }>(140 await socket.receive(),141 );142 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||143 !constantTimeEqual(hello.token, expectedToken)) {144 throw new Error("invalid_invitation");145 }146 socket.send(encodeJson({147 type: "PEACH_HELLO_ACK",148 ephemeralPublicKeyHex: bytesToHex(local.publicKey),149 }));150151 const session = await noiseNKResponder(152 local.privateKey,153 local.publicKey,154 async (bytes) => socket.send(bytes),155 () => socket.receive(),156 );157 noise = session;158 const channel: PairingTransport = {159 async send(plaintext) { socket.send(session.encrypt(plaintext)); },160 async receive() { return session.decrypt(await socket.receive()); },161 };162 const request = await receiveFrame(channel);163 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");164165 const device = await grantAdoption(channel, request, {166 sourceIdentity: local.identity,167 pairState: {168 rendezvousSecretHex: bytesToHex(169 deriveRendezvousSecret(session.handshakeHash),170 ),171 relayUrl,172 },173 async rootKeyHex() {174 175 176 const value = getRootKeyHex();177 if (!value) throw new Error("vault_locked");178 return value;179 },180 async vaultId() {181 const value = getRootKeyHex();182 if (!value) throw new Error("vault_locked");183 const bytes = hexToBytes(value);184 try {185 return await computeVaultId(bytes);186 } finally {187 bytes.fill(0);188 }189 },190 async license() {191 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;192 },193 onConsent: (adoptRequest) => requestConsent(194 adoptRequest,195 derivePairingSAS(session.handshakeHash),196 ),197 upsertPairedDevice,198 });199 if (!device) {200 push("ADOPTION_SOURCE_RESULT", {201 status: "error",202 error: "adoption_rejected",203 });204 return;205 }206 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });207 208 209 210 211 void triggerSyncIfReachable({ reason: "manual", force: true });212 setTimeout(() => {213 void triggerSyncIfReachable({ reason: "manual", force: true });214 }, 1_000);215 } catch {216 push("ADOPTION_SOURCE_RESULT", {217 status: "error",218 error: "adoption_failed",219 });220 } finally {221 clearTimeout(timeout);222 noise?.close();223 socket.close();224 }225}226227function requestConsent(request: AdoptRequest, sas: string): Promise<boolean> {228 push("ADOPTION_SOURCE_SAS", {229 sas,230 receiverDeviceName: request.device_name,231 });232 return new Promise<boolean>((resolve) => {233 pendingConsent = resolve;234 });235}236237function clearState(): void {238 pendingConsent?.(false);239 pendingConsent = null;240 activeSocket = null;241 inFlight = false;242}243244function constantTimeEqual(a: string, b: string): boolean {245 if (a.length !== b.length) return false;246 let diff = 0;247 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);248 return diff === 0;249}250251function encodeJson(value: unknown): Uint8Array {252 return new TextEncoder().encode(JSON.stringify(value));253}254255function decodeJson<T>(value: Uint8Array): T {256 return JSON.parse(new TextDecoder().decode(value)) as T;257}258259function hexToBytes(hex: string): Uint8Array {260 const out = new Uint8Array(hex.length / 2);261 for (let i = 0; i < out.length; i++) {262 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);263 }264 return out;265}