vault-adopt.ts

9.9 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}4142/**43 * Encrypted source-to-receiver acknowledgement sent only after the source has44 * durably recorded the receiver. Without it the receiver could report success45 * after sending ADOPT_IDENTITY even though the source never received it.46 */47export interface AdoptComplete {48  type: "ADOPT_COMPLETE";49}5051/** Receiver-to-source acknowledgement sent after the source record is durable. */52export interface AdoptApplied {53  type: "ADOPT_APPLIED";54}5556export type AdoptionFrame =57  | AdoptRequest58  | AdoptConfirm59  | AdoptGrant60  | AdoptIdentity61  | AdoptComplete62  | AdoptApplied;6364export interface AdoptionPairState {65  rendezvousSecretHex: string;66  relayUrl?: string;67}6869export interface AdoptionReceiverDeps {70  deviceName: string;71  generateDeviceId(): string;72  /** Root-key transfer cannot begin until this device confirms the SAS. */73  confirmSas(): Promise<boolean>;74  /**75   * Optional local safety policy evaluated after the authenticated grant is76   * validated but before any vault state changes.77   */78  validateSource?(source: AdoptGrant["source"]): Promise<void>;79  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;80  deriveLocalIdentity(): Promise<LocalIdentity>;81  upsertPairedDevice(device: PairedDevice): Promise<void>;82  applyLicense?(license: string): Promise<void>;83  /** Bound to the same completed Noise session, never taken from frame claims. */84  pairState?: AdoptionPairState;85  onError(error: string): void;86}8788export interface AdoptionSourceDeps {89  sourceIdentity: LocalIdentity;90  rootKeyHex(): Promise<string>;91  vaultId(): Promise<string>;92  license?(): Promise<string | undefined>;93  onConsent(request: AdoptRequest): Promise<boolean>;94  upsertPairedDevice(device: PairedDevice): Promise<void>;95  /** Bound to the same completed Noise session, never taken from frame claims. */96  pairState?: AdoptionPairState;97}9899export function isAdoptRequest(frame: unknown): frame is AdoptRequest {100  const value = frame as Partial<AdoptRequest> | null;101  return value?.type === "ADOPT_REQUEST" &&102    typeof value.device_id === "string" &&103    value.device_id.length > 0 &&104    typeof value.device_name === "string" &&105    isDeviceType(value.device_type);106}107108export function isAdoptGrant(frame: unknown): frame is AdoptGrant {109  const value = frame as Partial<AdoptGrant> | null;110  return value?.type === "ADOPT_GRANT" &&111    validateRootKeyHex(value.root_key_hex) &&112    typeof value.vault_id === "string" &&113    /^[0-9a-f]{64}$/.test(value.vault_id) &&114    typeof value.source === "object" &&115    value.source !== null &&116    typeof value.source.device_id === "string" &&117    typeof value.source.device_name === "string" &&118    isDeviceType(value.source.device_type) &&119    typeof value.source.long_term_public_key_hex === "string" &&120    /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&121    (value.source.relay_url === undefined ||122      (typeof value.source.relay_url === "string" &&123        isSecureRelayUrl(value.source.relay_url)));124}125126export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {127  const value = frame as Partial<AdoptConfirm> | null;128  return value?.type === "ADOPT_CONFIRM" &&129    typeof value.confirmed === "boolean";130}131132export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {133  const value = frame as Partial<AdoptIdentity> | null;134  return value?.type === "ADOPT_IDENTITY" &&135    typeof value.device_id === "string" &&136    value.device_id.length > 0 &&137    typeof value.device_name === "string" &&138    isDeviceType(value.device_type) &&139    typeof value.long_term_public_key_hex === "string" &&140    /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);141}142143export function isAdoptComplete(frame: unknown): frame is AdoptComplete {144  return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";145}146147export function isAdoptApplied(frame: unknown): frame is AdoptApplied {148  return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";149}150151export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {152  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);153}154155export async function requestAdoption(156  channel: PairingTransport,157  deps: AdoptionReceiverDeps,158): Promise<PairedDevice | null> {159  await sendFrame(channel, {160    type: "ADOPT_REQUEST",161    device_id: deps.generateDeviceId(),162    device_name: deps.deviceName,163    device_type: "extension",164  });165166  const confirmed = await deps.confirmSas();167  await sendFrame(channel, {168    type: "ADOPT_CONFIRM",169    confirmed,170  });171  if (!confirmed) {172    deps.onError("ADOPT_SAS_REJECTED");173    return null;174  }175176  const frame = await receiveFrame(channel);177  if (!isAdoptGrant(frame)) {178    deps.onError("ADOPT_UNEXPECTED_FRAME");179    return null;180  }181182  const rootKeyBytes = hexToBytes(frame.root_key_hex);183  try {184    const computedVaultId = await computeVaultId(rootKeyBytes);185    if (computedVaultId !== frame.vault_id) {186      deps.onError("ADOPT_VAULT_ID_MISMATCH");187      return null;188    }189  } finally {190    rootKeyBytes.fill(0);191  }192193  await deps.validateSource?.(frame.source);194  const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);195  if (vaultId !== frame.vault_id) {196    deps.onError("ADOPT_VAULT_ID_MISMATCH");197    return null;198  }199200  if (frame.license) await deps.applyLicense?.(frame.license);201202  const identity = await deps.deriveLocalIdentity();203  await sendFrame(channel, {204    type: "ADOPT_IDENTITY",205    device_id: identity.deviceId,206    device_name: identity.deviceName,207    device_type: identity.deviceType,208    long_term_public_key_hex: identity.longTermPublicKeyHex,209    signing_public_key_hex: identity.signingPublicKeyHex,210  });211212  const completion = await receiveFrame(channel);213  if (!isAdoptComplete(completion)) {214    deps.onError("ADOPT_COMPLETION_MISSING");215    return null;216  }217218  const sourceDevice: PairedDevice = {219    deviceId: frame.source.device_id,220    deviceName: frame.source.device_name,221    deviceType: frame.source.device_type,222    longTermPublicKeyHex: frame.source.long_term_public_key_hex,223    signingPublicKeyHex: frame.source.signing_public_key_hex,224    trustSource: "direct",225    wsPort: frame.source.ws_port,226    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,227    relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,228    pairedAt: Date.now(),229  };230  await deps.upsertPairedDevice(sourceDevice);231  await sendFrame(channel, { type: "ADOPT_APPLIED" });232  return sourceDevice;233}234235export async function grantAdoption(236  channel: PairingTransport,237  request: AdoptRequest,238  deps: AdoptionSourceDeps,239): Promise<PairedDevice | null> {240  const approved = await deps.onConsent(request);241  if (!approved) return null;242243  const confirmation = await receiveFrame(channel);244  if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;245246  const rootKeyHex = await deps.rootKeyHex();247  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");248249  await sendFrame(channel, {250    type: "ADOPT_GRANT",251    root_key_hex: rootKeyHex,252    vault_id: await deps.vaultId(),253    license: await deps.license?.(),254    source: {255      device_id: deps.sourceIdentity.deviceId,256      device_name: deps.sourceIdentity.deviceName,257      device_type: deps.sourceIdentity.deviceType,258      long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,259      signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,260      ws_port: deps.sourceIdentity.wsPort,261      relay_url: deps.pairState?.relayUrl,262    },263  });264265  const identity = await receiveFrame(channel);266  if (!isAdoptIdentity(identity)) return null;267268  const device: PairedDevice = {269    deviceId: identity.device_id,270    deviceName: identity.device_name,271    deviceType: identity.device_type,272    longTermPublicKeyHex: identity.long_term_public_key_hex,273    signingPublicKeyHex: identity.signing_public_key_hex,274    trustSource: "direct",275    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,276    relayUrl: deps.pairState?.relayUrl,277    pairedAt: Date.now(),278  };279  await deps.upsertPairedDevice(device);280  await sendFrame(channel, { type: "ADOPT_COMPLETE" });281  if (!isAdoptApplied(await receiveFrame(channel))) return null;282  return device;283}284285export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {286  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));287}288289export async function receiveFrame(channel: PairingTransport): Promise<unknown> {290  return JSON.parse(new TextDecoder().decode(await channel.receive()));291}292293function isDeviceType(value: unknown): value is DeviceType {294  return value === "extension" || value === "android" || value === "ios";295}296297function isSecureRelayUrl(value: string): boolean {298  try {299    const url = new URL(value);300    return url.protocol === "wss:" && !url.username && !url.password &&301      !url.search && !url.hash;302  } catch {303    return false;304  }305}306307function hexToBytes(hex: string): Uint8Array {308  const out = new Uint8Array(hex.length / 2);309  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);310  return out;311}