1import { generateKeyPair } from "@stablelib/x25519";2import {3 createPairingInvitation,4 pairingInvitationUrl,5 parsePairingInvitation,6} from "../shared/pairing-invitation";7import {8 openRelayPairingSocket,9 type PairingSocket,10} from "../shared/pairing-dial";11import {12 noiseNKResponder,13 type NoiseNKSession,14} from "../shared/noise-js";15import {16 grantAdoption,17 isAdoptRequest,18 receiveFrame,19 requestAdoption,20 type AdoptRequest,21} from "../shared/vault-adopt";22import {23 derivePairingSAS,24 type PairingTransport,25} from "../shared/pairing-handshake";26import {27 bytesToHex,28 deriveRendezvousSecret,29 generatePairingRendezvousToken,30} from "../shared/rendezvous";31import { getDeviceName, getLocalPairingIdentity } from "./device-identity";32import { getSyncSettings, relayInForce } from "./sync-settings";33import { getLicenseKey, isProUser } from "./license";34import { getRootKeyHex, isUnlocked } from "./vault";35import { computeVaultId } from "../shared/sync-identity";36import { upsertPairedDevice } from "./paired-devices";37import { triggerSyncIfReachable } from "./sync-trigger";3839const SOURCE_WINDOW_MS = 5 * 60 * 1000;4041424344const RECEIVER_SAS_TIMEOUT_MS = 5 * 60 * 1000;4546function push(type: string, payload: unknown): void {47 try {48 const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;49 if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {50 (maybe as Promise<unknown>).catch(() => undefined);51 }52 } catch {53 54 }55}5657let inFlight = false;58let activeSocket: PairingSocket | null = null;59606162let pendingSasConfirmation: {63 id: string;64 resolve: (ok: boolean) => void;65} | null = null;6667export function isRelayAdoptionSourceInFlight(): boolean {68 return inFlight;69}707172737475export async function startRelayAdoptionSource(): Promise<{76 invitation: string;77}> {78 if (inFlight) throw new Error("adoption_source_in_progress");79 if (!isUnlocked()) throw new Error("vault_locked");80 inFlight = true;8182 let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;83 try {84 const settings = await getSyncSettings();85 const relayUrl = relayInForce(settings);86 local = await getLocalPairingIdentity();87 const token = generatePairingRendezvousToken();88 89 90 const encoded = createPairingInvitation(relayUrl, token, "populated");91 const invitation = parsePairingInvitation(encoded);92 if (!invitation) throw new Error("invitation_generation_failed");9394 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));95 activeSocket = socket;96 const readyLocal = local;97 void runSourceSession(98 socket,99 token,100 relayUrl,101 readyLocal,102 ).finally(() => {103 readyLocal.privateKey.fill(0);104 readyLocal.publicKey.fill(0);105 clearState();106 });107 return { invitation: encoded };108 } catch (error) {109 local?.privateKey.fill(0);110 local?.publicKey.fill(0);111 clearState();112 throw error;113 }114}115116export function cancelRelayAdoptionSource(): void {117 activeSocket?.close();118 clearState();119}120121122123124125126127128129130131132export async function startRelayAdoptionReceiver(133 password: string,134): Promise<{ invitation: string }> {135 if (inFlight) throw new Error("adoption_receiver_in_progress");136 if (typeof password !== "string" || password.length === 0) {137 throw new Error("ADOPT_INVALID_PASSWORD");138 }139 inFlight = true;140 try {141 const settings = await getSyncSettings();142 const relayUrl = relayInForce(settings);143 const token = generatePairingRendezvousToken();144 const encoded = createPairingInvitation(relayUrl, token, "empty");145 const invitation = parsePairingInvitation(encoded);146 if (!invitation) throw new Error("invitation_generation_failed");147 const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));148 activeSocket = socket;149 void runReceiverSession(150 socket,151 token,152 relayUrl,153 password,154 ).finally(clearState);155 return { invitation: encoded };156 } catch (error) {157 clearState();158 throw error;159 }160}161162163164165166167168169export function resolveReceiverSasConfirmation(id: string, ok: boolean): boolean {170 if (!pendingSasConfirmation || pendingSasConfirmation.id !== id) return false;171 pendingSasConfirmation.resolve(ok);172 pendingSasConfirmation = null;173 return true;174}175176async function runSourceSession(177 socket: PairingSocket,178 expectedToken: string,179 relayUrl: string,180 local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,181): Promise<void> {182 const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);183 let noise: NoiseNKSession | undefined;184 try {185 const hello = decodeJson<{ type?: unknown; token?: unknown }>(186 await socket.receive(),187 );188 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||189 !constantTimeEqual(hello.token, expectedToken)) {190 throw new Error("invalid_invitation");191 }192 socket.send(encodeJson({193 type: "PEACH_HELLO_ACK",194 ephemeralPublicKeyHex: bytesToHex(local.publicKey),195 }));196197 const session = await noiseNKResponder(198 local.privateKey,199 local.publicKey,200 async (bytes) => socket.send(bytes),201 () => socket.receive(),202 );203 noise = session;204 const channel: PairingTransport = {205 async send(plaintext) { socket.send(session.encrypt(plaintext)); },206 async receive() { return session.decrypt(await socket.receive()); },207 };208 const request = await receiveFrame(channel);209 if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");210211 const device = await grantAdoption(channel, request, {212 sourceIdentity: local.identity,213 pairState: {214 rendezvousSecretHex: bytesToHex(215 deriveRendezvousSecret(session.handshakeHash),216 ),217 relayUrl,218 },219 async rootKeyHex() {220 221 222 const value = getRootKeyHex();223 if (!value) throw new Error("vault_locked");224 return value;225 },226 async vaultId() {227 const value = getRootKeyHex();228 if (!value) throw new Error("vault_locked");229 const bytes = hexToBytes(value);230 try {231 return await computeVaultId(bytes);232 } finally {233 bytes.fill(0);234 }235 },236 async license() {237 return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;238 },239 onConsent: (adoptRequest) => requestConsent(240 adoptRequest,241 derivePairingSAS(session.handshakeHash),242 ),243 upsertPairedDevice,244 });245 if (!device) {246 push("ADOPTION_SOURCE_RESULT", {247 status: "error",248 error: "adoption_rejected",249 });250 return;251 }252 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });253 254 255 256 257 void triggerSyncIfReachable({ reason: "manual", force: true });258 setTimeout(() => {259 void triggerSyncIfReachable({ reason: "manual", force: true });260 }, 1_000);261 } catch {262 push("ADOPTION_SOURCE_RESULT", {263 status: "error",264 error: "adoption_failed",265 });266 } finally {267 clearTimeout(timeout);268 noise?.close();269 socket.close();270 }271}272273274275276277278async function runReceiverSession(279 socket: PairingSocket,280 expectedToken: string,281 relayUrl: string,282 password: string,283): Promise<void> {284 const timeout = setTimeout(() => socket.close(), RECEIVER_SAS_TIMEOUT_MS);285 286 const ephemeral = generateKeyPair();287 const ephemeralPrivate = ephemeral.secretKey;288 const ephemeralPublic = ephemeral.publicKey;289 let noise: NoiseNKSession | undefined;290 try {291 const hello = decodeJson<{ type?: unknown; token?: unknown }>(292 await socket.receive(),293 );294 if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||295 !constantTimeEqual(hello.token, expectedToken)) {296 throw new Error("invalid_invitation");297 }298 socket.send(encodeJson({299 type: "PEACH_HELLO_ACK",300 ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),301 }));302303 const session = await noiseNKResponder(304 ephemeralPrivate,305 ephemeralPublic,306 async (bytes) => socket.send(bytes),307 () => socket.receive(),308 );309 noise = session;310 const channel: PairingTransport = {311 async send(plaintext) { socket.send(session.encrypt(plaintext)); },312 async receive() { return session.decrypt(await socket.receive()); },313 };314315 const sas = derivePairingSAS(session.handshakeHash);316 const deviceName = await getDeviceName();317 const sasId = bytesToHex(session.handshakeHash).slice(0, 16);318319 const device = await requestAdoption(channel, {320 deviceName,321 generateDeviceId: () => crypto.randomUUID(),322 async confirmSas() {323 return await waitForSasConfirmation(sasId, deviceName, sas);324 },325 async createVaultFromRootKey(rootKeyHex) {326 const response = (await chrome.runtime.sendMessage({327 type: "CREATE_VAULT_FROM_ROOT_KEY",328 payload: { password, rootKeyHex },329 })) as { ok: boolean; data?: { vaultId?: string }; error?: string };330 if (!response.ok) {331 throw new Error(response.error ?? "create_vault_failed");332 }333 return { vaultId: response.data?.vaultId ?? "" };334 },335 async applyLicense(license) {336 const response = (await chrome.runtime.sendMessage({337 type: "ACTIVATE_LICENSE",338 payload: { key: license },339 })) as { ok: boolean; error?: string };340 if (!response.ok) throw new Error(response.error ?? "license_activation_failed");341 },342 async deriveLocalIdentity() {343 const response = (await chrome.runtime.sendMessage({344 type: "GET_LOCAL_IDENTITY",345 })) as { ok: boolean; data?: Record<string, unknown>; error?: string };346 if (!response.ok) throw new Error(response.error ?? "identity_unavailable");347 return response.data as never;348 },349 async upsertPairedDevice(paired) {350 const response = (await chrome.runtime.sendMessage({351 type: "COMPLETE_PAIRING",352 payload: { paired },353 })) as { ok: boolean; error?: string };354 if (!response.ok) throw new Error(response.error ?? "complete_pairing_failed");355 },356 pairState: {357 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),358 relayUrl,359 },360 onError(error) {361 push("ADOPTION_SOURCE_RESULT", { status: "error", error });362 },363 });364365 if (!device) {366 push("ADOPTION_SOURCE_RESULT", {367 status: "error",368 error: "adoption_rejected",369 });370 return;371 }372 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });373 void triggerSyncIfReachable({ reason: "manual", force: true });374 } catch {375 push("ADOPTION_SOURCE_RESULT", {376 status: "error",377 error: "adoption_failed",378 });379 } finally {380 clearTimeout(timeout);381 noise?.close();382 socket.close();383 ephemeralPrivate.fill(0);384 ephemeralPublic.fill(0);385 386 if (pendingSasConfirmation) pendingSasConfirmation = null;387 }388}389390391392393394395396397398399400function waitForSasConfirmation(401 id: string,402 receiverDeviceName: string,403 sas: string,404): Promise<boolean> {405 return new Promise<boolean>((resolve) => {406 const timer = setTimeout(() => {407 if (pendingSasConfirmation?.id === id) pendingSasConfirmation = null;408 resolve(false);409 }, RECEIVER_SAS_TIMEOUT_MS);410 pendingSasConfirmation = {411 id,412 resolve: (ok) => {413 clearTimeout(timer);414 resolve(ok);415 },416 };417 push("ADOPTION_SOURCE_SAS", { id, sas, receiverDeviceName });418 });419}420421422423424425426427function requestConsent(request: AdoptRequest, sas: string): Promise<boolean> {428 push("ADOPTION_SOURCE_SAS", {429 sas,430 receiverDeviceName: request.device_name,431 });432 return Promise.resolve(true);433}434435function clearState(): void {436 activeSocket = null;437 inFlight = false;438}439440function constantTimeEqual(a: string, b: string): boolean {441 if (a.length !== b.length) return false;442 let diff = 0;443 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);444 return diff === 0;445}446447function encodeJson(value: unknown): Uint8Array {448 return new TextEncoder().encode(JSON.stringify(value));449}450451function decodeJson<T>(value: Uint8Array): T {452 return JSON.parse(new TextDecoder().decode(value)) as T;453}454455function hexToBytes(hex: string): Uint8Array {456 const out = new Uint8Array(hex.length / 2);457 for (let i = 0; i < out.length; i++) {458 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);459 }460 return out;461}