1234567891011121314151617181920import { noiseNKInitiator } from "../shared/noise-js";21import { derivePairingSAS } from "../shared/chirp-handshake";22import type { LocalIdentity } from "../shared/chirp-handshake";23import type { DeviceType, PairedDevice } from "../shared/sync-types";242526export const DESKTOP_PAIRING_PORT = 49142;2728const DEFAULT_SYNC_PORT = 8765;2930export type DesktopPairingError =31 | "invalid_code" 32 | "no_host_found" 33 | "handshake_failed" 34 | "sas_mismatch" 35 | "vault_locked"; 363738394041export interface DesktopPairingSocket {42 send(bytes: Uint8Array): void;43 44 receive(): Promise<Uint8Array>;45 close(): void;46 47 closedBeforeFrame(): boolean;48}4950export interface DesktopPairingDeps {51 52 findHost(port: number): Promise<string | null>;53 54 openSocket(ip: string, port: number): Promise<DesktopPairingSocket>;55 56 getLocalIdentity(): Promise<LocalIdentity>;57 58 persistDevice(device: PairedDevice): Promise<void>;59 60 cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;61}6263export interface PairWithDesktopCallbacks {64 65 onSasReady(sas: string): void;66 67 onComplete(device: PairedDevice): void;68 69 onError(error: DesktopPairingError): void;70}7172interface PairingFrame {73 type: "PAIR_IDENTITY";74 device_id: string;75 device_name: string;76 device_type: DeviceType;77 long_term_public_key_hex: string;78 signing_public_key_hex?: string;79 ws_port?: number;80 vault_id?: string; 81}8283function encodeJson(obj: unknown): Uint8Array {84 return new TextEncoder().encode(JSON.stringify(obj));85}8687function decodeJson<T>(bytes: Uint8Array): T {88 return JSON.parse(new TextDecoder().decode(bytes)) as T;89}9091function hexToBytes(hex: string): Uint8Array {92 if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");93 const out = new Uint8Array(32);94 for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);95 return out;96}9798function decodePairIdentity(bytes: Uint8Array): PairingFrame {99 const p = decodeJson<Record<string, unknown>>(bytes);100 if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");101 if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");102 if (typeof p.device_name !== "string") throw new Error("device_name missing");103 if (typeof p.device_type !== "string") throw new Error("device_type missing");104 if (typeof p.long_term_public_key_hex !== "string" ||105 !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {106 throw new Error("long_term_public_key_hex is not 32-byte hex");107 }108 return p as unknown as PairingFrame;109}110111112113114115116117118119export async function pairWithDesktopHelper(120 code: string,121 cb: PairWithDesktopCallbacks,122 deps: DesktopPairingDeps,123 confirm: (sas: string) => Promise<boolean>,124): Promise<void> {125 126 127 const normalizedCode = String(code).replace(/\D/g, "").padStart(6, "0").slice(-6);128129 let identity: LocalIdentity;130 try {131 identity = await deps.getLocalIdentity();132 } catch {133 cb.onError("vault_locked");134 return;135 }136137 138 const host = await deps.findHost(DESKTOP_PAIRING_PORT);139 if (!host) {140 cb.onError("no_host_found");141 return;142 }143144 145 let sock: DesktopPairingSocket;146 try {147 sock = await deps.openSocket(host, DESKTOP_PAIRING_PORT);148 } catch {149 cb.onError("no_host_found");150 return;151 }152153 try {154 155 sock.send(encodeJson({ type: "PEACH_HELLO", code: normalizedCode }));156157 158 159 160 let ackBytes: Uint8Array;161 try {162 ackBytes = await sock.receive();163 } catch {164 cb.onError("invalid_code");165 return;166 }167 if (sock.closedBeforeFrame() || ackBytes.length === 0) {168 cb.onError("invalid_code");169 return;170 }171172 let ephemeralPublicKeyHex: string;173 try {174 const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);175 if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {176 177 178 cb.onError("invalid_code");179 return;180 }181 ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;182 } catch {183 cb.onError("invalid_code");184 return;185 }186187 188 const responderStatic = hexToBytes(ephemeralPublicKeyHex);189 const session = await noiseNKInitiator(190 responderStatic,191 async (msg) => sock.send(msg),192 () => sock.receive(),193 );194195 196 const ourFrame: PairingFrame = {197 type: "PAIR_IDENTITY",198 device_id: identity.deviceId,199 device_name: identity.deviceName,200 device_type: identity.deviceType,201 long_term_public_key_hex: identity.longTermPublicKeyHex,202 signing_public_key_hex: identity.signingPublicKeyHex,203 ws_port: identity.wsPort,204 vault_id: identity.vaultId, 205 };206 sock.send(session.encrypt(encodeJson(ourFrame)));207208 const theirCipher = await sock.receive();209 const theirFrame = decodePairIdentity(session.decrypt(theirCipher));210211 212 213 const sas = derivePairingSAS(session.handshakeHash);214 cb.onSasReady(sas);215216 const confirmed = await confirm(sas);217 if (!confirmed) {218 cb.onError("sas_mismatch");219 return;220 }221222 223 224 const device: PairedDevice = {225 deviceId: theirFrame.device_id,226 deviceName: theirFrame.device_name,227 deviceType: (theirFrame.device_type as DeviceType) || "desktop",228 longTermPublicKeyHex: theirFrame.long_term_public_key_hex,229 signingPublicKeyHex: theirFrame.signing_public_key_hex,230 trustSource: "direct",231 wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,232 pairedAt: Date.now(),233 };234 await deps.persistDevice(device);235 if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {236 await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);237 }238 cb.onComplete(device);239 } catch {240 cb.onError("handshake_failed");241 } finally {242 sock.close();243 }244}