vault-adopt.ts

21.4 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}279280export function isAdoptDeviceManifestApplied(281  frame: unknown,282): frame is AdoptDeviceManifestApplied {283  return (frame as Partial<AdoptDeviceManifestApplied> | null)?.type ===284    "ADOPT_DEVICE_MANIFEST_APPLIED";285}286287function isStringRecord(value: unknown): value is Record<string, string> {288  if (typeof value !== "object" || value === null) return false;289  return Object.values(value).every((v) => typeof v === "string");290}291292function isNumberRecord(value: unknown): value is Record<string, number> {293  if (typeof value !== "object" || value === null) return false;294  return Object.values(value).every((v) => typeof v === "number");295}296297/**298 * Source side: chunk the vault snapshot and send it over the pairing channel.299 *300 * Wire shape (v4.6.4+, duress on its own frame):301 *   - N entries frames, each ≤ MAX_SNAPSHOT_CHUNK_BYTES serialized entries.302 *     Tombstones ride the last entries frame when no duress follows.303 *   - If duressSlot is set, one extra frame after the entries carrying only304 *     the duress slot (entries: {}, tombstones: tombstone map, duress_slot).305 *     This moves duress off the entries frame so a sizeable sealed duress306 *     vault can no longer push the entries frame raw bytes over307 *     SNAPSHOT_FRAME_WIRE_CEILING (768 KiB).308 *309 * Backward compat: pre-v4.6.4 senders put `duress_slot` on the entries final310 * chunk. Receivers tolerate both shapes because `receiveVaultSnapshot` reads311 * `duress_slot` off any chunk (a duress-only frame has empty312 * entries/tombstones which merge as a no-op).313 *314 * If the vault needs more than MAX_SNAPSHOT_CHUNKS - 1 entries chunks (i.e.315 * the duress frame would push chunk_count over the 200-chunk ceiling), the316 * duress frame is dropped and the fallback sync pipeline fills the duress317 * slot later (graceful degradation, never blocks adoption).318 */319export async function sendVaultSnapshot(320  channel: PairingTransport,321  entries: Record<string, string>,322  tombstones: Record<string, number>,323  duressSlot: string | null = null,324): Promise<void> {325  const chunks: Array<Record<string, string>> = [];326  let current: Record<string, string> = {};327  let currentBytes = 2; // "{}"328  for (const [id, envelope] of Object.entries(entries)) {329    // +1 for the separating comma; an overestimate only shrinks chunks.330    const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;331    if (332      Object.keys(current).length > 0 &&333      currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES334    ) {335      chunks.push(current);336      current = {};337      currentBytes = 2;338    }339    current[id] = envelope;340    currentBytes += entryBytes;341  }342  chunks.push(current);343344  const entriesChunkCount = Math.min(chunks.length, MAX_SNAPSHOT_CHUNKS);345  const includeDuress = !!duressSlot;346  // ponytail: cap total chunks to MAX_SNAPSHOT_CHUNKS; if duress would push347  // us over, drop the duress frame and let sync fill the duress slot later.348  const duressFits = entriesChunkCount < MAX_SNAPSHOT_CHUNKS;349  const sendDuress = includeDuress && duressFits;350  const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);351352  for (let index = 0; index < entriesChunkCount; index++) {353    const isFinalEntries = index === entriesChunkCount - 1;354    const frame: AdoptSnapshot = {355      type: "ADOPT_SNAPSHOT",356      chunk_index: index,357      chunk_count: totalChunks,358      entries: chunks[index],359      // Tombstones ride the LAST wire frame: when duress follows the entries,360      // it goes on the duress frame; otherwise on the last entries chunk.361      tombstones: isFinalEntries && !sendDuress ? tombstones : {},362    };363    await sendFrame(channel, frame);364  }365366  if (sendDuress) {367    await sendFrame(channel, {368      type: "ADOPT_SNAPSHOT",369      chunk_index: entriesChunkCount,370      chunk_count: totalChunks,371      entries: {},372      tombstones,373      duress_slot: duressSlot as string,374    });375  }376}377378/**379 * Receiver side: receive and reassemble every snapshot chunk. Validates the380 * encoded frame size before parsing, then enforces sequential 0-based381 * chunk_index ordering, a stable chunk_count within MAX_SNAPSHOT_CHUNKS, and382 * structural validity of each frame. Throws on any validation failure so the383 * caller can fall back to the sync pipeline.384 */385export async function receiveVaultSnapshot(386  channel: PairingTransport,387): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {388  const entries: Record<string, string> = {};389  const tombstones: Record<string, number> = {};390  let duressSlot: string | null = null;391  let chunkCount = -1;392  let received = 0;393  while (chunkCount === -1 || received < chunkCount) {394    const raw = await channel.receive();395    if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {396      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");397    }398    const frame: unknown = JSON.parse(new TextDecoder().decode(raw));399    if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");400    if (chunkCount === -1) {401      chunkCount = frame.chunk_count;402      if (chunkCount > MAX_SNAPSHOT_CHUNKS) {403        throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");404      }405    } else if (frame.chunk_count !== chunkCount) {406      throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");407    }408    if (frame.chunk_index !== received) {409      throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");410    }411    Object.assign(entries, frame.entries);412    Object.assign(tombstones, frame.tombstones);413    if (frame.duress_slot) duressSlot = frame.duress_slot;414    received++;415  }416  return { entries, tombstones, duressSlot };417}418419export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {420  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);421}422423export interface AdoptionRequestResult {424  device: PairedDevice;425  /** True iff the source echoed snapshot_capable: true in its ADOPT_GRANT. */426  snapshotCapable: boolean;427}428429export async function requestAdoption(430  channel: PairingTransport,431  deps: AdoptionReceiverDeps,432): Promise<AdoptionRequestResult | null> {433  const receiverLicense = await deps.license?.();434  await sendFrame(channel, {435    type: "ADOPT_REQUEST",436    device_id: deps.generateDeviceId(),437    device_name: deps.deviceName,438    device_type: "extension",439    license: receiverLicense,440    snapshot_capable: true,441  });442443  const confirmed = await deps.confirmSas();444  await sendFrame(channel, {445    type: "ADOPT_CONFIRM",446    confirmed,447  });448  if (!confirmed) {449    deps.onError("ADOPT_SAS_REJECTED");450    return null;451  }452453  const frame = await receiveFrame(channel);454  if (isAdoptReject(frame)) {455    deps.onError(frame.reason);456    return null;457  }458  if (!isAdoptGrant(frame)) {459    deps.onError("ADOPT_UNEXPECTED_FRAME");460    return null;461  }462  const grantSnapshotCapable = frame.snapshot_capable === true;463464  const rootKeyBytes = hexToBytes(frame.root_key_hex);465  try {466    const computedVaultId = await computeVaultId(rootKeyBytes);467    if (computedVaultId !== frame.vault_id) {468      deps.onError("ADOPT_VAULT_ID_MISMATCH");469      return null;470    }471  } finally {472    rootKeyBytes.fill(0);473  }474  if (!receiverLicense && !frame.license) {475    deps.onError("ADOPT_PRO_REQUIRED");476    return null;477  }478479  await deps.validateSource?.(frame.source);480  const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);481  if (vaultId !== frame.vault_id) {482    deps.onError("ADOPT_VAULT_ID_MISMATCH");483    return null;484  }485486  if (!receiverLicense && frame.license) {487    await deps.applyLicense?.(frame.license);488  }489490  const identity = await deps.deriveLocalIdentity();491  await sendFrame(channel, {492    type: "ADOPT_IDENTITY",493    device_id: identity.deviceId,494    device_name: identity.deviceName,495    device_type: identity.deviceType,496    long_term_public_key_hex: identity.longTermPublicKeyHex,497    signing_public_key_hex: identity.signingPublicKeyHex,498  });499500  const completion = await receiveFrame(channel);501  if (!isAdoptComplete(completion)) {502    deps.onError("ADOPT_COMPLETION_MISSING");503    return null;504  }505506  const sourceDevice: PairedDevice = {507    deviceId: frame.source.device_id,508    deviceName: frame.source.device_name,509    deviceType: frame.source.device_type,510    longTermPublicKeyHex: frame.source.long_term_public_key_hex,511    signingPublicKeyHex: frame.source.signing_public_key_hex,512    trustSource: "direct",513    wsPort: frame.source.ws_port,514    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,515    relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,516    pairedAt: Date.now(),517  };518  await deps.upsertPairedDevice(sourceDevice);519  await sendFrame(channel, { type: "ADOPT_APPLIED" });520  return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };521}522523export async function grantAdoption(524  channel: PairingTransport,525  request: AdoptRequest,526  deps: AdoptionSourceDeps,527): Promise<PairedDevice | null> {528  const approved = await deps.onConsent(request);529  if (!approved) return null;530531  const confirmation = await receiveFrame(channel);532  if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;533534  let sourceLicense = await deps.license?.();535  if (!sourceLicense && request.license) {536    try {537      if (!deps.applyLicense) throw new Error("license_transfer_unavailable");538      await deps.applyLicense(request.license);539      sourceLicense = await deps.license?.();540    } catch {541      sourceLicense = undefined;542    }543  }544  if (!sourceLicense) {545    await sendFrame(channel, {546      type: "ADOPT_REJECT",547      reason: "ADOPT_PRO_REQUIRED",548    });549    throw new Error("ADOPT_PRO_REQUIRED");550  }551552  const rootKeyHex = await deps.rootKeyHex();553  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");554555  await sendFrame(channel, {556    type: "ADOPT_GRANT",557    root_key_hex: rootKeyHex,558    vault_id: await deps.vaultId(),559    license: sourceLicense,560    source: {561      device_id: deps.sourceIdentity.deviceId,562      device_name: deps.sourceIdentity.deviceName,563      device_type: deps.sourceIdentity.deviceType,564      long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,565      signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,566      ws_port: deps.sourceIdentity.wsPort,567      relay_url: deps.pairState?.relayUrl,568    },569    snapshot_capable: request.snapshot_capable === true,570  });571572  const identity = await receiveFrame(channel);573  if (!isAdoptIdentity(identity)) return null;574575  const device: PairedDevice = {576    deviceId: identity.device_id,577    deviceName: identity.device_name,578    deviceType: identity.device_type,579    longTermPublicKeyHex: identity.long_term_public_key_hex,580    signingPublicKeyHex: identity.signing_public_key_hex,581    trustSource: "direct",582    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,583    relayUrl: deps.pairState?.relayUrl,584    pairedAt: Date.now(),585  };586  await deps.upsertPairedDevice(device);587  await sendFrame(channel, { type: "ADOPT_COMPLETE" });588  if (!isAdoptApplied(await receiveFrame(channel))) return null;589  return device;590}591592export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {593  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));594}595596export async function receiveFrame(channel: PairingTransport): Promise<unknown> {597  return JSON.parse(new TextDecoder().decode(await channel.receive()));598}599600function isDeviceType(value: unknown): value is DeviceType {601  return value === "extension" || value === "android" || value === "ios";602}603604function isSecureRelayUrl(value: string): boolean {605  try {606    const url = new URL(value);607    return url.protocol === "wss:" && !url.username && !url.password &&608      !url.search && !url.hash;609  } catch {610    return false;611  }612}613614function hexToBytes(hex: string): Uint8Array {615  const out = new Uint8Array(hex.length / 2);616  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);617  return out;618}