1234567891011121314151617import { dialPairingChannel, type PairingDialDeps } from "../shared/pairing-dial";18import type { LocalIdentity } from "../shared/pairing-handshake";19import { parsePairingCode } from "../shared/pairing-code";20import type { DeviceType, PairedDevice } from "../shared/sync-types";212223const DEFAULT_SYNC_PORT = 8765;2425export type CodePairingError =26 | "invalid_code" 27 | "no_host_found" 28 | "handshake_failed" 29 | "sas_mismatch" 30 | "vault_locked"; 3132export interface CodePairingDeps extends PairingDialDeps {33 34 getLocalIdentity(): Promise<LocalIdentity>;35 36 persistDevice(device: PairedDevice): Promise<void>;37 38 cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;39}4041export interface CodePairingCallbacks {42 43 onSasReady(sas: string): void;44 45 onComplete(device: PairedDevice): void;46 47 onError(error: CodePairingError): void;48}4950interface PairingFrame {51 type: "PAIR_IDENTITY";52 device_id: string;53 device_name: string;54 device_type: DeviceType;55 long_term_public_key_hex: string;56 signing_public_key_hex?: string;57 ws_port?: number;58 vault_id?: string; 59}6061function encodeJson(obj: unknown): Uint8Array {62 return new TextEncoder().encode(JSON.stringify(obj));63}6465function decodePairIdentity(bytes: Uint8Array): PairingFrame {66 const p = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;67 if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");68 if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");69 if (typeof p.device_name !== "string") throw new Error("device_name missing");70 if (typeof p.device_type !== "string") throw new Error("device_type missing");71 if (typeof p.long_term_public_key_hex !== "string" ||72 !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {73 throw new Error("long_term_public_key_hex is not 32-byte hex");74 }75 return p as unknown as PairingFrame;76}7778798081828384858687export async function pairWithCode(88 code: string,89 cb: CodePairingCallbacks,90 deps: CodePairingDeps,91 confirm: (sas: string) => Promise<boolean>,92): Promise<void> {93 let identity: LocalIdentity;94 try {95 identity = await deps.getLocalIdentity();96 } catch {97 cb.onError("vault_locked");98 return;99 }100101 const dial = await dialPairingChannel(code, deps);102 if (!dial.ok) {103 cb.onError(dial.error);104 return;105 }106 const { channel, sas, host, socket } = dial.dialled;107108 try {109 110 const ourFrame: PairingFrame = {111 type: "PAIR_IDENTITY",112 device_id: identity.deviceId,113 device_name: identity.deviceName,114 device_type: identity.deviceType,115 long_term_public_key_hex: identity.longTermPublicKeyHex,116 signing_public_key_hex: identity.signingPublicKeyHex,117 ws_port: identity.wsPort,118 vault_id: identity.vaultId, 119 };120 await channel.send(encodeJson(ourFrame));121122 const theirFrame = decodePairIdentity(await channel.receive());123124 cb.onSasReady(sas);125 const confirmed = await confirm(sas);126 if (!confirmed) {127 cb.onError("sas_mismatch");128 return;129 }130131 132 133 134 const device: PairedDevice = {135 deviceId: theirFrame.device_id,136 deviceName: theirFrame.device_name,137 deviceType:138 (theirFrame.device_type as DeviceType) ||139 (parsePairingCode(code) ? "android" : "desktop"),140 longTermPublicKeyHex: theirFrame.long_term_public_key_hex,141 signingPublicKeyHex: theirFrame.signing_public_key_hex,142 trustSource: "direct",143 wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,144 pairedAt: Date.now(),145 };146 await deps.persistDevice(device);147 if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {148 await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);149 }150 cb.onComplete(device);151 } catch (err) {152 if (typeof console !== "undefined") console.error("[desktop-pairing] handshake failed:", err);153 cb.onError("handshake_failed");154 } finally {155 socket.close();156 }157}