// DRAFT DESTINATION: src/popup/chirp-pair.ts plus a supporting export from // src/shared/chirp-handshake.ts. // Phase 6: source-side ADOPT_REQUEST routing. import { computeVaultId } from "../shared/sync-identity"; import { grantAdoption, isAdoptRequest, type AdoptRequest } from "../shared/chirp-adopt"; import type { LocalIdentity, PairingHandshakeResult, PairingTransport } from "../shared/chirp-handshake"; import type { PairedDevice } from "../shared/sync-types"; interface SourceAdoptionDeps { confirmSAS(sas: string): Promise; completePairing(device: PairedDevice): Promise; grantConsent(request: AdoptRequest, sas: string): Promise; fetchRootKeyHex(): Promise; fetchLicense?(): Promise; deriveVaultIdentity(): Promise; upsertPairedDevice(device: PairedDevice): Promise; } interface PostNoiseFrame { sas: string; frame: unknown; channel: PairingTransport; } // DRAFT DESTINATION: src/shared/chirp-handshake.ts // Export a responder primitive that completes Noise NK, receives exactly one // encrypted JSON frame, and leaves the encrypted channel open for the caller. // Existing runPairingResponder can be rebuilt on top of this by requiring // frame.type === "PAIR_IDENTITY" and sending the local PAIR_IDENTITY response. declare function runPairingResponderOpenFrame( ourEphemeralPrivateKey: Uint8Array, ourEphemeralPublicKey: Uint8Array, ourIdentity: LocalIdentity, transport: PairingTransport, ): Promise; declare function handlePairIdentityFrame( frame: unknown, channel: PairingTransport, localIdentity: LocalIdentity, ): Promise; export async function routeSourcePostSASFrame( postNoise: PostNoiseFrame, localIdentity: LocalIdentity, deps: SourceAdoptionDeps, ): Promise { const sasConfirmed = await deps.confirmSAS(postNoise.sas); if (!sasConfirmed) { await closeIfPossible(postNoise.channel); throw new Error("sas_rejected"); } if (isAdoptRequest(postNoise.frame)) { const rootKeyHex = await deps.fetchRootKeyHex(); if (!/^[0-9a-f]{64}$/.test(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY"); const rootKeyBytes = hexToBytes(rootKeyHex); try { const vaultId = await computeVaultId(rootKeyBytes); await grantAdoption(postNoise.channel, postNoise.frame, { sourceIdentity: localIdentity, rootKeyHex: async () => rootKeyHex, vaultId: async () => vaultId, license: deps.fetchLicense, onConsent: (request) => deps.grantConsent(request, postNoise.sas), upsertPairedDevice: deps.upsertPairedDevice, }); return null; } finally { rootKeyBytes.fill(0); // rootKeyHex is an immutable JS string; do not log it and keep the // variable scoped to this block so it becomes collectible immediately. } } if (isPairIdentityFrame(postNoise.frame)) { const result = await handlePairIdentityFrame(postNoise.frame, postNoise.channel, localIdentity); await deps.completePairing(result.device); return result.device; } await closeIfPossible(postNoise.channel); throw new Error("unexpected_pairing_frame"); } function isPairIdentityFrame(frame: unknown): boolean { return (frame as { type?: unknown } | null)?.type === "PAIR_IDENTITY"; } async function closeIfPossible(channel: PairingTransport): Promise { const maybe = channel as PairingTransport & { close?: () => Promise }; await maybe.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; }