12345678910111213141516import { dialPairingChannel, PAIRING_PORT, type PairingDialDeps } from "../shared/pairing-dial";17import type { LocalIdentity } from "../shared/pairing-handshake";18import type { DeviceType, PairedDevice } from "../shared/sync-types";19202122const DEFAULT_SYNC_PORT = PAIRING_PORT;2324export type CodePairingError =25 | "invalid_code" 26 | "no_host_found" 27 | "handshake_failed" 28 | "sas_mismatch" 29 | "vault_locked"; 3031export interface CodePairingDeps extends PairingDialDeps {32 33 getLocalIdentity(): Promise<LocalIdentity>;34 35 persistDevice(device: PairedDevice): Promise<void>;36 37 cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;38}3940export interface CodePairingCallbacks {41 42 onSasReady(sas: string): void;43 44 onComplete(device: PairedDevice): void;45 46 onError(error: CodePairingError): void;47}4849interface PairingFrame {50 type: "PAIR_IDENTITY";51 device_id: string;52 device_name: string;53 device_type: DeviceType;54 long_term_public_key_hex: string;55 signing_public_key_hex?: string;56 ws_port?: number;57 vault_id?: string; 58}5960function encodeJson(obj: unknown): Uint8Array {61 return new TextEncoder().encode(JSON.stringify(obj));62}6364function decodePairIdentity(bytes: Uint8Array): PairingFrame {65 const p = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;66 if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");67 if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");68 if (typeof p.device_name !== "string") throw new Error("device_name missing");69 if (70 p.device_type !== "extension" &&71 p.device_type !== "android" &&72 p.device_type !== "ios"73 ) {74 throw new Error("device_type invalid");75 }76 if (typeof p.long_term_public_key_hex !== "string" ||77 !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {78 throw new Error("long_term_public_key_hex is not 32-byte hex");79 }80 return p as unknown as PairingFrame;81}8283848586878889909192export async function pairWithCode(93 code: string,94 cb: CodePairingCallbacks,95 deps: CodePairingDeps,96 confirm: (sas: string) => Promise<boolean>,97): Promise<void> {98 let identity: LocalIdentity;99 try {100 identity = await deps.getLocalIdentity();101 } catch {102 cb.onError("vault_locked");103 return;104 }105106 const dial = await dialPairingChannel(code, deps);107 if (!dial.ok) {108 cb.onError(dial.error);109 return;110 }111 const { channel, sas, host, socket } = dial.dialled;112113 try {114 115 const ourFrame: PairingFrame = {116 type: "PAIR_IDENTITY",117 device_id: identity.deviceId,118 device_name: identity.deviceName,119 device_type: identity.deviceType,120 long_term_public_key_hex: identity.longTermPublicKeyHex,121 signing_public_key_hex: identity.signingPublicKeyHex,122 ws_port: identity.wsPort,123 vault_id: identity.vaultId, 124 };125 await channel.send(encodeJson(ourFrame));126127 const theirFrame = decodePairIdentity(await channel.receive());128129 cb.onSasReady(sas);130 const confirmed = await confirm(sas);131 if (!confirmed) {132 cb.onError("sas_mismatch");133 return;134 }135136 137 138 const device: PairedDevice = {139 deviceId: theirFrame.device_id,140 deviceName: theirFrame.device_name,141 deviceType: theirFrame.device_type,142 longTermPublicKeyHex: theirFrame.long_term_public_key_hex,143 signingPublicKeyHex: theirFrame.signing_public_key_hex,144 trustSource: "direct",145 wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,146 pairedAt: Date.now(),147 };148 await deps.persistDevice(device);149 if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {150 await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);151 }152 cb.onComplete(device);153 } catch (err) {154 if (typeof console !== "undefined") console.error("[code-pairing] handshake failed:", err);155 cb.onError("handshake_failed");156 } finally {157 socket.close();158 }159}