vault-adopt.ts

19.1 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  /**83   * Sealed duress (slot B) StoredVault as JSON.stringify of the browser84   * shape — identical to the Codex vault_b field. Populated on the final85   * chunk only, and only when duress is actually configured; absent otherwise.86   */87  duress_slot?: string;88}8990/** Receiver acknowledgement sent after all snapshot chunks are applied. */91export interface AdoptSnapshotApplied {92  type: "ADOPT_SNAPSHOT_APPLIED";93  entries_received: number;94  tombstones_received: number;95}9697/** Optional signed device graph appended after the snapshot (manifest v3+). */98export interface AdoptDeviceManifestFrame {99  type: "ADOPT_DEVICE_MANIFEST";100  manifest: SignedDeviceManifest;101}102103/** Receiver acknowledgement for an applied device manifest. */104export interface AdoptDeviceManifestApplied {105  type: "ADOPT_DEVICE_MANIFEST_APPLIED";106}107108export type AdoptionFrame =109  | AdoptRequest110  | AdoptConfirm111  | AdoptGrant112  | AdoptIdentity113  | AdoptComplete114  | AdoptApplied115  | AdoptReject116  | AdoptSnapshot117  | AdoptSnapshotApplied118  | AdoptDeviceManifestFrame119  | AdoptDeviceManifestApplied;120121/**122 * Wire guardrails for the inline snapshot. A chunk's serialized entries map123 * stays under MAX_SNAPSHOT_CHUNK_BYTES; the whole transfer is capped at124 * MAX_SNAPSHOT_CHUNKS frames so a compromised source cannot exhaust receiver125 * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator).126 */127export const MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024; // 512 KiB128export const MAX_SNAPSHOT_CHUNKS = 200;129// Headroom added to the per-chunk entries budget when validating an encoded130// frame on the wire: JSON envelope plus the final chunk's tombstone map.131const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;132133export interface AdoptionPairState {134  rendezvousSecretHex: string;135  relayUrl?: string;136}137138export interface AdoptionReceiverDeps {139  deviceName: string;140  generateDeviceId(): string;141  /** Root-key transfer cannot begin until this device confirms the SAS. */142  confirmSas(): Promise<boolean>;143  /**144   * Optional local safety policy evaluated after the authenticated grant is145   * validated but before any vault state changes.146   */147  validateSource?(source: AdoptGrant["source"]): Promise<void>;148  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;149  deriveLocalIdentity(): Promise<LocalIdentity>;150  upsertPairedDevice(device: PairedDevice): Promise<void>;151  applyLicense?(license: string): Promise<void>;152  /** Return this receiver's locally validated Pro key, if present. */153  license?(): Promise<string | undefined>;154  /** Bound to the same completed Noise session, never taken from frame claims. */155  pairState?: AdoptionPairState;156  onError(error: string): void;157}158159export interface AdoptionSourceDeps {160  sourceIdentity: LocalIdentity;161  rootKeyHex(): Promise<string>;162  vaultId(): Promise<string>;163  license?(): Promise<string | undefined>;164  /** Validate and durably store a Pro key offered by the receiver. */165  applyLicense?(license: string): Promise<void>;166  onConsent(request: AdoptRequest): Promise<boolean>;167  upsertPairedDevice(device: PairedDevice): Promise<void>;168  /** Bound to the same completed Noise session, never taken from frame claims. */169  pairState?: AdoptionPairState;170}171172export function isAdoptRequest(frame: unknown): frame is AdoptRequest {173  const value = frame as Partial<AdoptRequest> | null;174  return value?.type === "ADOPT_REQUEST" &&175    typeof value.device_id === "string" &&176    value.device_id.length > 0 &&177    typeof value.device_name === "string" &&178    isDeviceType(value.device_type) &&179    (value.license === undefined || typeof value.license === "string");180}181182export function isAdoptGrant(frame: unknown): frame is AdoptGrant {183  const value = frame as Partial<AdoptGrant> | null;184  return value?.type === "ADOPT_GRANT" &&185    validateRootKeyHex(value.root_key_hex) &&186    typeof value.vault_id === "string" &&187    /^[0-9a-f]{64}$/.test(value.vault_id) &&188    typeof value.source === "object" &&189    value.source !== null &&190    typeof value.source.device_id === "string" &&191    typeof value.source.device_name === "string" &&192    isDeviceType(value.source.device_type) &&193    typeof value.source.long_term_public_key_hex === "string" &&194    /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&195    (value.source.relay_url === undefined ||196      (typeof value.source.relay_url === "string" &&197        isSecureRelayUrl(value.source.relay_url)));198}199200export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {201  const value = frame as Partial<AdoptConfirm> | null;202  return value?.type === "ADOPT_CONFIRM" &&203    typeof value.confirmed === "boolean";204}205206export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {207  const value = frame as Partial<AdoptIdentity> | null;208  return value?.type === "ADOPT_IDENTITY" &&209    typeof value.device_id === "string" &&210    value.device_id.length > 0 &&211    typeof value.device_name === "string" &&212    isDeviceType(value.device_type) &&213    typeof value.long_term_public_key_hex === "string" &&214    /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);215}216217export function isAdoptComplete(frame: unknown): frame is AdoptComplete {218  return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";219}220221export function isAdoptApplied(frame: unknown): frame is AdoptApplied {222  return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";223}224225export function isAdoptReject(frame: unknown): frame is AdoptReject {226  const value = frame as Partial<AdoptReject> | null;227  return value?.type === "ADOPT_REJECT" &&228    value.reason === "ADOPT_PRO_REQUIRED";229}230231export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {232  const value = frame as Partial<AdoptSnapshot> | null;233  if (value?.type !== "ADOPT_SNAPSHOT") return false;234  if (235    typeof value.chunk_index !== "number" ||236    !Number.isInteger(value.chunk_index) ||237    value.chunk_index < 0238  ) return false;239  if (240    typeof value.chunk_count !== "number" ||241    !Number.isInteger(value.chunk_count) ||242    value.chunk_count < 1243  ) return false;244  if (!isStringRecord(value.entries)) return false;245  if (!isNumberRecord(value.tombstones)) return false;246  if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;247  return true;248}249250export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {251  const value = frame as Partial<AdoptSnapshotApplied> | null;252  return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&253    typeof value.entries_received === "number" &&254    typeof value.tombstones_received === "number";255}256257export function isAdoptDeviceManifestFrame(258  frame: unknown,259): frame is AdoptDeviceManifestFrame {260  const value = frame as Partial<AdoptDeviceManifestFrame> | null;261  const manifest = value?.manifest as262    | { body?: unknown; signatureHex?: unknown }263    | null;264  return value?.type === "ADOPT_DEVICE_MANIFEST" &&265    typeof manifest === "object" &&266    manifest !== null &&267    typeof manifest.body === "object" &&268    manifest.body !== null &&269    typeof manifest.signatureHex === "string";270}271272export function isAdoptDeviceManifestApplied(273  frame: unknown,274): frame is AdoptDeviceManifestApplied {275  return (frame as Partial<AdoptDeviceManifestApplied> | null)?.type ===276    "ADOPT_DEVICE_MANIFEST_APPLIED";277}278279function isStringRecord(value: unknown): value is Record<string, string> {280  if (typeof value !== "object" || value === null) return false;281  return Object.values(value).every((v) => typeof v === "string");282}283284function isNumberRecord(value: unknown): value is Record<string, number> {285  if (typeof value !== "object" || value === null) return false;286  return Object.values(value).every((v) => typeof v === "number");287}288289/**290 * Source side: chunk the vault snapshot and send it over the pairing channel.291 * Entries are split so each chunk's serialized entries map stays under292 * MAX_SNAPSHOT_CHUNK_BYTES; tombstones ride the final chunk. If the vault293 * needs more than MAX_SNAPSHOT_CHUNKS chunks, only the first that many are294 * sent and the fallback sync pipeline fills the remainder.295 */296export async function sendVaultSnapshot(297  channel: PairingTransport,298  entries: Record<string, string>,299  tombstones: Record<string, number>,300  duressSlot: string | null = null,301): Promise<void> {302  const chunks: Array<Record<string, string>> = [];303  let current: Record<string, string> = {};304  let currentBytes = 2; // "{}"305  for (const [id, envelope] of Object.entries(entries)) {306    // +1 for the separating comma; an overestimate only shrinks chunks.307    const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;308    if (309      Object.keys(current).length > 0 &&310      currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES311    ) {312      chunks.push(current);313      current = {};314      currentBytes = 2;315    }316    current[id] = envelope;317    currentBytes += entryBytes;318  }319  chunks.push(current);320321  const chunkCount = Math.min(chunks.length, MAX_SNAPSHOT_CHUNKS);322  for (let index = 0; index < chunkCount; index++) {323    const isFinal = index === chunkCount - 1;324    const frame: AdoptSnapshot = {325      type: "ADOPT_SNAPSHOT",326      chunk_index: index,327      chunk_count: chunkCount,328      entries: chunks[index],329      tombstones: isFinal ? tombstones : {},330    };331    if (isFinal && duressSlot) {332      frame.duress_slot = duressSlot;333    }334    await sendFrame(channel, frame);335  }336}337338/**339 * Receiver side: receive and reassemble every snapshot chunk. Validates the340 * encoded frame size before parsing, then enforces sequential 0-based341 * chunk_index ordering, a stable chunk_count within MAX_SNAPSHOT_CHUNKS, and342 * structural validity of each frame. Throws on any validation failure so the343 * caller can fall back to the sync pipeline.344 */345export async function receiveVaultSnapshot(346  channel: PairingTransport,347): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {348  const entries: Record<string, string> = {};349  const tombstones: Record<string, number> = {};350  let duressSlot: string | null = null;351  let chunkCount = -1;352  let received = 0;353  while (chunkCount === -1 || received < chunkCount) {354    const raw = await channel.receive();355    if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {356      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");357    }358    const frame: unknown = JSON.parse(new TextDecoder().decode(raw));359    if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");360    if (chunkCount === -1) {361      chunkCount = frame.chunk_count;362      if (chunkCount > MAX_SNAPSHOT_CHUNKS) {363        throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");364      }365    } else if (frame.chunk_count !== chunkCount) {366      throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");367    }368    if (frame.chunk_index !== received) {369      throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");370    }371    Object.assign(entries, frame.entries);372    Object.assign(tombstones, frame.tombstones);373    if (frame.duress_slot) duressSlot = frame.duress_slot;374    received++;375  }376  return { entries, tombstones, duressSlot };377}378379export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {380  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);381}382383export async function requestAdoption(384  channel: PairingTransport,385  deps: AdoptionReceiverDeps,386): Promise<PairedDevice | null> {387  const receiverLicense = await deps.license?.();388  await sendFrame(channel, {389    type: "ADOPT_REQUEST",390    device_id: deps.generateDeviceId(),391    device_name: deps.deviceName,392    device_type: "extension",393    license: receiverLicense,394    snapshot_capable: true,395  });396397  const confirmed = await deps.confirmSas();398  await sendFrame(channel, {399    type: "ADOPT_CONFIRM",400    confirmed,401  });402  if (!confirmed) {403    deps.onError("ADOPT_SAS_REJECTED");404    return null;405  }406407  const frame = await receiveFrame(channel);408  if (isAdoptReject(frame)) {409    deps.onError(frame.reason);410    return null;411  }412  if (!isAdoptGrant(frame)) {413    deps.onError("ADOPT_UNEXPECTED_FRAME");414    return null;415  }416417  const rootKeyBytes = hexToBytes(frame.root_key_hex);418  try {419    const computedVaultId = await computeVaultId(rootKeyBytes);420    if (computedVaultId !== frame.vault_id) {421      deps.onError("ADOPT_VAULT_ID_MISMATCH");422      return null;423    }424  } finally {425    rootKeyBytes.fill(0);426  }427  if (!receiverLicense && !frame.license) {428    deps.onError("ADOPT_PRO_REQUIRED");429    return null;430  }431432  await deps.validateSource?.(frame.source);433  const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);434  if (vaultId !== frame.vault_id) {435    deps.onError("ADOPT_VAULT_ID_MISMATCH");436    return null;437  }438439  if (!receiverLicense && frame.license) {440    await deps.applyLicense?.(frame.license);441  }442443  const identity = await deps.deriveLocalIdentity();444  await sendFrame(channel, {445    type: "ADOPT_IDENTITY",446    device_id: identity.deviceId,447    device_name: identity.deviceName,448    device_type: identity.deviceType,449    long_term_public_key_hex: identity.longTermPublicKeyHex,450    signing_public_key_hex: identity.signingPublicKeyHex,451  });452453  const completion = await receiveFrame(channel);454  if (!isAdoptComplete(completion)) {455    deps.onError("ADOPT_COMPLETION_MISSING");456    return null;457  }458459  const sourceDevice: PairedDevice = {460    deviceId: frame.source.device_id,461    deviceName: frame.source.device_name,462    deviceType: frame.source.device_type,463    longTermPublicKeyHex: frame.source.long_term_public_key_hex,464    signingPublicKeyHex: frame.source.signing_public_key_hex,465    trustSource: "direct",466    wsPort: frame.source.ws_port,467    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,468    relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,469    pairedAt: Date.now(),470  };471  await deps.upsertPairedDevice(sourceDevice);472  await sendFrame(channel, { type: "ADOPT_APPLIED" });473  return sourceDevice;474}475476export async function grantAdoption(477  channel: PairingTransport,478  request: AdoptRequest,479  deps: AdoptionSourceDeps,480): Promise<PairedDevice | null> {481  const approved = await deps.onConsent(request);482  if (!approved) return null;483484  const confirmation = await receiveFrame(channel);485  if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;486487  let sourceLicense = await deps.license?.();488  if (!sourceLicense && request.license) {489    try {490      if (!deps.applyLicense) throw new Error("license_transfer_unavailable");491      await deps.applyLicense(request.license);492      sourceLicense = await deps.license?.();493    } catch {494      sourceLicense = undefined;495    }496  }497  if (!sourceLicense) {498    await sendFrame(channel, {499      type: "ADOPT_REJECT",500      reason: "ADOPT_PRO_REQUIRED",501    });502    throw new Error("ADOPT_PRO_REQUIRED");503  }504505  const rootKeyHex = await deps.rootKeyHex();506  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");507508  await sendFrame(channel, {509    type: "ADOPT_GRANT",510    root_key_hex: rootKeyHex,511    vault_id: await deps.vaultId(),512    license: sourceLicense,513    source: {514      device_id: deps.sourceIdentity.deviceId,515      device_name: deps.sourceIdentity.deviceName,516      device_type: deps.sourceIdentity.deviceType,517      long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,518      signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,519      ws_port: deps.sourceIdentity.wsPort,520      relay_url: deps.pairState?.relayUrl,521    },522  });523524  const identity = await receiveFrame(channel);525  if (!isAdoptIdentity(identity)) return null;526527  const device: PairedDevice = {528    deviceId: identity.device_id,529    deviceName: identity.device_name,530    deviceType: identity.device_type,531    longTermPublicKeyHex: identity.long_term_public_key_hex,532    signingPublicKeyHex: identity.signing_public_key_hex,533    trustSource: "direct",534    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,535    relayUrl: deps.pairState?.relayUrl,536    pairedAt: Date.now(),537  };538  await deps.upsertPairedDevice(device);539  await sendFrame(channel, { type: "ADOPT_COMPLETE" });540  if (!isAdoptApplied(await receiveFrame(channel))) return null;541  return device;542}543544export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {545  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));546}547548export async function receiveFrame(channel: PairingTransport): Promise<unknown> {549  return JSON.parse(new TextDecoder().decode(await channel.receive()));550}551552function isDeviceType(value: unknown): value is DeviceType {553  return value === "extension" || value === "android" || value === "ios";554}555556function isSecureRelayUrl(value: string): boolean {557  try {558    const url = new URL(value);559    return url.protocol === "wss:" && !url.username && !url.password &&560      !url.search && !url.hash;561  } catch {562    return false;563  }564}565566function hexToBytes(hex: string): Uint8Array {567  const out = new Uint8Array(hex.length / 2);568  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);569  return out;570}