123456789101112131415161718192021import {22 ChirpExchangeError,23 createPairingAttempt,24 openBrowserAudioIO,25 runChirpExchange,26} from "../shared/chirp-pairing";27import {28 handlePairIdentityFrame,29 runPairingInitiatorOpenChannel,30 runPairingInitiator,31 runPairingResponderOpenFrame,32 type LocalIdentity,33 type PairingHandshakeResult,34 type PostNoiseFrame,35} from "../shared/chirp-handshake";36import { computeVaultId } from "../shared/sync-identity";37import { grantAdoption, isAdoptRequest, requestAdoption, type AdoptRequest } from "../shared/chirp-adopt";38import {39 DEVICE_TYPE_EXTENSION,40 DEVICE_TYPE_ANDROID,41 DEVICE_TYPE_IOS,42 type DeviceType,43 type PairedDevice,44 type PairingProgress,45} from "../shared/sync-types";464748495051525354export const CHIRP_PAIRING_PORT = 49142;5556export interface PairingTransport {57 send(msg: Uint8Array): Promise<void>;58 receive(): Promise<Uint8Array>;59 close(): Promise<void>;60}6162export interface ChirpPairingDeps {63 64 fetchLocalIdentity(): Promise<LocalIdentity>;65 66 completePairing(paired: PairedDevice): Promise<void>;67 686970717273747576 confirmSAS(sas: string): Promise<boolean>;77 78 cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;79 808182838485 activatePeerLicense?(license: string): Promise<void>;86 87 grantAdoptionConsent?(request: AdoptRequest, sas: string): Promise<boolean>;88 89 fetchAdoptionMaterial?(): Promise<{ rootKeyHex: string; vaultId: string; license?: string }>;90 91929394 getLocalSubnet(): Promise<string | null>;95 96979899100 openPairingTransport(endpoint: { ip: string; port: number }): Promise<PairingTransport>;101 102103104105106107108109110 openPairingServer?(): Promise<{111 transport: PairingTransport;112 peerIp: string;113 }>;114 115 openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;116 117 chirpTimeoutMs?: number;118 onProgress(p: PairingProgress): void;119}120121export type PairingRole = "initiator" | "responder";122123export class ChirpPairingFailure extends Error {124 readonly phase: PairingProgress["phase"];125 constructor(phase: PairingProgress["phase"], message: string) {126 super(message);127 this.phase = phase;128 }129}130131export async function runChirpPairing(132 deps: ChirpPairingDeps,133 role: PairingRole = "responder",134): Promise<PairedDevice> {135 deps.onProgress({ phase: "idle" });136137 const localIdentity = await deps.fetchLocalIdentity();138139 140 141 142 if (role === "responder" && localIdentity.deviceType !== "extension") {143 throw new ChirpPairingFailure("handshake_failed", "responder requires extension identity");144 }145 if (role === "initiator" && localIdentity.deviceType === "extension") {146 throw new ChirpPairingFailure("handshake_failed", "initiator requires non-extension identity");147 }148 if (role === "initiator" && !deps.openPairingServer) {149 throw new ChirpPairingFailure("handshake_failed", "initiator requires openPairingServer dep");150 }151152 deps.onProgress({ phase: "listening" });153154 const deviceTypeByte =155 localIdentity.deviceType === "android" ? DEVICE_TYPE_ANDROID :156 localIdentity.deviceType === "ios" ? DEVICE_TYPE_IOS :157 DEVICE_TYPE_EXTENSION;158 const attempt = createPairingAttempt(deviceTypeByte);159160 let remoteChirpPublicKey: Uint8Array;161 try {162 const audio = await (deps.openAudio ?? openBrowserAudioIO)();163 const result = await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });164 remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey;165 } catch (err) {166 if (err instanceof ChirpExchangeError) {167 const phase: PairingProgress["phase"] =168 err.kind === "timeout" ? "timeout" :169 err.kind === "version_mismatch" ? "version_mismatch" :170 err.kind === "mic_denied" ? "mic_denied" :171 "handshake_failed";172 deps.onProgress({ phase, errorMessage: err.message });173 throw new ChirpPairingFailure(phase, err.message);174 }175 throw err;176 }177178 deps.onProgress({ phase: "handshake" });179180 if (role === "responder") {181 return runResponderHandshake(deps, attempt, localIdentity);182 }183 return runInitiatorHandshake(deps, remoteChirpPublicKey, localIdentity);184}185186export interface ChirpAdoptionReceiverDeps {187 deviceName: string;188 deviceType?: DeviceType;189 generateDeviceId?(): string;190 createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;191 deriveLocalIdentity(): Promise<LocalIdentity>;192 completePairing(paired: PairedDevice): Promise<void>;193 applyLicense?(license: string): Promise<void>;194 confirmSAS(sas: string): Promise<boolean>;195 openPairingServer(): Promise<{ transport: PairingTransport; peerIp: string }>;196 openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;197 chirpTimeoutMs?: number;198 onProgress(p: PairingProgress): void;199}200201export async function runChirpAdoptionReceiver(202 deps: ChirpAdoptionReceiverDeps,203): Promise<PairedDevice> {204 deps.onProgress({ phase: "idle" });205 deps.onProgress({ phase: "listening" });206207 const attempt = createPairingAttempt(deviceTypeByteFor(deps.deviceType ?? "extension"));208 let remoteChirpPublicKey: Uint8Array;209 try {210 const audio = await (deps.openAudio ?? openBrowserAudioIO)();211 const result = await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });212 remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey;213 } catch (err) {214 if (err instanceof ChirpExchangeError) {215 const phase: PairingProgress["phase"] =216 err.kind === "timeout" ? "timeout" :217 err.kind === "version_mismatch" ? "version_mismatch" :218 err.kind === "mic_denied" ? "mic_denied" :219 "handshake_failed";220 deps.onProgress({ phase, errorMessage: err.message });221 throw new ChirpPairingFailure(phase, err.message);222 }223 throw err;224 }225226 deps.onProgress({ phase: "handshake" });227 let server: { transport: PairingTransport; peerIp: string };228 try {229 server = await deps.openPairingServer();230 } catch (err) {231 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });232 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);233 }234235 try {236 const postNoise = await runPairingInitiatorOpenChannel(remoteChirpPublicKey, server.transport);237 const confirmed = await deps.confirmSAS(postNoise.sas);238 if (!confirmed) {239 deps.onProgress({ phase: "handshake_failed", errorMessage: "sas_rejected" });240 throw new ChirpPairingFailure("handshake_failed", "sas_rejected");241 }242 const source = await requestAdoption(postNoise.channel, {243 deviceName: deps.deviceName,244 generateDeviceId: deps.generateDeviceId ?? generateUuid,245 createVaultFromRootKey: deps.createVaultFromRootKey,246 deriveLocalIdentity: deps.deriveLocalIdentity,247 upsertPairedDevice: deps.completePairing,248 applyLicense: deps.applyLicense,249 onError(error) {250 throw new Error(error);251 },252 });253 if (!source) throw new Error("adoption_failed");254 deps.onProgress({ phase: "paired", peerDeviceName: source.deviceName });255 return source;256 } catch (err) {257 if (err instanceof ChirpPairingFailure) throw err;258 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });259 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);260 } finally {261 await server.transport.close().catch(() => undefined);262 }263}264265export interface SourcePostSASDeps {266 confirmSAS(sas: string): Promise<boolean>;267 completePairing(device: PairedDevice): Promise<void>;268 grantAdoptionConsent?(request: AdoptRequest, sas: string): Promise<boolean>;269 fetchAdoptionMaterial?(): Promise<{ rootKeyHex: string; vaultId: string; license?: string }>;270 activatePeerLicense?(license: string): Promise<void>;271 onProgress(p: PairingProgress): void;272}273274export async function routeSourcePostSASFrame(275 postNoise: PostNoiseFrame,276 localIdentity: LocalIdentity,277 deps: SourcePostSASDeps,278): Promise<PairedDevice | null> {279 if (isAdoptRequest(postNoise.frame)) {280 const sasConfirmed = await deps.confirmSAS(postNoise.sas);281 if (!sasConfirmed) {282 await closeIfPossible(postNoise.channel);283 throw new ChirpPairingFailure("handshake_failed", "sas_rejected");284 }285 if (!deps.grantAdoptionConsent || !deps.fetchAdoptionMaterial) {286 await closeIfPossible(postNoise.channel);287 throw new ChirpPairingFailure("handshake_failed", "adoption_not_supported");288 }289290 const approved = await deps.grantAdoptionConsent(postNoise.frame, postNoise.sas);291 if (!approved) {292 await closeIfPossible(postNoise.channel);293 throw new ChirpPairingFailure("handshake_failed", "adoption_rejected");294 }295296 const material = await deps.fetchAdoptionMaterial();297 if (!/^[0-9a-f]{64}$/.test(material.rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");298 const rootKeyBytes = hexToBytes(material.rootKeyHex);299 try {300 const expectedVaultId = await computeVaultId(rootKeyBytes);301 if (expectedVaultId !== material.vaultId) throw new Error("ADOPT_VAULT_ID_MISMATCH");302 const adopted = await grantAdoption(postNoise.channel, postNoise.frame, {303 sourceIdentity: localIdentity,304 rootKeyHex: async () => material.rootKeyHex,305 vaultId: async () => material.vaultId,306 license: async () => material.license,307 onConsent: async () => true,308 upsertPairedDevice: deps.completePairing,309 });310 if (adopted) deps.onProgress({ phase: "paired", peerDeviceName: adopted.deviceName });311 return adopted;312 } finally {313 rootKeyBytes.fill(0);314 }315 }316317 if ((postNoise.frame as { type?: unknown } | null)?.type === "PAIR_IDENTITY") {318 const result = await handlePairIdentityFrame(319 postNoise.frame,320 postNoise.channel,321 localIdentity,322 postNoise.sas,323 );324 const paired = await gateOnSAS(deps, result);325 await activatePeerLicenseIfAny(deps, result);326 await deps.completePairing(paired);327 return paired;328 }329330 await closeIfPossible(postNoise.channel);331 throw new ChirpPairingFailure("handshake_failed", "unexpected_pairing_frame");332}333334async function runResponderHandshake(335 deps: ChirpPairingDeps,336 attempt: ReturnType<typeof createPairingAttempt>,337 localIdentity: LocalIdentity,338): Promise<PairedDevice> {339 340 341 342 const subnet = await deps.getLocalSubnet();343 if (!subnet) {344 deps.onProgress({ phase: "handshake_failed", errorMessage: "no_subnet" });345 throw new ChirpPairingFailure("handshake_failed", "could not determine local subnet");346 }347348 const peerEndpoint = await firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT);349 if (!peerEndpoint) {350 deps.onProgress({ phase: "handshake_failed", errorMessage: "peer_not_found" });351 throw new ChirpPairingFailure("handshake_failed", "could not reach paired peer on subnet");352 }353354 const transport = await deps.openPairingTransport(peerEndpoint);355 let postNoise: PostNoiseFrame;356 try {357 postNoise = await runPairingResponderOpenFrame(358 attempt.ephemeralPrivateKey,359 attempt.ephemeralPublicKey,360 transport,361 );362 } catch (err) {363 await transport.close();364 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });365 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);366 }367 const paired = await routeSourcePostSASFrame(postNoise, localIdentity, deps);368 await transport.close();369 if (!paired) {370 throw new ChirpPairingFailure("handshake_failed", "adoption_rejected");371 }372 await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", peerEndpoint.ip);373 deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });374 return paired;375}376377378379380381382383export async function gateOnSAS(384 deps: Pick<ChirpPairingDeps, "confirmSAS" | "onProgress">,385 result: PairingHandshakeResult,386): Promise<PairedDevice> {387 let confirmed: boolean;388 try {389 confirmed = await deps.confirmSAS(result.sas);390 } catch (err) {391 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });392 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);393 }394 if (!confirmed) {395 deps.onProgress({ phase: "handshake_failed", errorMessage: "sas_rejected" });396 throw new ChirpPairingFailure(397 "handshake_failed",398 "pairing rejected — the verification codes did not match",399 );400 }401 return result.device;402}403404405406407408409async function activatePeerLicenseIfAny(410 deps: Pick<ChirpPairingDeps, "activatePeerLicense">,411 result: PairingHandshakeResult,412): Promise<void> {413 if (!result.peerLicense || !deps.activatePeerLicense) return;414 try {415 await deps.activatePeerLicense(result.peerLicense);416 } catch {417 418 }419}420421async function closeIfPossible(channel: unknown): Promise<void> {422 await (channel as { close?: () => Promise<void> }).close?.();423}424425function hexToBytes(hex: string): Uint8Array {426 const out = new Uint8Array(hex.length / 2);427 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);428 return out;429}430431function deviceTypeByteFor(type: DeviceType) {432 return type === "android" ? DEVICE_TYPE_ANDROID :433 type === "ios" ? DEVICE_TYPE_IOS :434 DEVICE_TYPE_EXTENSION;435}436437function generateUuid(): string {438 if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {439 return crypto.randomUUID();440 }441 const bytes = new Uint8Array(16);442 crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);443 bytes[6] = (bytes[6] & 0x0f) | 0x40;444 bytes[8] = (bytes[8] & 0x3f) | 0x80;445 const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");446 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;447}448449async function runInitiatorHandshake(450 deps: ChirpPairingDeps,451 remoteChirpPublicKey: Uint8Array,452 localIdentity: LocalIdentity,453): Promise<PairedDevice> {454 455 456 457 let server: { transport: PairingTransport; peerIp: string };458 try {459 server = await deps.openPairingServer!();460 } catch (err) {461 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });462 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);463 }464465 let result: PairingHandshakeResult;466 try {467 result = await runPairingInitiator(468 remoteChirpPublicKey,469 localIdentity,470 server.transport,471 );472 } catch (err) {473 await server.transport.close();474 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });475 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);476 }477 await server.transport.close();478479 480 481 const paired = await gateOnSAS(deps, result);482 await activatePeerLicenseIfAny(deps, result);483484 await deps.completePairing(paired);485 await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", server.peerIp);486 deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });487 return paired;488}489490async function firstResponsivePeer(491 deps: ChirpPairingDeps,492 subnet: string,493 port: number,494): Promise<{ ip: string; port: number } | null> {495 496 497 498 499 500 501 502 503 504 505 506 507 508 const PROBE_TIMEOUT_MS = 250;509 const batch = 16;510 for (let start = 1; start <= 254; start += batch) {511 const end = Math.min(start + batch - 1, 254);512 const ips: string[] = [];513 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);514515 const results = await Promise.all(516 ips.map(async (ip) => {517 let timer: ReturnType<typeof setTimeout> | undefined;518 const timeout = new Promise<null>((resolve) => {519 timer = setTimeout(() => resolve(null), PROBE_TIMEOUT_MS);520 });521 const probe = (async () => {522 try {523 const t = await deps.openPairingTransport({ ip, port });524 return { transport: t, ip, port };525 } catch {526 return null;527 }528 })();529 const winner = await Promise.race([probe, timeout]);530 if (winner && "transport" in winner) {531 if (timer !== undefined) clearTimeout(timer);532 await winner.transport.close();533 return { ip: winner.ip, port: winner.port };534 }535 536 537 probe.then((late) => {538 if (late) void late.transport.close();539 }).catch(() => { });540 return null;541 }),542 );543 const hit = results.find((r): r is { ip: string; port: number } => r !== null);544 if (hit) return hit;545 }546 return null;547}