vault-adopt.ts

34.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  /**48   * The receiver's Pro key, sent only after the SAS comparison (PB-26-121):49   * a request-frame licence reaches a peer the user has not authenticated.50   */51  license?: string;52}5354export interface AdoptIdentity {55  type: "ADOPT_IDENTITY";56  device_id: string;57  device_name: string;58  device_type: DeviceType;59  long_term_public_key_hex: string;60  signing_public_key_hex?: string;61}6263/**64 * Encrypted source-to-receiver acknowledgement sent only after the source has65 * durably recorded the receiver. Without it the receiver could report success66 * after sending ADOPT_IDENTITY even though the source never received it.67 */68export interface AdoptComplete {69  type: "ADOPT_COMPLETE";70}7172/** Receiver-to-source acknowledgement sent after the source record is durable. */73export interface AdoptApplied {74  type: "ADOPT_APPLIED";75}7677/** Neither peer could prove a Pro entitlement, so no vault state may commit. */78export interface AdoptReject {79  type: "ADOPT_REJECT";80  reason: "ADOPT_PRO_REQUIRED" | "ADOPT_DEVICE_ID_COLLISION";81}8283/**84 * One chunk of the inline vault snapshot sent by the source after85 * ADOPT_APPLIED, over the same authenticated Noise NK channel. Entries are86 * opaque base64 ciphertext envelopes; tombstones ride the final chunk only.87 */88export interface AdoptSnapshot {89  type: "ADOPT_SNAPSHOT";90  chunk_index: number;91  chunk_count: number;92  entries: Record<string, string>;93  /** entryId → deletion timestamp; populated on the final chunk only. */94  tombstones: Record<string, number>;95  /**96   * Sealed duress (slot B) StoredVault as JSON.stringify of the browser97   * shape — identical to the Codex vault_b field. Populated on the final98   * chunk only, and only when duress is actually configured; absent otherwise.99   */100  duress_slot?: string;101}102103/** Receiver acknowledgement sent after all snapshot chunks are applied. */104export interface AdoptSnapshotApplied {105  type: "ADOPT_SNAPSHOT_APPLIED";106  entries_received: number;107  tombstones_received: number;108}109110/** Optional signed device graph appended after the snapshot (manifest v3+). */111export interface AdoptDeviceManifestFrame {112  type: "ADOPT_DEVICE_MANIFEST";113  manifest: SignedDeviceManifest;114}115116/** Receiver acknowledgement for an applied device manifest. */117export interface AdoptDeviceManifestApplied {118  type: "ADOPT_DEVICE_MANIFEST_APPLIED";119}120121export type AdoptionFrame =122  | AdoptRequest123  | AdoptConfirm124  | AdoptGrant125  | AdoptIdentity126  | AdoptComplete127  | AdoptApplied128  | AdoptReject129  | AdoptSnapshot130  | AdoptSnapshotApplied131  | AdoptDeviceManifestFrame132  | AdoptDeviceManifestApplied;133134/**135 * Wire guardrails for the inline snapshot. A chunk's serialized entries map136 * stays under MAX_SNAPSHOT_CHUNK_BYTES; the whole transfer is capped at137 * MAX_SNAPSHOT_CHUNKS frames so a compromised source cannot exhaust receiver138 * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator).139 */140export const MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024; // 512 KiB141export const MAX_SNAPSHOT_CHUNKS = 200;142// Headroom added to the per-chunk entries budget when validating an encoded143// frame on the wire: JSON envelope plus the final chunk's tombstone map.144export const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;145/**146 * PB-26-604: snapshot frames are padded to the sync bucket ladder before147 * Noise, so the wire bytes are larger than the JSON they carry. `+4` is148 * padSyncFrame's length header; the bucket ladder supplies the rest.149 */150export const MAX_PADDED_SNAPSHOT_FRAME_BYTES = syncFrameBucket(151  SNAPSHOT_FRAME_WIRE_CEILING + 4,152);153154/**155 * PB-26-206 aggregate budget shared by sender and receiver. The per-frame156 * guard above bounds one frame; this bounds the whole transfer — encoded157 * snapshot bytes (entries + tombstones + JSON framing + duress slot),158 * entry/tombstone counts, ID lengths, and per-envelope size — independently159 * of the 200-frame count, so many individually valid frames cannot OOM the160 * receiver. Aligned with the 12 MiB pairing-inbox transport backstop161 * (PB-26-205): an honest snapshot never approaches either ceiling.162 */163export const MAX_ADOPT_SNAPSHOT_BYTES = 10 * 1024 * 1024; // 10 MiB164export const MAX_ADOPT_SNAPSHOT_ENTRIES = 25_000;165export const MAX_ADOPT_SNAPSHOT_TOMBSTONES = 25_000;166/** Matches the cross-client anti-abuse ID ceiling (Android SyncLimits.MAX_ID_LENGTH). */167export const MAX_ADOPT_SNAPSHOT_ID_CHARS = 512;168/**169 * Maximum encrypted entry envelope: SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES170 * plus the existing base64 overhead allowance (mirrors sync-vault-adapter).171 */172export const MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES =173  (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64;174175/**176 * Running aggregate for one snapshot transfer. Counts are tracked alongside177 * the maps (not derived via Object.keys per frame) so the bound stays O(n).178 */179export interface AdoptSnapshotBudget {180  entries: Record<string, string>;181  tombstones: Record<string, number>;182  duressSlot: string | null;183  /** Sum of encoded wire-frame bytes consumed so far. */184  bytes: number;185  entryCount: number;186  tombstoneCount: number;187}188189export function createAdoptSnapshotBudget(): AdoptSnapshotBudget {190  return { entries: {}, tombstones: {}, duressSlot: null, bytes: 0, entryCount: 0, tombstoneCount: 0 };191}192193/**194 * PB-26-206 shared preflight/merge helper used by BOTH the sender195 * (preflight before the first frame goes out) and the receiver (per frame,196 * after the byte-budget check, before JSON.parse results are merged).197 *198 * One bounded pass per attacker-controlled map: each key/value is validated199 * and projected counts are enforced inside the same loop that merges, so the200 * maps are never iterated count→size→merge repeatedly. Throws before merging201 * the offending frame, leaving no partial adopted state.202 */203export function applyAdoptSnapshotFrame(204  acc: AdoptSnapshotBudget,205  entries: Record<string, string>,206  tombstones: Record<string, number>,207  duressSlot: string | null | undefined,208  frameBytes: number,209): void {210  if (frameBytes > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {211    throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");212  }213  for (const [id, envelope] of Object.entries(entries)) {214    if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw new Error("ADOPT_SNAPSHOT_ID_TOO_LONG");215    if (envelope.length > MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) {216      throw new Error("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE");217    }218    if (acc.entryCount >= MAX_ADOPT_SNAPSHOT_ENTRIES) {219      throw new Error("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES");220    }221    acc.entryCount++;222    acc.entries[id] = envelope;223  }224  for (const [id, ts] of Object.entries(tombstones)) {225    if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw new Error("ADOPT_SNAPSHOT_ID_TOO_LONG");226    if (typeof ts !== "number" || !Number.isFinite(ts)) {227      throw new Error("ADOPT_SNAPSHOT_INVALID_TOMBSTONE");228    }229    if (acc.tombstoneCount >= MAX_ADOPT_SNAPSHOT_TOMBSTONES) {230      throw new Error("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES");231    }232    acc.tombstoneCount++;233    acc.tombstones[id] = ts;234  }235  if (typeof duressSlot === "string" && duressSlot.length > 0) {236    // The duress slot rides inside wire frames already bounded by the frame237    // ceiling, and its raw bytes count against the aggregate above.238    if (duressSlot.length > SNAPSHOT_FRAME_WIRE_CEILING) {239      throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");240    }241    acc.duressSlot = duressSlot;242  }243  acc.bytes += frameBytes;244}245246export interface AdoptionPairState {247  rendezvousSecretHex: string;248  relayUrl?: string;249}250251export interface AdoptionReceiverDeps {252  deviceName: string;253  generateDeviceId(): string;254  /** Root-key transfer cannot begin until this device confirms the SAS. */255  confirmSas(): Promise<boolean>;256  /**257   * Optional local safety policy evaluated after the authenticated grant is258   * validated but before any vault state changes.259   */260  validateSource?(source: AdoptGrant["source"]): Promise<void>;261  /**262   * True when the source's identity (from ADOPT_GRANT) is in the local263   * terminal revocation ledger. When true, the receiver sends264   * ADOPT_DEVICE_ID_COLLISION so the source rotates and resends the grant.265   */266  hasSourceIdentityCollision?(source: AdoptGrant["source"]): Promise<boolean>;267  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;268  deriveLocalIdentity(): Promise<LocalIdentity>;269  rotateLocalIdentity?(): Promise<LocalIdentity>;270  upsertPairedDevice(device: PairedDevice): Promise<void>;271  applyLicense?(license: string): Promise<void>;272  /** Return this receiver's locally validated Pro key, if present. */273  license?(): Promise<string | undefined>;274  /** Bound to the same completed Noise session, never taken from frame claims. */275  pairState?: AdoptionPairState;276  onError(error: string): void;277}278279export interface AdoptionSourceDeps {280  sourceIdentity: LocalIdentity;281  rootKeyHex(): Promise<string>;282  vaultId(): Promise<string>;283  license?(): Promise<string | undefined>;284  /** Validate and durably store a Pro key offered by the receiver. */285  applyLicense?(license: string): Promise<void>;286  onConsent(request: AdoptRequest): Promise<boolean>;287  /** Persist the receiver pairing after authenticated identity exchange. */288  upsertPairedDevice(device: PairedDevice): Promise<void>;289  /** Persist the request identity before any root-bearing grant is sent. */290  persistPendingHolder?(request: AdoptRequest): Promise<void>;291  /** Remove the exact pending request after authenticated completion. */292  clearPendingHolder?(request: AdoptRequest, device: PairedDevice): Promise<void>293  /**294   * True when the receiver's identity conflicts with an existing paired295   * device or a terminal revocation record. When true, the source sends296   * ADOPT_DEVICE_ID_COLLISION so the receiver rotates its identity.297   */298  hasDeviceIdentityCollision?(device: PairedDevice): Promise<boolean>;299  /**300   * Rotate the source's identity after the receiver reports a collision on301   * the source's own identity. Returns the fresh identity to use in the302   * resent ADOPT_GRANT.303   */304  rotateSourceIdentity?(): Promise<LocalIdentity>;305  /** Bound to the same completed Noise session, never taken from frame claims. */306  pairState?: AdoptionPairState;307}308309export function isAdoptRequest(frame: unknown): frame is AdoptRequest {310  const value = frame as Partial<AdoptRequest> | null;311  return value?.type === "ADOPT_REQUEST" &&312    typeof value.device_id === "string" &&313    value.device_id.length > 0 &&314    typeof value.device_name === "string" &&315    isDeviceType(value.device_type) &&316    (value.license === undefined || typeof value.license === "string");317}318319export function isAdoptGrant(frame: unknown): frame is AdoptGrant {320  const value = frame as Partial<AdoptGrant> | null;321  return value?.type === "ADOPT_GRANT" &&322    validateRootKeyHex(value.root_key_hex) &&323    typeof value.vault_id === "string" &&324    /^[0-9a-f]{64}$/.test(value.vault_id) &&325    typeof value.source === "object" &&326    value.source !== null &&327    typeof value.source.device_id === "string" &&328    typeof value.source.device_name === "string" &&329    isDeviceType(value.source.device_type) &&330    typeof value.source.long_term_public_key_hex === "string" &&331    /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&332    (value.source.relay_url === undefined ||333      (typeof value.source.relay_url === "string" &&334        isSecureRelayUrl(value.source.relay_url))) &&335    (value.snapshot_capable === undefined ||336      typeof value.snapshot_capable === "boolean");337}338339export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {340  const value = frame as Partial<AdoptConfirm> | null;341  return value?.type === "ADOPT_CONFIRM" &&342    typeof value.confirmed === "boolean";343}344345export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {346  const value = frame as Partial<AdoptIdentity> | null;347  return value?.type === "ADOPT_IDENTITY" &&348    typeof value.device_id === "string" &&349    value.device_id.length > 0 &&350    typeof value.device_name === "string" &&351    isDeviceType(value.device_type) &&352    typeof value.long_term_public_key_hex === "string" &&353    /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);354}355356export function isAdoptComplete(frame: unknown): frame is AdoptComplete {357  return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";358}359360export function isAdoptApplied(frame: unknown): frame is AdoptApplied {361  return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";362}363364export function isAdoptReject(frame: unknown): frame is AdoptReject {365  const value = frame as Partial<AdoptReject> | null;366  return value?.type === "ADOPT_REJECT" &&367    (value.reason === "ADOPT_PRO_REQUIRED" ||368      value.reason === "ADOPT_DEVICE_ID_COLLISION");369}370371export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {372  const value = frame as Partial<AdoptSnapshot> | null;373  if (value?.type !== "ADOPT_SNAPSHOT") return false;374  if (375    typeof value.chunk_index !== "number" ||376    !Number.isInteger(value.chunk_index) ||377    value.chunk_index < 0378  ) return false;379  if (380    typeof value.chunk_count !== "number" ||381    !Number.isInteger(value.chunk_count) ||382    value.chunk_count < 1383  ) return false;384  if (!isStringRecord(value.entries)) return false;385  if (!isNumberRecord(value.tombstones)) return false;386  if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;387  return true;388}389390export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {391  const value = frame as Partial<AdoptSnapshotApplied> | null;392  return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&393    typeof value.entries_received === "number" &&394    typeof value.tombstones_received === "number";395}396397export function isAdoptDeviceManifestFrame(398  frame: unknown,399): frame is AdoptDeviceManifestFrame {400  const value = frame as Partial<AdoptDeviceManifestFrame> | null;401  const manifest = value?.manifest as402    | { body?: unknown; signatureHex?: unknown }403    | null;404  return value?.type === "ADOPT_DEVICE_MANIFEST" &&405    typeof manifest === "object" &&406    manifest !== null &&407    typeof manifest.body === "object" &&408    manifest.body !== null &&409    typeof manifest.signatureHex === "string";410}411412function isStringRecord(value: unknown): value is Record<string, string> {413  if (typeof value !== "object" || value === null) return false;414  return Object.values(value).every((v) => typeof v === "string");415}416417function isNumberRecord(value: unknown): value is Record<string, number> {418  if (typeof value !== "object" || value === null) return false;419  return Object.values(value).every((v) => typeof v === "number");420}421422/**423 * Source side: chunk the vault snapshot and send it over the pairing channel.424 *425 * Wire shape (v4.6.4+, duress on its own frame):426 *   - N entries frames, each ≤ MAX_SNAPSHOT_CHUNK_BYTES serialized entries.427 *     Tombstones ride the last entries frame when no duress follows.428 *   - If duressSlot is set, one extra frame after the entries carrying only429 *     the duress slot (entries: empty, tombstones: tombstone map,430 *     duress_slot: duressSlot). This moves duress off the entries frame so a431 *     sizeable sealed duress vault can no longer push the entries frame raw432 *     bytes over SNAPSHOT_FRAME_WIRE_CEILING (768 KiB).433 *434 * Frame budget (MAX_SNAPSHOT_CHUNKS = 200):435 *   - When duress is configured, ONE of the 200 frames is reserved for the436 *     duress slot. The entries cap is MAX_SNAPSHOT_CHUNKS - 1 = 199 entries437 *     chunks; excess entries are deferred to the sync fallback pipeline (sync438 *     carries entries and tombstones, so this is a graceful truncation).439 *     This reservation is REQUIRED: ordinary sync does NOT carry duress440 *     (vault.test.ts:158-179 test-locks duress as non-syncing), so silently441 *     dropping a duress frame at the cap would leave the adopted device442 *     permanently without its duress vault. We prioritize duress delivery443 *     over completeness of entries (the latter is recoverable via sync).444 *445 * Oversized duress: duress that itself exceeds SNAPSHOT_FRAME_WIRE_CEILING446 * (e.g. a ≥768 KiB sealed duress) is rejected with a thrown error BEFORE the447 * first duress frame is sent. This is a hard stop — the caller must split448 * the duress payload out of band. We do not silently truncate duress; sync449 * cannot restore it.450 *451 * Backward compat: pre-v4.6.4 senders put `duress_slot` on the entries452 * final chunk. Receivers tolerate both shapes because `receiveVaultSnapshot`453 * reads `duress_slot` off any chunk (a duress-only frame has empty454 * entries/tombstones which merge as a no-op).455 */456export async function sendVaultSnapshot(457  channel: PairingTransport,458  entries: Record<string, string>,459  tombstones: Record<string, number>,460  duressSlot: string | null = null,461): Promise<void> {462  const frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot);463  // PB-26-206 preflight with the SAME helper the receiver enforces: nothing464  // is sent until every frame fits the aggregate budget, so an over-budget465  // snapshot is never partially sent or silently truncated. The caller falls466  // through to the regular sync pipeline (deny-only: no wire-field change,467  // invitation prefix unchanged).468  const acc = createAdoptSnapshotBudget();469  const encoded: Uint8Array[] = frames.map((frame) =>470    new TextEncoder().encode(JSON.stringify(frame))471  );472  for (let index = 0; index < frames.length; index++) {473    const frame = frames[index];474    applyAdoptSnapshotFrame(475      acc,476      frame.entries,477      frame.tombstones,478      frame.duress_slot,479      encoded[index].length,480    );481  }482  // PB-26-604: pad to the sync bucket ladder so the relay sees bucket-sized483  // ciphertext and cannot read exact plaintext lengths (or the duress frame's484  // distinctive size) off the wire.485  for (const raw of encoded) await channel.send(padSyncFrame(raw));486}487488/**489 * Pure frame construction for sendVaultSnapshot: chunking, the duress-frame490 * reservation, and the oversized-duress hard stop. No I/O happens here, so491 * the preflight above can reject before the first byte goes out.492 */493function buildAdoptSnapshotFrames(494  entries: Record<string, string>,495  tombstones: Record<string, number>,496  duressSlot: string | null,497): AdoptSnapshot[] {498  const includeDuress = !!duressSlot;499  // Reserve one frame for duress when configured so duress is never silently500  // dropped at the cap; ordinary sync does NOT carry duress, so dropping a501  // duress frame would orphan the adopted device's duress vault.502  const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS;503504  const chunks: Array<Record<string, string>> = [];505  let current: Record<string, string> = {};506  let currentCount = 0;507  let currentBytes = 2; // "{}"508  for (const [id, envelope] of Object.entries(entries)) {509    // +1 for the separating comma; an overestimate only shrinks chunks.510    const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;511    if (currentCount > 0 && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {512      chunks.push(current);513      current = {};514      currentCount = 0;515      currentBytes = 2;516    }517    current[id] = envelope;518    currentCount++;519    currentBytes += entryBytes;520  }521  chunks.push(current);522523  const entriesChunkCount = Math.min(chunks.length, entryCap);524  const sendDuress = includeDuress;525  const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);526  const duressFrame: AdoptSnapshot | null = sendDuress527    ? {528        type: "ADOPT_SNAPSHOT",529        chunk_index: entriesChunkCount,530        chunk_count: totalChunks,531        entries: {},532        tombstones,533        duress_slot: duressSlot as string,534      }535    : null;536537  if (538    duressFrame &&539    new TextEncoder().encode(JSON.stringify(duressFrame)).length >540      SNAPSHOT_FRAME_WIRE_CEILING541  ) {542    throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");543  }544545  const frames: AdoptSnapshot[] = [];546  for (let index = 0; index < entriesChunkCount; index++) {547    const isFinalEntries = index === entriesChunkCount - 1;548    frames.push({549      type: "ADOPT_SNAPSHOT",550      chunk_index: index,551      chunk_count: totalChunks,552      entries: chunks[index],553      // Tombstones ride the LAST wire frame: when duress follows the entries,554      // it goes on the duress frame; otherwise on the last entries chunk.555      tombstones: isFinalEntries && !sendDuress ? tombstones : {},556    });557  }558559  if (duressFrame) frames.push(duressFrame);560  return frames;561}562563/**564 * Receiver side: receive and reassemble every snapshot chunk. Adds raw frame565 * bytes to the aggregate counter before decoding (rejecting before566 * JSON.parse when the next frame exceeds the remaining budget), then enforces567 * the per-frame wire ceiling, sequential 0-based chunk_index ordering, a568 * stable chunk_count within MAX_SNAPSHOT_CHUNKS, and structural validity of569 * each frame. Merging goes through the shared aggregate helper: every570 * key/value and projected count is validated in one bounded pass before571 * anything lands in the accumulator, so a rejection leaves no partial572 * adopted state and the caller can fall back to the sync pipeline.573 */574export async function receiveVaultSnapshot(575  channel: PairingTransport,576): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {577  const acc = createAdoptSnapshotBudget();578  let chunkCount = -1;579  let received = 0;580  while (chunkCount === -1 || received < chunkCount) {581    const wire = await channel.receive();582    if (wire.length > MAX_PADDED_SNAPSHOT_FRAME_BYTES) {583      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");584    }585    // PB-26-604: every budget below applies to the unpadded JSON, exactly as586    // the sender's preflight accounted for it.587    const raw = unpadSyncFrame(wire);588    if (raw.length > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {589      throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");590    }591    if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {592      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");593    }594    const frame: unknown = JSON.parse(new TextDecoder().decode(raw));595    if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");596    if (chunkCount === -1) {597      chunkCount = frame.chunk_count;598      if (chunkCount > MAX_SNAPSHOT_CHUNKS) {599        throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");600      }601    } else if (frame.chunk_count !== chunkCount) {602      throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");603    }604    if (frame.chunk_index !== received) {605      throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");606    }607    applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duress_slot, raw.length);608    received++;609  }610  return { entries: acc.entries, tombstones: acc.tombstones, duressSlot: acc.duressSlot };611}612613export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {614  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);615}616617export interface AdoptionRequestResult {618  device: PairedDevice;619  /** True iff the source echoed snapshot_capable: true in its ADOPT_GRANT. */620  snapshotCapable: boolean;621}622623export async function requestAdoption(624  channel: PairingTransport,625  deps: AdoptionReceiverDeps,626): Promise<AdoptionRequestResult | null> {627  const receiverLicense = await deps.license?.();628  await sendFrame(channel, {629    type: "ADOPT_REQUEST",630    device_id: deps.generateDeviceId(),631    device_name: deps.deviceName,632    device_type: "extension",633    snapshot_capable: true,634  });635636  const confirmed = await deps.confirmSas();637  // Sent only after the SAS decision (PB-26-121).638  await sendFrame(channel, {639    type: "ADOPT_CONFIRM",640    confirmed,641    license: receiverLicense,642  });643  if (!confirmed) {644    deps.onError("ADOPT_SAS_REJECTED");645    return null;646  }647648  const frame = await receiveFrame(channel);649  if (isAdoptReject(frame)) {650    deps.onError(frame.reason);651    return null;652  }653  if (!isAdoptGrant(frame)) {654    deps.onError("ADOPT_UNEXPECTED_FRAME");655    return null;656  }657  const grantSnapshotCapable = frame.snapshot_capable === true;658659  const rootKeyBytes = hexToBytes(frame.root_key_hex);660  try {661    const computedVaultId = await computeVaultId(rootKeyBytes);662    if (computedVaultId !== frame.vault_id) {663      deps.onError("ADOPT_VAULT_ID_MISMATCH");664      return null;665    }666  } finally {667    rootKeyBytes.fill(0);668  }669  if (!receiverLicense && !frame.license) {670    deps.onError("ADOPT_PRO_REQUIRED");671    return null;672  }673674  await deps.validateSource?.(frame.source);675676  // Check the source's identity against the local revocation ledger. A677  // previously-revoked device must present a fresh identity so that stale678  // signed revocations held by other mesh peers cannot remove it after679  // re-pair. If the source's identity is revoked, send ADOPT_DEVICE_ID_COLLISION680  // and wait for a fresh grant (one retry).681  let grant = frame;682  if (deps.hasSourceIdentityCollision && await deps.hasSourceIdentityCollision(grant.source)) {683    await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });684    const retry = await receiveFrame(channel);685    if (!isAdoptGrant(retry)) {686      deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED");687      return null;688    }689    grant = retry;690    if (deps.hasSourceIdentityCollision && await deps.hasSourceIdentityCollision(grant.source)) {691      await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });692      deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED");693      return null;694    }695    // Re-validate the fresh grant's vault id.696    const freshRootBytes = hexToBytes(grant.root_key_hex);697    try {698      if (await computeVaultId(freshRootBytes) !== grant.vault_id) {699        deps.onError("ADOPT_VAULT_ID_MISMATCH");700        return null;701      }702    } finally {703      freshRootBytes.fill(0);704    }705  }706707  const { vaultId } = await deps.createVaultFromRootKey(grant.root_key_hex);708  if (vaultId !== grant.vault_id) {709    deps.onError("ADOPT_VAULT_ID_MISMATCH");710    return null;711  }712713  if (!receiverLicense && grant.license) {714    await deps.applyLicense?.(grant.license);715  }716717  let identity = await deps.deriveLocalIdentity();718  await sendFrame(channel, {719    type: "ADOPT_IDENTITY",720    device_id: identity.deviceId,721    device_name: identity.deviceName,722    device_type: identity.deviceType,723    long_term_public_key_hex: identity.longTermPublicKeyHex,724    signing_public_key_hex: identity.signingPublicKeyHex,725  });726727  let completion = await receiveFrame(channel);728  if (729    isAdoptReject(completion) &&730    completion.reason === "ADOPT_DEVICE_ID_COLLISION" &&731    deps.rotateLocalIdentity732  ) {733    identity = await deps.rotateLocalIdentity();734    await sendFrame(channel, {735      type: "ADOPT_IDENTITY",736      device_id: identity.deviceId,737      device_name: identity.deviceName,738      device_type: identity.deviceType,739      long_term_public_key_hex: identity.longTermPublicKeyHex,740      signing_public_key_hex: identity.signingPublicKeyHex,741    });742    completion = await receiveFrame(channel);743  }744  if (!isAdoptComplete(completion)) {745    deps.onError("ADOPT_COMPLETION_MISSING");746    return null;747  }748749  const sourceDevice: PairedDevice = {750    deviceId: grant.source.device_id,751    deviceName: capDeviceName(grant.source.device_name),752    deviceType: grant.source.device_type,753    longTermPublicKeyHex: grant.source.long_term_public_key_hex,754    signingPublicKeyHex: grant.source.signing_public_key_hex,755    trustSource: "direct",756    wsPort: grant.source.ws_port,757    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,758    relayUrl: deps.pairState?.relayUrl ?? grant.source.relay_url,759    pairedAt: Date.now(),760  };761  await deps.upsertPairedDevice(sourceDevice);762  await sendFrame(channel, { type: "ADOPT_APPLIED" });763  return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };764}765766export async function grantAdoption(767  channel: PairingTransport,768  request: AdoptRequest,769  deps: AdoptionSourceDeps,770): Promise<PairedDevice | null> {771  const approved = await deps.onConsent(request);772  if (!approved) return null;773  const confirmation = await receiveFrame(channel);774  if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;775  await deps.persistPendingHolder?.(request);776777  let sourceLicense = await deps.license?.();778  // The receiver's key arrives on the confirm frame, after both SAS decisions779  // (PB-26-121). request.license is read only for a peer built before that change.780  const receiverLicense = confirmation.license ?? request.license;781  if (!sourceLicense && receiverLicense) {782    try {783      if (!deps.applyLicense) throw new Error("license_transfer_unavailable");784      await deps.applyLicense(receiverLicense);785      sourceLicense = await deps.license?.();786    } catch {787      sourceLicense = undefined;788    }789  }790  if (!sourceLicense) {791    await sendFrame(channel, {792      type: "ADOPT_REJECT",793      reason: "ADOPT_PRO_REQUIRED",794    });795    throw new Error("ADOPT_PRO_REQUIRED");796  }797798  const rootKeyHex = await deps.rootKeyHex();799  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");800801  let sourceIdentity = deps.sourceIdentity;802  let grantAttempts = 0;803  while (true) {804    await sendFrame(channel, {805      type: "ADOPT_GRANT",806      root_key_hex: rootKeyHex,807      vault_id: await deps.vaultId(),808      license: sourceLicense,809      source: {810        device_id: sourceIdentity.deviceId,811        device_name: sourceIdentity.deviceName,812        device_type: sourceIdentity.deviceType,813        long_term_public_key_hex: sourceIdentity.longTermPublicKeyHex,814        signing_public_key_hex: sourceIdentity.signingPublicKeyHex,815        ws_port: sourceIdentity.wsPort,816        relay_url: deps.pairState?.relayUrl,817      },818      snapshot_capable: request.snapshot_capable === true,819    });820821    const identity = await receiveFrame(channel);822823    // Receiver may reject the source's identity if it is in the receiver's824    // revocation ledger. Rotate and resend (one retry).825    if (826      isAdoptReject(identity) &&827      identity.reason === "ADOPT_DEVICE_ID_COLLISION" &&828      deps.rotateSourceIdentity &&829      grantAttempts === 0830    ) {831      sourceIdentity = await deps.rotateSourceIdentity();832      grantAttempts++;833      continue;834    }835836    if (!isAdoptIdentity(identity)) return null;837838    const device: PairedDevice = {839      deviceId: identity.device_id,840      deviceName: capDeviceName(identity.device_name),841      deviceType: identity.device_type,842      longTermPublicKeyHex: identity.long_term_public_key_hex,843      signingPublicKeyHex: identity.signing_public_key_hex,844      trustSource: "direct",845      rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,846      relayUrl: deps.pairState?.relayUrl,847      pairedAt: Date.now(),848    };849850    // Check the receiver's identity against local revocations (matching851    // Android's grantAdoption collision detection). If revoked, send852    // ADOPT_DEVICE_ID_COLLISION so the receiver rotates (one retry).853    if (deps.hasDeviceIdentityCollision && await deps.hasDeviceIdentityCollision(device)) {854      await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });855      const freshIdentity = await receiveFrame(channel);856      if (!isAdoptIdentity(freshIdentity)) return null;857      const freshDevice: PairedDevice = {858        deviceId: freshIdentity.device_id,859        deviceName: capDeviceName(freshIdentity.device_name),860        deviceType: freshIdentity.device_type,861        longTermPublicKeyHex: freshIdentity.long_term_public_key_hex,862        signingPublicKeyHex: freshIdentity.signing_public_key_hex,863        trustSource: "direct",864        rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,865        relayUrl: deps.pairState?.relayUrl,866        pairedAt: Date.now(),867      };868      if (deps.hasDeviceIdentityCollision && await deps.hasDeviceIdentityCollision(freshDevice)) {869        await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });870        return null;871      }872      await deps.upsertPairedDevice(freshDevice);873      await sendFrame(channel, { type: "ADOPT_COMPLETE" });874      if (!isAdoptApplied(await receiveFrame(channel))) return null;875      await deps.clearPendingHolder?.(request, freshDevice);876      return freshDevice;877    }878879      await deps.upsertPairedDevice(device);880      await sendFrame(channel, { type: "ADOPT_COMPLETE" });881      if (!isAdoptApplied(await receiveFrame(channel))) return null;882      await deps.clearPendingHolder?.(request, device);883      return device;884  }885}886887export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {888  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));889}890891export async function receiveFrame(channel: PairingTransport): Promise<unknown> {892  return JSON.parse(new TextDecoder().decode(await channel.receive()));893}894895function isDeviceType(value: unknown): value is DeviceType {896  return value === "extension" || value === "android" || value === "ios";897}898899function isSecureRelayUrl(value: string): boolean {900  try {901    const url = new URL(value);902    return url.protocol === "wss:" && !url.username && !url.password &&903      !url.search && !url.hash;904  } catch {905    return false;906  }907}908909function hexToBytes(hex: string): Uint8Array {910  const out = new Uint8Array(hex.length / 2);911  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);912  return out;913}