1234567891011121314151617181920212223242526272829303132import { noiseNKInitiator } from "../shared/noise-js";33import { parsePairingCode } from "../shared/pairing-code";34import { derivePairingSAS } from "../shared/chirp-handshake";35import type { LocalIdentity } from "../shared/chirp-handshake";36import type { DeviceType, PairedDevice } from "../shared/sync-types";373839export const DESKTOP_PAIRING_PORT = 49142;4041const DEFAULT_SYNC_PORT = 8765;4243export type DesktopPairingError =44 | "invalid_code" 45 | "no_host_found" 46 | "handshake_failed" 47 | "sas_mismatch" 48 | "vault_locked"; 495051525354export interface DesktopPairingSocket {55 send(bytes: Uint8Array): void;56 57 receive(): Promise<Uint8Array>;58 close(): void;59 60 closedBeforeFrame(): boolean;61}6263export interface DesktopPairingDeps {64 65 findHost(port: number): Promise<string | null>;66 67 openSocket(ip: string, port: number): Promise<DesktopPairingSocket>;68 69 getLocalIdentity(): Promise<LocalIdentity>;70 71 persistDevice(device: PairedDevice): Promise<void>;72 73 cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;74}7576export interface PairWithDesktopCallbacks {77 78 onSasReady(sas: string): void;79 80 onComplete(device: PairedDevice): void;81 82 onError(error: DesktopPairingError): void;83}8485interface PairingFrame {86 type: "PAIR_IDENTITY";87 device_id: string;88 device_name: string;89 device_type: DeviceType;90 long_term_public_key_hex: string;91 signing_public_key_hex?: string;92 ws_port?: number;93 vault_id?: string; 94}9596function encodeJson(obj: unknown): Uint8Array {97 return new TextEncoder().encode(JSON.stringify(obj));98}99100function decodeJson<T>(bytes: Uint8Array): T {101 return JSON.parse(new TextDecoder().decode(bytes)) as T;102}103104function hexToBytes(hex: string): Uint8Array {105 if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");106 const out = new Uint8Array(32);107 for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);108 return out;109}110111function decodePairIdentity(bytes: Uint8Array): PairingFrame {112 const p = decodeJson<Record<string, unknown>>(bytes);113 if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");114 if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");115 if (typeof p.device_name !== "string") throw new Error("device_name missing");116 if (typeof p.device_type !== "string") throw new Error("device_type missing");117 if (typeof p.long_term_public_key_hex !== "string" ||118 !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {119 throw new Error("long_term_public_key_hex is not 32-byte hex");120 }121 return p as unknown as PairingFrame;122}123124125126127128129130131132export async function pairWithDesktopHelper(133 code: string,134 cb: PairWithDesktopCallbacks,135 deps: DesktopPairingDeps,136 confirm: (sas: string) => Promise<boolean>,137): Promise<void> {138 const pairing = parsePairingCode(code);139140 let identity: LocalIdentity;141 try {142 identity = await deps.getLocalIdentity();143 } catch {144 cb.onError("vault_locked");145 return;146 }147148 149 150 const port = pairing?.port ?? DESKTOP_PAIRING_PORT;151 const host = pairing?.ip ?? (await deps.findHost(DESKTOP_PAIRING_PORT));152 if (!host) {153 cb.onError("no_host_found");154 return;155 }156157 158 let sock: DesktopPairingSocket;159 try {160 sock = await deps.openSocket(host, port);161 } catch {162 cb.onError("no_host_found");163 return;164 }165166 try {167 168 169 170 171 sock.send(encodeJson(172 pairing173 ? { type: "PEACH_HELLO", token: pairing.tokenHex }174 : { type: "PEACH_HELLO", code: String(code).replace(/\D/g, "").padStart(6, "0").slice(-6) },175 ));176177 178 179 180 let ackBytes: Uint8Array;181 try {182 ackBytes = await sock.receive();183 } catch {184 cb.onError("invalid_code");185 return;186 }187 if (sock.closedBeforeFrame() || ackBytes.length === 0) {188 cb.onError("invalid_code");189 return;190 }191192 let ephemeralPublicKeyHex: string;193 try {194 const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);195 if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {196 197 198 cb.onError("invalid_code");199 return;200 }201 ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;202 } catch {203 cb.onError("invalid_code");204 return;205 }206207 208 const responderStatic = hexToBytes(ephemeralPublicKeyHex);209 const session = await noiseNKInitiator(210 responderStatic,211 async (msg) => sock.send(msg),212 () => sock.receive(),213 );214215 216 const ourFrame: PairingFrame = {217 type: "PAIR_IDENTITY",218 device_id: identity.deviceId,219 device_name: identity.deviceName,220 device_type: identity.deviceType,221 long_term_public_key_hex: identity.longTermPublicKeyHex,222 signing_public_key_hex: identity.signingPublicKeyHex,223 ws_port: identity.wsPort,224 vault_id: identity.vaultId, 225 };226 sock.send(session.encrypt(encodeJson(ourFrame)));227228 const theirCipher = await sock.receive();229 const theirFrame = decodePairIdentity(session.decrypt(theirCipher));230231 232 233 const sas = derivePairingSAS(session.handshakeHash);234 cb.onSasReady(sas);235236 const confirmed = await confirm(sas);237 if (!confirmed) {238 cb.onError("sas_mismatch");239 return;240 }241242 243 244 245 const device: PairedDevice = {246 deviceId: theirFrame.device_id,247 deviceName: theirFrame.device_name,248 deviceType: (theirFrame.device_type as DeviceType) || (pairing ? "android" : "desktop"),249 longTermPublicKeyHex: theirFrame.long_term_public_key_hex,250 signingPublicKeyHex: theirFrame.signing_public_key_hex,251 trustSource: "direct",252 wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,253 pairedAt: Date.now(),254 };255 await deps.persistDevice(device);256 if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {257 await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);258 }259 cb.onComplete(device);260 } catch (err) {261 262 263 if (typeof console !== "undefined") console.error("[desktop-pairing] handshake failed:", err);264 cb.onError("handshake_failed");265 } finally {266 sock.close();267 }268}