chirp-adopt.ts

6.6 KB · raw

1// DRAFT DESTINATION: src/shared/chirp-adopt.ts2// Phase 3: adoption sub-protocol frames and source/receiver helpers.34import { computeVaultId } from "./sync-identity";5import type { DeviceType, PairedDevice } from "./sync-types";6import type { LocalIdentity, PairingTransport } from "./chirp-handshake";78export interface AdoptRequest {9  type: "ADOPT_REQUEST";10  device_id: string;11  device_name: string;12  device_type: "extension" | "android";13}1415export interface AdoptGrant {16  type: "ADOPT_GRANT";17  root_key_hex: string;18  vault_id: string;19  license?: string;20  source: {21    device_id: string;22    device_name: string;23    device_type: DeviceType;24    long_term_public_key_hex: string;25    signing_public_key_hex?: string;26    ws_port?: number;27  };28}2930export interface AdoptIdentity {31  type: "ADOPT_IDENTITY";32  device_id: string;33  device_name: string;34  device_type: DeviceType;35  long_term_public_key_hex: string;36  signing_public_key_hex?: string;37}3839export type AdoptionFrame = AdoptRequest | AdoptGrant | AdoptIdentity;4041export interface AdoptionReceiverDeps {42  deviceName: string;43  generateDeviceId(): string;44  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;45  deriveLocalIdentity(): Promise<LocalIdentity>;46  upsertPairedDevice(device: PairedDevice): Promise<void>;47  applyLicense?(license: string): Promise<void>;48  onError(error: string): void;49}5051export interface AdoptionSourceDeps {52  sourceIdentity: LocalIdentity;53  rootKeyHex(): Promise<string>;54  vaultId(): Promise<string>;55  license?(): Promise<string | undefined>;56  onConsent(request: AdoptRequest): Promise<boolean>;57  upsertPairedDevice(device: PairedDevice): Promise<void>;58}5960export function isAdoptRequest(frame: unknown): frame is AdoptRequest {61  const value = frame as Partial<AdoptRequest> | null;62  return value?.type === "ADOPT_REQUEST" &&63    typeof value.device_id === "string" &&64    value.device_id.length > 0 &&65    typeof value.device_name === "string" &&66    (value.device_type === "extension" || value.device_type === "android");67}6869export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {70  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);71}7273export async function requestAdoption(74  channel: PairingTransport,75  deps: AdoptionReceiverDeps,76): Promise<void> {77  await sendFrame(channel, {78    type: "ADOPT_REQUEST",79    device_id: deps.generateDeviceId(),80    device_name: deps.deviceName,81    device_type: "extension",82  });8384  const frame = await receiveFrame(channel);85  if (!isAdoptGrant(frame)) {86    deps.onError("ADOPT_UNEXPECTED_FRAME");87    return;88  }8990  if (!validateRootKeyHex(frame.root_key_hex)) {91    deps.onError("ADOPT_INVALID_ROOT_KEY");92    return;93  }9495  const rootKeyBytes = hexToBytes(frame.root_key_hex);96  try {97    const computedVaultId = await computeVaultId(rootKeyBytes);98    if (computedVaultId !== frame.vault_id) {99      deps.onError("ADOPT_VAULT_ID_MISMATCH");100      return;101    }102  } finally {103    rootKeyBytes.fill(0);104  }105106  const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);107  if (vaultId !== frame.vault_id) {108    deps.onError("ADOPT_VAULT_ID_MISMATCH");109    return;110  }111112  const identity = await deps.deriveLocalIdentity();113  await sendFrame(channel, {114    type: "ADOPT_IDENTITY",115    device_id: identity.deviceId,116    device_name: deps.deviceName,117    device_type: identity.deviceType,118    long_term_public_key_hex: identity.longTermPublicKeyHex,119    signing_public_key_hex: identity.signingPublicKeyHex,120  });121122  await deps.upsertPairedDevice({123    deviceId: frame.source.device_id,124    deviceName: frame.source.device_name,125    deviceType: frame.source.device_type,126    longTermPublicKeyHex: frame.source.long_term_public_key_hex,127    signingPublicKeyHex: frame.source.signing_public_key_hex,128    trustSource: "direct",129    wsPort: frame.source.ws_port,130    pairedAt: Date.now(),131  });132133  if (frame.license) await deps.applyLicense?.(frame.license);134}135136export async function grantAdoption(137  channel: PairingTransport,138  request: AdoptRequest,139  deps: AdoptionSourceDeps,140): Promise<void> {141  const approved = await deps.onConsent(request);142  if (!approved) return;143144  const rootKeyHex = await deps.rootKeyHex();145  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");146147  try {148    await sendFrame(channel, {149      type: "ADOPT_GRANT",150      root_key_hex: rootKeyHex,151      vault_id: await deps.vaultId(),152      license: await deps.license?.(),153      source: {154        device_id: deps.sourceIdentity.deviceId,155        device_name: deps.sourceIdentity.deviceName,156        device_type: deps.sourceIdentity.deviceType,157        long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,158        signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,159        ws_port: deps.sourceIdentity.wsPort,160      },161    });162  } finally {163    // Strings are immutable; callers should prefer byte buffers for real164    // zeroing. This hook at least centralizes where source cleanup belongs.165  }166167  const identity = await receiveFrame(channel);168  if (!isAdoptIdentity(identity)) return;169170  await deps.upsertPairedDevice({171    deviceId: identity.device_id,172    deviceName: identity.device_name,173    deviceType: identity.device_type,174    longTermPublicKeyHex: identity.long_term_public_key_hex,175    signingPublicKeyHex: identity.signing_public_key_hex,176    trustSource: "direct",177    pairedAt: Date.now(),178  });179}180181export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {182  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));183}184185export async function receiveFrame(channel: PairingTransport): Promise<unknown> {186  return JSON.parse(new TextDecoder().decode(await channel.receive()));187}188189function isAdoptGrant(frame: unknown): frame is AdoptGrant {190  const value = frame as Partial<AdoptGrant> | null;191  return value?.type === "ADOPT_GRANT" &&192    validateRootKeyHex(value.root_key_hex) &&193    typeof value.vault_id === "string" &&194    /^[0-9a-f]{64}$/.test(value.vault_id) &&195    typeof value.source === "object" &&196    value.source !== null;197}198199function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {200  const value = frame as Partial<AdoptIdentity> | null;201  return value?.type === "ADOPT_IDENTITY" &&202    typeof value.device_id === "string" &&203    typeof value.device_name === "string" &&204    typeof value.device_type === "string" &&205    typeof value.long_term_public_key_hex === "string" &&206    /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);207}208209function hexToBytes(hex: string): Uint8Array {210  const out = new Uint8Array(hex.length / 2);211  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);212  return out;213}214