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";19import { normalizeRelayUrl } from "../shared/rendezvous";20212223const DEFAULT_SYNC_PORT = PAIRING_PORT;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 getRelayUrl(): Promise<string>;37 38 persistDevice(device: PairedDevice): Promise<void>;39 40 cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;41}4243export interface CodePairingCallbacks {44 45 onSasReady(sas: string): void;46 47 onComplete(device: PairedDevice): void;48 49 onError(error: CodePairingError): void;50}5152interface PairingFrame {53 type: "PAIR_IDENTITY";54 device_id: string;55 device_name: string;56 device_type: DeviceType;57 long_term_public_key_hex: string;58 signing_public_key_hex?: string;59 ws_port?: number;60 vault_id?: string; 61 relay_url?: string;62}6364function encodeJson(obj: unknown): Uint8Array {65 return new TextEncoder().encode(JSON.stringify(obj));66}6768function decodePairIdentity(bytes: Uint8Array): PairingFrame {69 const p = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;70 if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");71 if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");72 if (typeof p.device_name !== "string") throw new Error("device_name missing");73 if (74 p.device_type !== "extension" &&75 p.device_type !== "android" &&76 p.device_type !== "ios"77 ) {78 throw new Error("device_type invalid");79 }80 if (typeof p.long_term_public_key_hex !== "string" ||81 !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {82 throw new Error("long_term_public_key_hex is not 32-byte hex");83 }84 if (p.relay_url !== undefined) {85 if (typeof p.relay_url !== "string") throw new Error("relay_url invalid");86 p.relay_url = normalizeRelayUrl(p.relay_url);87 }88 return p as unknown as PairingFrame;89}90919293949596979899100export async function pairWithCode(101 code: string,102 cb: CodePairingCallbacks,103 deps: CodePairingDeps,104 confirm: (sas: string) => Promise<boolean>,105): Promise<void> {106 let identity: LocalIdentity;107 try {108 identity = await deps.getLocalIdentity();109 } catch {110 cb.onError("vault_locked");111 return;112 }113114 const dial = await dialPairingChannel(code, deps);115 if (!dial.ok) {116 cb.onError(dial.error);117 return;118 }119 const { channel, sas, host, rendezvousSecretHex, socket } = dial.dialled;120121 try {122 const relayUrl = await deps.getRelayUrl();123 124 const ourFrame: PairingFrame = {125 type: "PAIR_IDENTITY",126 device_id: identity.deviceId,127 device_name: identity.deviceName,128 device_type: identity.deviceType,129 long_term_public_key_hex: identity.longTermPublicKeyHex,130 signing_public_key_hex: identity.signingPublicKeyHex,131 ws_port: identity.wsPort,132 vault_id: identity.vaultId, 133 relay_url: relayUrl,134 };135 await channel.send(encodeJson(ourFrame));136137 const theirFrame = decodePairIdentity(await channel.receive());138139 cb.onSasReady(sas);140 const confirmed = await confirm(sas);141 if (!confirmed) {142 cb.onError("sas_mismatch");143 return;144 }145146 147 148 const device: PairedDevice = {149 deviceId: theirFrame.device_id,150 deviceName: theirFrame.device_name,151 deviceType: theirFrame.device_type,152 longTermPublicKeyHex: theirFrame.long_term_public_key_hex,153 signingPublicKeyHex: theirFrame.signing_public_key_hex,154 trustSource: "direct",155 wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,156 rendezvousSecretHex,157 158 relayUrl: theirFrame.relay_url ?? relayUrl,159 pairedAt: Date.now(),160 };161 await deps.persistDevice(device);162 if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {163 await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);164 }165 cb.onComplete(device);166 } catch (err) {167 if (typeof console !== "undefined") console.error("[code-pairing] handshake failed:", err);168 cb.onError("handshake_failed");169 } finally {170 socket.close();171 }172}