vault-adopt.ts

19.8 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 * Entries are split so each chunk's serialized entries map stays under300 * MAX_SNAPSHOT_CHUNK_BYTES; tombstones ride the final chunk. If the vault301 * needs more than MAX_SNAPSHOT_CHUNKS chunks, only the first that many are302 * sent and the fallback sync pipeline fills the remainder.303 */304export async function sendVaultSnapshot(305  channel: PairingTransport,306  entries: Record<string, string>,307  tombstones: Record<string, number>,308  duressSlot: string | null = null,309): Promise<void> {310  const chunks: Array<Record<string, string>> = [];311  let current: Record<string, string> = {};312  let currentBytes = 2; // "{}"313  for (const [id, envelope] of Object.entries(entries)) {314    // +1 for the separating comma; an overestimate only shrinks chunks.315    const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;316    if (317      Object.keys(current).length > 0 &&318      currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES319    ) {320      chunks.push(current);321      current = {};322      currentBytes = 2;323    }324    current[id] = envelope;325    currentBytes += entryBytes;326  }327  chunks.push(current);328329  const chunkCount = Math.min(chunks.length, MAX_SNAPSHOT_CHUNKS);330  for (let index = 0; index < chunkCount; index++) {331    const isFinal = index === chunkCount - 1;332    const frame: AdoptSnapshot = {333      type: "ADOPT_SNAPSHOT",334      chunk_index: index,335      chunk_count: chunkCount,336      entries: chunks[index],337      tombstones: isFinal ? tombstones : {},338    };339    if (isFinal && duressSlot) {340      frame.duress_slot = duressSlot;341    }342    await sendFrame(channel, frame);343  }344}345346/**347 * Receiver side: receive and reassemble every snapshot chunk. Validates the348 * encoded frame size before parsing, then enforces sequential 0-based349 * chunk_index ordering, a stable chunk_count within MAX_SNAPSHOT_CHUNKS, and350 * structural validity of each frame. Throws on any validation failure so the351 * caller can fall back to the sync pipeline.352 */353export async function receiveVaultSnapshot(354  channel: PairingTransport,355): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {356  const entries: Record<string, string> = {};357  const tombstones: Record<string, number> = {};358  let duressSlot: string | null = null;359  let chunkCount = -1;360  let received = 0;361  while (chunkCount === -1 || received < chunkCount) {362    const raw = await channel.receive();363    if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {364      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");365    }366    const frame: unknown = JSON.parse(new TextDecoder().decode(raw));367    if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");368    if (chunkCount === -1) {369      chunkCount = frame.chunk_count;370      if (chunkCount > MAX_SNAPSHOT_CHUNKS) {371        throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");372      }373    } else if (frame.chunk_count !== chunkCount) {374      throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");375    }376    if (frame.chunk_index !== received) {377      throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");378    }379    Object.assign(entries, frame.entries);380    Object.assign(tombstones, frame.tombstones);381    if (frame.duress_slot) duressSlot = frame.duress_slot;382    received++;383  }384  return { entries, tombstones, duressSlot };385}386387export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {388  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);389}390391export interface AdoptionRequestResult {392  device: PairedDevice;393  /** True iff the source echoed snapshot_capable: true in its ADOPT_GRANT. */394  snapshotCapable: boolean;395}396397export async function requestAdoption(398  channel: PairingTransport,399  deps: AdoptionReceiverDeps,400): Promise<AdoptionRequestResult | null> {401  const receiverLicense = await deps.license?.();402  await sendFrame(channel, {403    type: "ADOPT_REQUEST",404    device_id: deps.generateDeviceId(),405    device_name: deps.deviceName,406    device_type: "extension",407    license: receiverLicense,408    snapshot_capable: true,409  });410411  const confirmed = await deps.confirmSas();412  await sendFrame(channel, {413    type: "ADOPT_CONFIRM",414    confirmed,415  });416  if (!confirmed) {417    deps.onError("ADOPT_SAS_REJECTED");418    return null;419  }420421  const frame = await receiveFrame(channel);422  if (isAdoptReject(frame)) {423    deps.onError(frame.reason);424    return null;425  }426  if (!isAdoptGrant(frame)) {427    deps.onError("ADOPT_UNEXPECTED_FRAME");428    return null;429  }430  const grantSnapshotCapable = frame.snapshot_capable === true;431432  const rootKeyBytes = hexToBytes(frame.root_key_hex);433  try {434    const computedVaultId = await computeVaultId(rootKeyBytes);435    if (computedVaultId !== frame.vault_id) {436      deps.onError("ADOPT_VAULT_ID_MISMATCH");437      return null;438    }439  } finally {440    rootKeyBytes.fill(0);441  }442  if (!receiverLicense && !frame.license) {443    deps.onError("ADOPT_PRO_REQUIRED");444    return null;445  }446447  await deps.validateSource?.(frame.source);448  const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);449  if (vaultId !== frame.vault_id) {450    deps.onError("ADOPT_VAULT_ID_MISMATCH");451    return null;452  }453454  if (!receiverLicense && frame.license) {455    await deps.applyLicense?.(frame.license);456  }457458  const identity = await deps.deriveLocalIdentity();459  await sendFrame(channel, {460    type: "ADOPT_IDENTITY",461    device_id: identity.deviceId,462    device_name: identity.deviceName,463    device_type: identity.deviceType,464    long_term_public_key_hex: identity.longTermPublicKeyHex,465    signing_public_key_hex: identity.signingPublicKeyHex,466  });467468  const completion = await receiveFrame(channel);469  if (!isAdoptComplete(completion)) {470    deps.onError("ADOPT_COMPLETION_MISSING");471    return null;472  }473474  const sourceDevice: PairedDevice = {475    deviceId: frame.source.device_id,476    deviceName: frame.source.device_name,477    deviceType: frame.source.device_type,478    longTermPublicKeyHex: frame.source.long_term_public_key_hex,479    signingPublicKeyHex: frame.source.signing_public_key_hex,480    trustSource: "direct",481    wsPort: frame.source.ws_port,482    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,483    relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,484    pairedAt: Date.now(),485  };486  await deps.upsertPairedDevice(sourceDevice);487  await sendFrame(channel, { type: "ADOPT_APPLIED" });488  return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };489}490491export async function grantAdoption(492  channel: PairingTransport,493  request: AdoptRequest,494  deps: AdoptionSourceDeps,495): Promise<PairedDevice | null> {496  const approved = await deps.onConsent(request);497  if (!approved) return null;498499  const confirmation = await receiveFrame(channel);500  if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;501502  let sourceLicense = await deps.license?.();503  if (!sourceLicense && request.license) {504    try {505      if (!deps.applyLicense) throw new Error("license_transfer_unavailable");506      await deps.applyLicense(request.license);507      sourceLicense = await deps.license?.();508    } catch {509      sourceLicense = undefined;510    }511  }512  if (!sourceLicense) {513    await sendFrame(channel, {514      type: "ADOPT_REJECT",515      reason: "ADOPT_PRO_REQUIRED",516    });517    throw new Error("ADOPT_PRO_REQUIRED");518  }519520  const rootKeyHex = await deps.rootKeyHex();521  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");522523  await sendFrame(channel, {524    type: "ADOPT_GRANT",525    root_key_hex: rootKeyHex,526    vault_id: await deps.vaultId(),527    license: sourceLicense,528    source: {529      device_id: deps.sourceIdentity.deviceId,530      device_name: deps.sourceIdentity.deviceName,531      device_type: deps.sourceIdentity.deviceType,532      long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,533      signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,534      ws_port: deps.sourceIdentity.wsPort,535      relay_url: deps.pairState?.relayUrl,536    },537    snapshot_capable: request.snapshot_capable === true,538  });539540  const identity = await receiveFrame(channel);541  if (!isAdoptIdentity(identity)) return null;542543  const device: PairedDevice = {544    deviceId: identity.device_id,545    deviceName: identity.device_name,546    deviceType: identity.device_type,547    longTermPublicKeyHex: identity.long_term_public_key_hex,548    signingPublicKeyHex: identity.signing_public_key_hex,549    trustSource: "direct",550    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,551    relayUrl: deps.pairState?.relayUrl,552    pairedAt: Date.now(),553  };554  await deps.upsertPairedDevice(device);555  await sendFrame(channel, { type: "ADOPT_COMPLETE" });556  if (!isAdoptApplied(await receiveFrame(channel))) return null;557  return device;558}559560export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {561  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));562}563564export async function receiveFrame(channel: PairingTransport): Promise<unknown> {565  return JSON.parse(new TextDecoder().decode(await channel.receive()));566}567568function isDeviceType(value: unknown): value is DeviceType {569  return value === "extension" || value === "android" || value === "ios";570}571572function isSecureRelayUrl(value: string): boolean {573  try {574    const url = new URL(value);575    return url.protocol === "wss:" && !url.username && !url.password &&576      !url.search && !url.hash;577  } catch {578    return false;579  }580}581582function hexToBytes(hex: string): Uint8Array {583  const out = new Uint8Array(hex.length / 2);584  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);585  return out;586}