123456789101112131415161718192021import {22 ChirpExchangeError,23 createPairingAttempt,24 openBrowserAudioIO,25 runChirpExchange,26} from "../shared/chirp-pairing";27import {28 handlePairIdentityFrame,29 runPairingInitiator,30 runPairingResponderOpenFrame,31 type LocalIdentity,32 type PairingHandshakeResult,33 type PostNoiseFrame,34} from "../shared/chirp-handshake";35import { computeVaultId } from "../shared/sync-identity";36import { grantAdoption, isAdoptRequest, type AdoptRequest } from "../shared/chirp-adopt";37import {38 DEVICE_TYPE_EXTENSION,39 DEVICE_TYPE_ANDROID,40 DEVICE_TYPE_IOS,41 type PairedDevice,42 type PairingProgress,43} from "../shared/sync-types";444546474849505152export const CHIRP_PAIRING_PORT = 49142;5354export interface PairingTransport {55 send(msg: Uint8Array): Promise<void>;56 receive(): Promise<Uint8Array>;57 close(): Promise<void>;58}5960export interface ChirpPairingDeps {61 62 fetchLocalIdentity(): Promise<LocalIdentity>;63 64 completePairing(paired: PairedDevice): Promise<void>;65 666768697071727374 confirmSAS(sas: string): Promise<boolean>;75 76 cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;77 787980818283 activatePeerLicense?(license: string): Promise<void>;84 85 grantAdoptionConsent?(request: AdoptRequest, sas: string): Promise<boolean>;86 87 fetchAdoptionMaterial?(): Promise<{ rootKeyHex: string; vaultId: string; license?: string }>;88 89909192 getLocalSubnet(): Promise<string | null>;93 9495969798 openPairingTransport(endpoint: { ip: string; port: number }): Promise<PairingTransport>;99 100101102103104105106107108 openPairingServer?(): Promise<{109 transport: PairingTransport;110 peerIp: string;111 }>;112 113 openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;114 115 chirpTimeoutMs?: number;116 onProgress(p: PairingProgress): void;117}118119export type PairingRole = "initiator" | "responder";120121export class ChirpPairingFailure extends Error {122 readonly phase: PairingProgress["phase"];123 constructor(phase: PairingProgress["phase"], message: string) {124 super(message);125 this.phase = phase;126 }127}128129export async function runChirpPairing(130 deps: ChirpPairingDeps,131 role: PairingRole = "responder",132): Promise<PairedDevice> {133 deps.onProgress({ phase: "idle" });134135 const localIdentity = await deps.fetchLocalIdentity();136137 138 139 140 if (role === "responder" && localIdentity.deviceType !== "extension") {141 throw new ChirpPairingFailure("handshake_failed", "responder requires extension identity");142 }143 if (role === "initiator" && localIdentity.deviceType === "extension") {144 throw new ChirpPairingFailure("handshake_failed", "initiator requires non-extension identity");145 }146 if (role === "initiator" && !deps.openPairingServer) {147 throw new ChirpPairingFailure("handshake_failed", "initiator requires openPairingServer dep");148 }149150 deps.onProgress({ phase: "listening" });151152 const deviceTypeByte =153 localIdentity.deviceType === "android" ? DEVICE_TYPE_ANDROID :154 localIdentity.deviceType === "ios" ? DEVICE_TYPE_IOS :155 DEVICE_TYPE_EXTENSION;156 const attempt = createPairingAttempt(deviceTypeByte);157158 let remoteChirpPublicKey: Uint8Array;159 try {160 const audio = await (deps.openAudio ?? openBrowserAudioIO)();161 const result = await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });162 remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey;163 } catch (err) {164 if (err instanceof ChirpExchangeError) {165 const phase: PairingProgress["phase"] =166 err.kind === "timeout" ? "timeout" :167 err.kind === "version_mismatch" ? "version_mismatch" :168 err.kind === "mic_denied" ? "mic_denied" :169 "handshake_failed";170 deps.onProgress({ phase, errorMessage: err.message });171 throw new ChirpPairingFailure(phase, err.message);172 }173 throw err;174 }175176 deps.onProgress({ phase: "handshake" });177178 if (role === "responder") {179 return runResponderHandshake(deps, attempt, localIdentity);180 }181 return runInitiatorHandshake(deps, remoteChirpPublicKey, localIdentity);182}183184export interface SourcePostSASDeps {185 confirmSAS(sas: string): Promise<boolean>;186 completePairing(device: PairedDevice): Promise<void>;187 grantAdoptionConsent?(request: AdoptRequest, sas: string): Promise<boolean>;188 fetchAdoptionMaterial?(): Promise<{ rootKeyHex: string; vaultId: string; license?: string }>;189 activatePeerLicense?(license: string): Promise<void>;190 onProgress(p: PairingProgress): void;191}192193export async function routeSourcePostSASFrame(194 postNoise: PostNoiseFrame,195 localIdentity: LocalIdentity,196 deps: SourcePostSASDeps,197): Promise<PairedDevice | null> {198 if (isAdoptRequest(postNoise.frame)) {199 const sasConfirmed = await deps.confirmSAS(postNoise.sas);200 if (!sasConfirmed) {201 await closeIfPossible(postNoise.channel);202 throw new ChirpPairingFailure("handshake_failed", "sas_rejected");203 }204 if (!deps.grantAdoptionConsent || !deps.fetchAdoptionMaterial) {205 await closeIfPossible(postNoise.channel);206 throw new ChirpPairingFailure("handshake_failed", "adoption_not_supported");207 }208209 const approved = await deps.grantAdoptionConsent(postNoise.frame, postNoise.sas);210 if (!approved) {211 await closeIfPossible(postNoise.channel);212 throw new ChirpPairingFailure("handshake_failed", "adoption_rejected");213 }214215 const material = await deps.fetchAdoptionMaterial();216 if (!/^[0-9a-f]{64}$/.test(material.rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");217 const rootKeyBytes = hexToBytes(material.rootKeyHex);218 try {219 const expectedVaultId = await computeVaultId(rootKeyBytes);220 if (expectedVaultId !== material.vaultId) throw new Error("ADOPT_VAULT_ID_MISMATCH");221 const adopted = await grantAdoption(postNoise.channel, postNoise.frame, {222 sourceIdentity: localIdentity,223 rootKeyHex: async () => material.rootKeyHex,224 vaultId: async () => material.vaultId,225 license: async () => material.license,226 onConsent: async () => true,227 upsertPairedDevice: deps.completePairing,228 });229 if (adopted) deps.onProgress({ phase: "paired", peerDeviceName: adopted.deviceName });230 return adopted;231 } finally {232 rootKeyBytes.fill(0);233 }234 }235236 if ((postNoise.frame as { type?: unknown } | null)?.type === "PAIR_IDENTITY") {237 const result = await handlePairIdentityFrame(238 postNoise.frame,239 postNoise.channel,240 localIdentity,241 postNoise.sas,242 );243 const paired = await gateOnSAS(deps, result);244 await activatePeerLicenseIfAny(deps, result);245 await deps.completePairing(paired);246 return paired;247 }248249 await closeIfPossible(postNoise.channel);250 throw new ChirpPairingFailure("handshake_failed", "unexpected_pairing_frame");251}252253async function runResponderHandshake(254 deps: ChirpPairingDeps,255 attempt: ReturnType<typeof createPairingAttempt>,256 localIdentity: LocalIdentity,257): Promise<PairedDevice> {258 259 260 261 const subnet = await deps.getLocalSubnet();262 if (!subnet) {263 deps.onProgress({ phase: "handshake_failed", errorMessage: "no_subnet" });264 throw new ChirpPairingFailure("handshake_failed", "could not determine local subnet");265 }266267 const peerEndpoint = await firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT);268 if (!peerEndpoint) {269 deps.onProgress({ phase: "handshake_failed", errorMessage: "peer_not_found" });270 throw new ChirpPairingFailure("handshake_failed", "could not reach paired peer on subnet");271 }272273 const transport = await deps.openPairingTransport(peerEndpoint);274 let postNoise: PostNoiseFrame;275 try {276 postNoise = await runPairingResponderOpenFrame(277 attempt.ephemeralPrivateKey,278 attempt.ephemeralPublicKey,279 transport,280 );281 } catch (err) {282 await transport.close();283 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });284 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);285 }286 const paired = await routeSourcePostSASFrame(postNoise, localIdentity, deps);287 await transport.close();288 if (!paired) {289 throw new ChirpPairingFailure("handshake_failed", "adoption_rejected");290 }291 await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", peerEndpoint.ip);292 deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });293 return paired;294}295296297298299300301302export async function gateOnSAS(303 deps: Pick<ChirpPairingDeps, "confirmSAS" | "onProgress">,304 result: PairingHandshakeResult,305): Promise<PairedDevice> {306 let confirmed: boolean;307 try {308 confirmed = await deps.confirmSAS(result.sas);309 } catch (err) {310 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });311 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);312 }313 if (!confirmed) {314 deps.onProgress({ phase: "handshake_failed", errorMessage: "sas_rejected" });315 throw new ChirpPairingFailure(316 "handshake_failed",317 "pairing rejected — the verification codes did not match",318 );319 }320 return result.device;321}322323324325326327328async function activatePeerLicenseIfAny(329 deps: Pick<ChirpPairingDeps, "activatePeerLicense">,330 result: PairingHandshakeResult,331): Promise<void> {332 if (!result.peerLicense || !deps.activatePeerLicense) return;333 try {334 await deps.activatePeerLicense(result.peerLicense);335 } catch {336 337 }338}339340async function closeIfPossible(channel: unknown): Promise<void> {341 await (channel as { close?: () => Promise<void> }).close?.();342}343344function hexToBytes(hex: string): Uint8Array {345 const out = new Uint8Array(hex.length / 2);346 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);347 return out;348}349350async function runInitiatorHandshake(351 deps: ChirpPairingDeps,352 remoteChirpPublicKey: Uint8Array,353 localIdentity: LocalIdentity,354): Promise<PairedDevice> {355 356 357 358 let server: { transport: PairingTransport; peerIp: string };359 try {360 server = await deps.openPairingServer!();361 } catch (err) {362 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });363 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);364 }365366 let result: PairingHandshakeResult;367 try {368 result = await runPairingInitiator(369 remoteChirpPublicKey,370 localIdentity,371 server.transport,372 );373 } catch (err) {374 await server.transport.close();375 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });376 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);377 }378 await server.transport.close();379380 381 382 const paired = await gateOnSAS(deps, result);383 await activatePeerLicenseIfAny(deps, result);384385 await deps.completePairing(paired);386 await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", server.peerIp);387 deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });388 return paired;389}390391async function firstResponsivePeer(392 deps: ChirpPairingDeps,393 subnet: string,394 port: number,395): Promise<{ ip: string; port: number } | null> {396 397 398 399 400 401 402 403 404 405 406 407 408 409 const PROBE_TIMEOUT_MS = 250;410 const batch = 16;411 for (let start = 1; start <= 254; start += batch) {412 const end = Math.min(start + batch - 1, 254);413 const ips: string[] = [];414 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);415416 const results = await Promise.all(417 ips.map(async (ip) => {418 let timer: ReturnType<typeof setTimeout> | undefined;419 const timeout = new Promise<null>((resolve) => {420 timer = setTimeout(() => resolve(null), PROBE_TIMEOUT_MS);421 });422 const probe = (async () => {423 try {424 const t = await deps.openPairingTransport({ ip, port });425 return { transport: t, ip, port };426 } catch {427 return null;428 }429 })();430 const winner = await Promise.race([probe, timeout]);431 if (winner && "transport" in winner) {432 if (timer !== undefined) clearTimeout(timer);433 await winner.transport.close();434 return { ip: winner.ip, port: winner.port };435 }436 437 438 probe.then((late) => {439 if (late) void late.transport.close();440 }).catch(() => { });441 return null;442 }),443 );444 const hit = results.find((r): r is { ip: string; port: number } => r !== null);445 if (hit) return hit;446 }447 return null;448}