chirp-pair-adopt-source.ts

3.7 KB · raw

1// DRAFT DESTINATION: src/popup/chirp-pair.ts plus a supporting export from2// src/shared/chirp-handshake.ts.3// Phase 6: source-side ADOPT_REQUEST routing.45import { computeVaultId } from "../shared/sync-identity";6import { grantAdoption, isAdoptRequest, type AdoptRequest } from "../shared/chirp-adopt";7import type { LocalIdentity, PairingHandshakeResult, PairingTransport } from "../shared/chirp-handshake";8import type { PairedDevice } from "../shared/sync-types";910interface SourceAdoptionDeps {11  confirmSAS(sas: string): Promise<boolean>;12  completePairing(device: PairedDevice): Promise<void>;13  grantConsent(request: AdoptRequest, sas: string): Promise<boolean>;14  fetchRootKeyHex(): Promise<string>;15  fetchLicense?(): Promise<string | undefined>;16  deriveVaultIdentity(): Promise<LocalIdentity>;17  upsertPairedDevice(device: PairedDevice): Promise<void>;18}1920interface PostNoiseFrame {21  sas: string;22  frame: unknown;23  channel: PairingTransport;24}2526// DRAFT DESTINATION: src/shared/chirp-handshake.ts27// Export a responder primitive that completes Noise NK, receives exactly one28// encrypted JSON frame, and leaves the encrypted channel open for the caller.29// Existing runPairingResponder can be rebuilt on top of this by requiring30// frame.type === "PAIR_IDENTITY" and sending the local PAIR_IDENTITY response.31declare function runPairingResponderOpenFrame(32  ourEphemeralPrivateKey: Uint8Array,33  ourEphemeralPublicKey: Uint8Array,34  ourIdentity: LocalIdentity,35  transport: PairingTransport,36): Promise<PostNoiseFrame>;3738declare function handlePairIdentityFrame(39  frame: unknown,40  channel: PairingTransport,41  localIdentity: LocalIdentity,42): Promise<PairingHandshakeResult>;4344export async function routeSourcePostSASFrame(45  postNoise: PostNoiseFrame,46  localIdentity: LocalIdentity,47  deps: SourceAdoptionDeps,48): Promise<PairedDevice | null> {49  const sasConfirmed = await deps.confirmSAS(postNoise.sas);50  if (!sasConfirmed) {51    await closeIfPossible(postNoise.channel);52    throw new Error("sas_rejected");53  }5455  if (isAdoptRequest(postNoise.frame)) {56    const rootKeyHex = await deps.fetchRootKeyHex();57    if (!/^[0-9a-f]{64}$/.test(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");58    const rootKeyBytes = hexToBytes(rootKeyHex);59    try {60      const vaultId = await computeVaultId(rootKeyBytes);61      await grantAdoption(postNoise.channel, postNoise.frame, {62        sourceIdentity: localIdentity,63        rootKeyHex: async () => rootKeyHex,64        vaultId: async () => vaultId,65        license: deps.fetchLicense,66        onConsent: (request) => deps.grantConsent(request, postNoise.sas),67        upsertPairedDevice: deps.upsertPairedDevice,68      });69      return null;70    } finally {71      rootKeyBytes.fill(0);72      // rootKeyHex is an immutable JS string; do not log it and keep the73      // variable scoped to this block so it becomes collectible immediately.74    }75  }7677  if (isPairIdentityFrame(postNoise.frame)) {78    const result = await handlePairIdentityFrame(postNoise.frame, postNoise.channel, localIdentity);79    await deps.completePairing(result.device);80    return result.device;81  }8283  await closeIfPossible(postNoise.channel);84  throw new Error("unexpected_pairing_frame");85}8687function isPairIdentityFrame(frame: unknown): boolean {88  return (frame as { type?: unknown } | null)?.type === "PAIR_IDENTITY";89}9091async function closeIfPossible(channel: PairingTransport): Promise<void> {92  const maybe = channel as PairingTransport & { close?: () => Promise<void> };93  await maybe.close?.();94}9596function hexToBytes(hex: string): Uint8Array {97  const out = new Uint8Array(hex.length / 2);98  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);99  return out;100}101