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 { bytesToHex, 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 34353637 getLocalIdentity(): Promise<{38 identity: LocalIdentity;39 privateKey: Uint8Array;40 publicKey: Uint8Array;41 }>;42 43 getRelayUrl(): Promise<string>;44 45 persistDevice(device: PairedDevice): Promise<void>;46 47 cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;48}4950export interface CodePairingCallbacks {51 52 onSasReady(sas: string): void;53 54 onComplete(device: PairedDevice): void;55 56 onError(error: CodePairingError): void;57}5859interface PairingFrame {60 type: "PAIR_IDENTITY";61 device_id: string;62 device_name: string;63 device_type: DeviceType;64 long_term_public_key_hex: string;65 signing_public_key_hex?: string;66 ws_port?: number;67 vault_id?: string; 68 relay_url?: string;69}7071function encodeJson(obj: unknown): Uint8Array {72 return new TextEncoder().encode(JSON.stringify(obj));73}7475function decodePairIdentity(bytes: Uint8Array): PairingFrame {76 const p = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;77 if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");78 if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");79 if (typeof p.device_name !== "string") throw new Error("device_name missing");80 if (81 p.device_type !== "extension" &&82 p.device_type !== "android" &&83 p.device_type !== "ios"84 ) {85 throw new Error("device_type invalid");86 }87 if (typeof p.long_term_public_key_hex !== "string" ||88 !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {89 throw new Error("long_term_public_key_hex is not 32-byte hex");90 }91 if (p.relay_url !== undefined) {92 if (typeof p.relay_url !== "string") throw new Error("relay_url invalid");93 p.relay_url = normalizeRelayUrl(p.relay_url);94 }95 return p as unknown as PairingFrame;96}979899100101102103104105106107export async function pairWithCode(108 code: string,109 cb: CodePairingCallbacks,110 deps: CodePairingDeps,111 confirm: (sas: string) => Promise<boolean>,112): Promise<void> {113 let local: Awaited<ReturnType<CodePairingDeps["getLocalIdentity"]>>;114 try {115 local = await deps.getLocalIdentity();116 } catch {117 cb.onError("vault_locked");118 return;119 }120121 const dial = await dialPairingChannel(code, deps);122 if (!dial.ok) {123 cb.onError(dial.error);124 return;125 }126 const {127 channel,128 sas,129 host,130 rendezvousSecretHex,131 remoteStaticPublicKey,132 authenticateInitiator,133 socket,134 } = dial.dialled;135136 try {137 const { identity, privateKey, publicKey } = local;138 if (bytesToHex(publicKey) !== identity.longTermPublicKeyHex) {139 throw new Error("local pairing key does not match advertised identity");140 }141 142 143 await authenticateInitiator(privateKey, publicKey);144145 const relayUrl = await deps.getRelayUrl();146 147 const ourFrame: PairingFrame = {148 type: "PAIR_IDENTITY",149 device_id: identity.deviceId,150 device_name: identity.deviceName,151 device_type: identity.deviceType,152 long_term_public_key_hex: identity.longTermPublicKeyHex,153 signing_public_key_hex: identity.signingPublicKeyHex,154 ws_port: identity.wsPort,155 vault_id: identity.vaultId, 156 relay_url: relayUrl,157 };158 await channel.send(encodeJson(ourFrame));159160 const theirFrame = decodePairIdentity(await channel.receive());161 if (theirFrame.long_term_public_key_hex !== bytesToHex(remoteStaticPublicKey)) {162 throw new Error("peer identity does not match Noise responder key");163 }164165 cb.onSasReady(sas);166 const confirmed = await confirm(sas);167 if (!confirmed) {168 cb.onError("sas_mismatch");169 return;170 }171172 173 174 const device: PairedDevice = {175 deviceId: theirFrame.device_id,176 deviceName: theirFrame.device_name,177 deviceType: theirFrame.device_type,178 longTermPublicKeyHex: theirFrame.long_term_public_key_hex,179 signingPublicKeyHex: theirFrame.signing_public_key_hex,180 trustSource: "direct",181 wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,182 rendezvousSecretHex,183 184 relayUrl: theirFrame.relay_url ?? relayUrl,185 pairedAt: Date.now(),186 };187 await deps.persistDevice(device);188 if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {189 await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);190 }191 cb.onComplete(device);192 } catch (err) {193 if (typeof console !== "undefined") console.error("[code-pairing] handshake failed:", err);194 cb.onError("handshake_failed");195 } finally {196 socket.close();197 }198}