1import {2 createPairingInvitation,3 pairingInvitationUrl,4 parsePairingInvitation,5} from "../shared/pairing-invitation";6import {7 openRelayPairingSocket,8 type PairingSocket,9} from "../shared/pairing-dial";10import { noiseNKResponder } from "../shared/noise-js";11import {12 grantAdoption,13 isAdoptRequest,14 receiveFrame,15 type AdoptRequest,16} from "../shared/vault-adopt";17import {18 derivePairingSAS,19 type PairingTransport,20} from "../shared/pairing-handshake";21import {22 bytesToHex,23 deriveRendezvousSecret,24 generatePairingRendezvousToken,25} from "../shared/rendezvous";26import { productionCodePairingDeps } from "./code-pairing-bg";27import { getRelaySettings } from "./relay-settings";28import { getLicenseKey, isProUser } from "./license";29import { getRootKeyHex, isUnlocked, verifyMainPassword } from "./vault";30import { computeVaultId } from "../shared/sync-identity";31import { upsertPairedDevice } from "./paired-devices";32import { triggerSyncIfReachable } from "./sync-trigger";3334const SOURCE_WINDOW_MS = 5 * 60 * 1000;3536function push(type: string, payload: unknown): void {37 try {38 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;39 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {40 (maybe as Promise<unknown>).catch(() => undefined);41 }42 } catch {43 44 }45}4647let inFlight = false;48let activeSocket: PairingSocket | null = null;49let pendingConsent: ((approved: boolean) => void) | null = null;5051export function isRelayAdoptionSourceInFlight(): boolean {52 return inFlight;53}545556575859export async function startRelayAdoptionSource(): Promise<{60 invitation: string;61}> {62 if (inFlight) throw new Error("adoption_source_in_progress");63 if (!isUnlocked()) throw new Error("vault_locked");64 inFlight = true;6566 let local: Awaited<67 ReturnType<ReturnType<typeof productionCodePairingDeps>["getLocalIdentity"]>68 > | null = null;69 try {70 local = await productionCodePairingDeps().getLocalIdentity();71 const relayUrl = (await getRelaySettings()).relayUrl;72 const token = generatePairingRendezvousToken();73 const encoded = createPairingInvitation(relayUrl, token);74 const invitation = parsePairingInvitation(encoded);75 if (!invitation) throw new Error("invitation_generation_failed");7677 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));78 activeSocket = socket;79 const readyLocal = local;80 void runSourceSession(81 socket,82 token,83 relayUrl,84 readyLocal,85 ).finally(() => {86 readyLocal.privateKey.fill(0);87 readyLocal.publicKey.fill(0);88 clearState();89 });90 return { invitation: encoded };91 } catch (error) {92 local?.privateKey.fill(0);93 local?.publicKey.fill(0);94 clearState();95 throw error;96 }97}9899100101102export async function confirmRelayAdoptionSource(103 password: string | null,104): Promise<{ valid: boolean }> {105 const pending = pendingConsent;106 if (!pending) return { valid: false };107 if (password === null) {108 pendingConsent = null;109 pending(false);110 return { valid: true };111 }112 const valid = await verifyMainPassword(password);113 if (valid) {114 pendingConsent = null;115 pending(true);116 }117 return { valid };118}119120export function cancelRelayAdoptionSource(): void {121 pendingConsent?.(false);122 pendingConsent = null;123 activeSocket?.close();124 clearState();125}126127async function runSourceSession(128 socket: PairingSocket,129 expectedToken: string,130 relayUrl: string,131 local: Awaited<ReturnType<ReturnType<typeof productionCodePairingDeps>["getLocalIdentity"]>>,132): Promise<void> {133 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);134 try {135 const hello = decodeJson<{ type?: unknown; token?: unknown }>(136 await socket.receive(),137 );138 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||139 !constantTimeEqual(hello.token, expectedToken)) {140 throw new Error("invalid_invitation");141 }142 socket.send(encodeJson({143 type: "PEACH_HELLO_ACK",144 ephemeralPublicKeyHex: bytesToHex(local.publicKey),145 }));146147 const noise = await noiseNKResponder(148 local.privateKey,149 local.publicKey,150 async (bytes) => socket.send(bytes),151 () => socket.receive(),152 );153 const channel: PairingTransport = {154 async send(plaintext) { socket.send(noise.encrypt(plaintext)); },155 async receive() { return noise.decrypt(await socket.receive()); },156 };157 const request = await receiveFrame(channel);158 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");159160 const device = await grantAdoption(channel, request, {161 sourceIdentity: local.identity,162 pairState: {163 rendezvousSecretHex: bytesToHex(164 deriveRendezvousSecret(noise.handshakeHash),165 ),166 relayUrl,167 },168 async rootKeyHex() {169 170 171 const value = getRootKeyHex();172 if (!value) throw new Error("vault_locked");173 return value;174 },175 async vaultId() {176 const value = getRootKeyHex();177 if (!value) throw new Error("vault_locked");178 const bytes = hexToBytes(value);179 try {180 return await computeVaultId(bytes);181 } finally {182 bytes.fill(0);183 }184 },185 async license() {186 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;187 },188 onConsent: (adoptRequest) => requestConsent(189 adoptRequest,190 derivePairingSAS(noise.handshakeHash),191 ),192 upsertPairedDevice,193 });194 if (!device) {195 push("ADOPTION_SOURCE_RESULT", {196 status: "error",197 error: "adoption_rejected",198 });199 return;200 }201 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });202 203 204 205 206 void triggerSyncIfReachable({ reason: "manual", force: true });207 setTimeout(() => {208 void triggerSyncIfReachable({ reason: "manual", force: true });209 }, 1_000);210 } catch {211 push("ADOPTION_SOURCE_RESULT", {212 status: "error",213 error: "adoption_failed",214 });215 } finally {216 clearTimeout(timeout);217 socket.close();218 }219}220221function requestConsent(request: AdoptRequest, sas: string): Promise<boolean> {222 push("ADOPTION_SOURCE_SAS", {223 sas,224 receiverDeviceName: request.device_name,225 });226 return new Promise<boolean>((resolve) => {227 pendingConsent = resolve;228 });229}230231function clearState(): void {232 pendingConsent?.(false);233 pendingConsent = null;234 activeSocket = null;235 inFlight = false;236}237238function constantTimeEqual(a: string, b: string): boolean {239 if (a.length !== b.length) return false;240 let diff = 0;241 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);242 return diff === 0;243}244245function encodeJson(value: unknown): Uint8Array {246 return new TextEncoder().encode(JSON.stringify(value));247}248249function decodeJson<T>(value: Uint8Array): T {250 return JSON.parse(new TextDecoder().decode(value)) as T;251}252253function hexToBytes(hex: string): Uint8Array {254 const out = new Uint8Array(hex.length / 2);255 for (let i = 0; i < out.length; i++) {256 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);257 }258 return out;259}