123456789101112131415161718192021import {22 ChirpExchangeError,23 createPairingAttempt,24 openBrowserAudioIO,25 runChirpExchange,26} from "../shared/chirp-pairing";27import {28 runPairingInitiator,29 runPairingResponder,30 type LocalIdentity,31 type PairingHandshakeResult,32} from "../shared/chirp-handshake";33import {34 DEVICE_TYPE_EXTENSION,35 DEVICE_TYPE_ANDROID,36 DEVICE_TYPE_IOS,37 type PairedDevice,38 type PairingProgress,39} from "../shared/sync-types";404142434445464748export const CHIRP_PAIRING_PORT = 49142;4950export interface PairingTransport {51 send(msg: Uint8Array): Promise<void>;52 receive(): Promise<Uint8Array>;53 close(): Promise<void>;54}5556export interface ChirpPairingDeps {57 58 fetchLocalIdentity(): Promise<LocalIdentity>;59 60 completePairing(paired: PairedDevice): Promise<void>;61 626364656667686970 confirmSAS(sas: string): Promise<boolean>;71 72 cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;73 747576777879 activatePeerLicense?(license: string): Promise<void>;80 81828384 getLocalSubnet(): Promise<string | null>;85 8687888990 openPairingTransport(endpoint: { ip: string; port: number }): Promise<PairingTransport>;91 9293949596979899100 openPairingServer?(): Promise<{101 transport: PairingTransport;102 peerIp: string;103 }>;104 105 openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;106 107 chirpTimeoutMs?: number;108 onProgress(p: PairingProgress): void;109}110111export type PairingRole = "initiator" | "responder";112113export class ChirpPairingFailure extends Error {114 readonly phase: PairingProgress["phase"];115 constructor(phase: PairingProgress["phase"], message: string) {116 super(message);117 this.phase = phase;118 }119}120121export async function runChirpPairing(122 deps: ChirpPairingDeps,123 role: PairingRole = "responder",124): Promise<PairedDevice> {125 deps.onProgress({ phase: "idle" });126127 const localIdentity = await deps.fetchLocalIdentity();128129 130 131 132 if (role === "responder" && localIdentity.deviceType !== "extension") {133 throw new ChirpPairingFailure("handshake_failed", "responder requires extension identity");134 }135 if (role === "initiator" && localIdentity.deviceType === "extension") {136 throw new ChirpPairingFailure("handshake_failed", "initiator requires non-extension identity");137 }138 if (role === "initiator" && !deps.openPairingServer) {139 throw new ChirpPairingFailure("handshake_failed", "initiator requires openPairingServer dep");140 }141142 deps.onProgress({ phase: "listening" });143144 const deviceTypeByte =145 localIdentity.deviceType === "android" ? DEVICE_TYPE_ANDROID :146 localIdentity.deviceType === "ios" ? DEVICE_TYPE_IOS :147 DEVICE_TYPE_EXTENSION;148 const attempt = createPairingAttempt(deviceTypeByte);149150 let remoteChirpPublicKey: Uint8Array;151 try {152 const audio = await (deps.openAudio ?? openBrowserAudioIO)();153 const result = await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });154 remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey;155 } catch (err) {156 if (err instanceof ChirpExchangeError) {157 const phase: PairingProgress["phase"] =158 err.kind === "timeout" ? "timeout" :159 err.kind === "version_mismatch" ? "version_mismatch" :160 err.kind === "mic_denied" ? "mic_denied" :161 "handshake_failed";162 deps.onProgress({ phase, errorMessage: err.message });163 throw new ChirpPairingFailure(phase, err.message);164 }165 throw err;166 }167168 deps.onProgress({ phase: "handshake" });169170 if (role === "responder") {171 return runResponderHandshake(deps, attempt, localIdentity);172 }173 return runInitiatorHandshake(deps, remoteChirpPublicKey, localIdentity);174}175176async function runResponderHandshake(177 deps: ChirpPairingDeps,178 attempt: ReturnType<typeof createPairingAttempt>,179 localIdentity: LocalIdentity,180): Promise<PairedDevice> {181 182 183 184 const subnet = await deps.getLocalSubnet();185 if (!subnet) {186 deps.onProgress({ phase: "handshake_failed", errorMessage: "no_subnet" });187 throw new ChirpPairingFailure("handshake_failed", "could not determine local subnet");188 }189190 const peerEndpoint = await firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT);191 if (!peerEndpoint) {192 deps.onProgress({ phase: "handshake_failed", errorMessage: "peer_not_found" });193 throw new ChirpPairingFailure("handshake_failed", "could not reach paired peer on subnet");194 }195196 const transport = await deps.openPairingTransport(peerEndpoint);197 let result: PairingHandshakeResult;198 try {199 result = await runPairingResponder(200 attempt.ephemeralPrivateKey,201 attempt.ephemeralPublicKey,202 localIdentity,203 transport,204 );205 } catch (err) {206 await transport.close();207 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });208 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);209 }210 await transport.close();211212 213 214 215 216 const paired = await gateOnSAS(deps, result);217 await activatePeerLicenseIfAny(deps, result);218219 220 221 222 223 await deps.completePairing(paired);224 await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", peerEndpoint.ip);225 deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });226 return paired;227}228229230231232233234235export async function gateOnSAS(236 deps: ChirpPairingDeps,237 result: PairingHandshakeResult,238): Promise<PairedDevice> {239 let confirmed: boolean;240 try {241 confirmed = await deps.confirmSAS(result.sas);242 } catch (err) {243 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });244 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);245 }246 if (!confirmed) {247 deps.onProgress({ phase: "handshake_failed", errorMessage: "sas_rejected" });248 throw new ChirpPairingFailure(249 "handshake_failed",250 "pairing rejected — the verification codes did not match",251 );252 }253 return result.device;254}255256257258259260261async function activatePeerLicenseIfAny(262 deps: ChirpPairingDeps,263 result: PairingHandshakeResult,264): Promise<void> {265 if (!result.peerLicense || !deps.activatePeerLicense) return;266 try {267 await deps.activatePeerLicense(result.peerLicense);268 } catch {269 270 }271}272273async function runInitiatorHandshake(274 deps: ChirpPairingDeps,275 remoteChirpPublicKey: Uint8Array,276 localIdentity: LocalIdentity,277): Promise<PairedDevice> {278 279 280 281 let server: { transport: PairingTransport; peerIp: string };282 try {283 server = await deps.openPairingServer!();284 } catch (err) {285 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });286 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);287 }288289 let result: PairingHandshakeResult;290 try {291 result = await runPairingInitiator(292 remoteChirpPublicKey,293 localIdentity,294 server.transport,295 );296 } catch (err) {297 await server.transport.close();298 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });299 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);300 }301 await server.transport.close();302303 304 305 const paired = await gateOnSAS(deps, result);306 await activatePeerLicenseIfAny(deps, result);307308 await deps.completePairing(paired);309 await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", server.peerIp);310 deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });311 return paired;312}313314async function firstResponsivePeer(315 deps: ChirpPairingDeps,316 subnet: string,317 port: number,318): Promise<{ ip: string; port: number } | null> {319 320 321 322 323 324 325 326 327 328 329 330 331 332 const PROBE_TIMEOUT_MS = 250;333 const batch = 16;334 for (let start = 1; start <= 254; start += batch) {335 const end = Math.min(start + batch - 1, 254);336 const ips: string[] = [];337 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);338339 const results = await Promise.all(340 ips.map(async (ip) => {341 let timer: ReturnType<typeof setTimeout> | undefined;342 const timeout = new Promise<null>((resolve) => {343 timer = setTimeout(() => resolve(null), PROBE_TIMEOUT_MS);344 });345 const probe = (async () => {346 try {347 const t = await deps.openPairingTransport({ ip, port });348 return { transport: t, ip, port };349 } catch {350 return null;351 }352 })();353 const winner = await Promise.race([probe, timeout]);354 if (winner && "transport" in winner) {355 if (timer !== undefined) clearTimeout(timer);356 await winner.transport.close();357 return { ip: winner.ip, port: winner.port };358 }359 360 361 probe.then((late) => {362 if (late) void late.transport.close();363 }).catch(() => { });364 return null;365 }),366 );367 const hit = results.find((r): r is { ip: string; port: number } => r !== null);368 if (hit) return hit;369 }370 return null;371}