123456789101112131415161718192021import {22 ChirpExchangeError,23 createPairingAttempt,24 openBrowserAudioIO,25 runChirpExchange,26} from "../shared/chirp-pairing";27import {28 runPairingInitiator,29 runPairingResponder,30 type LocalIdentity,31} from "../shared/chirp-handshake";32import {33 DEVICE_TYPE_EXTENSION,34 DEVICE_TYPE_ANDROID,35 DEVICE_TYPE_IOS,36 type PairedDevice,37 type PairingProgress,38} from "../shared/sync-types";394041424344454647export const CHIRP_PAIRING_PORT = 49142;4849export interface PairingTransport {50 send(msg: Uint8Array): Promise<void>;51 receive(): Promise<Uint8Array>;52 close(): Promise<void>;53}5455export interface ChirpPairingDeps {56 57 fetchLocalIdentity(): Promise<LocalIdentity>;58 59 completePairing(paired: PairedDevice): Promise<void>;60 616263646566676869 confirmSAS(sas: string): Promise<boolean>;70 71 cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;72 73747576 getLocalSubnet(): Promise<string | null>;77 7879808182 openPairingTransport(endpoint: { ip: string; port: number }): Promise<PairingTransport>;83 848586878889909192 openPairingServer?(): Promise<{93 transport: PairingTransport;94 peerIp: string;95 }>;96 97 openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;98 99 chirpTimeoutMs?: number;100 onProgress(p: PairingProgress): void;101}102103export type PairingRole = "initiator" | "responder";104105export class ChirpPairingFailure extends Error {106 readonly phase: PairingProgress["phase"];107 constructor(phase: PairingProgress["phase"], message: string) {108 super(message);109 this.phase = phase;110 }111}112113export async function runChirpPairing(114 deps: ChirpPairingDeps,115 role: PairingRole = "responder",116): Promise<PairedDevice> {117 deps.onProgress({ phase: "idle" });118119 const localIdentity = await deps.fetchLocalIdentity();120121 122 123 124 if (role === "responder" && localIdentity.deviceType !== "extension") {125 throw new ChirpPairingFailure("handshake_failed", "responder requires extension identity");126 }127 if (role === "initiator" && localIdentity.deviceType === "extension") {128 throw new ChirpPairingFailure("handshake_failed", "initiator requires non-extension identity");129 }130 if (role === "initiator" && !deps.openPairingServer) {131 throw new ChirpPairingFailure("handshake_failed", "initiator requires openPairingServer dep");132 }133134 deps.onProgress({ phase: "listening" });135136 const deviceTypeByte =137 localIdentity.deviceType === "android" ? DEVICE_TYPE_ANDROID :138 localIdentity.deviceType === "ios" ? DEVICE_TYPE_IOS :139 DEVICE_TYPE_EXTENSION;140 const attempt = createPairingAttempt(deviceTypeByte);141142 let remoteChirpPublicKey: Uint8Array;143 try {144 const audio = await (deps.openAudio ?? openBrowserAudioIO)();145 const result = await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });146 remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey;147 } catch (err) {148 if (err instanceof ChirpExchangeError) {149 const phase: PairingProgress["phase"] =150 err.kind === "timeout" ? "timeout" :151 err.kind === "version_mismatch" ? "version_mismatch" :152 err.kind === "mic_denied" ? "mic_denied" :153 "handshake_failed";154 deps.onProgress({ phase, errorMessage: err.message });155 throw new ChirpPairingFailure(phase, err.message);156 }157 throw err;158 }159160 deps.onProgress({ phase: "handshake" });161162 if (role === "responder") {163 return runResponderHandshake(deps, attempt, localIdentity);164 }165 return runInitiatorHandshake(deps, remoteChirpPublicKey, localIdentity);166}167168async function runResponderHandshake(169 deps: ChirpPairingDeps,170 attempt: ReturnType<typeof createPairingAttempt>,171 localIdentity: LocalIdentity,172): Promise<PairedDevice> {173 174 175 176 const subnet = await deps.getLocalSubnet();177 if (!subnet) {178 deps.onProgress({ phase: "handshake_failed", errorMessage: "no_subnet" });179 throw new ChirpPairingFailure("handshake_failed", "could not determine local subnet");180 }181182 const peerEndpoint = await firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT);183 if (!peerEndpoint) {184 deps.onProgress({ phase: "handshake_failed", errorMessage: "peer_not_found" });185 throw new ChirpPairingFailure("handshake_failed", "could not reach paired peer on subnet");186 }187188 const transport = await deps.openPairingTransport(peerEndpoint);189 let result: { device: PairedDevice; sas: string };190 try {191 result = await runPairingResponder(192 attempt.ephemeralPrivateKey,193 attempt.ephemeralPublicKey,194 localIdentity,195 transport,196 );197 } catch (err) {198 await transport.close();199 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });200 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);201 }202 await transport.close();203204 205 206 207 208 const paired = await gateOnSAS(deps, result);209210 211 212 213 214 await deps.completePairing(paired);215 await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", peerEndpoint.ip);216 deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });217 return paired;218}219220221222223224async function gateOnSAS(225 deps: ChirpPairingDeps,226 result: { device: PairedDevice; sas: string },227): Promise<PairedDevice> {228 let confirmed: boolean;229 try {230 confirmed = await deps.confirmSAS(result.sas);231 } catch (err) {232 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });233 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);234 }235 if (!confirmed) {236 deps.onProgress({ phase: "handshake_failed", errorMessage: "sas_rejected" });237 throw new ChirpPairingFailure(238 "handshake_failed",239 "pairing rejected — the verification codes did not match",240 );241 }242 return result.device;243}244245async function runInitiatorHandshake(246 deps: ChirpPairingDeps,247 remoteChirpPublicKey: Uint8Array,248 localIdentity: LocalIdentity,249): Promise<PairedDevice> {250 251 252 253 let server: { transport: PairingTransport; peerIp: string };254 try {255 server = await deps.openPairingServer!();256 } catch (err) {257 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });258 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);259 }260261 let result: { device: PairedDevice; sas: string };262 try {263 result = await runPairingInitiator(264 remoteChirpPublicKey,265 localIdentity,266 server.transport,267 );268 } catch (err) {269 await server.transport.close();270 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });271 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);272 }273 await server.transport.close();274275 276 277 const paired = await gateOnSAS(deps, result);278279 await deps.completePairing(paired);280 await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", server.peerIp);281 deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });282 return paired;283}284285async function firstResponsivePeer(286 deps: ChirpPairingDeps,287 subnet: string,288 port: number,289): Promise<{ ip: string; port: number } | null> {290 291 292 293 294 295 296 297 298 299 300 301 302 303 const PROBE_TIMEOUT_MS = 250;304 const batch = 16;305 for (let start = 1; start <= 254; start += batch) {306 const end = Math.min(start + batch - 1, 254);307 const ips: string[] = [];308 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);309310 const results = await Promise.all(311 ips.map(async (ip) => {312 let timer: ReturnType<typeof setTimeout> | undefined;313 const timeout = new Promise<null>((resolve) => {314 timer = setTimeout(() => resolve(null), PROBE_TIMEOUT_MS);315 });316 const probe = (async () => {317 try {318 const t = await deps.openPairingTransport({ ip, port });319 return { transport: t, ip, port };320 } catch {321 return null;322 }323 })();324 const winner = await Promise.race([probe, timeout]);325 if (winner && "transport" in winner) {326 if (timer !== undefined) clearTimeout(timer);327 await winner.transport.close();328 return { ip: winner.ip, port: winner.port };329 }330 331 332 probe.then((late) => {333 if (late) void late.transport.close();334 }).catch(() => { });335 return null;336 }),337 );338 const hit = results.find((r): r is { ip: string; port: number } => r !== null);339 if (hit) return hit;340 }341 return null;342}