vault-adopt.ts

7.5 KB · raw

1import { computeVaultId } from "./sync-identity";2import type { DeviceType, PairedDevice } from "./sync-types";3import type { LocalIdentity, PairingTransport } from "./pairing-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 AdoptConfirm {28  type: "ADOPT_CONFIRM";29  confirmed: boolean;30}3132export interface AdoptIdentity {33  type: "ADOPT_IDENTITY";34  device_id: string;35  device_name: string;36  device_type: DeviceType;37  long_term_public_key_hex: string;38  signing_public_key_hex?: string;39}4041export type AdoptionFrame =42  | AdoptRequest43  | AdoptConfirm44  | AdoptGrant45  | AdoptIdentity;4647export interface AdoptionReceiverDeps {48  deviceName: string;49  generateDeviceId(): string;50  /** Root-key transfer cannot begin until this device confirms the SAS. */51  confirmSas(): Promise<boolean>;52  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;53  deriveLocalIdentity(): Promise<LocalIdentity>;54  upsertPairedDevice(device: PairedDevice): Promise<void>;55  applyLicense?(license: string): Promise<void>;56  onError(error: string): void;57}5859export interface AdoptionSourceDeps {60  sourceIdentity: LocalIdentity;61  rootKeyHex(): Promise<string>;62  vaultId(): Promise<string>;63  license?(): Promise<string | undefined>;64  onConsent(request: AdoptRequest): Promise<boolean>;65  upsertPairedDevice(device: PairedDevice): Promise<void>;66}6768export function isAdoptRequest(frame: unknown): frame is AdoptRequest {69  const value = frame as Partial<AdoptRequest> | null;70  return value?.type === "ADOPT_REQUEST" &&71    typeof value.device_id === "string" &&72    value.device_id.length > 0 &&73    typeof value.device_name === "string" &&74    isDeviceType(value.device_type);75}7677export function isAdoptGrant(frame: unknown): frame is AdoptGrant {78  const value = frame as Partial<AdoptGrant> | null;79  return value?.type === "ADOPT_GRANT" &&80    validateRootKeyHex(value.root_key_hex) &&81    typeof value.vault_id === "string" &&82    /^[0-9a-f]{64}$/.test(value.vault_id) &&83    typeof value.source === "object" &&84    value.source !== null &&85    typeof value.source.device_id === "string" &&86    typeof value.source.device_name === "string" &&87    isDeviceType(value.source.device_type) &&88    typeof value.source.long_term_public_key_hex === "string" &&89    /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex);90}9192export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {93  const value = frame as Partial<AdoptConfirm> | null;94  return value?.type === "ADOPT_CONFIRM" &&95    typeof value.confirmed === "boolean";96}9798export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {99  const value = frame as Partial<AdoptIdentity> | null;100  return value?.type === "ADOPT_IDENTITY" &&101    typeof value.device_id === "string" &&102    value.device_id.length > 0 &&103    typeof value.device_name === "string" &&104    isDeviceType(value.device_type) &&105    typeof value.long_term_public_key_hex === "string" &&106    /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);107}108109export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {110  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);111}112113export async function requestAdoption(114  channel: PairingTransport,115  deps: AdoptionReceiverDeps,116): Promise<PairedDevice | null> {117  await sendFrame(channel, {118    type: "ADOPT_REQUEST",119    device_id: deps.generateDeviceId(),120    device_name: deps.deviceName,121    device_type: "extension",122  });123124  const confirmed = await deps.confirmSas();125  await sendFrame(channel, {126    type: "ADOPT_CONFIRM",127    confirmed,128  });129  if (!confirmed) {130    deps.onError("ADOPT_SAS_REJECTED");131    return null;132  }133134  const frame = await receiveFrame(channel);135  if (!isAdoptGrant(frame)) {136    deps.onError("ADOPT_UNEXPECTED_FRAME");137    return null;138  }139140  const rootKeyBytes = hexToBytes(frame.root_key_hex);141  try {142    const computedVaultId = await computeVaultId(rootKeyBytes);143    if (computedVaultId !== frame.vault_id) {144      deps.onError("ADOPT_VAULT_ID_MISMATCH");145      return null;146    }147  } finally {148    rootKeyBytes.fill(0);149  }150151  const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);152  if (vaultId !== frame.vault_id) {153    deps.onError("ADOPT_VAULT_ID_MISMATCH");154    return null;155  }156157  if (frame.license) await deps.applyLicense?.(frame.license);158159  const identity = await deps.deriveLocalIdentity();160  await sendFrame(channel, {161    type: "ADOPT_IDENTITY",162    device_id: identity.deviceId,163    device_name: identity.deviceName,164    device_type: identity.deviceType,165    long_term_public_key_hex: identity.longTermPublicKeyHex,166    signing_public_key_hex: identity.signingPublicKeyHex,167  });168169  const sourceDevice: PairedDevice = {170    deviceId: frame.source.device_id,171    deviceName: frame.source.device_name,172    deviceType: frame.source.device_type,173    longTermPublicKeyHex: frame.source.long_term_public_key_hex,174    signingPublicKeyHex: frame.source.signing_public_key_hex,175    trustSource: "direct",176    wsPort: frame.source.ws_port,177    pairedAt: Date.now(),178  };179  await deps.upsertPairedDevice(sourceDevice);180  return sourceDevice;181}182183export async function grantAdoption(184  channel: PairingTransport,185  request: AdoptRequest,186  deps: AdoptionSourceDeps,187): Promise<PairedDevice | null> {188  const approved = await deps.onConsent(request);189  if (!approved) return null;190191  const confirmation = await receiveFrame(channel);192  if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;193194  const rootKeyHex = await deps.rootKeyHex();195  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");196197  await sendFrame(channel, {198    type: "ADOPT_GRANT",199    root_key_hex: rootKeyHex,200    vault_id: await deps.vaultId(),201    license: await deps.license?.(),202    source: {203      device_id: deps.sourceIdentity.deviceId,204      device_name: deps.sourceIdentity.deviceName,205      device_type: deps.sourceIdentity.deviceType,206      long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,207      signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,208      ws_port: deps.sourceIdentity.wsPort,209    },210  });211212  const identity = await receiveFrame(channel);213  if (!isAdoptIdentity(identity)) return null;214215  const device: PairedDevice = {216    deviceId: identity.device_id,217    deviceName: identity.device_name,218    deviceType: identity.device_type,219    longTermPublicKeyHex: identity.long_term_public_key_hex,220    signingPublicKeyHex: identity.signing_public_key_hex,221    trustSource: "direct",222    pairedAt: Date.now(),223  };224  await deps.upsertPairedDevice(device);225  return device;226}227228export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {229  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));230}231232export async function receiveFrame(channel: PairingTransport): Promise<unknown> {233  return JSON.parse(new TextDecoder().decode(await channel.receive()));234}235236function isDeviceType(value: unknown): value is DeviceType {237  return value === "extension" || value === "android" || value === "ios";238}239240function hexToBytes(hex: string): Uint8Array {241  const out = new Uint8Array(hex.length / 2);242  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);243  return out;244}