vault-adopt.ts

11.4 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  /** Signed Pro entitlement offered by the receiver, if it has one. */11  license?: string;12}1314export interface AdoptGrant {15  type: "ADOPT_GRANT";16  root_key_hex: string;17  vault_id: string;18  license?: string;19  source: {20    device_id: string;21    device_name: string;22    device_type: DeviceType;23    long_term_public_key_hex: string;24    signing_public_key_hex?: string;25    ws_port?: number;26    relay_url?: string;27  };28}2930export interface AdoptConfirm {31  type: "ADOPT_CONFIRM";32  confirmed: boolean;33}3435export interface AdoptIdentity {36  type: "ADOPT_IDENTITY";37  device_id: string;38  device_name: string;39  device_type: DeviceType;40  long_term_public_key_hex: string;41  signing_public_key_hex?: string;42}4344/**45 * Encrypted source-to-receiver acknowledgement sent only after the source has46 * durably recorded the receiver. Without it the receiver could report success47 * after sending ADOPT_IDENTITY even though the source never received it.48 */49export interface AdoptComplete {50  type: "ADOPT_COMPLETE";51}5253/** Receiver-to-source acknowledgement sent after the source record is durable. */54export interface AdoptApplied {55  type: "ADOPT_APPLIED";56}5758/** Neither peer could prove a Pro entitlement, so no vault state may commit. */59export interface AdoptReject {60  type: "ADOPT_REJECT";61  reason: "ADOPT_PRO_REQUIRED";62}6364export type AdoptionFrame =65  | AdoptRequest66  | AdoptConfirm67  | AdoptGrant68  | AdoptIdentity69  | AdoptComplete70  | AdoptApplied71  | AdoptReject;7273export interface AdoptionPairState {74  rendezvousSecretHex: string;75  relayUrl?: string;76}7778export interface AdoptionReceiverDeps {79  deviceName: string;80  generateDeviceId(): string;81  /** Root-key transfer cannot begin until this device confirms the SAS. */82  confirmSas(): Promise<boolean>;83  /**84   * Optional local safety policy evaluated after the authenticated grant is85   * validated but before any vault state changes.86   */87  validateSource?(source: AdoptGrant["source"]): Promise<void>;88  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;89  deriveLocalIdentity(): Promise<LocalIdentity>;90  upsertPairedDevice(device: PairedDevice): Promise<void>;91  applyLicense?(license: string): Promise<void>;92  /** Return this receiver's locally validated Pro key, if present. */93  license?(): Promise<string | undefined>;94  /** Bound to the same completed Noise session, never taken from frame claims. */95  pairState?: AdoptionPairState;96  onError(error: string): void;97}9899export interface AdoptionSourceDeps {100  sourceIdentity: LocalIdentity;101  rootKeyHex(): Promise<string>;102  vaultId(): Promise<string>;103  license?(): Promise<string | undefined>;104  /** Validate and durably store a Pro key offered by the receiver. */105  applyLicense?(license: string): Promise<void>;106  onConsent(request: AdoptRequest): Promise<boolean>;107  upsertPairedDevice(device: PairedDevice): Promise<void>;108  /** Bound to the same completed Noise session, never taken from frame claims. */109  pairState?: AdoptionPairState;110}111112export function isAdoptRequest(frame: unknown): frame is AdoptRequest {113  const value = frame as Partial<AdoptRequest> | null;114  return value?.type === "ADOPT_REQUEST" &&115    typeof value.device_id === "string" &&116    value.device_id.length > 0 &&117    typeof value.device_name === "string" &&118    isDeviceType(value.device_type) &&119    (value.license === undefined || typeof value.license === "string");120}121122export function isAdoptGrant(frame: unknown): frame is AdoptGrant {123  const value = frame as Partial<AdoptGrant> | null;124  return value?.type === "ADOPT_GRANT" &&125    validateRootKeyHex(value.root_key_hex) &&126    typeof value.vault_id === "string" &&127    /^[0-9a-f]{64}$/.test(value.vault_id) &&128    typeof value.source === "object" &&129    value.source !== null &&130    typeof value.source.device_id === "string" &&131    typeof value.source.device_name === "string" &&132    isDeviceType(value.source.device_type) &&133    typeof value.source.long_term_public_key_hex === "string" &&134    /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&135    (value.source.relay_url === undefined ||136      (typeof value.source.relay_url === "string" &&137        isSecureRelayUrl(value.source.relay_url)));138}139140export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {141  const value = frame as Partial<AdoptConfirm> | null;142  return value?.type === "ADOPT_CONFIRM" &&143    typeof value.confirmed === "boolean";144}145146export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {147  const value = frame as Partial<AdoptIdentity> | null;148  return value?.type === "ADOPT_IDENTITY" &&149    typeof value.device_id === "string" &&150    value.device_id.length > 0 &&151    typeof value.device_name === "string" &&152    isDeviceType(value.device_type) &&153    typeof value.long_term_public_key_hex === "string" &&154    /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);155}156157export function isAdoptComplete(frame: unknown): frame is AdoptComplete {158  return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";159}160161export function isAdoptApplied(frame: unknown): frame is AdoptApplied {162  return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";163}164165export function isAdoptReject(frame: unknown): frame is AdoptReject {166  const value = frame as Partial<AdoptReject> | null;167  return value?.type === "ADOPT_REJECT" &&168    value.reason === "ADOPT_PRO_REQUIRED";169}170171export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {172  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);173}174175export async function requestAdoption(176  channel: PairingTransport,177  deps: AdoptionReceiverDeps,178): Promise<PairedDevice | null> {179  const receiverLicense = await deps.license?.();180  await sendFrame(channel, {181    type: "ADOPT_REQUEST",182    device_id: deps.generateDeviceId(),183    device_name: deps.deviceName,184    device_type: "extension",185    license: receiverLicense,186  });187188  const confirmed = await deps.confirmSas();189  await sendFrame(channel, {190    type: "ADOPT_CONFIRM",191    confirmed,192  });193  if (!confirmed) {194    deps.onError("ADOPT_SAS_REJECTED");195    return null;196  }197198  const frame = await receiveFrame(channel);199  if (isAdoptReject(frame)) {200    deps.onError(frame.reason);201    return null;202  }203  if (!isAdoptGrant(frame)) {204    deps.onError("ADOPT_UNEXPECTED_FRAME");205    return null;206  }207208  const rootKeyBytes = hexToBytes(frame.root_key_hex);209  try {210    const computedVaultId = await computeVaultId(rootKeyBytes);211    if (computedVaultId !== frame.vault_id) {212      deps.onError("ADOPT_VAULT_ID_MISMATCH");213      return null;214    }215  } finally {216    rootKeyBytes.fill(0);217  }218  if (!receiverLicense && !frame.license) {219    deps.onError("ADOPT_PRO_REQUIRED");220    return null;221  }222223  await deps.validateSource?.(frame.source);224  const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);225  if (vaultId !== frame.vault_id) {226    deps.onError("ADOPT_VAULT_ID_MISMATCH");227    return null;228  }229230  if (!receiverLicense && frame.license) {231    await deps.applyLicense?.(frame.license);232  }233234  const identity = await deps.deriveLocalIdentity();235  await sendFrame(channel, {236    type: "ADOPT_IDENTITY",237    device_id: identity.deviceId,238    device_name: identity.deviceName,239    device_type: identity.deviceType,240    long_term_public_key_hex: identity.longTermPublicKeyHex,241    signing_public_key_hex: identity.signingPublicKeyHex,242  });243244  const completion = await receiveFrame(channel);245  if (!isAdoptComplete(completion)) {246    deps.onError("ADOPT_COMPLETION_MISSING");247    return null;248  }249250  const sourceDevice: PairedDevice = {251    deviceId: frame.source.device_id,252    deviceName: frame.source.device_name,253    deviceType: frame.source.device_type,254    longTermPublicKeyHex: frame.source.long_term_public_key_hex,255    signingPublicKeyHex: frame.source.signing_public_key_hex,256    trustSource: "direct",257    wsPort: frame.source.ws_port,258    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,259    relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,260    pairedAt: Date.now(),261  };262  await deps.upsertPairedDevice(sourceDevice);263  await sendFrame(channel, { type: "ADOPT_APPLIED" });264  return sourceDevice;265}266267export async function grantAdoption(268  channel: PairingTransport,269  request: AdoptRequest,270  deps: AdoptionSourceDeps,271): Promise<PairedDevice | null> {272  const approved = await deps.onConsent(request);273  if (!approved) return null;274275  const confirmation = await receiveFrame(channel);276  if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;277278  let sourceLicense = await deps.license?.();279  if (!sourceLicense && request.license) {280    try {281      if (!deps.applyLicense) throw new Error("license_transfer_unavailable");282      await deps.applyLicense(request.license);283      sourceLicense = await deps.license?.();284    } catch {285      sourceLicense = undefined;286    }287  }288  if (!sourceLicense) {289    await sendFrame(channel, {290      type: "ADOPT_REJECT",291      reason: "ADOPT_PRO_REQUIRED",292    });293    throw new Error("ADOPT_PRO_REQUIRED");294  }295296  const rootKeyHex = await deps.rootKeyHex();297  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");298299  await sendFrame(channel, {300    type: "ADOPT_GRANT",301    root_key_hex: rootKeyHex,302    vault_id: await deps.vaultId(),303    license: sourceLicense,304    source: {305      device_id: deps.sourceIdentity.deviceId,306      device_name: deps.sourceIdentity.deviceName,307      device_type: deps.sourceIdentity.deviceType,308      long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,309      signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,310      ws_port: deps.sourceIdentity.wsPort,311      relay_url: deps.pairState?.relayUrl,312    },313  });314315  const identity = await receiveFrame(channel);316  if (!isAdoptIdentity(identity)) return null;317318  const device: PairedDevice = {319    deviceId: identity.device_id,320    deviceName: identity.device_name,321    deviceType: identity.device_type,322    longTermPublicKeyHex: identity.long_term_public_key_hex,323    signingPublicKeyHex: identity.signing_public_key_hex,324    trustSource: "direct",325    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,326    relayUrl: deps.pairState?.relayUrl,327    pairedAt: Date.now(),328  };329  await deps.upsertPairedDevice(device);330  await sendFrame(channel, { type: "ADOPT_COMPLETE" });331  if (!isAdoptApplied(await receiveFrame(channel))) return null;332  return device;333}334335export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {336  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));337}338339export async function receiveFrame(channel: PairingTransport): Promise<unknown> {340  return JSON.parse(new TextDecoder().decode(await channel.receive()));341}342343function isDeviceType(value: unknown): value is DeviceType {344  return value === "extension" || value === "android" || value === "ios";345}346347function isSecureRelayUrl(value: string): boolean {348  try {349    const url = new URL(value);350    return url.protocol === "wss:" && !url.username && !url.password &&351      !url.search && !url.hash;352  } catch {353    return false;354  }355}356357function hexToBytes(hex: string): Uint8Array {358  const out = new Uint8Array(hex.length / 2);359  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);360  return out;361}