vault-adopt.ts

18.5 KB · raw

1import { computeVaultId } from "./sync-identity";2import type { DeviceType, PairedDevice, SignedDeviceManifest } 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  /**13   * Receiver advertises inline-snapshot support. A source that understands it14   * sends the vault snapshot over this same channel after ADOPT_APPLIED;15   * older sources ignore it and the receiver falls back to the sync pipeline.16   */17  snapshot_capable?: boolean;18}1920export interface AdoptGrant {21  type: "ADOPT_GRANT";22  root_key_hex: string;23  vault_id: string;24  license?: string;25  source: {26    device_id: string;27    device_name: string;28    device_type: DeviceType;29    long_term_public_key_hex: string;30    signing_public_key_hex?: string;31    ws_port?: number;32    relay_url?: string;33  };34}3536export interface AdoptConfirm {37  type: "ADOPT_CONFIRM";38  confirmed: boolean;39}4041export interface AdoptIdentity {42  type: "ADOPT_IDENTITY";43  device_id: string;44  device_name: string;45  device_type: DeviceType;46  long_term_public_key_hex: string;47  signing_public_key_hex?: string;48}4950/**51 * Encrypted source-to-receiver acknowledgement sent only after the source has52 * durably recorded the receiver. Without it the receiver could report success53 * after sending ADOPT_IDENTITY even though the source never received it.54 */55export interface AdoptComplete {56  type: "ADOPT_COMPLETE";57}5859/** Receiver-to-source acknowledgement sent after the source record is durable. */60export interface AdoptApplied {61  type: "ADOPT_APPLIED";62}6364/** Neither peer could prove a Pro entitlement, so no vault state may commit. */65export interface AdoptReject {66  type: "ADOPT_REJECT";67  reason: "ADOPT_PRO_REQUIRED";68}6970/**71 * One chunk of the inline vault snapshot sent by the source after72 * ADOPT_APPLIED, over the same authenticated Noise NK channel. Entries are73 * opaque base64 ciphertext envelopes; tombstones ride the final chunk only.74 */75export interface AdoptSnapshot {76  type: "ADOPT_SNAPSHOT";77  chunk_index: number;78  chunk_count: number;79  entries: Record<string, string>;80  /** entryId → deletion timestamp; populated on the final chunk only. */81  tombstones: Record<string, number>;82}8384/** Receiver acknowledgement sent after all snapshot chunks are applied. */85export interface AdoptSnapshotApplied {86  type: "ADOPT_SNAPSHOT_APPLIED";87  entries_received: number;88  tombstones_received: number;89}9091/** Optional signed device graph appended after the snapshot (manifest v3+). */92export interface AdoptDeviceManifestFrame {93  type: "ADOPT_DEVICE_MANIFEST";94  manifest: SignedDeviceManifest;95}9697/** Receiver acknowledgement for an applied device manifest. */98export interface AdoptDeviceManifestApplied {99  type: "ADOPT_DEVICE_MANIFEST_APPLIED";100}101102export type AdoptionFrame =103  | AdoptRequest104  | AdoptConfirm105  | AdoptGrant106  | AdoptIdentity107  | AdoptComplete108  | AdoptApplied109  | AdoptReject110  | AdoptSnapshot111  | AdoptSnapshotApplied112  | AdoptDeviceManifestFrame113  | AdoptDeviceManifestApplied;114115/**116 * Wire guardrails for the inline snapshot. A chunk's serialized entries map117 * stays under MAX_SNAPSHOT_CHUNK_BYTES; the whole transfer is capped at118 * MAX_SNAPSHOT_CHUNKS frames so a compromised source cannot exhaust receiver119 * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator).120 */121export const MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024; // 512 KiB122export const MAX_SNAPSHOT_CHUNKS = 200;123// Headroom added to the per-chunk entries budget when validating an encoded124// frame on the wire: JSON envelope plus the final chunk's tombstone map.125const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;126127export interface AdoptionPairState {128  rendezvousSecretHex: string;129  relayUrl?: string;130}131132export interface AdoptionReceiverDeps {133  deviceName: string;134  generateDeviceId(): string;135  /** Root-key transfer cannot begin until this device confirms the SAS. */136  confirmSas(): Promise<boolean>;137  /**138   * Optional local safety policy evaluated after the authenticated grant is139   * validated but before any vault state changes.140   */141  validateSource?(source: AdoptGrant["source"]): Promise<void>;142  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;143  deriveLocalIdentity(): Promise<LocalIdentity>;144  upsertPairedDevice(device: PairedDevice): Promise<void>;145  applyLicense?(license: string): Promise<void>;146  /** Return this receiver's locally validated Pro key, if present. */147  license?(): Promise<string | undefined>;148  /** Bound to the same completed Noise session, never taken from frame claims. */149  pairState?: AdoptionPairState;150  onError(error: string): void;151}152153export interface AdoptionSourceDeps {154  sourceIdentity: LocalIdentity;155  rootKeyHex(): Promise<string>;156  vaultId(): Promise<string>;157  license?(): Promise<string | undefined>;158  /** Validate and durably store a Pro key offered by the receiver. */159  applyLicense?(license: string): Promise<void>;160  onConsent(request: AdoptRequest): Promise<boolean>;161  upsertPairedDevice(device: PairedDevice): Promise<void>;162  /** Bound to the same completed Noise session, never taken from frame claims. */163  pairState?: AdoptionPairState;164}165166export function isAdoptRequest(frame: unknown): frame is AdoptRequest {167  const value = frame as Partial<AdoptRequest> | null;168  return value?.type === "ADOPT_REQUEST" &&169    typeof value.device_id === "string" &&170    value.device_id.length > 0 &&171    typeof value.device_name === "string" &&172    isDeviceType(value.device_type) &&173    (value.license === undefined || typeof value.license === "string");174}175176export function isAdoptGrant(frame: unknown): frame is AdoptGrant {177  const value = frame as Partial<AdoptGrant> | null;178  return value?.type === "ADOPT_GRANT" &&179    validateRootKeyHex(value.root_key_hex) &&180    typeof value.vault_id === "string" &&181    /^[0-9a-f]{64}$/.test(value.vault_id) &&182    typeof value.source === "object" &&183    value.source !== null &&184    typeof value.source.device_id === "string" &&185    typeof value.source.device_name === "string" &&186    isDeviceType(value.source.device_type) &&187    typeof value.source.long_term_public_key_hex === "string" &&188    /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&189    (value.source.relay_url === undefined ||190      (typeof value.source.relay_url === "string" &&191        isSecureRelayUrl(value.source.relay_url)));192}193194export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {195  const value = frame as Partial<AdoptConfirm> | null;196  return value?.type === "ADOPT_CONFIRM" &&197    typeof value.confirmed === "boolean";198}199200export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {201  const value = frame as Partial<AdoptIdentity> | null;202  return value?.type === "ADOPT_IDENTITY" &&203    typeof value.device_id === "string" &&204    value.device_id.length > 0 &&205    typeof value.device_name === "string" &&206    isDeviceType(value.device_type) &&207    typeof value.long_term_public_key_hex === "string" &&208    /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);209}210211export function isAdoptComplete(frame: unknown): frame is AdoptComplete {212  return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";213}214215export function isAdoptApplied(frame: unknown): frame is AdoptApplied {216  return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";217}218219export function isAdoptReject(frame: unknown): frame is AdoptReject {220  const value = frame as Partial<AdoptReject> | null;221  return value?.type === "ADOPT_REJECT" &&222    value.reason === "ADOPT_PRO_REQUIRED";223}224225export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {226  const value = frame as Partial<AdoptSnapshot> | null;227  if (value?.type !== "ADOPT_SNAPSHOT") return false;228  if (229    typeof value.chunk_index !== "number" ||230    !Number.isInteger(value.chunk_index) ||231    value.chunk_index < 0232  ) return false;233  if (234    typeof value.chunk_count !== "number" ||235    !Number.isInteger(value.chunk_count) ||236    value.chunk_count < 1237  ) return false;238  if (!isStringRecord(value.entries)) return false;239  if (!isNumberRecord(value.tombstones)) return false;240  return true;241}242243export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {244  const value = frame as Partial<AdoptSnapshotApplied> | null;245  return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&246    typeof value.entries_received === "number" &&247    typeof value.tombstones_received === "number";248}249250export function isAdoptDeviceManifestFrame(251  frame: unknown,252): frame is AdoptDeviceManifestFrame {253  const value = frame as Partial<AdoptDeviceManifestFrame> | null;254  const manifest = value?.manifest as255    | { body?: unknown; signatureHex?: unknown }256    | null;257  return value?.type === "ADOPT_DEVICE_MANIFEST" &&258    typeof manifest === "object" &&259    manifest !== null &&260    typeof manifest.body === "object" &&261    manifest.body !== null &&262    typeof manifest.signatureHex === "string";263}264265export function isAdoptDeviceManifestApplied(266  frame: unknown,267): frame is AdoptDeviceManifestApplied {268  return (frame as Partial<AdoptDeviceManifestApplied> | null)?.type ===269    "ADOPT_DEVICE_MANIFEST_APPLIED";270}271272function isStringRecord(value: unknown): value is Record<string, string> {273  if (typeof value !== "object" || value === null) return false;274  return Object.values(value).every((v) => typeof v === "string");275}276277function isNumberRecord(value: unknown): value is Record<string, number> {278  if (typeof value !== "object" || value === null) return false;279  return Object.values(value).every((v) => typeof v === "number");280}281282/**283 * Source side: chunk the vault snapshot and send it over the pairing channel.284 * Entries are split so each chunk's serialized entries map stays under285 * MAX_SNAPSHOT_CHUNK_BYTES; tombstones ride the final chunk. If the vault286 * needs more than MAX_SNAPSHOT_CHUNKS chunks, only the first that many are287 * sent and the fallback sync pipeline fills the remainder.288 */289export async function sendVaultSnapshot(290  channel: PairingTransport,291  entries: Record<string, string>,292  tombstones: Record<string, number>,293): Promise<void> {294  const chunks: Array<Record<string, string>> = [];295  let current: Record<string, string> = {};296  let currentBytes = 2; // "{}"297  for (const [id, envelope] of Object.entries(entries)) {298    // +1 for the separating comma; an overestimate only shrinks chunks.299    const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;300    if (301      Object.keys(current).length > 0 &&302      currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES303    ) {304      chunks.push(current);305      current = {};306      currentBytes = 2;307    }308    current[id] = envelope;309    currentBytes += entryBytes;310  }311  chunks.push(current);312313  const chunkCount = Math.min(chunks.length, MAX_SNAPSHOT_CHUNKS);314  for (let index = 0; index < chunkCount; index++) {315    const isFinal = index === chunkCount - 1;316    await sendFrame(channel, {317      type: "ADOPT_SNAPSHOT",318      chunk_index: index,319      chunk_count: chunkCount,320      entries: chunks[index],321      tombstones: isFinal ? tombstones : {},322    });323  }324}325326/**327 * Receiver side: receive and reassemble every snapshot chunk. Validates the328 * encoded frame size before parsing, then enforces sequential 0-based329 * chunk_index ordering, a stable chunk_count within MAX_SNAPSHOT_CHUNKS, and330 * structural validity of each frame. Throws on any validation failure so the331 * caller can fall back to the sync pipeline.332 */333export async function receiveVaultSnapshot(334  channel: PairingTransport,335): Promise<{ entries: Record<string, string>; tombstones: Record<string, number> }> {336  const entries: Record<string, string> = {};337  const tombstones: Record<string, number> = {};338  let chunkCount = -1;339  let received = 0;340  while (chunkCount === -1 || received < chunkCount) {341    const raw = await channel.receive();342    if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {343      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");344    }345    const frame: unknown = JSON.parse(new TextDecoder().decode(raw));346    if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");347    if (chunkCount === -1) {348      chunkCount = frame.chunk_count;349      if (chunkCount > MAX_SNAPSHOT_CHUNKS) {350        throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");351      }352    } else if (frame.chunk_count !== chunkCount) {353      throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");354    }355    if (frame.chunk_index !== received) {356      throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");357    }358    Object.assign(entries, frame.entries);359    Object.assign(tombstones, frame.tombstones);360    received++;361  }362  return { entries, tombstones };363}364365export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {366  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);367}368369export async function requestAdoption(370  channel: PairingTransport,371  deps: AdoptionReceiverDeps,372): Promise<PairedDevice | null> {373  const receiverLicense = await deps.license?.();374  await sendFrame(channel, {375    type: "ADOPT_REQUEST",376    device_id: deps.generateDeviceId(),377    device_name: deps.deviceName,378    device_type: "extension",379    license: receiverLicense,380    snapshot_capable: true,381  });382383  const confirmed = await deps.confirmSas();384  await sendFrame(channel, {385    type: "ADOPT_CONFIRM",386    confirmed,387  });388  if (!confirmed) {389    deps.onError("ADOPT_SAS_REJECTED");390    return null;391  }392393  const frame = await receiveFrame(channel);394  if (isAdoptReject(frame)) {395    deps.onError(frame.reason);396    return null;397  }398  if (!isAdoptGrant(frame)) {399    deps.onError("ADOPT_UNEXPECTED_FRAME");400    return null;401  }402403  const rootKeyBytes = hexToBytes(frame.root_key_hex);404  try {405    const computedVaultId = await computeVaultId(rootKeyBytes);406    if (computedVaultId !== frame.vault_id) {407      deps.onError("ADOPT_VAULT_ID_MISMATCH");408      return null;409    }410  } finally {411    rootKeyBytes.fill(0);412  }413  if (!receiverLicense && !frame.license) {414    deps.onError("ADOPT_PRO_REQUIRED");415    return null;416  }417418  await deps.validateSource?.(frame.source);419  const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);420  if (vaultId !== frame.vault_id) {421    deps.onError("ADOPT_VAULT_ID_MISMATCH");422    return null;423  }424425  if (!receiverLicense && frame.license) {426    await deps.applyLicense?.(frame.license);427  }428429  const identity = await deps.deriveLocalIdentity();430  await sendFrame(channel, {431    type: "ADOPT_IDENTITY",432    device_id: identity.deviceId,433    device_name: identity.deviceName,434    device_type: identity.deviceType,435    long_term_public_key_hex: identity.longTermPublicKeyHex,436    signing_public_key_hex: identity.signingPublicKeyHex,437  });438439  const completion = await receiveFrame(channel);440  if (!isAdoptComplete(completion)) {441    deps.onError("ADOPT_COMPLETION_MISSING");442    return null;443  }444445  const sourceDevice: PairedDevice = {446    deviceId: frame.source.device_id,447    deviceName: frame.source.device_name,448    deviceType: frame.source.device_type,449    longTermPublicKeyHex: frame.source.long_term_public_key_hex,450    signingPublicKeyHex: frame.source.signing_public_key_hex,451    trustSource: "direct",452    wsPort: frame.source.ws_port,453    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,454    relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,455    pairedAt: Date.now(),456  };457  await deps.upsertPairedDevice(sourceDevice);458  await sendFrame(channel, { type: "ADOPT_APPLIED" });459  return sourceDevice;460}461462export async function grantAdoption(463  channel: PairingTransport,464  request: AdoptRequest,465  deps: AdoptionSourceDeps,466): Promise<PairedDevice | null> {467  const approved = await deps.onConsent(request);468  if (!approved) return null;469470  const confirmation = await receiveFrame(channel);471  if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;472473  let sourceLicense = await deps.license?.();474  if (!sourceLicense && request.license) {475    try {476      if (!deps.applyLicense) throw new Error("license_transfer_unavailable");477      await deps.applyLicense(request.license);478      sourceLicense = await deps.license?.();479    } catch {480      sourceLicense = undefined;481    }482  }483  if (!sourceLicense) {484    await sendFrame(channel, {485      type: "ADOPT_REJECT",486      reason: "ADOPT_PRO_REQUIRED",487    });488    throw new Error("ADOPT_PRO_REQUIRED");489  }490491  const rootKeyHex = await deps.rootKeyHex();492  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");493494  await sendFrame(channel, {495    type: "ADOPT_GRANT",496    root_key_hex: rootKeyHex,497    vault_id: await deps.vaultId(),498    license: sourceLicense,499    source: {500      device_id: deps.sourceIdentity.deviceId,501      device_name: deps.sourceIdentity.deviceName,502      device_type: deps.sourceIdentity.deviceType,503      long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,504      signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,505      ws_port: deps.sourceIdentity.wsPort,506      relay_url: deps.pairState?.relayUrl,507    },508  });509510  const identity = await receiveFrame(channel);511  if (!isAdoptIdentity(identity)) return null;512513  const device: PairedDevice = {514    deviceId: identity.device_id,515    deviceName: identity.device_name,516    deviceType: identity.device_type,517    longTermPublicKeyHex: identity.long_term_public_key_hex,518    signingPublicKeyHex: identity.signing_public_key_hex,519    trustSource: "direct",520    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,521    relayUrl: deps.pairState?.relayUrl,522    pairedAt: Date.now(),523  };524  await deps.upsertPairedDevice(device);525  await sendFrame(channel, { type: "ADOPT_COMPLETE" });526  if (!isAdoptApplied(await receiveFrame(channel))) return null;527  return device;528}529530export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {531  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));532}533534export async function receiveFrame(channel: PairingTransport): Promise<unknown> {535  return JSON.parse(new TextDecoder().decode(await channel.receive()));536}537538function isDeviceType(value: unknown): value is DeviceType {539  return value === "extension" || value === "android" || value === "ios";540}541542function isSecureRelayUrl(value: string): boolean {543  try {544    const url = new URL(value);545    return url.protocol === "wss:" && !url.username && !url.password &&546      !url.search && !url.hash;547  } catch {548    return false;549  }550}551552function hexToBytes(hex: string): Uint8Array {553  const out = new Uint8Array(hex.length / 2);554  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);555  return out;556}