// Popup-side orchestrator for Phase-1 chirp pairing. // // Runs end-to-end inside the popup or the settings page (it depends on // `getUserMedia`, which is unavailable in the MV3 service worker). The // background's role is limited to handing us the local identity and // persisting the resulting PairedDevice record. // // Flow: // 1. fetch local identity via CHIRP_GET_LOCAL_IDENTITY // 2. open Web Audio + microphone // 3. encode our chirp and broadcast on a 4s loop while listening // 4. on chirp decoded, transition to handshake phase // 5. probe local subnet on the well-known pairing port for the peer's // WebSocket; complete Noise NK over it; exchange long-term identities // 6. send CHIRP_COMPLETE_PAIRING to persist the new pair // // The orchestrator notifies the UI through an `onProgress` callback so // `ChirpPairingFlow` can drive its three-act animation in lockstep with // the real protocol state. import { ChirpExchangeError, createPairingAttempt, openBrowserAudioIO, runChirpExchange, } from "../shared/chirp-pairing"; import { handlePairIdentityFrame, runPairingInitiatorOpenChannel, runPairingInitiator, runPairingResponderOpenFrame, type LocalIdentity, type PairingHandshakeResult, type PostNoiseFrame, } from "../shared/chirp-handshake"; import { computeVaultId } from "../shared/sync-identity"; import { grantAdoption, isAdoptRequest, requestAdoption, type AdoptRequest } from "../shared/chirp-adopt"; import { DEVICE_TYPE_EXTENSION, DEVICE_TYPE_ANDROID, DEVICE_TYPE_IOS, type DeviceType, type PairedDevice, type PairingProgress, } from "../shared/sync-types"; /** * Well-known TCP port used during Phase-1 pairing. Android opens a * WebSocket server on this port only while the pairing screen is active; * the extension scans the /24 for it after the chirp exchange completes. * The persistent sync port (random high port) is announced via the * encrypted PAIR_IDENTITY frame and stored on the PairedDevice. */ export const CHIRP_PAIRING_PORT = 49142; export interface PairingTransport { send(msg: Uint8Array): Promise; receive(): Promise; close(): Promise; } export interface ChirpPairingDeps { /** Background round-trip — returns the local public identity. */ fetchLocalIdentity(): Promise; /** Background round-trip — persists the paired device. */ completePairing(paired: PairedDevice): Promise; /** * Show the Short Authentication String and resolve true only when the user * confirms it matches the code displayed on the OTHER device. Pairing is * aborted (the peer is NOT persisted) on false. This is the out-of-band * mutual-authentication step that defeats a LAN/audio-range attacker who * completes the Noise NK handshake in place of the real device — their * handshake produces a different SAS than the genuine peer shows, so the * user declines (audit HIGH — pairing initiator authentication). */ confirmSAS(sas: string): Promise; /** Optional — cache the peer's IP for next session's fast path. */ cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise; /** * Optional — activate a Pro license the peer sent in its identity frame. * Called ONLY after the SAS is confirmed. The implementation verifies the * signature and ignores it if this device is already Pro. This is how one * Pro device grants Pro to the other when pairing (either direction). */ activatePeerLicense?(license: string): Promise; /** Source-device adoption consent gate, shown only after SAS confirmation. */ grantAdoptionConsent?(request: AdoptRequest, sas: string): Promise; /** Source-device grant material, fetched only after SAS and consent. */ fetchAdoptionMaterial?(): Promise<{ rootKeyHex: string; vaultId: string; license?: string }>; /** * RESPONDER ONLY — returns "192.168.1" for a /24; null if not detectable. * Used to scan the LAN for the initiator's pairing-port WebSocket server. */ getLocalSubnet(): Promise; /** * RESPONDER ONLY — connect to the initiator's WebSocket server at the * given endpoint. Used during the /24 scan and again for the real * handshake transport. */ openPairingTransport(endpoint: { ip: string; port: number }): Promise; /** * INITIATOR ONLY — open a WebSocket server on CHIRP_PAIRING_PORT and * resolve with a transport when the responder connects. Backed by a * native plugin on Capacitor; not used in the extension build. * * The promise resolves once a peer connection has been accepted. If no * peer arrives before the chirp window expires, reject so the orchestrator * can surface a `handshake_failed` phase. Close the server in `.close()`. */ openPairingServer?(): Promise<{ transport: PairingTransport; peerIp: string; }>; /** Override the chirp exchange's `AudioIO` for tests. */ openAudio?(): Promise>>; /** Override the audio-exchange timeout — defaults to 90s per the brief. */ chirpTimeoutMs?: number; onProgress(p: PairingProgress): void; } export type PairingRole = "initiator" | "responder"; export class ChirpPairingFailure extends Error { readonly phase: PairingProgress["phase"]; constructor(phase: PairingProgress["phase"], message: string) { super(message); this.phase = phase; } } export async function runChirpPairing( deps: ChirpPairingDeps, role: PairingRole = "responder", ): Promise { deps.onProgress({ phase: "idle" }); const localIdentity = await deps.fetchLocalIdentity(); // Each role only runs on the matching platform — the extension is always // responder (it scans the LAN), Android/iOS/desktop are always initiator // (they open the WS server). Mismatches indicate a wiring bug upstream. if (role === "responder" && localIdentity.deviceType !== "extension") { throw new ChirpPairingFailure("handshake_failed", "responder requires extension identity"); } if (role === "initiator" && localIdentity.deviceType === "extension") { throw new ChirpPairingFailure("handshake_failed", "initiator requires non-extension identity"); } if (role === "initiator" && !deps.openPairingServer) { throw new ChirpPairingFailure("handshake_failed", "initiator requires openPairingServer dep"); } deps.onProgress({ phase: "listening" }); const deviceTypeByte = localIdentity.deviceType === "android" ? DEVICE_TYPE_ANDROID : localIdentity.deviceType === "ios" ? DEVICE_TYPE_IOS : DEVICE_TYPE_EXTENSION; const attempt = createPairingAttempt(deviceTypeByte); let remoteChirpPublicKey: Uint8Array; try { const audio = await (deps.openAudio ?? openBrowserAudioIO)(); const result = await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs }); remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey; } catch (err) { if (err instanceof ChirpExchangeError) { const phase: PairingProgress["phase"] = err.kind === "timeout" ? "timeout" : err.kind === "version_mismatch" ? "version_mismatch" : err.kind === "mic_denied" ? "mic_denied" : "handshake_failed"; deps.onProgress({ phase, errorMessage: err.message }); throw new ChirpPairingFailure(phase, err.message); } throw err; } deps.onProgress({ phase: "handshake" }); if (role === "responder") { return runResponderHandshake(deps, attempt, localIdentity); } return runInitiatorHandshake(deps, remoteChirpPublicKey, localIdentity); } export interface ChirpAdoptionReceiverDeps { deviceName: string; deviceType?: DeviceType; generateDeviceId?(): string; createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>; deriveLocalIdentity(): Promise; completePairing(paired: PairedDevice): Promise; applyLicense?(license: string): Promise; confirmSAS(sas: string): Promise; openPairingServer(): Promise<{ transport: PairingTransport; peerIp: string }>; openAudio?(): Promise>>; chirpTimeoutMs?: number; onProgress(p: PairingProgress): void; } export async function runChirpAdoptionReceiver( deps: ChirpAdoptionReceiverDeps, ): Promise { deps.onProgress({ phase: "idle" }); deps.onProgress({ phase: "listening" }); const attempt = createPairingAttempt(deviceTypeByteFor(deps.deviceType ?? "extension")); let remoteChirpPublicKey: Uint8Array; try { const audio = await (deps.openAudio ?? openBrowserAudioIO)(); const result = await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs }); remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey; } catch (err) { if (err instanceof ChirpExchangeError) { const phase: PairingProgress["phase"] = err.kind === "timeout" ? "timeout" : err.kind === "version_mismatch" ? "version_mismatch" : err.kind === "mic_denied" ? "mic_denied" : "handshake_failed"; deps.onProgress({ phase, errorMessage: err.message }); throw new ChirpPairingFailure(phase, err.message); } throw err; } deps.onProgress({ phase: "handshake" }); let server: { transport: PairingTransport; peerIp: string }; try { server = await deps.openPairingServer(); } catch (err) { deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message }); throw new ChirpPairingFailure("handshake_failed", (err as Error).message); } try { const postNoise = await runPairingInitiatorOpenChannel(remoteChirpPublicKey, server.transport); const confirmed = await deps.confirmSAS(postNoise.sas); if (!confirmed) { deps.onProgress({ phase: "handshake_failed", errorMessage: "sas_rejected" }); throw new ChirpPairingFailure("handshake_failed", "sas_rejected"); } const source = await requestAdoption(postNoise.channel, { deviceName: deps.deviceName, generateDeviceId: deps.generateDeviceId ?? generateUuid, createVaultFromRootKey: deps.createVaultFromRootKey, deriveLocalIdentity: deps.deriveLocalIdentity, upsertPairedDevice: deps.completePairing, applyLicense: deps.applyLicense, onError(error) { throw new Error(error); }, }); if (!source) throw new Error("adoption_failed"); deps.onProgress({ phase: "paired", peerDeviceName: source.deviceName }); return source; } catch (err) { if (err instanceof ChirpPairingFailure) throw err; deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message }); throw new ChirpPairingFailure("handshake_failed", (err as Error).message); } finally { await server.transport.close().catch(() => undefined); } } export interface SourcePostSASDeps { confirmSAS(sas: string): Promise; completePairing(device: PairedDevice): Promise; grantAdoptionConsent?(request: AdoptRequest, sas: string): Promise; fetchAdoptionMaterial?(): Promise<{ rootKeyHex: string; vaultId: string; license?: string }>; activatePeerLicense?(license: string): Promise; onProgress(p: PairingProgress): void; } export async function routeSourcePostSASFrame( postNoise: PostNoiseFrame, localIdentity: LocalIdentity, deps: SourcePostSASDeps, ): Promise { if (isAdoptRequest(postNoise.frame)) { const sasConfirmed = await deps.confirmSAS(postNoise.sas); if (!sasConfirmed) { await closeIfPossible(postNoise.channel); throw new ChirpPairingFailure("handshake_failed", "sas_rejected"); } if (!deps.grantAdoptionConsent || !deps.fetchAdoptionMaterial) { await closeIfPossible(postNoise.channel); throw new ChirpPairingFailure("handshake_failed", "adoption_not_supported"); } const approved = await deps.grantAdoptionConsent(postNoise.frame, postNoise.sas); if (!approved) { await closeIfPossible(postNoise.channel); throw new ChirpPairingFailure("handshake_failed", "adoption_rejected"); } const material = await deps.fetchAdoptionMaterial(); if (!/^[0-9a-f]{64}$/.test(material.rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY"); const rootKeyBytes = hexToBytes(material.rootKeyHex); try { const expectedVaultId = await computeVaultId(rootKeyBytes); if (expectedVaultId !== material.vaultId) throw new Error("ADOPT_VAULT_ID_MISMATCH"); const adopted = await grantAdoption(postNoise.channel, postNoise.frame, { sourceIdentity: localIdentity, rootKeyHex: async () => material.rootKeyHex, vaultId: async () => material.vaultId, license: async () => material.license, onConsent: async () => true, upsertPairedDevice: deps.completePairing, }); if (adopted) deps.onProgress({ phase: "paired", peerDeviceName: adopted.deviceName }); return adopted; } finally { rootKeyBytes.fill(0); } } if ((postNoise.frame as { type?: unknown } | null)?.type === "PAIR_IDENTITY") { const result = await handlePairIdentityFrame( postNoise.frame, postNoise.channel, localIdentity, postNoise.sas, ); const paired = await gateOnSAS(deps, result); await activatePeerLicenseIfAny(deps, result); await deps.completePairing(paired); return paired; } await closeIfPossible(postNoise.channel); throw new ChirpPairingFailure("handshake_failed", "unexpected_pairing_frame"); } async function runResponderHandshake( deps: ChirpPairingDeps, attempt: ReturnType, localIdentity: LocalIdentity, ): Promise { // The peer is somewhere on the LAN; we don't know its IP yet because // the chirp payload deliberately omits it. Scan the /24 for the // pairing-mode WebSocket port. const subnet = await deps.getLocalSubnet(); if (!subnet) { deps.onProgress({ phase: "handshake_failed", errorMessage: "no_subnet" }); throw new ChirpPairingFailure("handshake_failed", "could not determine local subnet"); } const peerEndpoint = await firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT); if (!peerEndpoint) { deps.onProgress({ phase: "handshake_failed", errorMessage: "peer_not_found" }); throw new ChirpPairingFailure("handshake_failed", "could not reach paired peer on subnet"); } const transport = await deps.openPairingTransport(peerEndpoint); let postNoise: PostNoiseFrame; try { postNoise = await runPairingResponderOpenFrame( attempt.ephemeralPrivateKey, attempt.ephemeralPublicKey, transport, ); } catch (err) { await transport.close(); deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message }); throw new ChirpPairingFailure("handshake_failed", (err as Error).message); } const paired = await routeSourcePostSASFrame(postNoise, localIdentity, deps); await transport.close(); if (!paired) { throw new ChirpPairingFailure("handshake_failed", "adoption_rejected"); } await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", peerEndpoint.ip); deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName }); return paired; } /** * Surface the SAS for user confirmation and abort the pairing if it is not * confirmed to match the peer device. Shared by both roles. */ // Exported for the PCH-01-024 regression test that pins the SAS gate as // MANDATORY (the chirp-handshake.ts security comment depends on this staying true). export async function gateOnSAS( deps: Pick, result: PairingHandshakeResult, ): Promise { let confirmed: boolean; try { confirmed = await deps.confirmSAS(result.sas); } catch (err) { deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message }); throw new ChirpPairingFailure("handshake_failed", (err as Error).message); } if (!confirmed) { deps.onProgress({ phase: "handshake_failed", errorMessage: "sas_rejected" }); throw new ChirpPairingFailure( "handshake_failed", "pairing rejected — the verification codes did not match", ); } return result.device; } /** * If the (now SAS-confirmed) peer advertised a Pro license, hand it to the * background to verify + activate. Best-effort: a bad/duplicate license never * fails the pairing — the device link still succeeds. */ async function activatePeerLicenseIfAny( deps: Pick, result: PairingHandshakeResult, ): Promise { if (!result.peerLicense || !deps.activatePeerLicense) return; try { await deps.activatePeerLicense(result.peerLicense); } catch { // Invalid signature, already-Pro, or storage error — pairing stands. } } async function closeIfPossible(channel: unknown): Promise { await (channel as { close?: () => Promise }).close?.(); } function hexToBytes(hex: string): Uint8Array { const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); return out; } function deviceTypeByteFor(type: DeviceType) { return type === "android" ? DEVICE_TYPE_ANDROID : type === "ios" ? DEVICE_TYPE_IOS : DEVICE_TYPE_EXTENSION; } function generateUuid(): string { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } const bytes = new Uint8Array(16); crypto.getRandomValues(bytes as Uint8Array); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } async function runInitiatorHandshake( deps: ChirpPairingDeps, remoteChirpPublicKey: Uint8Array, localIdentity: LocalIdentity, ): Promise { // Initiator opened a WebSocket server on CHIRP_PAIRING_PORT before the // chirp exchange started; here we wait for the responder (extension) // to finish its /24 scan and connect to us. let server: { transport: PairingTransport; peerIp: string }; try { server = await deps.openPairingServer!(); } catch (err) { deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message }); throw new ChirpPairingFailure("handshake_failed", (err as Error).message); } let result: PairingHandshakeResult; try { result = await runPairingInitiator( remoteChirpPublicKey, localIdentity, server.transport, ); } catch (err) { await server.transport.close(); deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message }); throw new ChirpPairingFailure("handshake_failed", (err as Error).message); } await server.transport.close(); // Same out-of-band SAS confirmation as the responder side — the initiator // (mobile vault host) must not sync to an unverified peer either. const paired = await gateOnSAS(deps, result); await activatePeerLicenseIfAny(deps, result); await deps.completePairing(paired); await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", server.peerIp); deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName }); return paired; } async function firstResponsivePeer( deps: ChirpPairingDeps, subnet: string, port: number, ): Promise<{ ip: string; port: number } | null> { // We don't have the peer's long-term key yet (that's what we're about // to exchange), so we can't run a Noise XX probe like the steady-state // sync path does. The pairing-port server accepts any incoming Noise NK // request during its short pairing window; the first WebSocket that // accepts our connection is the peer. // // Per-IP timeout strategy: browser WebSocket constructors against an // unbound TCP port can hang for several seconds before erroring, which // would stretch a /24 scan to minutes. Each probe is raced against a // 250ms timer via Promise.race — if the timer wins, we treat the IP as // a miss and close the underlying socket if it lands after the loss so // we don't leak connections. With 16-wide batching this bounds the full // scan to ~254 * 250ms / 16 ≈ 4 seconds in the worst case. const PROBE_TIMEOUT_MS = 250; const batch = 16; for (let start = 1; start <= 254; start += batch) { const end = Math.min(start + batch - 1, 254); const ips: string[] = []; for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`); const results = await Promise.all( ips.map(async (ip) => { let timer: ReturnType | undefined; const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(null), PROBE_TIMEOUT_MS); }); const probe = (async () => { try { const t = await deps.openPairingTransport({ ip, port }); return { transport: t, ip, port }; } catch { return null; } })(); const winner = await Promise.race([probe, timeout]); if (winner && "transport" in winner) { if (timer !== undefined) clearTimeout(timer); await winner.transport.close(); return { ip: winner.ip, port: winner.port }; } // Timer won — make sure a late-arriving open socket is closed so // we don't leak a connection past the scan. probe.then((late) => { if (late) void late.transport.close(); }).catch(() => { /* already a miss */ }); return null; }), ); const hit = results.find((r): r is { ip: string; port: number } => r !== null); if (hit) return hit; } return null; }