chirp-adopt.ts

6.8 KB · raw

1import { computeVaultId } from "./sync-identity";2import type { DeviceType, PairedDevice } from "./sync-types";3import type { LocalIdentity, PairingTransport } from "./chirp-handshake";45export interface AdoptRequest {6  type: "ADOPT_REQUEST";7  device_id: string;8  device_name: string;9  device_type: DeviceType;10}1112export interface AdoptGrant {13  type: "ADOPT_GRANT";14  root_key_hex: string;15  vault_id: string;16  license?: string;17  source: {18    device_id: string;19    device_name: string;20    device_type: DeviceType;21    long_term_public_key_hex: string;22    signing_public_key_hex?: string;23    ws_port?: number;24  };25}2627export interface AdoptIdentity {28  type: "ADOPT_IDENTITY";29  device_id: string;30  device_name: string;31  device_type: DeviceType;32  long_term_public_key_hex: string;33  signing_public_key_hex?: string;34}3536export type AdoptionFrame = AdoptRequest | AdoptGrant | AdoptIdentity;3738export interface AdoptionReceiverDeps {39  deviceName: string;40  generateDeviceId(): string;41  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;42  deriveLocalIdentity(): Promise<LocalIdentity>;43  upsertPairedDevice(device: PairedDevice): Promise<void>;44  applyLicense?(license: string): Promise<void>;45  onError(error: string): void;46}4748export interface AdoptionSourceDeps {49  sourceIdentity: LocalIdentity;50  rootKeyHex(): Promise<string>;51  vaultId(): Promise<string>;52  license?(): Promise<string | undefined>;53  onConsent(request: AdoptRequest): Promise<boolean>;54  upsertPairedDevice(device: PairedDevice): Promise<void>;55}5657export function isAdoptRequest(frame: unknown): frame is AdoptRequest {58  const value = frame as Partial<AdoptRequest> | null;59  return value?.type === "ADOPT_REQUEST" &&60    typeof value.device_id === "string" &&61    value.device_id.length > 0 &&62    typeof value.device_name === "string" &&63    isDeviceType(value.device_type);64}6566export function isAdoptGrant(frame: unknown): frame is AdoptGrant {67  const value = frame as Partial<AdoptGrant> | null;68  return value?.type === "ADOPT_GRANT" &&69    validateRootKeyHex(value.root_key_hex) &&70    typeof value.vault_id === "string" &&71    /^[0-9a-f]{64}$/.test(value.vault_id) &&72    typeof value.source === "object" &&73    value.source !== null &&74    typeof value.source.device_id === "string" &&75    typeof value.source.device_name === "string" &&76    isDeviceType(value.source.device_type) &&77    typeof value.source.long_term_public_key_hex === "string" &&78    /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex);79}8081export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {82  const value = frame as Partial<AdoptIdentity> | null;83  return value?.type === "ADOPT_IDENTITY" &&84    typeof value.device_id === "string" &&85    value.device_id.length > 0 &&86    typeof value.device_name === "string" &&87    isDeviceType(value.device_type) &&88    typeof value.long_term_public_key_hex === "string" &&89    /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);90}9192export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {93  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);94}9596export async function requestAdoption(97  channel: PairingTransport,98  deps: AdoptionReceiverDeps,99): Promise<PairedDevice | null> {100  await sendFrame(channel, {101    type: "ADOPT_REQUEST",102    device_id: deps.generateDeviceId(),103    device_name: deps.deviceName,104    device_type: "extension",105  });106107  const frame = await receiveFrame(channel);108  if (!isAdoptGrant(frame)) {109    deps.onError("ADOPT_UNEXPECTED_FRAME");110    return null;111  }112113  const rootKeyBytes = hexToBytes(frame.root_key_hex);114  try {115    const computedVaultId = await computeVaultId(rootKeyBytes);116    if (computedVaultId !== frame.vault_id) {117      deps.onError("ADOPT_VAULT_ID_MISMATCH");118      return null;119    }120  } finally {121    rootKeyBytes.fill(0);122  }123124  const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);125  if (vaultId !== frame.vault_id) {126    deps.onError("ADOPT_VAULT_ID_MISMATCH");127    return null;128  }129130  if (frame.license) await deps.applyLicense?.(frame.license);131132  const identity = await deps.deriveLocalIdentity();133  await sendFrame(channel, {134    type: "ADOPT_IDENTITY",135    device_id: identity.deviceId,136    device_name: identity.deviceName,137    device_type: identity.deviceType,138    long_term_public_key_hex: identity.longTermPublicKeyHex,139    signing_public_key_hex: identity.signingPublicKeyHex,140  });141142  const sourceDevice: PairedDevice = {143    deviceId: frame.source.device_id,144    deviceName: frame.source.device_name,145    deviceType: frame.source.device_type,146    longTermPublicKeyHex: frame.source.long_term_public_key_hex,147    signingPublicKeyHex: frame.source.signing_public_key_hex,148    trustSource: "direct",149    wsPort: frame.source.ws_port,150    pairedAt: Date.now(),151  };152  await deps.upsertPairedDevice(sourceDevice);153  return sourceDevice;154}155156export async function grantAdoption(157  channel: PairingTransport,158  request: AdoptRequest,159  deps: AdoptionSourceDeps,160): Promise<PairedDevice | null> {161  const approved = await deps.onConsent(request);162  if (!approved) return null;163164  const rootKeyHex = await deps.rootKeyHex();165  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");166167  await sendFrame(channel, {168    type: "ADOPT_GRANT",169    root_key_hex: rootKeyHex,170    vault_id: await deps.vaultId(),171    license: await deps.license?.(),172    source: {173      device_id: deps.sourceIdentity.deviceId,174      device_name: deps.sourceIdentity.deviceName,175      device_type: deps.sourceIdentity.deviceType,176      long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,177      signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,178      ws_port: deps.sourceIdentity.wsPort,179    },180  });181182  const identity = await receiveFrame(channel);183  if (!isAdoptIdentity(identity)) return null;184185  const device: PairedDevice = {186    deviceId: identity.device_id,187    deviceName: identity.device_name,188    deviceType: identity.device_type,189    longTermPublicKeyHex: identity.long_term_public_key_hex,190    signingPublicKeyHex: identity.signing_public_key_hex,191    trustSource: "direct",192    pairedAt: Date.now(),193  };194  await deps.upsertPairedDevice(device);195  return device;196}197198export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {199  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));200}201202export async function receiveFrame(channel: PairingTransport): Promise<unknown> {203  return JSON.parse(new TextDecoder().decode(await channel.receive()));204}205206function isDeviceType(value: unknown): value is DeviceType {207  return value === "extension" || value === "android" || value === "ios" || value === "desktop";208}209210function hexToBytes(hex: string): Uint8Array {211  const out = new Uint8Array(hex.length / 2);212  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);213  return out;214}