vault-adopt.ts

22.3 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  /**35   * ECHO: source reflects receiver's ADOPT_REQUEST.snapshot_capable here so36   * the receiver can skip the inline-snapshot wait when the source will not37   * send one (pre-C source, or explicit opt-out). Additive; absent = old source.38   */39  snapshot_capable?: boolean;40}4142export interface AdoptConfirm {43  type: "ADOPT_CONFIRM";44  confirmed: boolean;45}4647export interface AdoptIdentity {48  type: "ADOPT_IDENTITY";49  device_id: string;50  device_name: string;51  device_type: DeviceType;52  long_term_public_key_hex: string;53  signing_public_key_hex?: string;54}5556/**57 * Encrypted source-to-receiver acknowledgement sent only after the source has58 * durably recorded the receiver. Without it the receiver could report success59 * after sending ADOPT_IDENTITY even though the source never received it.60 */61export interface AdoptComplete {62  type: "ADOPT_COMPLETE";63}6465/** Receiver-to-source acknowledgement sent after the source record is durable. */66export interface AdoptApplied {67  type: "ADOPT_APPLIED";68}6970/** Neither peer could prove a Pro entitlement, so no vault state may commit. */71export interface AdoptReject {72  type: "ADOPT_REJECT";73  reason: "ADOPT_PRO_REQUIRED";74}7576/**77 * One chunk of the inline vault snapshot sent by the source after78 * ADOPT_APPLIED, over the same authenticated Noise NK channel. Entries are79 * opaque base64 ciphertext envelopes; tombstones ride the final chunk only.80 */81export interface AdoptSnapshot {82  type: "ADOPT_SNAPSHOT";83  chunk_index: number;84  chunk_count: number;85  entries: Record<string, string>;86  /** entryId → deletion timestamp; populated on the final chunk only. */87  tombstones: Record<string, number>;88  /**89   * Sealed duress (slot B) StoredVault as JSON.stringify of the browser90   * shape — identical to the Codex vault_b field. Populated on the final91   * chunk only, and only when duress is actually configured; absent otherwise.92   */93  duress_slot?: string;94}9596/** Receiver acknowledgement sent after all snapshot chunks are applied. */97export interface AdoptSnapshotApplied {98  type: "ADOPT_SNAPSHOT_APPLIED";99  entries_received: number;100  tombstones_received: number;101}102103/** Optional signed device graph appended after the snapshot (manifest v3+). */104export interface AdoptDeviceManifestFrame {105  type: "ADOPT_DEVICE_MANIFEST";106  manifest: SignedDeviceManifest;107}108109/** Receiver acknowledgement for an applied device manifest. */110export interface AdoptDeviceManifestApplied {111  type: "ADOPT_DEVICE_MANIFEST_APPLIED";112}113114export type AdoptionFrame =115  | AdoptRequest116  | AdoptConfirm117  | AdoptGrant118  | AdoptIdentity119  | AdoptComplete120  | AdoptApplied121  | AdoptReject122  | AdoptSnapshot123  | AdoptSnapshotApplied124  | AdoptDeviceManifestFrame125  | AdoptDeviceManifestApplied;126127/**128 * Wire guardrails for the inline snapshot. A chunk's serialized entries map129 * stays under MAX_SNAPSHOT_CHUNK_BYTES; the whole transfer is capped at130 * MAX_SNAPSHOT_CHUNKS frames so a compromised source cannot exhaust receiver131 * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator).132 */133export const MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024; // 512 KiB134export const MAX_SNAPSHOT_CHUNKS = 200;135// Headroom added to the per-chunk entries budget when validating an encoded136// frame on the wire: JSON envelope plus the final chunk's tombstone map.137const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;138139export interface AdoptionPairState {140  rendezvousSecretHex: string;141  relayUrl?: string;142}143144export interface AdoptionReceiverDeps {145  deviceName: string;146  generateDeviceId(): string;147  /** Root-key transfer cannot begin until this device confirms the SAS. */148  confirmSas(): Promise<boolean>;149  /**150   * Optional local safety policy evaluated after the authenticated grant is151   * validated but before any vault state changes.152   */153  validateSource?(source: AdoptGrant["source"]): Promise<void>;154  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;155  deriveLocalIdentity(): Promise<LocalIdentity>;156  upsertPairedDevice(device: PairedDevice): Promise<void>;157  applyLicense?(license: string): Promise<void>;158  /** Return this receiver's locally validated Pro key, if present. */159  license?(): Promise<string | undefined>;160  /** Bound to the same completed Noise session, never taken from frame claims. */161  pairState?: AdoptionPairState;162  onError(error: string): void;163}164165export interface AdoptionSourceDeps {166  sourceIdentity: LocalIdentity;167  rootKeyHex(): Promise<string>;168  vaultId(): Promise<string>;169  license?(): Promise<string | undefined>;170  /** Validate and durably store a Pro key offered by the receiver. */171  applyLicense?(license: string): Promise<void>;172  onConsent(request: AdoptRequest): Promise<boolean>;173  upsertPairedDevice(device: PairedDevice): Promise<void>;174  /** Bound to the same completed Noise session, never taken from frame claims. */175  pairState?: AdoptionPairState;176}177178export function isAdoptRequest(frame: unknown): frame is AdoptRequest {179  const value = frame as Partial<AdoptRequest> | null;180  return value?.type === "ADOPT_REQUEST" &&181    typeof value.device_id === "string" &&182    value.device_id.length > 0 &&183    typeof value.device_name === "string" &&184    isDeviceType(value.device_type) &&185    (value.license === undefined || typeof value.license === "string");186}187188export function isAdoptGrant(frame: unknown): frame is AdoptGrant {189  const value = frame as Partial<AdoptGrant> | null;190  return value?.type === "ADOPT_GRANT" &&191    validateRootKeyHex(value.root_key_hex) &&192    typeof value.vault_id === "string" &&193    /^[0-9a-f]{64}$/.test(value.vault_id) &&194    typeof value.source === "object" &&195    value.source !== null &&196    typeof value.source.device_id === "string" &&197    typeof value.source.device_name === "string" &&198    isDeviceType(value.source.device_type) &&199    typeof value.source.long_term_public_key_hex === "string" &&200    /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&201    (value.source.relay_url === undefined ||202      (typeof value.source.relay_url === "string" &&203        isSecureRelayUrl(value.source.relay_url))) &&204    (value.snapshot_capable === undefined ||205      typeof value.snapshot_capable === "boolean");206}207208export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {209  const value = frame as Partial<AdoptConfirm> | null;210  return value?.type === "ADOPT_CONFIRM" &&211    typeof value.confirmed === "boolean";212}213214export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {215  const value = frame as Partial<AdoptIdentity> | null;216  return value?.type === "ADOPT_IDENTITY" &&217    typeof value.device_id === "string" &&218    value.device_id.length > 0 &&219    typeof value.device_name === "string" &&220    isDeviceType(value.device_type) &&221    typeof value.long_term_public_key_hex === "string" &&222    /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);223}224225export function isAdoptComplete(frame: unknown): frame is AdoptComplete {226  return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";227}228229export function isAdoptApplied(frame: unknown): frame is AdoptApplied {230  return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";231}232233export function isAdoptReject(frame: unknown): frame is AdoptReject {234  const value = frame as Partial<AdoptReject> | null;235  return value?.type === "ADOPT_REJECT" &&236    value.reason === "ADOPT_PRO_REQUIRED";237}238239export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {240  const value = frame as Partial<AdoptSnapshot> | null;241  if (value?.type !== "ADOPT_SNAPSHOT") return false;242  if (243    typeof value.chunk_index !== "number" ||244    !Number.isInteger(value.chunk_index) ||245    value.chunk_index < 0246  ) return false;247  if (248    typeof value.chunk_count !== "number" ||249    !Number.isInteger(value.chunk_count) ||250    value.chunk_count < 1251  ) return false;252  if (!isStringRecord(value.entries)) return false;253  if (!isNumberRecord(value.tombstones)) return false;254  if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;255  return true;256}257258export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {259  const value = frame as Partial<AdoptSnapshotApplied> | null;260  return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&261    typeof value.entries_received === "number" &&262    typeof value.tombstones_received === "number";263}264265export function isAdoptDeviceManifestFrame(266  frame: unknown,267): frame is AdoptDeviceManifestFrame {268  const value = frame as Partial<AdoptDeviceManifestFrame> | null;269  const manifest = value?.manifest as270    | { body?: unknown; signatureHex?: unknown }271    | null;272  return value?.type === "ADOPT_DEVICE_MANIFEST" &&273    typeof manifest === "object" &&274    manifest !== null &&275    typeof manifest.body === "object" &&276    manifest.body !== null &&277    typeof manifest.signatureHex === "string";278}279280function isStringRecord(value: unknown): value is Record<string, string> {281  if (typeof value !== "object" || value === null) return false;282  return Object.values(value).every((v) => typeof v === "string");283}284285function isNumberRecord(value: unknown): value is Record<string, number> {286  if (typeof value !== "object" || value === null) return false;287  return Object.values(value).every((v) => typeof v === "number");288}289290/**291 * Source side: chunk the vault snapshot and send it over the pairing channel.292 *293 * Wire shape (v4.6.4+, duress on its own frame):294 *   - N entries frames, each ≤ MAX_SNAPSHOT_CHUNK_BYTES serialized entries.295 *     Tombstones ride the last entries frame when no duress follows.296 *   - If duressSlot is set, one extra frame after the entries carrying only297 *     the duress slot (entries: empty, tombstones: tombstone map,298 *     duress_slot: duressSlot). This moves duress off the entries frame so a299 *     sizeable sealed duress vault can no longer push the entries frame raw300 *     bytes over SNAPSHOT_FRAME_WIRE_CEILING (768 KiB).301 *302 * Frame budget (MAX_SNAPSHOT_CHUNKS = 200):303 *   - When duress is configured, ONE of the 200 frames is reserved for the304 *     duress slot. The entries cap is MAX_SNAPSHOT_CHUNKS - 1 = 199 entries305 *     chunks; excess entries are deferred to the sync fallback pipeline (sync306 *     carries entries and tombstones, so this is a graceful truncation).307 *     This reservation is REQUIRED: ordinary sync does NOT carry duress308 *     (vault.test.ts:158-179 test-locks duress as non-syncing), so silently309 *     dropping a duress frame at the cap would leave the adopted device310 *     permanently without its duress vault. We prioritize duress delivery311 *     over completeness of entries (the latter is recoverable via sync).312 *313 * Oversized duress: duress that itself exceeds SNAPSHOT_FRAME_WIRE_CEILING314 * (e.g. a ≥768 KiB sealed duress) is rejected with a thrown error BEFORE the315 * first duress frame is sent. This is a hard stop — the caller must split316 * the duress payload out of band. We do not silently truncate duress; sync317 * cannot restore it.318 *319 * Backward compat: pre-v4.6.4 senders put `duress_slot` on the entries320 * final chunk. Receivers tolerate both shapes because `receiveVaultSnapshot`321 * reads `duress_slot` off any chunk (a duress-only frame has empty322 * entries/tombstones which merge as a no-op).323 */324export async function sendVaultSnapshot(325  channel: PairingTransport,326  entries: Record<string, string>,327  tombstones: Record<string, number>,328  duressSlot: string | null = null,329): Promise<void> {330  const includeDuress = !!duressSlot;331  // Reserve one frame for duress when configured so duress is never silently332  // dropped at the cap; ordinary sync does NOT carry duress, so dropping a333  // duress frame would orphan the adopted device's duress vault.334  const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS;335336  const chunks: Array<Record<string, string>> = [];337  let current: Record<string, string> = {};338  let currentBytes = 2; // "{}"339  for (const [id, envelope] of Object.entries(entries)) {340    // +1 for the separating comma; an overestimate only shrinks chunks.341    const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;342    if (343      Object.keys(current).length > 0 &&344      currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES345    ) {346      chunks.push(current);347      current = {};348      currentBytes = 2;349    }350    current[id] = envelope;351    currentBytes += entryBytes;352  }353  chunks.push(current);354355  const entriesChunkCount = Math.min(chunks.length, entryCap);356  const sendDuress = includeDuress;357  const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);358  const duressFrame: AdoptSnapshot | null = sendDuress359    ? {360        type: "ADOPT_SNAPSHOT",361        chunk_index: entriesChunkCount,362        chunk_count: totalChunks,363        entries: {},364        tombstones,365        duress_slot: duressSlot as string,366      }367    : null;368369  if (370    duressFrame &&371    new TextEncoder().encode(JSON.stringify(duressFrame)).length >372      SNAPSHOT_FRAME_WIRE_CEILING373  ) {374    throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");375  }376377  for (let index = 0; index < entriesChunkCount; index++) {378    const isFinalEntries = index === entriesChunkCount - 1;379    const frame: AdoptSnapshot = {380      type: "ADOPT_SNAPSHOT",381      chunk_index: index,382      chunk_count: totalChunks,383      entries: chunks[index],384      // Tombstones ride the LAST wire frame: when duress follows the entries,385      // it goes on the duress frame; otherwise on the last entries chunk.386      tombstones: isFinalEntries && !sendDuress ? tombstones : {},387    };388    await sendFrame(channel, frame);389  }390391  if (duressFrame) await sendFrame(channel, duressFrame);392}393394/**395 * Receiver side: receive and reassemble every snapshot chunk. Validates the396 * encoded frame size before parsing, then enforces sequential 0-based397 * chunk_index ordering, a stable chunk_count within MAX_SNAPSHOT_CHUNKS, and398 * structural validity of each frame. Throws on any validation failure so the399 * caller can fall back to the sync pipeline.400 */401export async function receiveVaultSnapshot(402  channel: PairingTransport,403): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {404  const entries: Record<string, string> = {};405  const tombstones: Record<string, number> = {};406  let duressSlot: string | null = null;407  let chunkCount = -1;408  let received = 0;409  while (chunkCount === -1 || received < chunkCount) {410    const raw = await channel.receive();411    if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {412      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");413    }414    const frame: unknown = JSON.parse(new TextDecoder().decode(raw));415    if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");416    if (chunkCount === -1) {417      chunkCount = frame.chunk_count;418      if (chunkCount > MAX_SNAPSHOT_CHUNKS) {419        throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");420      }421    } else if (frame.chunk_count !== chunkCount) {422      throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");423    }424    if (frame.chunk_index !== received) {425      throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");426    }427    Object.assign(entries, frame.entries);428    Object.assign(tombstones, frame.tombstones);429    if (frame.duress_slot) duressSlot = frame.duress_slot;430    received++;431  }432  return { entries, tombstones, duressSlot };433}434435export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {436  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);437}438439export interface AdoptionRequestResult {440  device: PairedDevice;441  /** True iff the source echoed snapshot_capable: true in its ADOPT_GRANT. */442  snapshotCapable: boolean;443}444445export async function requestAdoption(446  channel: PairingTransport,447  deps: AdoptionReceiverDeps,448): Promise<AdoptionRequestResult | null> {449  const receiverLicense = await deps.license?.();450  await sendFrame(channel, {451    type: "ADOPT_REQUEST",452    device_id: deps.generateDeviceId(),453    device_name: deps.deviceName,454    device_type: "extension",455    license: receiverLicense,456    snapshot_capable: true,457  });458459  const confirmed = await deps.confirmSas();460  await sendFrame(channel, {461    type: "ADOPT_CONFIRM",462    confirmed,463  });464  if (!confirmed) {465    deps.onError("ADOPT_SAS_REJECTED");466    return null;467  }468469  const frame = await receiveFrame(channel);470  if (isAdoptReject(frame)) {471    deps.onError(frame.reason);472    return null;473  }474  if (!isAdoptGrant(frame)) {475    deps.onError("ADOPT_UNEXPECTED_FRAME");476    return null;477  }478  const grantSnapshotCapable = frame.snapshot_capable === true;479480  const rootKeyBytes = hexToBytes(frame.root_key_hex);481  try {482    const computedVaultId = await computeVaultId(rootKeyBytes);483    if (computedVaultId !== frame.vault_id) {484      deps.onError("ADOPT_VAULT_ID_MISMATCH");485      return null;486    }487  } finally {488    rootKeyBytes.fill(0);489  }490  if (!receiverLicense && !frame.license) {491    deps.onError("ADOPT_PRO_REQUIRED");492    return null;493  }494495  await deps.validateSource?.(frame.source);496  const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);497  if (vaultId !== frame.vault_id) {498    deps.onError("ADOPT_VAULT_ID_MISMATCH");499    return null;500  }501502  if (!receiverLicense && frame.license) {503    await deps.applyLicense?.(frame.license);504  }505506  const identity = await deps.deriveLocalIdentity();507  await sendFrame(channel, {508    type: "ADOPT_IDENTITY",509    device_id: identity.deviceId,510    device_name: identity.deviceName,511    device_type: identity.deviceType,512    long_term_public_key_hex: identity.longTermPublicKeyHex,513    signing_public_key_hex: identity.signingPublicKeyHex,514  });515516  const completion = await receiveFrame(channel);517  if (!isAdoptComplete(completion)) {518    deps.onError("ADOPT_COMPLETION_MISSING");519    return null;520  }521522  const sourceDevice: PairedDevice = {523    deviceId: frame.source.device_id,524    deviceName: frame.source.device_name,525    deviceType: frame.source.device_type,526    longTermPublicKeyHex: frame.source.long_term_public_key_hex,527    signingPublicKeyHex: frame.source.signing_public_key_hex,528    trustSource: "direct",529    wsPort: frame.source.ws_port,530    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,531    relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,532    pairedAt: Date.now(),533  };534  await deps.upsertPairedDevice(sourceDevice);535  await sendFrame(channel, { type: "ADOPT_APPLIED" });536  return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };537}538539export async function grantAdoption(540  channel: PairingTransport,541  request: AdoptRequest,542  deps: AdoptionSourceDeps,543): Promise<PairedDevice | null> {544  const approved = await deps.onConsent(request);545  if (!approved) return null;546547  const confirmation = await receiveFrame(channel);548  if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;549550  let sourceLicense = await deps.license?.();551  if (!sourceLicense && request.license) {552    try {553      if (!deps.applyLicense) throw new Error("license_transfer_unavailable");554      await deps.applyLicense(request.license);555      sourceLicense = await deps.license?.();556    } catch {557      sourceLicense = undefined;558    }559  }560  if (!sourceLicense) {561    await sendFrame(channel, {562      type: "ADOPT_REJECT",563      reason: "ADOPT_PRO_REQUIRED",564    });565    throw new Error("ADOPT_PRO_REQUIRED");566  }567568  const rootKeyHex = await deps.rootKeyHex();569  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");570571  await sendFrame(channel, {572    type: "ADOPT_GRANT",573    root_key_hex: rootKeyHex,574    vault_id: await deps.vaultId(),575    license: sourceLicense,576    source: {577      device_id: deps.sourceIdentity.deviceId,578      device_name: deps.sourceIdentity.deviceName,579      device_type: deps.sourceIdentity.deviceType,580      long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,581      signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,582      ws_port: deps.sourceIdentity.wsPort,583      relay_url: deps.pairState?.relayUrl,584    },585    snapshot_capable: request.snapshot_capable === true,586  });587588  const identity = await receiveFrame(channel);589  if (!isAdoptIdentity(identity)) return null;590591  const device: PairedDevice = {592    deviceId: identity.device_id,593    deviceName: identity.device_name,594    deviceType: identity.device_type,595    longTermPublicKeyHex: identity.long_term_public_key_hex,596    signingPublicKeyHex: identity.signing_public_key_hex,597    trustSource: "direct",598    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,599    relayUrl: deps.pairState?.relayUrl,600    pairedAt: Date.now(),601  };602  await deps.upsertPairedDevice(device);603  await sendFrame(channel, { type: "ADOPT_COMPLETE" });604  if (!isAdoptApplied(await receiveFrame(channel))) return null;605  return device;606}607608export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {609  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));610}611612export async function receiveFrame(channel: PairingTransport): Promise<unknown> {613  return JSON.parse(new TextDecoder().decode(await channel.receive()));614}615616function isDeviceType(value: unknown): value is DeviceType {617  return value === "extension" || value === "android" || value === "ios";618}619620function isSecureRelayUrl(value: string): boolean {621  try {622    const url = new URL(value);623    return url.protocol === "wss:" && !url.username && !url.password &&624      !url.search && !url.hash;625  } catch {626    return false;627  }628}629630function hexToBytes(hex: string): Uint8Array {631  const out = new Uint8Array(hex.length / 2);632  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);633  return out;634}