vault-adopt.ts

33.8 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  /** Persist the receiver pairing after authenticated identity exchange. */283  upsertPairedDevice(device: PairedDevice): Promise<void>;284  /** Persist the request identity before any root-bearing grant is sent. */285  persistPendingHolder?(request: AdoptRequest): Promise<void>;286  /** Remove the exact pending request after authenticated completion. */287  clearPendingHolder?(request: AdoptRequest, device: PairedDevice): Promise<void>288  /**289   * True when the receiver's identity conflicts with an existing paired290   * device or a terminal revocation record. When true, the source sends291   * ADOPT_DEVICE_ID_COLLISION so the receiver rotates its identity.292   */293  hasDeviceIdentityCollision?(device: PairedDevice): Promise<boolean>;294  /**295   * Rotate the source's identity after the receiver reports a collision on296   * the source's own identity. Returns the fresh identity to use in the297   * resent ADOPT_GRANT.298   */299  rotateSourceIdentity?(): Promise<LocalIdentity>;300  /** Bound to the same completed Noise session, never taken from frame claims. */301  pairState?: AdoptionPairState;302}303304export function isAdoptRequest(frame: unknown): frame is AdoptRequest {305  const value = frame as Partial<AdoptRequest> | null;306  return value?.type === "ADOPT_REQUEST" &&307    typeof value.device_id === "string" &&308    value.device_id.length > 0 &&309    typeof value.device_name === "string" &&310    isDeviceType(value.device_type) &&311    (value.license === undefined || typeof value.license === "string");312}313314export function isAdoptGrant(frame: unknown): frame is AdoptGrant {315  const value = frame as Partial<AdoptGrant> | null;316  return value?.type === "ADOPT_GRANT" &&317    validateRootKeyHex(value.root_key_hex) &&318    typeof value.vault_id === "string" &&319    /^[0-9a-f]{64}$/.test(value.vault_id) &&320    typeof value.source === "object" &&321    value.source !== null &&322    typeof value.source.device_id === "string" &&323    typeof value.source.device_name === "string" &&324    isDeviceType(value.source.device_type) &&325    typeof value.source.long_term_public_key_hex === "string" &&326    /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&327    (value.source.relay_url === undefined ||328      (typeof value.source.relay_url === "string" &&329        isSecureRelayUrl(value.source.relay_url))) &&330    (value.snapshot_capable === undefined ||331      typeof value.snapshot_capable === "boolean");332}333334export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {335  const value = frame as Partial<AdoptConfirm> | null;336  return value?.type === "ADOPT_CONFIRM" &&337    typeof value.confirmed === "boolean";338}339340export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {341  const value = frame as Partial<AdoptIdentity> | null;342  return value?.type === "ADOPT_IDENTITY" &&343    typeof value.device_id === "string" &&344    value.device_id.length > 0 &&345    typeof value.device_name === "string" &&346    isDeviceType(value.device_type) &&347    typeof value.long_term_public_key_hex === "string" &&348    /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);349}350351export function isAdoptComplete(frame: unknown): frame is AdoptComplete {352  return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";353}354355export function isAdoptApplied(frame: unknown): frame is AdoptApplied {356  return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";357}358359export function isAdoptReject(frame: unknown): frame is AdoptReject {360  const value = frame as Partial<AdoptReject> | null;361  return value?.type === "ADOPT_REJECT" &&362    (value.reason === "ADOPT_PRO_REQUIRED" ||363      value.reason === "ADOPT_DEVICE_ID_COLLISION");364}365366export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {367  const value = frame as Partial<AdoptSnapshot> | null;368  if (value?.type !== "ADOPT_SNAPSHOT") return false;369  if (370    typeof value.chunk_index !== "number" ||371    !Number.isInteger(value.chunk_index) ||372    value.chunk_index < 0373  ) return false;374  if (375    typeof value.chunk_count !== "number" ||376    !Number.isInteger(value.chunk_count) ||377    value.chunk_count < 1378  ) return false;379  if (!isStringRecord(value.entries)) return false;380  if (!isNumberRecord(value.tombstones)) return false;381  if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;382  return true;383}384385export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {386  const value = frame as Partial<AdoptSnapshotApplied> | null;387  return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&388    typeof value.entries_received === "number" &&389    typeof value.tombstones_received === "number";390}391392export function isAdoptDeviceManifestFrame(393  frame: unknown,394): frame is AdoptDeviceManifestFrame {395  const value = frame as Partial<AdoptDeviceManifestFrame> | null;396  const manifest = value?.manifest as397    | { body?: unknown; signatureHex?: unknown }398    | null;399  return value?.type === "ADOPT_DEVICE_MANIFEST" &&400    typeof manifest === "object" &&401    manifest !== null &&402    typeof manifest.body === "object" &&403    manifest.body !== null &&404    typeof manifest.signatureHex === "string";405}406407function isStringRecord(value: unknown): value is Record<string, string> {408  if (typeof value !== "object" || value === null) return false;409  return Object.values(value).every((v) => typeof v === "string");410}411412function isNumberRecord(value: unknown): value is Record<string, number> {413  if (typeof value !== "object" || value === null) return false;414  return Object.values(value).every((v) => typeof v === "number");415}416417/**418 * Source side: chunk the vault snapshot and send it over the pairing channel.419 *420 * Wire shape (v4.6.4+, duress on its own frame):421 *   - N entries frames, each ≤ MAX_SNAPSHOT_CHUNK_BYTES serialized entries.422 *     Tombstones ride the last entries frame when no duress follows.423 *   - If duressSlot is set, one extra frame after the entries carrying only424 *     the duress slot (entries: empty, tombstones: tombstone map,425 *     duress_slot: duressSlot). This moves duress off the entries frame so a426 *     sizeable sealed duress vault can no longer push the entries frame raw427 *     bytes over SNAPSHOT_FRAME_WIRE_CEILING (768 KiB).428 *429 * Frame budget (MAX_SNAPSHOT_CHUNKS = 200):430 *   - When duress is configured, ONE of the 200 frames is reserved for the431 *     duress slot. The entries cap is MAX_SNAPSHOT_CHUNKS - 1 = 199 entries432 *     chunks; excess entries are deferred to the sync fallback pipeline (sync433 *     carries entries and tombstones, so this is a graceful truncation).434 *     This reservation is REQUIRED: ordinary sync does NOT carry duress435 *     (vault.test.ts:158-179 test-locks duress as non-syncing), so silently436 *     dropping a duress frame at the cap would leave the adopted device437 *     permanently without its duress vault. We prioritize duress delivery438 *     over completeness of entries (the latter is recoverable via sync).439 *440 * Oversized duress: duress that itself exceeds SNAPSHOT_FRAME_WIRE_CEILING441 * (e.g. a ≥768 KiB sealed duress) is rejected with a thrown error BEFORE the442 * first duress frame is sent. This is a hard stop — the caller must split443 * the duress payload out of band. We do not silently truncate duress; sync444 * cannot restore it.445 *446 * Backward compat: pre-v4.6.4 senders put `duress_slot` on the entries447 * final chunk. Receivers tolerate both shapes because `receiveVaultSnapshot`448 * reads `duress_slot` off any chunk (a duress-only frame has empty449 * entries/tombstones which merge as a no-op).450 */451export async function sendVaultSnapshot(452  channel: PairingTransport,453  entries: Record<string, string>,454  tombstones: Record<string, number>,455  duressSlot: string | null = null,456): Promise<void> {457  const frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot);458  // PB-26-206 preflight with the SAME helper the receiver enforces: nothing459  // is sent until every frame fits the aggregate budget, so an over-budget460  // snapshot is never partially sent or silently truncated. The caller falls461  // through to the regular sync pipeline (deny-only: no wire-field change,462  // invitation prefix unchanged).463  const acc = createAdoptSnapshotBudget();464  const encoded: Uint8Array[] = frames.map((frame) =>465    new TextEncoder().encode(JSON.stringify(frame))466  );467  for (let index = 0; index < frames.length; index++) {468    const frame = frames[index];469    applyAdoptSnapshotFrame(470      acc,471      frame.entries,472      frame.tombstones,473      frame.duress_slot,474      encoded[index].length,475    );476  }477  // PB-26-604: pad to the sync bucket ladder so the relay sees bucket-sized478  // ciphertext and cannot read exact plaintext lengths (or the duress frame's479  // distinctive size) off the wire.480  for (const raw of encoded) await channel.send(padSyncFrame(raw));481}482483/**484 * Pure frame construction for sendVaultSnapshot: chunking, the duress-frame485 * reservation, and the oversized-duress hard stop. No I/O happens here, so486 * the preflight above can reject before the first byte goes out.487 */488function buildAdoptSnapshotFrames(489  entries: Record<string, string>,490  tombstones: Record<string, number>,491  duressSlot: string | null,492): AdoptSnapshot[] {493  const includeDuress = !!duressSlot;494  // Reserve one frame for duress when configured so duress is never silently495  // dropped at the cap; ordinary sync does NOT carry duress, so dropping a496  // duress frame would orphan the adopted device's duress vault.497  const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS;498499  const chunks: Array<Record<string, string>> = [];500  let current: Record<string, string> = {};501  let currentCount = 0;502  let currentBytes = 2; // "{}"503  for (const [id, envelope] of Object.entries(entries)) {504    // +1 for the separating comma; an overestimate only shrinks chunks.505    const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;506    if (currentCount > 0 && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {507      chunks.push(current);508      current = {};509      currentCount = 0;510      currentBytes = 2;511    }512    current[id] = envelope;513    currentCount++;514    currentBytes += entryBytes;515  }516  chunks.push(current);517518  const entriesChunkCount = Math.min(chunks.length, entryCap);519  const sendDuress = includeDuress;520  const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);521  const duressFrame: AdoptSnapshot | null = sendDuress522    ? {523        type: "ADOPT_SNAPSHOT",524        chunk_index: entriesChunkCount,525        chunk_count: totalChunks,526        entries: {},527        tombstones,528        duress_slot: duressSlot as string,529      }530    : null;531532  if (533    duressFrame &&534    new TextEncoder().encode(JSON.stringify(duressFrame)).length >535      SNAPSHOT_FRAME_WIRE_CEILING536  ) {537    throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");538  }539540  const frames: AdoptSnapshot[] = [];541  for (let index = 0; index < entriesChunkCount; index++) {542    const isFinalEntries = index === entriesChunkCount - 1;543    frames.push({544      type: "ADOPT_SNAPSHOT",545      chunk_index: index,546      chunk_count: totalChunks,547      entries: chunks[index],548      // Tombstones ride the LAST wire frame: when duress follows the entries,549      // it goes on the duress frame; otherwise on the last entries chunk.550      tombstones: isFinalEntries && !sendDuress ? tombstones : {},551    });552  }553554  if (duressFrame) frames.push(duressFrame);555  return frames;556}557558/**559 * Receiver side: receive and reassemble every snapshot chunk. Adds raw frame560 * bytes to the aggregate counter before decoding (rejecting before561 * JSON.parse when the next frame exceeds the remaining budget), then enforces562 * the per-frame wire ceiling, sequential 0-based chunk_index ordering, a563 * stable chunk_count within MAX_SNAPSHOT_CHUNKS, and structural validity of564 * each frame. Merging goes through the shared aggregate helper: every565 * key/value and projected count is validated in one bounded pass before566 * anything lands in the accumulator, so a rejection leaves no partial567 * adopted state and the caller can fall back to the sync pipeline.568 */569export async function receiveVaultSnapshot(570  channel: PairingTransport,571): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {572  const acc = createAdoptSnapshotBudget();573  let chunkCount = -1;574  let received = 0;575  while (chunkCount === -1 || received < chunkCount) {576    const wire = await channel.receive();577    if (wire.length > MAX_PADDED_SNAPSHOT_FRAME_BYTES) {578      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");579    }580    // PB-26-604: every budget below applies to the unpadded JSON, exactly as581    // the sender's preflight accounted for it.582    const raw = unpadSyncFrame(wire);583    if (raw.length > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {584      throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");585    }586    if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {587      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");588    }589    const frame: unknown = JSON.parse(new TextDecoder().decode(raw));590    if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");591    if (chunkCount === -1) {592      chunkCount = frame.chunk_count;593      if (chunkCount > MAX_SNAPSHOT_CHUNKS) {594        throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");595      }596    } else if (frame.chunk_count !== chunkCount) {597      throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");598    }599    if (frame.chunk_index !== received) {600      throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");601    }602    applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duress_slot, raw.length);603    received++;604  }605  return { entries: acc.entries, tombstones: acc.tombstones, duressSlot: acc.duressSlot };606}607608export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {609  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);610}611612export interface AdoptionRequestResult {613  device: PairedDevice;614  /** True iff the source echoed snapshot_capable: true in its ADOPT_GRANT. */615  snapshotCapable: boolean;616}617618export async function requestAdoption(619  channel: PairingTransport,620  deps: AdoptionReceiverDeps,621): Promise<AdoptionRequestResult | null> {622  const receiverLicense = await deps.license?.();623  await sendFrame(channel, {624    type: "ADOPT_REQUEST",625    device_id: deps.generateDeviceId(),626    device_name: deps.deviceName,627    device_type: "extension",628    license: receiverLicense,629    snapshot_capable: true,630  });631632  const confirmed = await deps.confirmSas();633  await sendFrame(channel, {634    type: "ADOPT_CONFIRM",635    confirmed,636  });637  if (!confirmed) {638    deps.onError("ADOPT_SAS_REJECTED");639    return null;640  }641642  const frame = await receiveFrame(channel);643  if (isAdoptReject(frame)) {644    deps.onError(frame.reason);645    return null;646  }647  if (!isAdoptGrant(frame)) {648    deps.onError("ADOPT_UNEXPECTED_FRAME");649    return null;650  }651  const grantSnapshotCapable = frame.snapshot_capable === true;652653  const rootKeyBytes = hexToBytes(frame.root_key_hex);654  try {655    const computedVaultId = await computeVaultId(rootKeyBytes);656    if (computedVaultId !== frame.vault_id) {657      deps.onError("ADOPT_VAULT_ID_MISMATCH");658      return null;659    }660  } finally {661    rootKeyBytes.fill(0);662  }663  if (!receiverLicense && !frame.license) {664    deps.onError("ADOPT_PRO_REQUIRED");665    return null;666  }667668  await deps.validateSource?.(frame.source);669670  // Check the source's identity against the local revocation ledger. A671  // previously-revoked device must present a fresh identity so that stale672  // signed revocations held by other mesh peers cannot remove it after673  // re-pair. If the source's identity is revoked, send ADOPT_DEVICE_ID_COLLISION674  // and wait for a fresh grant (one retry).675  let grant = frame;676  if (deps.hasSourceIdentityCollision && await deps.hasSourceIdentityCollision(grant.source)) {677    await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });678    const retry = await receiveFrame(channel);679    if (!isAdoptGrant(retry)) {680      deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED");681      return null;682    }683    grant = retry;684    if (deps.hasSourceIdentityCollision && await deps.hasSourceIdentityCollision(grant.source)) {685      await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });686      deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED");687      return null;688    }689    // Re-validate the fresh grant's vault id.690    const freshRootBytes = hexToBytes(grant.root_key_hex);691    try {692      if (await computeVaultId(freshRootBytes) !== grant.vault_id) {693        deps.onError("ADOPT_VAULT_ID_MISMATCH");694        return null;695      }696    } finally {697      freshRootBytes.fill(0);698    }699  }700701  const { vaultId } = await deps.createVaultFromRootKey(grant.root_key_hex);702  if (vaultId !== grant.vault_id) {703    deps.onError("ADOPT_VAULT_ID_MISMATCH");704    return null;705  }706707  if (!receiverLicense && grant.license) {708    await deps.applyLicense?.(grant.license);709  }710711  let identity = await deps.deriveLocalIdentity();712  await sendFrame(channel, {713    type: "ADOPT_IDENTITY",714    device_id: identity.deviceId,715    device_name: identity.deviceName,716    device_type: identity.deviceType,717    long_term_public_key_hex: identity.longTermPublicKeyHex,718    signing_public_key_hex: identity.signingPublicKeyHex,719  });720721  let completion = await receiveFrame(channel);722  if (723    isAdoptReject(completion) &&724    completion.reason === "ADOPT_DEVICE_ID_COLLISION" &&725    deps.rotateLocalIdentity726  ) {727    identity = await deps.rotateLocalIdentity();728    await sendFrame(channel, {729      type: "ADOPT_IDENTITY",730      device_id: identity.deviceId,731      device_name: identity.deviceName,732      device_type: identity.deviceType,733      long_term_public_key_hex: identity.longTermPublicKeyHex,734      signing_public_key_hex: identity.signingPublicKeyHex,735    });736    completion = await receiveFrame(channel);737  }738  if (!isAdoptComplete(completion)) {739    deps.onError("ADOPT_COMPLETION_MISSING");740    return null;741  }742743  const sourceDevice: PairedDevice = {744    deviceId: grant.source.device_id,745    deviceName: capDeviceName(grant.source.device_name),746    deviceType: grant.source.device_type,747    longTermPublicKeyHex: grant.source.long_term_public_key_hex,748    signingPublicKeyHex: grant.source.signing_public_key_hex,749    trustSource: "direct",750    wsPort: grant.source.ws_port,751    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,752    relayUrl: deps.pairState?.relayUrl ?? grant.source.relay_url,753    pairedAt: Date.now(),754  };755  await deps.upsertPairedDevice(sourceDevice);756  await sendFrame(channel, { type: "ADOPT_APPLIED" });757  return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };758}759760export async function grantAdoption(761  channel: PairingTransport,762  request: AdoptRequest,763  deps: AdoptionSourceDeps,764): Promise<PairedDevice | null> {765  const approved = await deps.onConsent(request);766  if (!approved) return null;767  const confirmation = await receiveFrame(channel);768  if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;769  await deps.persistPendingHolder?.(request);770771  let sourceLicense = await deps.license?.();772  if (!sourceLicense && request.license) {773    try {774      if (!deps.applyLicense) throw new Error("license_transfer_unavailable");775      await deps.applyLicense(request.license);776      sourceLicense = await deps.license?.();777    } catch {778      sourceLicense = undefined;779    }780  }781  if (!sourceLicense) {782    await sendFrame(channel, {783      type: "ADOPT_REJECT",784      reason: "ADOPT_PRO_REQUIRED",785    });786    throw new Error("ADOPT_PRO_REQUIRED");787  }788789  const rootKeyHex = await deps.rootKeyHex();790  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");791792  let sourceIdentity = deps.sourceIdentity;793  let grantAttempts = 0;794  while (true) {795    await sendFrame(channel, {796      type: "ADOPT_GRANT",797      root_key_hex: rootKeyHex,798      vault_id: await deps.vaultId(),799      license: sourceLicense,800      source: {801        device_id: sourceIdentity.deviceId,802        device_name: sourceIdentity.deviceName,803        device_type: sourceIdentity.deviceType,804        long_term_public_key_hex: sourceIdentity.longTermPublicKeyHex,805        signing_public_key_hex: sourceIdentity.signingPublicKeyHex,806        ws_port: sourceIdentity.wsPort,807        relay_url: deps.pairState?.relayUrl,808      },809      snapshot_capable: request.snapshot_capable === true,810    });811812    const identity = await receiveFrame(channel);813814    // Receiver may reject the source's identity if it is in the receiver's815    // revocation ledger. Rotate and resend (one retry).816    if (817      isAdoptReject(identity) &&818      identity.reason === "ADOPT_DEVICE_ID_COLLISION" &&819      deps.rotateSourceIdentity &&820      grantAttempts === 0821    ) {822      sourceIdentity = await deps.rotateSourceIdentity();823      grantAttempts++;824      continue;825    }826827    if (!isAdoptIdentity(identity)) return null;828829    const device: PairedDevice = {830      deviceId: identity.device_id,831      deviceName: capDeviceName(identity.device_name),832      deviceType: identity.device_type,833      longTermPublicKeyHex: identity.long_term_public_key_hex,834      signingPublicKeyHex: identity.signing_public_key_hex,835      trustSource: "direct",836      rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,837      relayUrl: deps.pairState?.relayUrl,838      pairedAt: Date.now(),839    };840841    // Check the receiver's identity against local revocations (matching842    // Android's grantAdoption collision detection). If revoked, send843    // ADOPT_DEVICE_ID_COLLISION so the receiver rotates (one retry).844    if (deps.hasDeviceIdentityCollision && await deps.hasDeviceIdentityCollision(device)) {845      await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });846      const freshIdentity = await receiveFrame(channel);847      if (!isAdoptIdentity(freshIdentity)) return null;848      const freshDevice: PairedDevice = {849        deviceId: freshIdentity.device_id,850        deviceName: capDeviceName(freshIdentity.device_name),851        deviceType: freshIdentity.device_type,852        longTermPublicKeyHex: freshIdentity.long_term_public_key_hex,853        signingPublicKeyHex: freshIdentity.signing_public_key_hex,854        trustSource: "direct",855        rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,856        relayUrl: deps.pairState?.relayUrl,857        pairedAt: Date.now(),858      };859      if (deps.hasDeviceIdentityCollision && await deps.hasDeviceIdentityCollision(freshDevice)) {860        await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });861        return null;862      }863      await deps.upsertPairedDevice(freshDevice);864      await sendFrame(channel, { type: "ADOPT_COMPLETE" });865      if (!isAdoptApplied(await receiveFrame(channel))) return null;866      await deps.clearPendingHolder?.(request, freshDevice);867      return freshDevice;868    }869870      await deps.upsertPairedDevice(device);871      await sendFrame(channel, { type: "ADOPT_COMPLETE" });872      if (!isAdoptApplied(await receiveFrame(channel))) return null;873      await deps.clearPendingHolder?.(request, device);874      return device;875  }876}877878export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {879  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));880}881882export async function receiveFrame(channel: PairingTransport): Promise<unknown> {883  return JSON.parse(new TextDecoder().decode(await channel.receive()));884}885886function isDeviceType(value: unknown): value is DeviceType {887  return value === "extension" || value === "android" || value === "ios";888}889890function isSecureRelayUrl(value: string): boolean {891  try {892    const url = new URL(value);893    return url.protocol === "wss:" && !url.username && !url.password &&894      !url.search && !url.hash;895  } catch {896    return false;897  }898}899900function hexToBytes(hex: string): Uint8Array {901  const out = new Uint8Array(hex.length / 2);902  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);903  return out;904}