vault-adopt.ts

28.6 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";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  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;257  deriveLocalIdentity(): Promise<LocalIdentity>;258  upsertPairedDevice(device: PairedDevice): Promise<void>;259  applyLicense?(license: string): Promise<void>;260  /** Return this receiver's locally validated Pro key, if present. */261  license?(): Promise<string | undefined>;262  /** Bound to the same completed Noise session, never taken from frame claims. */263  pairState?: AdoptionPairState;264  onError(error: string): void;265}266267export interface AdoptionSourceDeps {268  sourceIdentity: LocalIdentity;269  rootKeyHex(): Promise<string>;270  vaultId(): Promise<string>;271  license?(): Promise<string | undefined>;272  /** Validate and durably store a Pro key offered by the receiver. */273  applyLicense?(license: string): Promise<void>;274  onConsent(request: AdoptRequest): Promise<boolean>;275  upsertPairedDevice(device: PairedDevice): Promise<void>;276  /** Bound to the same completed Noise session, never taken from frame claims. */277  pairState?: AdoptionPairState;278}279280export function isAdoptRequest(frame: unknown): frame is AdoptRequest {281  const value = frame as Partial<AdoptRequest> | null;282  return value?.type === "ADOPT_REQUEST" &&283    typeof value.device_id === "string" &&284    value.device_id.length > 0 &&285    typeof value.device_name === "string" &&286    isDeviceType(value.device_type) &&287    (value.license === undefined || typeof value.license === "string");288}289290export function isAdoptGrant(frame: unknown): frame is AdoptGrant {291  const value = frame as Partial<AdoptGrant> | null;292  return value?.type === "ADOPT_GRANT" &&293    validateRootKeyHex(value.root_key_hex) &&294    typeof value.vault_id === "string" &&295    /^[0-9a-f]{64}$/.test(value.vault_id) &&296    typeof value.source === "object" &&297    value.source !== null &&298    typeof value.source.device_id === "string" &&299    typeof value.source.device_name === "string" &&300    isDeviceType(value.source.device_type) &&301    typeof value.source.long_term_public_key_hex === "string" &&302    /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&303    (value.source.relay_url === undefined ||304      (typeof value.source.relay_url === "string" &&305        isSecureRelayUrl(value.source.relay_url))) &&306    (value.snapshot_capable === undefined ||307      typeof value.snapshot_capable === "boolean");308}309310export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {311  const value = frame as Partial<AdoptConfirm> | null;312  return value?.type === "ADOPT_CONFIRM" &&313    typeof value.confirmed === "boolean";314}315316export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {317  const value = frame as Partial<AdoptIdentity> | null;318  return value?.type === "ADOPT_IDENTITY" &&319    typeof value.device_id === "string" &&320    value.device_id.length > 0 &&321    typeof value.device_name === "string" &&322    isDeviceType(value.device_type) &&323    typeof value.long_term_public_key_hex === "string" &&324    /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);325}326327export function isAdoptComplete(frame: unknown): frame is AdoptComplete {328  return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";329}330331export function isAdoptApplied(frame: unknown): frame is AdoptApplied {332  return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";333}334335export function isAdoptReject(frame: unknown): frame is AdoptReject {336  const value = frame as Partial<AdoptReject> | null;337  return value?.type === "ADOPT_REJECT" &&338    value.reason === "ADOPT_PRO_REQUIRED";339}340341export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {342  const value = frame as Partial<AdoptSnapshot> | null;343  if (value?.type !== "ADOPT_SNAPSHOT") return false;344  if (345    typeof value.chunk_index !== "number" ||346    !Number.isInteger(value.chunk_index) ||347    value.chunk_index < 0348  ) return false;349  if (350    typeof value.chunk_count !== "number" ||351    !Number.isInteger(value.chunk_count) ||352    value.chunk_count < 1353  ) return false;354  if (!isStringRecord(value.entries)) return false;355  if (!isNumberRecord(value.tombstones)) return false;356  if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;357  return true;358}359360export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {361  const value = frame as Partial<AdoptSnapshotApplied> | null;362  return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&363    typeof value.entries_received === "number" &&364    typeof value.tombstones_received === "number";365}366367export function isAdoptDeviceManifestFrame(368  frame: unknown,369): frame is AdoptDeviceManifestFrame {370  const value = frame as Partial<AdoptDeviceManifestFrame> | null;371  const manifest = value?.manifest as372    | { body?: unknown; signatureHex?: unknown }373    | null;374  return value?.type === "ADOPT_DEVICE_MANIFEST" &&375    typeof manifest === "object" &&376    manifest !== null &&377    typeof manifest.body === "object" &&378    manifest.body !== null &&379    typeof manifest.signatureHex === "string";380}381382function isStringRecord(value: unknown): value is Record<string, string> {383  if (typeof value !== "object" || value === null) return false;384  return Object.values(value).every((v) => typeof v === "string");385}386387function isNumberRecord(value: unknown): value is Record<string, number> {388  if (typeof value !== "object" || value === null) return false;389  return Object.values(value).every((v) => typeof v === "number");390}391392/**393 * Source side: chunk the vault snapshot and send it over the pairing channel.394 *395 * Wire shape (v4.6.4+, duress on its own frame):396 *   - N entries frames, each ≤ MAX_SNAPSHOT_CHUNK_BYTES serialized entries.397 *     Tombstones ride the last entries frame when no duress follows.398 *   - If duressSlot is set, one extra frame after the entries carrying only399 *     the duress slot (entries: empty, tombstones: tombstone map,400 *     duress_slot: duressSlot). This moves duress off the entries frame so a401 *     sizeable sealed duress vault can no longer push the entries frame raw402 *     bytes over SNAPSHOT_FRAME_WIRE_CEILING (768 KiB).403 *404 * Frame budget (MAX_SNAPSHOT_CHUNKS = 200):405 *   - When duress is configured, ONE of the 200 frames is reserved for the406 *     duress slot. The entries cap is MAX_SNAPSHOT_CHUNKS - 1 = 199 entries407 *     chunks; excess entries are deferred to the sync fallback pipeline (sync408 *     carries entries and tombstones, so this is a graceful truncation).409 *     This reservation is REQUIRED: ordinary sync does NOT carry duress410 *     (vault.test.ts:158-179 test-locks duress as non-syncing), so silently411 *     dropping a duress frame at the cap would leave the adopted device412 *     permanently without its duress vault. We prioritize duress delivery413 *     over completeness of entries (the latter is recoverable via sync).414 *415 * Oversized duress: duress that itself exceeds SNAPSHOT_FRAME_WIRE_CEILING416 * (e.g. a ≥768 KiB sealed duress) is rejected with a thrown error BEFORE the417 * first duress frame is sent. This is a hard stop — the caller must split418 * the duress payload out of band. We do not silently truncate duress; sync419 * cannot restore it.420 *421 * Backward compat: pre-v4.6.4 senders put `duress_slot` on the entries422 * final chunk. Receivers tolerate both shapes because `receiveVaultSnapshot`423 * reads `duress_slot` off any chunk (a duress-only frame has empty424 * entries/tombstones which merge as a no-op).425 */426export async function sendVaultSnapshot(427  channel: PairingTransport,428  entries: Record<string, string>,429  tombstones: Record<string, number>,430  duressSlot: string | null = null,431): Promise<void> {432  const frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot);433  // PB-26-206 preflight with the SAME helper the receiver enforces: nothing434  // is sent until every frame fits the aggregate budget, so an over-budget435  // snapshot is never partially sent or silently truncated. The caller falls436  // through to the regular sync pipeline (deny-only: no wire-field change,437  // invitation prefix unchanged).438  const acc = createAdoptSnapshotBudget();439  const encoded: Uint8Array[] = frames.map((frame) =>440    new TextEncoder().encode(JSON.stringify(frame))441  );442  for (let index = 0; index < frames.length; index++) {443    const frame = frames[index];444    applyAdoptSnapshotFrame(445      acc,446      frame.entries,447      frame.tombstones,448      frame.duress_slot,449      encoded[index].length,450    );451  }452  // PB-26-604: pad to the sync bucket ladder so the relay sees bucket-sized453  // ciphertext and cannot read exact plaintext lengths (or the duress frame's454  // distinctive size) off the wire.455  for (const raw of encoded) await channel.send(padSyncFrame(raw));456}457458/**459 * Pure frame construction for sendVaultSnapshot: chunking, the duress-frame460 * reservation, and the oversized-duress hard stop. No I/O happens here, so461 * the preflight above can reject before the first byte goes out.462 */463function buildAdoptSnapshotFrames(464  entries: Record<string, string>,465  tombstones: Record<string, number>,466  duressSlot: string | null,467): AdoptSnapshot[] {468  const includeDuress = !!duressSlot;469  // Reserve one frame for duress when configured so duress is never silently470  // dropped at the cap; ordinary sync does NOT carry duress, so dropping a471  // duress frame would orphan the adopted device's duress vault.472  const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS;473474  const chunks: Array<Record<string, string>> = [];475  let current: Record<string, string> = {};476  let currentCount = 0;477  let currentBytes = 2; // "{}"478  for (const [id, envelope] of Object.entries(entries)) {479    // +1 for the separating comma; an overestimate only shrinks chunks.480    const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;481    if (currentCount > 0 && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {482      chunks.push(current);483      current = {};484      currentCount = 0;485      currentBytes = 2;486    }487    current[id] = envelope;488    currentCount++;489    currentBytes += entryBytes;490  }491  chunks.push(current);492493  const entriesChunkCount = Math.min(chunks.length, entryCap);494  const sendDuress = includeDuress;495  const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);496  const duressFrame: AdoptSnapshot | null = sendDuress497    ? {498        type: "ADOPT_SNAPSHOT",499        chunk_index: entriesChunkCount,500        chunk_count: totalChunks,501        entries: {},502        tombstones,503        duress_slot: duressSlot as string,504      }505    : null;506507  if (508    duressFrame &&509    new TextEncoder().encode(JSON.stringify(duressFrame)).length >510      SNAPSHOT_FRAME_WIRE_CEILING511  ) {512    throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");513  }514515  const frames: AdoptSnapshot[] = [];516  for (let index = 0; index < entriesChunkCount; index++) {517    const isFinalEntries = index === entriesChunkCount - 1;518    frames.push({519      type: "ADOPT_SNAPSHOT",520      chunk_index: index,521      chunk_count: totalChunks,522      entries: chunks[index],523      // Tombstones ride the LAST wire frame: when duress follows the entries,524      // it goes on the duress frame; otherwise on the last entries chunk.525      tombstones: isFinalEntries && !sendDuress ? tombstones : {},526    });527  }528529  if (duressFrame) frames.push(duressFrame);530  return frames;531}532533/**534 * Receiver side: receive and reassemble every snapshot chunk. Adds raw frame535 * bytes to the aggregate counter before decoding (rejecting before536 * JSON.parse when the next frame exceeds the remaining budget), then enforces537 * the per-frame wire ceiling, sequential 0-based chunk_index ordering, a538 * stable chunk_count within MAX_SNAPSHOT_CHUNKS, and structural validity of539 * each frame. Merging goes through the shared aggregate helper: every540 * key/value and projected count is validated in one bounded pass before541 * anything lands in the accumulator, so a rejection leaves no partial542 * adopted state and the caller can fall back to the sync pipeline.543 */544export async function receiveVaultSnapshot(545  channel: PairingTransport,546): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {547  const acc = createAdoptSnapshotBudget();548  let chunkCount = -1;549  let received = 0;550  while (chunkCount === -1 || received < chunkCount) {551    const wire = await channel.receive();552    if (wire.length > MAX_PADDED_SNAPSHOT_FRAME_BYTES) {553      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");554    }555    // PB-26-604: every budget below applies to the unpadded JSON, exactly as556    // the sender's preflight accounted for it.557    const raw = unpadSyncFrame(wire);558    if (raw.length > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {559      throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");560    }561    if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {562      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");563    }564    const frame: unknown = JSON.parse(new TextDecoder().decode(raw));565    if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");566    if (chunkCount === -1) {567      chunkCount = frame.chunk_count;568      if (chunkCount > MAX_SNAPSHOT_CHUNKS) {569        throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");570      }571    } else if (frame.chunk_count !== chunkCount) {572      throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");573    }574    if (frame.chunk_index !== received) {575      throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");576    }577    applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duress_slot, raw.length);578    received++;579  }580  return { entries: acc.entries, tombstones: acc.tombstones, duressSlot: acc.duressSlot };581}582583export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {584  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);585}586587export interface AdoptionRequestResult {588  device: PairedDevice;589  /** True iff the source echoed snapshot_capable: true in its ADOPT_GRANT. */590  snapshotCapable: boolean;591}592593export async function requestAdoption(594  channel: PairingTransport,595  deps: AdoptionReceiverDeps,596): Promise<AdoptionRequestResult | null> {597  const receiverLicense = await deps.license?.();598  await sendFrame(channel, {599    type: "ADOPT_REQUEST",600    device_id: deps.generateDeviceId(),601    device_name: deps.deviceName,602    device_type: "extension",603    license: receiverLicense,604    snapshot_capable: true,605  });606607  const confirmed = await deps.confirmSas();608  await sendFrame(channel, {609    type: "ADOPT_CONFIRM",610    confirmed,611  });612  if (!confirmed) {613    deps.onError("ADOPT_SAS_REJECTED");614    return null;615  }616617  const frame = await receiveFrame(channel);618  if (isAdoptReject(frame)) {619    deps.onError(frame.reason);620    return null;621  }622  if (!isAdoptGrant(frame)) {623    deps.onError("ADOPT_UNEXPECTED_FRAME");624    return null;625  }626  const grantSnapshotCapable = frame.snapshot_capable === true;627628  const rootKeyBytes = hexToBytes(frame.root_key_hex);629  try {630    const computedVaultId = await computeVaultId(rootKeyBytes);631    if (computedVaultId !== frame.vault_id) {632      deps.onError("ADOPT_VAULT_ID_MISMATCH");633      return null;634    }635  } finally {636    rootKeyBytes.fill(0);637  }638  if (!receiverLicense && !frame.license) {639    deps.onError("ADOPT_PRO_REQUIRED");640    return null;641  }642643  await deps.validateSource?.(frame.source);644  const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);645  if (vaultId !== frame.vault_id) {646    deps.onError("ADOPT_VAULT_ID_MISMATCH");647    return null;648  }649650  if (!receiverLicense && frame.license) {651    await deps.applyLicense?.(frame.license);652  }653654  const identity = await deps.deriveLocalIdentity();655  await sendFrame(channel, {656    type: "ADOPT_IDENTITY",657    device_id: identity.deviceId,658    device_name: identity.deviceName,659    device_type: identity.deviceType,660    long_term_public_key_hex: identity.longTermPublicKeyHex,661    signing_public_key_hex: identity.signingPublicKeyHex,662  });663664  const completion = await receiveFrame(channel);665  if (!isAdoptComplete(completion)) {666    deps.onError("ADOPT_COMPLETION_MISSING");667    return null;668  }669670  const sourceDevice: PairedDevice = {671    deviceId: frame.source.device_id,672    deviceName: capDeviceName(frame.source.device_name),673    deviceType: frame.source.device_type,674    longTermPublicKeyHex: frame.source.long_term_public_key_hex,675    signingPublicKeyHex: frame.source.signing_public_key_hex,676    trustSource: "direct",677    wsPort: frame.source.ws_port,678    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,679    relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,680    pairedAt: Date.now(),681  };682  await deps.upsertPairedDevice(sourceDevice);683  await sendFrame(channel, { type: "ADOPT_APPLIED" });684  return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };685}686687export async function grantAdoption(688  channel: PairingTransport,689  request: AdoptRequest,690  deps: AdoptionSourceDeps,691): Promise<PairedDevice | null> {692  const approved = await deps.onConsent(request);693  if (!approved) return null;694695  const confirmation = await receiveFrame(channel);696  if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;697698  let sourceLicense = await deps.license?.();699  if (!sourceLicense && request.license) {700    try {701      if (!deps.applyLicense) throw new Error("license_transfer_unavailable");702      await deps.applyLicense(request.license);703      sourceLicense = await deps.license?.();704    } catch {705      sourceLicense = undefined;706    }707  }708  if (!sourceLicense) {709    await sendFrame(channel, {710      type: "ADOPT_REJECT",711      reason: "ADOPT_PRO_REQUIRED",712    });713    throw new Error("ADOPT_PRO_REQUIRED");714  }715716  const rootKeyHex = await deps.rootKeyHex();717  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");718719  await sendFrame(channel, {720    type: "ADOPT_GRANT",721    root_key_hex: rootKeyHex,722    vault_id: await deps.vaultId(),723    license: sourceLicense,724    source: {725      device_id: deps.sourceIdentity.deviceId,726      device_name: deps.sourceIdentity.deviceName,727      device_type: deps.sourceIdentity.deviceType,728      long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,729      signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,730      ws_port: deps.sourceIdentity.wsPort,731      relay_url: deps.pairState?.relayUrl,732    },733    snapshot_capable: request.snapshot_capable === true,734  });735736  const identity = await receiveFrame(channel);737  if (!isAdoptIdentity(identity)) return null;738739  const device: PairedDevice = {740    deviceId: identity.device_id,741    deviceName: capDeviceName(identity.device_name),742    deviceType: identity.device_type,743    longTermPublicKeyHex: identity.long_term_public_key_hex,744    signingPublicKeyHex: identity.signing_public_key_hex,745    trustSource: "direct",746    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,747    relayUrl: deps.pairState?.relayUrl,748    pairedAt: Date.now(),749  };750  await deps.upsertPairedDevice(device);751  await sendFrame(channel, { type: "ADOPT_COMPLETE" });752  if (!isAdoptApplied(await receiveFrame(channel))) return null;753  return device;754}755756export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {757  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));758}759760export async function receiveFrame(channel: PairingTransport): Promise<unknown> {761  return JSON.parse(new TextDecoder().decode(await channel.receive()));762}763764function isDeviceType(value: unknown): value is DeviceType {765  return value === "extension" || value === "android" || value === "ios";766}767768function isSecureRelayUrl(value: string): boolean {769  try {770    const url = new URL(value);771    return url.protocol === "wss:" && !url.username && !url.password &&772      !url.search && !url.hash;773  } catch {774    return false;775  }776}777778function hexToBytes(hex: string): Uint8Array {779  const out = new Uint8Array(hex.length / 2);780  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);781  return out;782}