vault-adopt.ts

29.2 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  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;257  deriveLocalIdentity(): Promise<LocalIdentity>;258  rotateLocalIdentity?(): Promise<LocalIdentity>;259  upsertPairedDevice(device: PairedDevice): Promise<void>;260  applyLicense?(license: string): Promise<void>;261  /** Return this receiver's locally validated Pro key, if present. */262  license?(): Promise<string | undefined>;263  /** Bound to the same completed Noise session, never taken from frame claims. */264  pairState?: AdoptionPairState;265  onError(error: string): void;266}267268export interface AdoptionSourceDeps {269  sourceIdentity: LocalIdentity;270  rootKeyHex(): Promise<string>;271  vaultId(): Promise<string>;272  license?(): Promise<string | undefined>;273  /** Validate and durably store a Pro key offered by the receiver. */274  applyLicense?(license: string): Promise<void>;275  onConsent(request: AdoptRequest): Promise<boolean>;276  upsertPairedDevice(device: PairedDevice): Promise<void>;277  /** Bound to the same completed Noise session, never taken from frame claims. */278  pairState?: AdoptionPairState;279}280281export function isAdoptRequest(frame: unknown): frame is AdoptRequest {282  const value = frame as Partial<AdoptRequest> | null;283  return value?.type === "ADOPT_REQUEST" &&284    typeof value.device_id === "string" &&285    value.device_id.length > 0 &&286    typeof value.device_name === "string" &&287    isDeviceType(value.device_type) &&288    (value.license === undefined || typeof value.license === "string");289}290291export function isAdoptGrant(frame: unknown): frame is AdoptGrant {292  const value = frame as Partial<AdoptGrant> | null;293  return value?.type === "ADOPT_GRANT" &&294    validateRootKeyHex(value.root_key_hex) &&295    typeof value.vault_id === "string" &&296    /^[0-9a-f]{64}$/.test(value.vault_id) &&297    typeof value.source === "object" &&298    value.source !== null &&299    typeof value.source.device_id === "string" &&300    typeof value.source.device_name === "string" &&301    isDeviceType(value.source.device_type) &&302    typeof value.source.long_term_public_key_hex === "string" &&303    /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&304    (value.source.relay_url === undefined ||305      (typeof value.source.relay_url === "string" &&306        isSecureRelayUrl(value.source.relay_url))) &&307    (value.snapshot_capable === undefined ||308      typeof value.snapshot_capable === "boolean");309}310311export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {312  const value = frame as Partial<AdoptConfirm> | null;313  return value?.type === "ADOPT_CONFIRM" &&314    typeof value.confirmed === "boolean";315}316317export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {318  const value = frame as Partial<AdoptIdentity> | null;319  return value?.type === "ADOPT_IDENTITY" &&320    typeof value.device_id === "string" &&321    value.device_id.length > 0 &&322    typeof value.device_name === "string" &&323    isDeviceType(value.device_type) &&324    typeof value.long_term_public_key_hex === "string" &&325    /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);326}327328export function isAdoptComplete(frame: unknown): frame is AdoptComplete {329  return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";330}331332export function isAdoptApplied(frame: unknown): frame is AdoptApplied {333  return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";334}335336export function isAdoptReject(frame: unknown): frame is AdoptReject {337  const value = frame as Partial<AdoptReject> | null;338  return value?.type === "ADOPT_REJECT" &&339    (value.reason === "ADOPT_PRO_REQUIRED" ||340      value.reason === "ADOPT_DEVICE_ID_COLLISION");341}342343export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {344  const value = frame as Partial<AdoptSnapshot> | null;345  if (value?.type !== "ADOPT_SNAPSHOT") return false;346  if (347    typeof value.chunk_index !== "number" ||348    !Number.isInteger(value.chunk_index) ||349    value.chunk_index < 0350  ) return false;351  if (352    typeof value.chunk_count !== "number" ||353    !Number.isInteger(value.chunk_count) ||354    value.chunk_count < 1355  ) return false;356  if (!isStringRecord(value.entries)) return false;357  if (!isNumberRecord(value.tombstones)) return false;358  if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;359  return true;360}361362export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {363  const value = frame as Partial<AdoptSnapshotApplied> | null;364  return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&365    typeof value.entries_received === "number" &&366    typeof value.tombstones_received === "number";367}368369export function isAdoptDeviceManifestFrame(370  frame: unknown,371): frame is AdoptDeviceManifestFrame {372  const value = frame as Partial<AdoptDeviceManifestFrame> | null;373  const manifest = value?.manifest as374    | { body?: unknown; signatureHex?: unknown }375    | null;376  return value?.type === "ADOPT_DEVICE_MANIFEST" &&377    typeof manifest === "object" &&378    manifest !== null &&379    typeof manifest.body === "object" &&380    manifest.body !== null &&381    typeof manifest.signatureHex === "string";382}383384function isStringRecord(value: unknown): value is Record<string, string> {385  if (typeof value !== "object" || value === null) return false;386  return Object.values(value).every((v) => typeof v === "string");387}388389function isNumberRecord(value: unknown): value is Record<string, number> {390  if (typeof value !== "object" || value === null) return false;391  return Object.values(value).every((v) => typeof v === "number");392}393394/**395 * Source side: chunk the vault snapshot and send it over the pairing channel.396 *397 * Wire shape (v4.6.4+, duress on its own frame):398 *   - N entries frames, each ≤ MAX_SNAPSHOT_CHUNK_BYTES serialized entries.399 *     Tombstones ride the last entries frame when no duress follows.400 *   - If duressSlot is set, one extra frame after the entries carrying only401 *     the duress slot (entries: empty, tombstones: tombstone map,402 *     duress_slot: duressSlot). This moves duress off the entries frame so a403 *     sizeable sealed duress vault can no longer push the entries frame raw404 *     bytes over SNAPSHOT_FRAME_WIRE_CEILING (768 KiB).405 *406 * Frame budget (MAX_SNAPSHOT_CHUNKS = 200):407 *   - When duress is configured, ONE of the 200 frames is reserved for the408 *     duress slot. The entries cap is MAX_SNAPSHOT_CHUNKS - 1 = 199 entries409 *     chunks; excess entries are deferred to the sync fallback pipeline (sync410 *     carries entries and tombstones, so this is a graceful truncation).411 *     This reservation is REQUIRED: ordinary sync does NOT carry duress412 *     (vault.test.ts:158-179 test-locks duress as non-syncing), so silently413 *     dropping a duress frame at the cap would leave the adopted device414 *     permanently without its duress vault. We prioritize duress delivery415 *     over completeness of entries (the latter is recoverable via sync).416 *417 * Oversized duress: duress that itself exceeds SNAPSHOT_FRAME_WIRE_CEILING418 * (e.g. a ≥768 KiB sealed duress) is rejected with a thrown error BEFORE the419 * first duress frame is sent. This is a hard stop — the caller must split420 * the duress payload out of band. We do not silently truncate duress; sync421 * cannot restore it.422 *423 * Backward compat: pre-v4.6.4 senders put `duress_slot` on the entries424 * final chunk. Receivers tolerate both shapes because `receiveVaultSnapshot`425 * reads `duress_slot` off any chunk (a duress-only frame has empty426 * entries/tombstones which merge as a no-op).427 */428export async function sendVaultSnapshot(429  channel: PairingTransport,430  entries: Record<string, string>,431  tombstones: Record<string, number>,432  duressSlot: string | null = null,433): Promise<void> {434  const frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot);435  // PB-26-206 preflight with the SAME helper the receiver enforces: nothing436  // is sent until every frame fits the aggregate budget, so an over-budget437  // snapshot is never partially sent or silently truncated. The caller falls438  // through to the regular sync pipeline (deny-only: no wire-field change,439  // invitation prefix unchanged).440  const acc = createAdoptSnapshotBudget();441  const encoded: Uint8Array[] = frames.map((frame) =>442    new TextEncoder().encode(JSON.stringify(frame))443  );444  for (let index = 0; index < frames.length; index++) {445    const frame = frames[index];446    applyAdoptSnapshotFrame(447      acc,448      frame.entries,449      frame.tombstones,450      frame.duress_slot,451      encoded[index].length,452    );453  }454  // PB-26-604: pad to the sync bucket ladder so the relay sees bucket-sized455  // ciphertext and cannot read exact plaintext lengths (or the duress frame's456  // distinctive size) off the wire.457  for (const raw of encoded) await channel.send(padSyncFrame(raw));458}459460/**461 * Pure frame construction for sendVaultSnapshot: chunking, the duress-frame462 * reservation, and the oversized-duress hard stop. No I/O happens here, so463 * the preflight above can reject before the first byte goes out.464 */465function buildAdoptSnapshotFrames(466  entries: Record<string, string>,467  tombstones: Record<string, number>,468  duressSlot: string | null,469): AdoptSnapshot[] {470  const includeDuress = !!duressSlot;471  // Reserve one frame for duress when configured so duress is never silently472  // dropped at the cap; ordinary sync does NOT carry duress, so dropping a473  // duress frame would orphan the adopted device's duress vault.474  const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS;475476  const chunks: Array<Record<string, string>> = [];477  let current: Record<string, string> = {};478  let currentCount = 0;479  let currentBytes = 2; // "{}"480  for (const [id, envelope] of Object.entries(entries)) {481    // +1 for the separating comma; an overestimate only shrinks chunks.482    const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;483    if (currentCount > 0 && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {484      chunks.push(current);485      current = {};486      currentCount = 0;487      currentBytes = 2;488    }489    current[id] = envelope;490    currentCount++;491    currentBytes += entryBytes;492  }493  chunks.push(current);494495  const entriesChunkCount = Math.min(chunks.length, entryCap);496  const sendDuress = includeDuress;497  const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);498  const duressFrame: AdoptSnapshot | null = sendDuress499    ? {500        type: "ADOPT_SNAPSHOT",501        chunk_index: entriesChunkCount,502        chunk_count: totalChunks,503        entries: {},504        tombstones,505        duress_slot: duressSlot as string,506      }507    : null;508509  if (510    duressFrame &&511    new TextEncoder().encode(JSON.stringify(duressFrame)).length >512      SNAPSHOT_FRAME_WIRE_CEILING513  ) {514    throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");515  }516517  const frames: AdoptSnapshot[] = [];518  for (let index = 0; index < entriesChunkCount; index++) {519    const isFinalEntries = index === entriesChunkCount - 1;520    frames.push({521      type: "ADOPT_SNAPSHOT",522      chunk_index: index,523      chunk_count: totalChunks,524      entries: chunks[index],525      // Tombstones ride the LAST wire frame: when duress follows the entries,526      // it goes on the duress frame; otherwise on the last entries chunk.527      tombstones: isFinalEntries && !sendDuress ? tombstones : {},528    });529  }530531  if (duressFrame) frames.push(duressFrame);532  return frames;533}534535/**536 * Receiver side: receive and reassemble every snapshot chunk. Adds raw frame537 * bytes to the aggregate counter before decoding (rejecting before538 * JSON.parse when the next frame exceeds the remaining budget), then enforces539 * the per-frame wire ceiling, sequential 0-based chunk_index ordering, a540 * stable chunk_count within MAX_SNAPSHOT_CHUNKS, and structural validity of541 * each frame. Merging goes through the shared aggregate helper: every542 * key/value and projected count is validated in one bounded pass before543 * anything lands in the accumulator, so a rejection leaves no partial544 * adopted state and the caller can fall back to the sync pipeline.545 */546export async function receiveVaultSnapshot(547  channel: PairingTransport,548): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {549  const acc = createAdoptSnapshotBudget();550  let chunkCount = -1;551  let received = 0;552  while (chunkCount === -1 || received < chunkCount) {553    const wire = await channel.receive();554    if (wire.length > MAX_PADDED_SNAPSHOT_FRAME_BYTES) {555      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");556    }557    // PB-26-604: every budget below applies to the unpadded JSON, exactly as558    // the sender's preflight accounted for it.559    const raw = unpadSyncFrame(wire);560    if (raw.length > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {561      throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");562    }563    if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {564      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");565    }566    const frame: unknown = JSON.parse(new TextDecoder().decode(raw));567    if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");568    if (chunkCount === -1) {569      chunkCount = frame.chunk_count;570      if (chunkCount > MAX_SNAPSHOT_CHUNKS) {571        throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");572      }573    } else if (frame.chunk_count !== chunkCount) {574      throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");575    }576    if (frame.chunk_index !== received) {577      throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");578    }579    applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duress_slot, raw.length);580    received++;581  }582  return { entries: acc.entries, tombstones: acc.tombstones, duressSlot: acc.duressSlot };583}584585export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {586  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);587}588589export interface AdoptionRequestResult {590  device: PairedDevice;591  /** True iff the source echoed snapshot_capable: true in its ADOPT_GRANT. */592  snapshotCapable: boolean;593}594595export async function requestAdoption(596  channel: PairingTransport,597  deps: AdoptionReceiverDeps,598): Promise<AdoptionRequestResult | null> {599  const receiverLicense = await deps.license?.();600  await sendFrame(channel, {601    type: "ADOPT_REQUEST",602    device_id: deps.generateDeviceId(),603    device_name: deps.deviceName,604    device_type: "extension",605    license: receiverLicense,606    snapshot_capable: true,607  });608609  const confirmed = await deps.confirmSas();610  await sendFrame(channel, {611    type: "ADOPT_CONFIRM",612    confirmed,613  });614  if (!confirmed) {615    deps.onError("ADOPT_SAS_REJECTED");616    return null;617  }618619  const frame = await receiveFrame(channel);620  if (isAdoptReject(frame)) {621    deps.onError(frame.reason);622    return null;623  }624  if (!isAdoptGrant(frame)) {625    deps.onError("ADOPT_UNEXPECTED_FRAME");626    return null;627  }628  const grantSnapshotCapable = frame.snapshot_capable === true;629630  const rootKeyBytes = hexToBytes(frame.root_key_hex);631  try {632    const computedVaultId = await computeVaultId(rootKeyBytes);633    if (computedVaultId !== frame.vault_id) {634      deps.onError("ADOPT_VAULT_ID_MISMATCH");635      return null;636    }637  } finally {638    rootKeyBytes.fill(0);639  }640  if (!receiverLicense && !frame.license) {641    deps.onError("ADOPT_PRO_REQUIRED");642    return null;643  }644645  await deps.validateSource?.(frame.source);646  const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);647  if (vaultId !== frame.vault_id) {648    deps.onError("ADOPT_VAULT_ID_MISMATCH");649    return null;650  }651652  if (!receiverLicense && frame.license) {653    await deps.applyLicense?.(frame.license);654  }655656  let identity = await deps.deriveLocalIdentity();657  await sendFrame(channel, {658    type: "ADOPT_IDENTITY",659    device_id: identity.deviceId,660    device_name: identity.deviceName,661    device_type: identity.deviceType,662    long_term_public_key_hex: identity.longTermPublicKeyHex,663    signing_public_key_hex: identity.signingPublicKeyHex,664  });665666  let completion = await receiveFrame(channel);667  if (668    isAdoptReject(completion) &&669    completion.reason === "ADOPT_DEVICE_ID_COLLISION" &&670    deps.rotateLocalIdentity671  ) {672    identity = await deps.rotateLocalIdentity();673    await sendFrame(channel, {674      type: "ADOPT_IDENTITY",675      device_id: identity.deviceId,676      device_name: identity.deviceName,677      device_type: identity.deviceType,678      long_term_public_key_hex: identity.longTermPublicKeyHex,679      signing_public_key_hex: identity.signingPublicKeyHex,680    });681    completion = await receiveFrame(channel);682  }683  if (!isAdoptComplete(completion)) {684    deps.onError("ADOPT_COMPLETION_MISSING");685    return null;686  }687688  const sourceDevice: PairedDevice = {689    deviceId: frame.source.device_id,690    deviceName: capDeviceName(frame.source.device_name),691    deviceType: frame.source.device_type,692    longTermPublicKeyHex: frame.source.long_term_public_key_hex,693    signingPublicKeyHex: frame.source.signing_public_key_hex,694    trustSource: "direct",695    wsPort: frame.source.ws_port,696    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,697    relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,698    pairedAt: Date.now(),699  };700  await deps.upsertPairedDevice(sourceDevice);701  await sendFrame(channel, { type: "ADOPT_APPLIED" });702  return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };703}704705export async function grantAdoption(706  channel: PairingTransport,707  request: AdoptRequest,708  deps: AdoptionSourceDeps,709): Promise<PairedDevice | null> {710  const approved = await deps.onConsent(request);711  if (!approved) return null;712713  const confirmation = await receiveFrame(channel);714  if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;715716  let sourceLicense = await deps.license?.();717  if (!sourceLicense && request.license) {718    try {719      if (!deps.applyLicense) throw new Error("license_transfer_unavailable");720      await deps.applyLicense(request.license);721      sourceLicense = await deps.license?.();722    } catch {723      sourceLicense = undefined;724    }725  }726  if (!sourceLicense) {727    await sendFrame(channel, {728      type: "ADOPT_REJECT",729      reason: "ADOPT_PRO_REQUIRED",730    });731    throw new Error("ADOPT_PRO_REQUIRED");732  }733734  const rootKeyHex = await deps.rootKeyHex();735  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");736737  await sendFrame(channel, {738    type: "ADOPT_GRANT",739    root_key_hex: rootKeyHex,740    vault_id: await deps.vaultId(),741    license: sourceLicense,742    source: {743      device_id: deps.sourceIdentity.deviceId,744      device_name: deps.sourceIdentity.deviceName,745      device_type: deps.sourceIdentity.deviceType,746      long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,747      signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,748      ws_port: deps.sourceIdentity.wsPort,749      relay_url: deps.pairState?.relayUrl,750    },751    snapshot_capable: request.snapshot_capable === true,752  });753754  const identity = await receiveFrame(channel);755  if (!isAdoptIdentity(identity)) return null;756757  const device: PairedDevice = {758    deviceId: identity.device_id,759    deviceName: capDeviceName(identity.device_name),760    deviceType: identity.device_type,761    longTermPublicKeyHex: identity.long_term_public_key_hex,762    signingPublicKeyHex: identity.signing_public_key_hex,763    trustSource: "direct",764    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,765    relayUrl: deps.pairState?.relayUrl,766    pairedAt: Date.now(),767  };768  await deps.upsertPairedDevice(device);769  await sendFrame(channel, { type: "ADOPT_COMPLETE" });770  if (!isAdoptApplied(await receiveFrame(channel))) return null;771  return device;772}773774export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {775  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));776}777778export async function receiveFrame(channel: PairingTransport): Promise<unknown> {779  return JSON.parse(new TextDecoder().decode(await channel.receive()));780}781782function isDeviceType(value: unknown): value is DeviceType {783  return value === "extension" || value === "android" || value === "ios";784}785786function isSecureRelayUrl(value: string): boolean {787  try {788    const url = new URL(value);789    return url.protocol === "wss:" && !url.username && !url.password &&790      !url.search && !url.hash;791  } catch {792    return false;793  }794}795796function hexToBytes(hex: string): Uint8Array {797  const out = new Uint8Array(hex.length / 2);798  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);799  return out;800}