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";20import { constantTimeEqualHex } from "../shared/constant-time";21222324const DEFAULT_SYNC_PORT = PAIRING_PORT;2526export type CodePairingError =27 | "invalid_code" 28 | "no_host_found" 29 | "handshake_failed" 30 | "sas_mismatch" 31 | "vault_locked"; 3233export interface CodePairingDeps extends PairingDialDeps {34 35363738 getLocalIdentity(): Promise<{39 identity: LocalIdentity;40 privateKey: Uint8Array;41 publicKey: Uint8Array;42 }>;43 44 getRelayUrl(): Promise<string>;45 46 persistDevice(device: PairedDevice): Promise<void>;47 48 cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;49}5051export interface CodePairingCallbacks {52 53 onSasReady(sas: string): void;54 55 onComplete(device: PairedDevice): void;56 57 onError(error: CodePairingError): void;58}5960interface PairingFrame {61 type: "PAIR_IDENTITY";62 device_id: string;63 device_name: string;64 device_type: DeviceType;65 long_term_public_key_hex: string;66 signing_public_key_hex?: string;67 ws_port?: number;68 vault_id?: string; 69 relay_url?: string;70}7172function encodeJson(obj: unknown): Uint8Array {73 return new TextEncoder().encode(JSON.stringify(obj));74}7576function decodePairIdentity(bytes: Uint8Array): PairingFrame {77 const p = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;78 if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");79 if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");80 if (typeof p.device_name !== "string") throw new Error("device_name missing");81 if (82 p.device_type !== "extension" &&83 p.device_type !== "android" &&84 p.device_type !== "ios"85 ) {86 throw new Error("device_type invalid");87 }88 if (typeof p.long_term_public_key_hex !== "string" ||89 !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {90 throw new Error("long_term_public_key_hex is not 32-byte hex");91 }92 if (p.relay_url !== undefined) {93 if (typeof p.relay_url !== "string") throw new Error("relay_url invalid");94 p.relay_url = normalizeRelayUrl(p.relay_url);95 }96 return p as unknown as PairingFrame;97}9899100101102103104105106107108export async function pairWithCode(109 code: string,110 cb: CodePairingCallbacks,111 deps: CodePairingDeps,112 confirm: (sas: string) => Promise<boolean>,113): Promise<void> {114 let local: Awaited<ReturnType<CodePairingDeps["getLocalIdentity"]>>;115 try {116 local = await deps.getLocalIdentity();117 } catch {118 cb.onError("vault_locked");119 return;120 }121122 const dial = await dialPairingChannel(code, deps);123 if (!dial.ok) {124 cb.onError(dial.error);125 return;126 }127 const {128 channel,129 sas,130 host,131 rendezvousSecretHex,132 remoteStaticPublicKey,133 authenticateInitiator,134 socket,135 } = dial.dialled;136137 try {138 const { identity, privateKey, publicKey } = local;139 if (!constantTimeEqualHex(bytesToHex(publicKey), identity.longTermPublicKeyHex)) {140 throw new Error("local pairing key does not match advertised identity");141 }142 143 144 await authenticateInitiator(privateKey, publicKey);145146 const relayUrl = await deps.getRelayUrl();147 148 const ourFrame: PairingFrame = {149 type: "PAIR_IDENTITY",150 device_id: identity.deviceId,151 device_name: identity.deviceName,152 device_type: identity.deviceType,153 long_term_public_key_hex: identity.longTermPublicKeyHex,154 signing_public_key_hex: identity.signingPublicKeyHex,155 ws_port: identity.wsPort,156 vault_id: identity.vaultId, 157 relay_url: relayUrl,158 };159 await channel.send(encodeJson(ourFrame));160161 const theirFrame = decodePairIdentity(await channel.receive());162 if (!constantTimeEqualHex(163 theirFrame.long_term_public_key_hex,164 bytesToHex(remoteStaticPublicKey),165 )) {166 throw new Error("peer identity does not match Noise responder key");167 }168169 cb.onSasReady(sas);170 const confirmed = await confirm(sas);171 if (!confirmed) {172 cb.onError("sas_mismatch");173 return;174 }175176 177 178 const device: PairedDevice = {179 deviceId: theirFrame.device_id,180 deviceName: theirFrame.device_name,181 deviceType: theirFrame.device_type,182 longTermPublicKeyHex: theirFrame.long_term_public_key_hex,183 signingPublicKeyHex: theirFrame.signing_public_key_hex,184 trustSource: "direct",185 wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,186 rendezvousSecretHex,187 188 relayUrl: theirFrame.relay_url ?? relayUrl,189 pairedAt: Date.now(),190 };191 await deps.persistDevice(device);192 if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {193 await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);194 }195 cb.onComplete(device);196 } catch (err) {197 if (typeof console !== "undefined") console.error("[code-pairing] handshake failed:", err);198 cb.onError("handshake_failed");199 } finally {200 socket.close();201 }202}