vault-adopt.ts

33.3 KB · raw

1import { padSyncFrame, syncFrameBucket, unpadSyncFrame } from "./sync-padding";2import { capDeviceName, SYNC_LIMITS } from "./sync-limits";3import { computeVaultId } from "./sync-identity";4import type { DeviceType, PairedDevice, SignedDeviceManifest } from "./sync-types";5import type { LocalIdentity, PairingTransport } from "./pairing-handshake";67export interface AdoptRequest {8  type: "ADOPT_REQUEST";9  device_id: string;10  device_name: string;11  device_type: DeviceType;12  /** Signed Pro entitlement offered by the receiver, if it has one. */13  license?: string;14  /**15   * Receiver advertises inline-snapshot support. A source that understands it16   * sends the vault snapshot over this same channel after ADOPT_APPLIED;17   * older sources ignore it and the receiver falls back to the sync pipeline.18   */19  snapshot_capable?: boolean;20}2122export interface AdoptGrant {23  type: "ADOPT_GRANT";24  root_key_hex: string;25  vault_id: string;26  license?: string;27  source: {28    device_id: string;29    device_name: string;30    device_type: DeviceType;31    long_term_public_key_hex: string;32    signing_public_key_hex?: string;33    ws_port?: number;34    relay_url?: string;35  };36  /**37   * ECHO: source reflects receiver's ADOPT_REQUEST.snapshot_capable here so38   * the receiver can skip the inline-snapshot wait when the source will not39   * send one (pre-C source, or explicit opt-out). Additive; absent = old source.40   */41  snapshot_capable?: boolean;42}4344export interface AdoptConfirm {45  type: "ADOPT_CONFIRM";46  confirmed: boolean;47}4849export interface AdoptIdentity {50  type: "ADOPT_IDENTITY";51  device_id: string;52  device_name: string;53  device_type: DeviceType;54  long_term_public_key_hex: string;55  signing_public_key_hex?: string;56}5758/**59 * Encrypted source-to-receiver acknowledgement sent only after the source has60 * durably recorded the receiver. Without it the receiver could report success61 * after sending ADOPT_IDENTITY even though the source never received it.62 */63export interface AdoptComplete {64  type: "ADOPT_COMPLETE";65}6667/** Receiver-to-source acknowledgement sent after the source record is durable. */68export interface AdoptApplied {69  type: "ADOPT_APPLIED";70}7172/** Neither peer could prove a Pro entitlement, so no vault state may commit. */73export interface AdoptReject {74  type: "ADOPT_REJECT";75  reason: "ADOPT_PRO_REQUIRED" | "ADOPT_DEVICE_ID_COLLISION";76}7778/**79 * One chunk of the inline vault snapshot sent by the source after80 * ADOPT_APPLIED, over the same authenticated Noise NK channel. Entries are81 * opaque base64 ciphertext envelopes; tombstones ride the final chunk only.82 */83export interface AdoptSnapshot {84  type: "ADOPT_SNAPSHOT";85  chunk_index: number;86  chunk_count: number;87  entries: Record<string, string>;88  /** entryId → deletion timestamp; populated on the final chunk only. */89  tombstones: Record<string, number>;90  /**91   * Sealed duress (slot B) StoredVault as JSON.stringify of the browser92   * shape — identical to the Codex vault_b field. Populated on the final93   * chunk only, and only when duress is actually configured; absent otherwise.94   */95  duress_slot?: string;96}9798/** Receiver acknowledgement sent after all snapshot chunks are applied. */99export interface AdoptSnapshotApplied {100  type: "ADOPT_SNAPSHOT_APPLIED";101  entries_received: number;102  tombstones_received: number;103}104105/** Optional signed device graph appended after the snapshot (manifest v3+). */106export interface AdoptDeviceManifestFrame {107  type: "ADOPT_DEVICE_MANIFEST";108  manifest: SignedDeviceManifest;109}110111/** Receiver acknowledgement for an applied device manifest. */112export interface AdoptDeviceManifestApplied {113  type: "ADOPT_DEVICE_MANIFEST_APPLIED";114}115116export type AdoptionFrame =117  | AdoptRequest118  | AdoptConfirm119  | AdoptGrant120  | AdoptIdentity121  | AdoptComplete122  | AdoptApplied123  | AdoptReject124  | AdoptSnapshot125  | AdoptSnapshotApplied126  | AdoptDeviceManifestFrame127  | AdoptDeviceManifestApplied;128129/**130 * Wire guardrails for the inline snapshot. A chunk's serialized entries map131 * stays under MAX_SNAPSHOT_CHUNK_BYTES; the whole transfer is capped at132 * MAX_SNAPSHOT_CHUNKS frames so a compromised source cannot exhaust receiver133 * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator).134 */135export const MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024; // 512 KiB136export const MAX_SNAPSHOT_CHUNKS = 200;137// Headroom added to the per-chunk entries budget when validating an encoded138// frame on the wire: JSON envelope plus the final chunk's tombstone map.139export const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;140/**141 * PB-26-604: snapshot frames are padded to the sync bucket ladder before142 * Noise, so the wire bytes are larger than the JSON they carry. `+4` is143 * padSyncFrame's length header; the bucket ladder supplies the rest.144 */145export const MAX_PADDED_SNAPSHOT_FRAME_BYTES = syncFrameBucket(146  SNAPSHOT_FRAME_WIRE_CEILING + 4,147);148149/**150 * PB-26-206 aggregate budget shared by sender and receiver. The per-frame151 * guard above bounds one frame; this bounds the whole transfer — encoded152 * snapshot bytes (entries + tombstones + JSON framing + duress slot),153 * entry/tombstone counts, ID lengths, and per-envelope size — independently154 * of the 200-frame count, so many individually valid frames cannot OOM the155 * receiver. Aligned with the 12 MiB pairing-inbox transport backstop156 * (PB-26-205): an honest snapshot never approaches either ceiling.157 */158export const MAX_ADOPT_SNAPSHOT_BYTES = 10 * 1024 * 1024; // 10 MiB159export const MAX_ADOPT_SNAPSHOT_ENTRIES = 25_000;160export const MAX_ADOPT_SNAPSHOT_TOMBSTONES = 25_000;161/** Matches the cross-client anti-abuse ID ceiling (Android SyncLimits.MAX_ID_LENGTH). */162export const MAX_ADOPT_SNAPSHOT_ID_CHARS = 512;163/**164 * Maximum encrypted entry envelope: SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES165 * plus the existing base64 overhead allowance (mirrors sync-vault-adapter).166 */167export const MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES =168  (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64;169170/**171 * Running aggregate for one snapshot transfer. Counts are tracked alongside172 * the maps (not derived via Object.keys per frame) so the bound stays O(n).173 */174export interface AdoptSnapshotBudget {175  entries: Record<string, string>;176  tombstones: Record<string, number>;177  duressSlot: string | null;178  /** Sum of encoded wire-frame bytes consumed so far. */179  bytes: number;180  entryCount: number;181  tombstoneCount: number;182}183184export function createAdoptSnapshotBudget(): AdoptSnapshotBudget {185  return { entries: {}, tombstones: {}, duressSlot: null, bytes: 0, entryCount: 0, tombstoneCount: 0 };186}187188/**189 * PB-26-206 shared preflight/merge helper used by BOTH the sender190 * (preflight before the first frame goes out) and the receiver (per frame,191 * after the byte-budget check, before JSON.parse results are merged).192 *193 * One bounded pass per attacker-controlled map: each key/value is validated194 * and projected counts are enforced inside the same loop that merges, so the195 * maps are never iterated count→size→merge repeatedly. Throws before merging196 * the offending frame, leaving no partial adopted state.197 */198export function applyAdoptSnapshotFrame(199  acc: AdoptSnapshotBudget,200  entries: Record<string, string>,201  tombstones: Record<string, number>,202  duressSlot: string | null | undefined,203  frameBytes: number,204): void {205  if (frameBytes > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {206    throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");207  }208  for (const [id, envelope] of Object.entries(entries)) {209    if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw new Error("ADOPT_SNAPSHOT_ID_TOO_LONG");210    if (envelope.length > MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) {211      throw new Error("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE");212    }213    if (acc.entryCount >= MAX_ADOPT_SNAPSHOT_ENTRIES) {214      throw new Error("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES");215    }216    acc.entryCount++;217    acc.entries[id] = envelope;218  }219  for (const [id, ts] of Object.entries(tombstones)) {220    if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw new Error("ADOPT_SNAPSHOT_ID_TOO_LONG");221    if (typeof ts !== "number" || !Number.isFinite(ts)) {222      throw new Error("ADOPT_SNAPSHOT_INVALID_TOMBSTONE");223    }224    if (acc.tombstoneCount >= MAX_ADOPT_SNAPSHOT_TOMBSTONES) {225      throw new Error("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES");226    }227    acc.tombstoneCount++;228    acc.tombstones[id] = ts;229  }230  if (typeof duressSlot === "string" && duressSlot.length > 0) {231    // The duress slot rides inside wire frames already bounded by the frame232    // ceiling, and its raw bytes count against the aggregate above.233    if (duressSlot.length > SNAPSHOT_FRAME_WIRE_CEILING) {234      throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");235    }236    acc.duressSlot = duressSlot;237  }238  acc.bytes += frameBytes;239}240241export interface AdoptionPairState {242  rendezvousSecretHex: string;243  relayUrl?: string;244}245246export interface AdoptionReceiverDeps {247  deviceName: string;248  generateDeviceId(): string;249  /** Root-key transfer cannot begin until this device confirms the SAS. */250  confirmSas(): Promise<boolean>;251  /**252   * Optional local safety policy evaluated after the authenticated grant is253   * validated but before any vault state changes.254   */255  validateSource?(source: AdoptGrant["source"]): Promise<void>;256  /**257   * True when the source's identity (from ADOPT_GRANT) is in the local258   * terminal revocation ledger. When true, the receiver sends259   * ADOPT_DEVICE_ID_COLLISION so the source rotates and resends the grant.260   */261  hasSourceIdentityCollision?(source: AdoptGrant["source"]): Promise<boolean>;262  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;263  deriveLocalIdentity(): Promise<LocalIdentity>;264  rotateLocalIdentity?(): Promise<LocalIdentity>;265  upsertPairedDevice(device: PairedDevice): Promise<void>;266  applyLicense?(license: string): Promise<void>;267  /** Return this receiver's locally validated Pro key, if present. */268  license?(): Promise<string | undefined>;269  /** Bound to the same completed Noise session, never taken from frame claims. */270  pairState?: AdoptionPairState;271  onError(error: string): void;272}273274export interface AdoptionSourceDeps {275  sourceIdentity: LocalIdentity;276  rootKeyHex(): Promise<string>;277  vaultId(): Promise<string>;278  license?(): Promise<string | undefined>;279  /** Validate and durably store a Pro key offered by the receiver. */280  applyLicense?(license: string): Promise<void>;281  onConsent(request: AdoptRequest): Promise<boolean>;282  upsertPairedDevice(device: PairedDevice): Promise<void>;283  /**284   * True when the receiver's identity conflicts with an existing paired285   * device or a terminal revocation record. When true, the source sends286   * ADOPT_DEVICE_ID_COLLISION so the receiver rotates its identity.287   */288  hasDeviceIdentityCollision?(device: PairedDevice): Promise<boolean>;289  /**290   * Rotate the source's identity after the receiver reports a collision on291   * the source's own identity. Returns the fresh identity to use in the292   * resent ADOPT_GRANT.293   */294  rotateSourceIdentity?(): Promise<LocalIdentity>;295  /** Bound to the same completed Noise session, never taken from frame claims. */296  pairState?: AdoptionPairState;297}298299export function isAdoptRequest(frame: unknown): frame is AdoptRequest {300  const value = frame as Partial<AdoptRequest> | null;301  return value?.type === "ADOPT_REQUEST" &&302    typeof value.device_id === "string" &&303    value.device_id.length > 0 &&304    typeof value.device_name === "string" &&305    isDeviceType(value.device_type) &&306    (value.license === undefined || typeof value.license === "string");307}308309export function isAdoptGrant(frame: unknown): frame is AdoptGrant {310  const value = frame as Partial<AdoptGrant> | null;311  return value?.type === "ADOPT_GRANT" &&312    validateRootKeyHex(value.root_key_hex) &&313    typeof value.vault_id === "string" &&314    /^[0-9a-f]{64}$/.test(value.vault_id) &&315    typeof value.source === "object" &&316    value.source !== null &&317    typeof value.source.device_id === "string" &&318    typeof value.source.device_name === "string" &&319    isDeviceType(value.source.device_type) &&320    typeof value.source.long_term_public_key_hex === "string" &&321    /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&322    (value.source.relay_url === undefined ||323      (typeof value.source.relay_url === "string" &&324        isSecureRelayUrl(value.source.relay_url))) &&325    (value.snapshot_capable === undefined ||326      typeof value.snapshot_capable === "boolean");327}328329export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {330  const value = frame as Partial<AdoptConfirm> | null;331  return value?.type === "ADOPT_CONFIRM" &&332    typeof value.confirmed === "boolean";333}334335export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {336  const value = frame as Partial<AdoptIdentity> | null;337  return value?.type === "ADOPT_IDENTITY" &&338    typeof value.device_id === "string" &&339    value.device_id.length > 0 &&340    typeof value.device_name === "string" &&341    isDeviceType(value.device_type) &&342    typeof value.long_term_public_key_hex === "string" &&343    /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);344}345346export function isAdoptComplete(frame: unknown): frame is AdoptComplete {347  return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";348}349350export function isAdoptApplied(frame: unknown): frame is AdoptApplied {351  return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";352}353354export function isAdoptReject(frame: unknown): frame is AdoptReject {355  const value = frame as Partial<AdoptReject> | null;356  return value?.type === "ADOPT_REJECT" &&357    (value.reason === "ADOPT_PRO_REQUIRED" ||358      value.reason === "ADOPT_DEVICE_ID_COLLISION");359}360361export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {362  const value = frame as Partial<AdoptSnapshot> | null;363  if (value?.type !== "ADOPT_SNAPSHOT") return false;364  if (365    typeof value.chunk_index !== "number" ||366    !Number.isInteger(value.chunk_index) ||367    value.chunk_index < 0368  ) return false;369  if (370    typeof value.chunk_count !== "number" ||371    !Number.isInteger(value.chunk_count) ||372    value.chunk_count < 1373  ) return false;374  if (!isStringRecord(value.entries)) return false;375  if (!isNumberRecord(value.tombstones)) return false;376  if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;377  return true;378}379380export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {381  const value = frame as Partial<AdoptSnapshotApplied> | null;382  return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&383    typeof value.entries_received === "number" &&384    typeof value.tombstones_received === "number";385}386387export function isAdoptDeviceManifestFrame(388  frame: unknown,389): frame is AdoptDeviceManifestFrame {390  const value = frame as Partial<AdoptDeviceManifestFrame> | null;391  const manifest = value?.manifest as392    | { body?: unknown; signatureHex?: unknown }393    | null;394  return value?.type === "ADOPT_DEVICE_MANIFEST" &&395    typeof manifest === "object" &&396    manifest !== null &&397    typeof manifest.body === "object" &&398    manifest.body !== null &&399    typeof manifest.signatureHex === "string";400}401402function isStringRecord(value: unknown): value is Record<string, string> {403  if (typeof value !== "object" || value === null) return false;404  return Object.values(value).every((v) => typeof v === "string");405}406407function isNumberRecord(value: unknown): value is Record<string, number> {408  if (typeof value !== "object" || value === null) return false;409  return Object.values(value).every((v) => typeof v === "number");410}411412/**413 * Source side: chunk the vault snapshot and send it over the pairing channel.414 *415 * Wire shape (v4.6.4+, duress on its own frame):416 *   - N entries frames, each ≤ MAX_SNAPSHOT_CHUNK_BYTES serialized entries.417 *     Tombstones ride the last entries frame when no duress follows.418 *   - If duressSlot is set, one extra frame after the entries carrying only419 *     the duress slot (entries: empty, tombstones: tombstone map,420 *     duress_slot: duressSlot). This moves duress off the entries frame so a421 *     sizeable sealed duress vault can no longer push the entries frame raw422 *     bytes over SNAPSHOT_FRAME_WIRE_CEILING (768 KiB).423 *424 * Frame budget (MAX_SNAPSHOT_CHUNKS = 200):425 *   - When duress is configured, ONE of the 200 frames is reserved for the426 *     duress slot. The entries cap is MAX_SNAPSHOT_CHUNKS - 1 = 199 entries427 *     chunks; excess entries are deferred to the sync fallback pipeline (sync428 *     carries entries and tombstones, so this is a graceful truncation).429 *     This reservation is REQUIRED: ordinary sync does NOT carry duress430 *     (vault.test.ts:158-179 test-locks duress as non-syncing), so silently431 *     dropping a duress frame at the cap would leave the adopted device432 *     permanently without its duress vault. We prioritize duress delivery433 *     over completeness of entries (the latter is recoverable via sync).434 *435 * Oversized duress: duress that itself exceeds SNAPSHOT_FRAME_WIRE_CEILING436 * (e.g. a ≥768 KiB sealed duress) is rejected with a thrown error BEFORE the437 * first duress frame is sent. This is a hard stop — the caller must split438 * the duress payload out of band. We do not silently truncate duress; sync439 * cannot restore it.440 *441 * Backward compat: pre-v4.6.4 senders put `duress_slot` on the entries442 * final chunk. Receivers tolerate both shapes because `receiveVaultSnapshot`443 * reads `duress_slot` off any chunk (a duress-only frame has empty444 * entries/tombstones which merge as a no-op).445 */446export async function sendVaultSnapshot(447  channel: PairingTransport,448  entries: Record<string, string>,449  tombstones: Record<string, number>,450  duressSlot: string | null = null,451): Promise<void> {452  const frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot);453  // PB-26-206 preflight with the SAME helper the receiver enforces: nothing454  // is sent until every frame fits the aggregate budget, so an over-budget455  // snapshot is never partially sent or silently truncated. The caller falls456  // through to the regular sync pipeline (deny-only: no wire-field change,457  // invitation prefix unchanged).458  const acc = createAdoptSnapshotBudget();459  const encoded: Uint8Array[] = frames.map((frame) =>460    new TextEncoder().encode(JSON.stringify(frame))461  );462  for (let index = 0; index < frames.length; index++) {463    const frame = frames[index];464    applyAdoptSnapshotFrame(465      acc,466      frame.entries,467      frame.tombstones,468      frame.duress_slot,469      encoded[index].length,470    );471  }472  // PB-26-604: pad to the sync bucket ladder so the relay sees bucket-sized473  // ciphertext and cannot read exact plaintext lengths (or the duress frame's474  // distinctive size) off the wire.475  for (const raw of encoded) await channel.send(padSyncFrame(raw));476}477478/**479 * Pure frame construction for sendVaultSnapshot: chunking, the duress-frame480 * reservation, and the oversized-duress hard stop. No I/O happens here, so481 * the preflight above can reject before the first byte goes out.482 */483function buildAdoptSnapshotFrames(484  entries: Record<string, string>,485  tombstones: Record<string, number>,486  duressSlot: string | null,487): AdoptSnapshot[] {488  const includeDuress = !!duressSlot;489  // Reserve one frame for duress when configured so duress is never silently490  // dropped at the cap; ordinary sync does NOT carry duress, so dropping a491  // duress frame would orphan the adopted device's duress vault.492  const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS;493494  const chunks: Array<Record<string, string>> = [];495  let current: Record<string, string> = {};496  let currentCount = 0;497  let currentBytes = 2; // "{}"498  for (const [id, envelope] of Object.entries(entries)) {499    // +1 for the separating comma; an overestimate only shrinks chunks.500    const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;501    if (currentCount > 0 && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {502      chunks.push(current);503      current = {};504      currentCount = 0;505      currentBytes = 2;506    }507    current[id] = envelope;508    currentCount++;509    currentBytes += entryBytes;510  }511  chunks.push(current);512513  const entriesChunkCount = Math.min(chunks.length, entryCap);514  const sendDuress = includeDuress;515  const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);516  const duressFrame: AdoptSnapshot | null = sendDuress517    ? {518        type: "ADOPT_SNAPSHOT",519        chunk_index: entriesChunkCount,520        chunk_count: totalChunks,521        entries: {},522        tombstones,523        duress_slot: duressSlot as string,524      }525    : null;526527  if (528    duressFrame &&529    new TextEncoder().encode(JSON.stringify(duressFrame)).length >530      SNAPSHOT_FRAME_WIRE_CEILING531  ) {532    throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");533  }534535  const frames: AdoptSnapshot[] = [];536  for (let index = 0; index < entriesChunkCount; index++) {537    const isFinalEntries = index === entriesChunkCount - 1;538    frames.push({539      type: "ADOPT_SNAPSHOT",540      chunk_index: index,541      chunk_count: totalChunks,542      entries: chunks[index],543      // Tombstones ride the LAST wire frame: when duress follows the entries,544      // it goes on the duress frame; otherwise on the last entries chunk.545      tombstones: isFinalEntries && !sendDuress ? tombstones : {},546    });547  }548549  if (duressFrame) frames.push(duressFrame);550  return frames;551}552553/**554 * Receiver side: receive and reassemble every snapshot chunk. Adds raw frame555 * bytes to the aggregate counter before decoding (rejecting before556 * JSON.parse when the next frame exceeds the remaining budget), then enforces557 * the per-frame wire ceiling, sequential 0-based chunk_index ordering, a558 * stable chunk_count within MAX_SNAPSHOT_CHUNKS, and structural validity of559 * each frame. Merging goes through the shared aggregate helper: every560 * key/value and projected count is validated in one bounded pass before561 * anything lands in the accumulator, so a rejection leaves no partial562 * adopted state and the caller can fall back to the sync pipeline.563 */564export async function receiveVaultSnapshot(565  channel: PairingTransport,566): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {567  const acc = createAdoptSnapshotBudget();568  let chunkCount = -1;569  let received = 0;570  while (chunkCount === -1 || received < chunkCount) {571    const wire = await channel.receive();572    if (wire.length > MAX_PADDED_SNAPSHOT_FRAME_BYTES) {573      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");574    }575    // PB-26-604: every budget below applies to the unpadded JSON, exactly as576    // the sender's preflight accounted for it.577    const raw = unpadSyncFrame(wire);578    if (raw.length > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {579      throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");580    }581    if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {582      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");583    }584    const frame: unknown = JSON.parse(new TextDecoder().decode(raw));585    if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");586    if (chunkCount === -1) {587      chunkCount = frame.chunk_count;588      if (chunkCount > MAX_SNAPSHOT_CHUNKS) {589        throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");590      }591    } else if (frame.chunk_count !== chunkCount) {592      throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");593    }594    if (frame.chunk_index !== received) {595      throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");596    }597    applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duress_slot, raw.length);598    received++;599  }600  return { entries: acc.entries, tombstones: acc.tombstones, duressSlot: acc.duressSlot };601}602603export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {604  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);605}606607export interface AdoptionRequestResult {608  device: PairedDevice;609  /** True iff the source echoed snapshot_capable: true in its ADOPT_GRANT. */610  snapshotCapable: boolean;611}612613export async function requestAdoption(614  channel: PairingTransport,615  deps: AdoptionReceiverDeps,616): Promise<AdoptionRequestResult | null> {617  const receiverLicense = await deps.license?.();618  await sendFrame(channel, {619    type: "ADOPT_REQUEST",620    device_id: deps.generateDeviceId(),621    device_name: deps.deviceName,622    device_type: "extension",623    license: receiverLicense,624    snapshot_capable: true,625  });626627  const confirmed = await deps.confirmSas();628  await sendFrame(channel, {629    type: "ADOPT_CONFIRM",630    confirmed,631  });632  if (!confirmed) {633    deps.onError("ADOPT_SAS_REJECTED");634    return null;635  }636637  const frame = await receiveFrame(channel);638  if (isAdoptReject(frame)) {639    deps.onError(frame.reason);640    return null;641  }642  if (!isAdoptGrant(frame)) {643    deps.onError("ADOPT_UNEXPECTED_FRAME");644    return null;645  }646  const grantSnapshotCapable = frame.snapshot_capable === true;647648  const rootKeyBytes = hexToBytes(frame.root_key_hex);649  try {650    const computedVaultId = await computeVaultId(rootKeyBytes);651    if (computedVaultId !== frame.vault_id) {652      deps.onError("ADOPT_VAULT_ID_MISMATCH");653      return null;654    }655  } finally {656    rootKeyBytes.fill(0);657  }658  if (!receiverLicense && !frame.license) {659    deps.onError("ADOPT_PRO_REQUIRED");660    return null;661  }662663  await deps.validateSource?.(frame.source);664665  // Check the source's identity against the local revocation ledger. A666  // previously-revoked device must present a fresh identity so that stale667  // signed revocations held by other mesh peers cannot remove it after668  // re-pair. If the source's identity is revoked, send ADOPT_DEVICE_ID_COLLISION669  // and wait for a fresh grant (one retry).670  let grant = frame;671  if (deps.hasSourceIdentityCollision && await deps.hasSourceIdentityCollision(grant.source)) {672    await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });673    const retry = await receiveFrame(channel);674    if (!isAdoptGrant(retry)) {675      deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED");676      return null;677    }678    grant = retry;679    if (deps.hasSourceIdentityCollision && await deps.hasSourceIdentityCollision(grant.source)) {680      await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });681      deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED");682      return null;683    }684    // Re-validate the fresh grant's vault id.685    const freshRootBytes = hexToBytes(grant.root_key_hex);686    try {687      if (await computeVaultId(freshRootBytes) !== grant.vault_id) {688        deps.onError("ADOPT_VAULT_ID_MISMATCH");689        return null;690      }691    } finally {692      freshRootBytes.fill(0);693    }694  }695696  const { vaultId } = await deps.createVaultFromRootKey(grant.root_key_hex);697  if (vaultId !== grant.vault_id) {698    deps.onError("ADOPT_VAULT_ID_MISMATCH");699    return null;700  }701702  if (!receiverLicense && grant.license) {703    await deps.applyLicense?.(grant.license);704  }705706  let identity = await deps.deriveLocalIdentity();707  await sendFrame(channel, {708    type: "ADOPT_IDENTITY",709    device_id: identity.deviceId,710    device_name: identity.deviceName,711    device_type: identity.deviceType,712    long_term_public_key_hex: identity.longTermPublicKeyHex,713    signing_public_key_hex: identity.signingPublicKeyHex,714  });715716  let completion = await receiveFrame(channel);717  if (718    isAdoptReject(completion) &&719    completion.reason === "ADOPT_DEVICE_ID_COLLISION" &&720    deps.rotateLocalIdentity721  ) {722    identity = await deps.rotateLocalIdentity();723    await sendFrame(channel, {724      type: "ADOPT_IDENTITY",725      device_id: identity.deviceId,726      device_name: identity.deviceName,727      device_type: identity.deviceType,728      long_term_public_key_hex: identity.longTermPublicKeyHex,729      signing_public_key_hex: identity.signingPublicKeyHex,730    });731    completion = await receiveFrame(channel);732  }733  if (!isAdoptComplete(completion)) {734    deps.onError("ADOPT_COMPLETION_MISSING");735    return null;736  }737738  const sourceDevice: PairedDevice = {739    deviceId: grant.source.device_id,740    deviceName: capDeviceName(grant.source.device_name),741    deviceType: grant.source.device_type,742    longTermPublicKeyHex: grant.source.long_term_public_key_hex,743    signingPublicKeyHex: grant.source.signing_public_key_hex,744    trustSource: "direct",745    wsPort: grant.source.ws_port,746    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,747    relayUrl: deps.pairState?.relayUrl ?? grant.source.relay_url,748    pairedAt: Date.now(),749  };750  await deps.upsertPairedDevice(sourceDevice);751  await sendFrame(channel, { type: "ADOPT_APPLIED" });752  return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };753}754755export async function grantAdoption(756  channel: PairingTransport,757  request: AdoptRequest,758  deps: AdoptionSourceDeps,759): Promise<PairedDevice | null> {760  const approved = await deps.onConsent(request);761  if (!approved) return null;762763  const confirmation = await receiveFrame(channel);764  if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;765766  let sourceLicense = await deps.license?.();767  if (!sourceLicense && request.license) {768    try {769      if (!deps.applyLicense) throw new Error("license_transfer_unavailable");770      await deps.applyLicense(request.license);771      sourceLicense = await deps.license?.();772    } catch {773      sourceLicense = undefined;774    }775  }776  if (!sourceLicense) {777    await sendFrame(channel, {778      type: "ADOPT_REJECT",779      reason: "ADOPT_PRO_REQUIRED",780    });781    throw new Error("ADOPT_PRO_REQUIRED");782  }783784  const rootKeyHex = await deps.rootKeyHex();785  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");786787  let sourceIdentity = deps.sourceIdentity;788  let grantAttempts = 0;789  while (true) {790    await sendFrame(channel, {791      type: "ADOPT_GRANT",792      root_key_hex: rootKeyHex,793      vault_id: await deps.vaultId(),794      license: sourceLicense,795      source: {796        device_id: sourceIdentity.deviceId,797        device_name: sourceIdentity.deviceName,798        device_type: sourceIdentity.deviceType,799        long_term_public_key_hex: sourceIdentity.longTermPublicKeyHex,800        signing_public_key_hex: sourceIdentity.signingPublicKeyHex,801        ws_port: sourceIdentity.wsPort,802        relay_url: deps.pairState?.relayUrl,803      },804      snapshot_capable: request.snapshot_capable === true,805    });806807    const identity = await receiveFrame(channel);808809    // Receiver may reject the source's identity if it is in the receiver's810    // revocation ledger. Rotate and resend (one retry).811    if (812      isAdoptReject(identity) &&813      identity.reason === "ADOPT_DEVICE_ID_COLLISION" &&814      deps.rotateSourceIdentity &&815      grantAttempts === 0816    ) {817      sourceIdentity = await deps.rotateSourceIdentity();818      grantAttempts++;819      continue;820    }821822    if (!isAdoptIdentity(identity)) return null;823824    const device: PairedDevice = {825      deviceId: identity.device_id,826      deviceName: capDeviceName(identity.device_name),827      deviceType: identity.device_type,828      longTermPublicKeyHex: identity.long_term_public_key_hex,829      signingPublicKeyHex: identity.signing_public_key_hex,830      trustSource: "direct",831      rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,832      relayUrl: deps.pairState?.relayUrl,833      pairedAt: Date.now(),834    };835836    // Check the receiver's identity against local revocations (matching837    // Android's grantAdoption collision detection). If revoked, send838    // ADOPT_DEVICE_ID_COLLISION so the receiver rotates (one retry).839    if (deps.hasDeviceIdentityCollision && await deps.hasDeviceIdentityCollision(device)) {840      await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });841      const freshIdentity = await receiveFrame(channel);842      if (!isAdoptIdentity(freshIdentity)) return null;843      const freshDevice: PairedDevice = {844        deviceId: freshIdentity.device_id,845        deviceName: capDeviceName(freshIdentity.device_name),846        deviceType: freshIdentity.device_type,847        longTermPublicKeyHex: freshIdentity.long_term_public_key_hex,848        signingPublicKeyHex: freshIdentity.signing_public_key_hex,849        trustSource: "direct",850        rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,851        relayUrl: deps.pairState?.relayUrl,852        pairedAt: Date.now(),853      };854      if (deps.hasDeviceIdentityCollision && await deps.hasDeviceIdentityCollision(freshDevice)) {855        await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });856        return null;857      }858      await deps.upsertPairedDevice(freshDevice);859      await sendFrame(channel, { type: "ADOPT_COMPLETE" });860      if (!isAdoptApplied(await receiveFrame(channel))) return null;861      return freshDevice;862    }863864    await deps.upsertPairedDevice(device);865    await sendFrame(channel, { type: "ADOPT_COMPLETE" });866    if (!isAdoptApplied(await receiveFrame(channel))) return null;867    return device;868  }869}870871export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {872  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));873}874875export async function receiveFrame(channel: PairingTransport): Promise<unknown> {876  return JSON.parse(new TextDecoder().decode(await channel.receive()));877}878879function isDeviceType(value: unknown): value is DeviceType {880  return value === "extension" || value === "android" || value === "ios";881}882883function isSecureRelayUrl(value: string): boolean {884  try {885    const url = new URL(value);886    return url.protocol === "wss:" && !url.username && !url.password &&887      !url.search && !url.hash;888  } catch {889    return false;890  }891}892893function hexToBytes(hex: string): Uint8Array {894  const out = new Uint8Array(hex.length / 2);895  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);896  return out;897}