vault-adopt.ts

8.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    relay_url?: string;25  };26}2728export interface AdoptConfirm {29  type: "ADOPT_CONFIRM";30  confirmed: boolean;31}3233export interface AdoptIdentity {34  type: "ADOPT_IDENTITY";35  device_id: string;36  device_name: string;37  device_type: DeviceType;38  long_term_public_key_hex: string;39  signing_public_key_hex?: string;40}4142export type AdoptionFrame =43  | AdoptRequest44  | AdoptConfirm45  | AdoptGrant46  | AdoptIdentity;4748export interface AdoptionPairState {49  rendezvousSecretHex: string;50  relayUrl?: string;51}5253export interface AdoptionReceiverDeps {54  deviceName: string;55  generateDeviceId(): string;56  /** Root-key transfer cannot begin until this device confirms the SAS. */57  confirmSas(): Promise<boolean>;58  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;59  deriveLocalIdentity(): Promise<LocalIdentity>;60  upsertPairedDevice(device: PairedDevice): Promise<void>;61  applyLicense?(license: string): Promise<void>;62  /** Bound to the same completed Noise session, never taken from frame claims. */63  pairState?: AdoptionPairState;64  onError(error: string): void;65}6667export interface AdoptionSourceDeps {68  sourceIdentity: LocalIdentity;69  rootKeyHex(): Promise<string>;70  vaultId(): Promise<string>;71  license?(): Promise<string | undefined>;72  onConsent(request: AdoptRequest): Promise<boolean>;73  upsertPairedDevice(device: PairedDevice): Promise<void>;74  /** Bound to the same completed Noise session, never taken from frame claims. */75  pairState?: AdoptionPairState;76}7778export function isAdoptRequest(frame: unknown): frame is AdoptRequest {79  const value = frame as Partial<AdoptRequest> | null;80  return value?.type === "ADOPT_REQUEST" &&81    typeof value.device_id === "string" &&82    value.device_id.length > 0 &&83    typeof value.device_name === "string" &&84    isDeviceType(value.device_type);85}8687export function isAdoptGrant(frame: unknown): frame is AdoptGrant {88  const value = frame as Partial<AdoptGrant> | null;89  return value?.type === "ADOPT_GRANT" &&90    validateRootKeyHex(value.root_key_hex) &&91    typeof value.vault_id === "string" &&92    /^[0-9a-f]{64}$/.test(value.vault_id) &&93    typeof value.source === "object" &&94    value.source !== null &&95    typeof value.source.device_id === "string" &&96    typeof value.source.device_name === "string" &&97    isDeviceType(value.source.device_type) &&98    typeof value.source.long_term_public_key_hex === "string" &&99    /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&100    (value.source.relay_url === undefined ||101      (typeof value.source.relay_url === "string" &&102        isSecureRelayUrl(value.source.relay_url)));103}104105export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {106  const value = frame as Partial<AdoptConfirm> | null;107  return value?.type === "ADOPT_CONFIRM" &&108    typeof value.confirmed === "boolean";109}110111export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {112  const value = frame as Partial<AdoptIdentity> | null;113  return value?.type === "ADOPT_IDENTITY" &&114    typeof value.device_id === "string" &&115    value.device_id.length > 0 &&116    typeof value.device_name === "string" &&117    isDeviceType(value.device_type) &&118    typeof value.long_term_public_key_hex === "string" &&119    /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);120}121122export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {123  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);124}125126export async function requestAdoption(127  channel: PairingTransport,128  deps: AdoptionReceiverDeps,129): Promise<PairedDevice | null> {130  await sendFrame(channel, {131    type: "ADOPT_REQUEST",132    device_id: deps.generateDeviceId(),133    device_name: deps.deviceName,134    device_type: "extension",135  });136137  const confirmed = await deps.confirmSas();138  await sendFrame(channel, {139    type: "ADOPT_CONFIRM",140    confirmed,141  });142  if (!confirmed) {143    deps.onError("ADOPT_SAS_REJECTED");144    return null;145  }146147  const frame = await receiveFrame(channel);148  if (!isAdoptGrant(frame)) {149    deps.onError("ADOPT_UNEXPECTED_FRAME");150    return null;151  }152153  const rootKeyBytes = hexToBytes(frame.root_key_hex);154  try {155    const computedVaultId = await computeVaultId(rootKeyBytes);156    if (computedVaultId !== frame.vault_id) {157      deps.onError("ADOPT_VAULT_ID_MISMATCH");158      return null;159    }160  } finally {161    rootKeyBytes.fill(0);162  }163164  const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);165  if (vaultId !== frame.vault_id) {166    deps.onError("ADOPT_VAULT_ID_MISMATCH");167    return null;168  }169170  if (frame.license) await deps.applyLicense?.(frame.license);171172  const identity = await deps.deriveLocalIdentity();173  await sendFrame(channel, {174    type: "ADOPT_IDENTITY",175    device_id: identity.deviceId,176    device_name: identity.deviceName,177    device_type: identity.deviceType,178    long_term_public_key_hex: identity.longTermPublicKeyHex,179    signing_public_key_hex: identity.signingPublicKeyHex,180  });181182  const sourceDevice: PairedDevice = {183    deviceId: frame.source.device_id,184    deviceName: frame.source.device_name,185    deviceType: frame.source.device_type,186    longTermPublicKeyHex: frame.source.long_term_public_key_hex,187    signingPublicKeyHex: frame.source.signing_public_key_hex,188    trustSource: "direct",189    wsPort: frame.source.ws_port,190    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,191    relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,192    pairedAt: Date.now(),193  };194  await deps.upsertPairedDevice(sourceDevice);195  return sourceDevice;196}197198export async function grantAdoption(199  channel: PairingTransport,200  request: AdoptRequest,201  deps: AdoptionSourceDeps,202): Promise<PairedDevice | null> {203  const approved = await deps.onConsent(request);204  if (!approved) return null;205206  const confirmation = await receiveFrame(channel);207  if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;208209  const rootKeyHex = await deps.rootKeyHex();210  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");211212  await sendFrame(channel, {213    type: "ADOPT_GRANT",214    root_key_hex: rootKeyHex,215    vault_id: await deps.vaultId(),216    license: await deps.license?.(),217    source: {218      device_id: deps.sourceIdentity.deviceId,219      device_name: deps.sourceIdentity.deviceName,220      device_type: deps.sourceIdentity.deviceType,221      long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,222      signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,223      ws_port: deps.sourceIdentity.wsPort,224      relay_url: deps.pairState?.relayUrl,225    },226  });227228  const identity = await receiveFrame(channel);229  if (!isAdoptIdentity(identity)) return null;230231  const device: PairedDevice = {232    deviceId: identity.device_id,233    deviceName: identity.device_name,234    deviceType: identity.device_type,235    longTermPublicKeyHex: identity.long_term_public_key_hex,236    signingPublicKeyHex: identity.signing_public_key_hex,237    trustSource: "direct",238    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,239    relayUrl: deps.pairState?.relayUrl,240    pairedAt: Date.now(),241  };242  await deps.upsertPairedDevice(device);243  return device;244}245246export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {247  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));248}249250export async function receiveFrame(channel: PairingTransport): Promise<unknown> {251  return JSON.parse(new TextDecoder().decode(await channel.receive()));252}253254function isDeviceType(value: unknown): value is DeviceType {255  return value === "extension" || value === "android" || value === "ios";256}257258function isSecureRelayUrl(value: string): boolean {259  try {260    const url = new URL(value);261    return url.protocol === "wss:" && !url.username && !url.password &&262      !url.search && !url.hash;263  } catch {264    return false;265  }266}267268function hexToBytes(hex: string): Uint8Array {269  const out = new Uint8Array(hex.length / 2);270  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);271  return out;272}