vault-adopt.ts

27.7 KB · raw

1import { SYNC_LIMITS } from "./sync-limits";2import { computeVaultId } from "./sync-identity";3import type { DeviceType, PairedDevice, SignedDeviceManifest } from "./sync-types";4import type { LocalIdentity, PairingTransport } from "./pairing-handshake";56export interface AdoptRequest {7  type: "ADOPT_REQUEST";8  device_id: string;9  device_name: string;10  device_type: DeviceType;11  /** Signed Pro entitlement offered by the receiver, if it has one. */12  license?: string;13  /**14   * Receiver advertises inline-snapshot support. A source that understands it15   * sends the vault snapshot over this same channel after ADOPT_APPLIED;16   * older sources ignore it and the receiver falls back to the sync pipeline.17   */18  snapshot_capable?: boolean;19}2021export interface AdoptGrant {22  type: "ADOPT_GRANT";23  root_key_hex: string;24  vault_id: string;25  license?: string;26  source: {27    device_id: string;28    device_name: string;29    device_type: DeviceType;30    long_term_public_key_hex: string;31    signing_public_key_hex?: string;32    ws_port?: number;33    relay_url?: string;34  };35  /**36   * ECHO: source reflects receiver's ADOPT_REQUEST.snapshot_capable here so37   * the receiver can skip the inline-snapshot wait when the source will not38   * send one (pre-C source, or explicit opt-out). Additive; absent = old source.39   */40  snapshot_capable?: boolean;41}4243export interface AdoptConfirm {44  type: "ADOPT_CONFIRM";45  confirmed: boolean;46}4748export interface AdoptIdentity {49  type: "ADOPT_IDENTITY";50  device_id: string;51  device_name: string;52  device_type: DeviceType;53  long_term_public_key_hex: string;54  signing_public_key_hex?: string;55}5657/**58 * Encrypted source-to-receiver acknowledgement sent only after the source has59 * durably recorded the receiver. Without it the receiver could report success60 * after sending ADOPT_IDENTITY even though the source never received it.61 */62export interface AdoptComplete {63  type: "ADOPT_COMPLETE";64}6566/** Receiver-to-source acknowledgement sent after the source record is durable. */67export interface AdoptApplied {68  type: "ADOPT_APPLIED";69}7071/** Neither peer could prove a Pro entitlement, so no vault state may commit. */72export interface AdoptReject {73  type: "ADOPT_REJECT";74  reason: "ADOPT_PRO_REQUIRED";75}7677/**78 * One chunk of the inline vault snapshot sent by the source after79 * ADOPT_APPLIED, over the same authenticated Noise NK channel. Entries are80 * opaque base64 ciphertext envelopes; tombstones ride the final chunk only.81 */82export interface AdoptSnapshot {83  type: "ADOPT_SNAPSHOT";84  chunk_index: number;85  chunk_count: number;86  entries: Record<string, string>;87  /** entryId → deletion timestamp; populated on the final chunk only. */88  tombstones: Record<string, number>;89  /**90   * Sealed duress (slot B) StoredVault as JSON.stringify of the browser91   * shape — identical to the Codex vault_b field. Populated on the final92   * chunk only, and only when duress is actually configured; absent otherwise.93   */94  duress_slot?: string;95}9697/** Receiver acknowledgement sent after all snapshot chunks are applied. */98export interface AdoptSnapshotApplied {99  type: "ADOPT_SNAPSHOT_APPLIED";100  entries_received: number;101  tombstones_received: number;102}103104/** Optional signed device graph appended after the snapshot (manifest v3+). */105export interface AdoptDeviceManifestFrame {106  type: "ADOPT_DEVICE_MANIFEST";107  manifest: SignedDeviceManifest;108}109110/** Receiver acknowledgement for an applied device manifest. */111export interface AdoptDeviceManifestApplied {112  type: "ADOPT_DEVICE_MANIFEST_APPLIED";113}114115export type AdoptionFrame =116  | AdoptRequest117  | AdoptConfirm118  | AdoptGrant119  | AdoptIdentity120  | AdoptComplete121  | AdoptApplied122  | AdoptReject123  | AdoptSnapshot124  | AdoptSnapshotApplied125  | AdoptDeviceManifestFrame126  | AdoptDeviceManifestApplied;127128/**129 * Wire guardrails for the inline snapshot. A chunk's serialized entries map130 * stays under MAX_SNAPSHOT_CHUNK_BYTES; the whole transfer is capped at131 * MAX_SNAPSHOT_CHUNKS frames so a compromised source cannot exhaust receiver132 * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator).133 */134export const MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024; // 512 KiB135export const MAX_SNAPSHOT_CHUNKS = 200;136// Headroom added to the per-chunk entries budget when validating an encoded137// frame on the wire: JSON envelope plus the final chunk's tombstone map.138export const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;139140/**141 * PB-26-206 aggregate budget shared by sender and receiver. The per-frame142 * guard above bounds one frame; this bounds the whole transfer — encoded143 * snapshot bytes (entries + tombstones + JSON framing + duress slot),144 * entry/tombstone counts, ID lengths, and per-envelope size — independently145 * of the 200-frame count, so many individually valid frames cannot OOM the146 * receiver. Aligned with the 12 MiB pairing-inbox transport backstop147 * (PB-26-205): an honest snapshot never approaches either ceiling.148 */149export const MAX_ADOPT_SNAPSHOT_BYTES = 10 * 1024 * 1024; // 10 MiB150export const MAX_ADOPT_SNAPSHOT_ENTRIES = 25_000;151export const MAX_ADOPT_SNAPSHOT_TOMBSTONES = 25_000;152/** Matches the cross-client anti-abuse ID ceiling (Android SyncLimits.MAX_ID_LENGTH). */153export const MAX_ADOPT_SNAPSHOT_ID_CHARS = 512;154/**155 * Maximum encrypted entry envelope: SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES156 * plus the existing base64 overhead allowance (mirrors sync-vault-adapter).157 */158export const MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES =159  (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64;160161/**162 * Running aggregate for one snapshot transfer. Counts are tracked alongside163 * the maps (not derived via Object.keys per frame) so the bound stays O(n).164 */165export interface AdoptSnapshotBudget {166  entries: Record<string, string>;167  tombstones: Record<string, number>;168  duressSlot: string | null;169  /** Sum of encoded wire-frame bytes consumed so far. */170  bytes: number;171  entryCount: number;172  tombstoneCount: number;173}174175export function createAdoptSnapshotBudget(): AdoptSnapshotBudget {176  return { entries: {}, tombstones: {}, duressSlot: null, bytes: 0, entryCount: 0, tombstoneCount: 0 };177}178179/**180 * PB-26-206 shared preflight/merge helper used by BOTH the sender181 * (preflight before the first frame goes out) and the receiver (per frame,182 * after the byte-budget check, before JSON.parse results are merged).183 *184 * One bounded pass per attacker-controlled map: each key/value is validated185 * and projected counts are enforced inside the same loop that merges, so the186 * maps are never iterated count→size→merge repeatedly. Throws before merging187 * the offending frame, leaving no partial adopted state.188 */189export function applyAdoptSnapshotFrame(190  acc: AdoptSnapshotBudget,191  entries: Record<string, string>,192  tombstones: Record<string, number>,193  duressSlot: string | null | undefined,194  frameBytes: number,195): void {196  if (frameBytes > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {197    throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");198  }199  for (const [id, envelope] of Object.entries(entries)) {200    if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw new Error("ADOPT_SNAPSHOT_ID_TOO_LONG");201    if (envelope.length > MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) {202      throw new Error("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE");203    }204    if (acc.entryCount >= MAX_ADOPT_SNAPSHOT_ENTRIES) {205      throw new Error("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES");206    }207    acc.entryCount++;208    acc.entries[id] = envelope;209  }210  for (const [id, ts] of Object.entries(tombstones)) {211    if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw new Error("ADOPT_SNAPSHOT_ID_TOO_LONG");212    if (typeof ts !== "number" || !Number.isFinite(ts)) {213      throw new Error("ADOPT_SNAPSHOT_INVALID_TOMBSTONE");214    }215    if (acc.tombstoneCount >= MAX_ADOPT_SNAPSHOT_TOMBSTONES) {216      throw new Error("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES");217    }218    acc.tombstoneCount++;219    acc.tombstones[id] = ts;220  }221  if (typeof duressSlot === "string" && duressSlot.length > 0) {222    // The duress slot rides inside wire frames already bounded by the frame223    // ceiling, and its raw bytes count against the aggregate above.224    if (duressSlot.length > SNAPSHOT_FRAME_WIRE_CEILING) {225      throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");226    }227    acc.duressSlot = duressSlot;228  }229  acc.bytes += frameBytes;230}231232export interface AdoptionPairState {233  rendezvousSecretHex: string;234  relayUrl?: string;235}236237export interface AdoptionReceiverDeps {238  deviceName: string;239  generateDeviceId(): string;240  /** Root-key transfer cannot begin until this device confirms the SAS. */241  confirmSas(): Promise<boolean>;242  /**243   * Optional local safety policy evaluated after the authenticated grant is244   * validated but before any vault state changes.245   */246  validateSource?(source: AdoptGrant["source"]): Promise<void>;247  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;248  deriveLocalIdentity(): Promise<LocalIdentity>;249  upsertPairedDevice(device: PairedDevice): Promise<void>;250  applyLicense?(license: string): Promise<void>;251  /** Return this receiver's locally validated Pro key, if present. */252  license?(): Promise<string | undefined>;253  /** Bound to the same completed Noise session, never taken from frame claims. */254  pairState?: AdoptionPairState;255  onError(error: string): void;256}257258export interface AdoptionSourceDeps {259  sourceIdentity: LocalIdentity;260  rootKeyHex(): Promise<string>;261  vaultId(): Promise<string>;262  license?(): Promise<string | undefined>;263  /** Validate and durably store a Pro key offered by the receiver. */264  applyLicense?(license: string): Promise<void>;265  onConsent(request: AdoptRequest): Promise<boolean>;266  upsertPairedDevice(device: PairedDevice): Promise<void>;267  /** Bound to the same completed Noise session, never taken from frame claims. */268  pairState?: AdoptionPairState;269}270271export function isAdoptRequest(frame: unknown): frame is AdoptRequest {272  const value = frame as Partial<AdoptRequest> | null;273  return value?.type === "ADOPT_REQUEST" &&274    typeof value.device_id === "string" &&275    value.device_id.length > 0 &&276    typeof value.device_name === "string" &&277    isDeviceType(value.device_type) &&278    (value.license === undefined || typeof value.license === "string");279}280281export function isAdoptGrant(frame: unknown): frame is AdoptGrant {282  const value = frame as Partial<AdoptGrant> | null;283  return value?.type === "ADOPT_GRANT" &&284    validateRootKeyHex(value.root_key_hex) &&285    typeof value.vault_id === "string" &&286    /^[0-9a-f]{64}$/.test(value.vault_id) &&287    typeof value.source === "object" &&288    value.source !== null &&289    typeof value.source.device_id === "string" &&290    typeof value.source.device_name === "string" &&291    isDeviceType(value.source.device_type) &&292    typeof value.source.long_term_public_key_hex === "string" &&293    /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&294    (value.source.relay_url === undefined ||295      (typeof value.source.relay_url === "string" &&296        isSecureRelayUrl(value.source.relay_url))) &&297    (value.snapshot_capable === undefined ||298      typeof value.snapshot_capable === "boolean");299}300301export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {302  const value = frame as Partial<AdoptConfirm> | null;303  return value?.type === "ADOPT_CONFIRM" &&304    typeof value.confirmed === "boolean";305}306307export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {308  const value = frame as Partial<AdoptIdentity> | null;309  return value?.type === "ADOPT_IDENTITY" &&310    typeof value.device_id === "string" &&311    value.device_id.length > 0 &&312    typeof value.device_name === "string" &&313    isDeviceType(value.device_type) &&314    typeof value.long_term_public_key_hex === "string" &&315    /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);316}317318export function isAdoptComplete(frame: unknown): frame is AdoptComplete {319  return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";320}321322export function isAdoptApplied(frame: unknown): frame is AdoptApplied {323  return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";324}325326export function isAdoptReject(frame: unknown): frame is AdoptReject {327  const value = frame as Partial<AdoptReject> | null;328  return value?.type === "ADOPT_REJECT" &&329    value.reason === "ADOPT_PRO_REQUIRED";330}331332export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {333  const value = frame as Partial<AdoptSnapshot> | null;334  if (value?.type !== "ADOPT_SNAPSHOT") return false;335  if (336    typeof value.chunk_index !== "number" ||337    !Number.isInteger(value.chunk_index) ||338    value.chunk_index < 0339  ) return false;340  if (341    typeof value.chunk_count !== "number" ||342    !Number.isInteger(value.chunk_count) ||343    value.chunk_count < 1344  ) return false;345  if (!isStringRecord(value.entries)) return false;346  if (!isNumberRecord(value.tombstones)) return false;347  if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;348  return true;349}350351export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {352  const value = frame as Partial<AdoptSnapshotApplied> | null;353  return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&354    typeof value.entries_received === "number" &&355    typeof value.tombstones_received === "number";356}357358export function isAdoptDeviceManifestFrame(359  frame: unknown,360): frame is AdoptDeviceManifestFrame {361  const value = frame as Partial<AdoptDeviceManifestFrame> | null;362  const manifest = value?.manifest as363    | { body?: unknown; signatureHex?: unknown }364    | null;365  return value?.type === "ADOPT_DEVICE_MANIFEST" &&366    typeof manifest === "object" &&367    manifest !== null &&368    typeof manifest.body === "object" &&369    manifest.body !== null &&370    typeof manifest.signatureHex === "string";371}372373function isStringRecord(value: unknown): value is Record<string, string> {374  if (typeof value !== "object" || value === null) return false;375  return Object.values(value).every((v) => typeof v === "string");376}377378function isNumberRecord(value: unknown): value is Record<string, number> {379  if (typeof value !== "object" || value === null) return false;380  return Object.values(value).every((v) => typeof v === "number");381}382383/**384 * Source side: chunk the vault snapshot and send it over the pairing channel.385 *386 * Wire shape (v4.6.4+, duress on its own frame):387 *   - N entries frames, each ≤ MAX_SNAPSHOT_CHUNK_BYTES serialized entries.388 *     Tombstones ride the last entries frame when no duress follows.389 *   - If duressSlot is set, one extra frame after the entries carrying only390 *     the duress slot (entries: empty, tombstones: tombstone map,391 *     duress_slot: duressSlot). This moves duress off the entries frame so a392 *     sizeable sealed duress vault can no longer push the entries frame raw393 *     bytes over SNAPSHOT_FRAME_WIRE_CEILING (768 KiB).394 *395 * Frame budget (MAX_SNAPSHOT_CHUNKS = 200):396 *   - When duress is configured, ONE of the 200 frames is reserved for the397 *     duress slot. The entries cap is MAX_SNAPSHOT_CHUNKS - 1 = 199 entries398 *     chunks; excess entries are deferred to the sync fallback pipeline (sync399 *     carries entries and tombstones, so this is a graceful truncation).400 *     This reservation is REQUIRED: ordinary sync does NOT carry duress401 *     (vault.test.ts:158-179 test-locks duress as non-syncing), so silently402 *     dropping a duress frame at the cap would leave the adopted device403 *     permanently without its duress vault. We prioritize duress delivery404 *     over completeness of entries (the latter is recoverable via sync).405 *406 * Oversized duress: duress that itself exceeds SNAPSHOT_FRAME_WIRE_CEILING407 * (e.g. a ≥768 KiB sealed duress) is rejected with a thrown error BEFORE the408 * first duress frame is sent. This is a hard stop — the caller must split409 * the duress payload out of band. We do not silently truncate duress; sync410 * cannot restore it.411 *412 * Backward compat: pre-v4.6.4 senders put `duress_slot` on the entries413 * final chunk. Receivers tolerate both shapes because `receiveVaultSnapshot`414 * reads `duress_slot` off any chunk (a duress-only frame has empty415 * entries/tombstones which merge as a no-op).416 */417export async function sendVaultSnapshot(418  channel: PairingTransport,419  entries: Record<string, string>,420  tombstones: Record<string, number>,421  duressSlot: string | null = null,422): Promise<void> {423  const frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot);424  // PB-26-206 preflight with the SAME helper the receiver enforces: nothing425  // is sent until every frame fits the aggregate budget, so an over-budget426  // snapshot is never partially sent or silently truncated. The caller falls427  // through to the regular sync pipeline (deny-only: no wire-field change,428  // invitation prefix unchanged).429  const acc = createAdoptSnapshotBudget();430  const encoded: Uint8Array[] = frames.map((frame) =>431    new TextEncoder().encode(JSON.stringify(frame))432  );433  for (let index = 0; index < frames.length; index++) {434    const frame = frames[index];435    applyAdoptSnapshotFrame(436      acc,437      frame.entries,438      frame.tombstones,439      frame.duress_slot,440      encoded[index].length,441    );442  }443  for (const raw of encoded) await channel.send(raw);444}445446/**447 * Pure frame construction for sendVaultSnapshot: chunking, the duress-frame448 * reservation, and the oversized-duress hard stop. No I/O happens here, so449 * the preflight above can reject before the first byte goes out.450 */451function buildAdoptSnapshotFrames(452  entries: Record<string, string>,453  tombstones: Record<string, number>,454  duressSlot: string | null,455): AdoptSnapshot[] {456  const includeDuress = !!duressSlot;457  // Reserve one frame for duress when configured so duress is never silently458  // dropped at the cap; ordinary sync does NOT carry duress, so dropping a459  // duress frame would orphan the adopted device's duress vault.460  const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS;461462  const chunks: Array<Record<string, string>> = [];463  let current: Record<string, string> = {};464  let currentCount = 0;465  let currentBytes = 2; // "{}"466  for (const [id, envelope] of Object.entries(entries)) {467    // +1 for the separating comma; an overestimate only shrinks chunks.468    const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;469    if (currentCount > 0 && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {470      chunks.push(current);471      current = {};472      currentCount = 0;473      currentBytes = 2;474    }475    current[id] = envelope;476    currentCount++;477    currentBytes += entryBytes;478  }479  chunks.push(current);480481  const entriesChunkCount = Math.min(chunks.length, entryCap);482  const sendDuress = includeDuress;483  const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);484  const duressFrame: AdoptSnapshot | null = sendDuress485    ? {486        type: "ADOPT_SNAPSHOT",487        chunk_index: entriesChunkCount,488        chunk_count: totalChunks,489        entries: {},490        tombstones,491        duress_slot: duressSlot as string,492      }493    : null;494495  if (496    duressFrame &&497    new TextEncoder().encode(JSON.stringify(duressFrame)).length >498      SNAPSHOT_FRAME_WIRE_CEILING499  ) {500    throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");501  }502503  const frames: AdoptSnapshot[] = [];504  for (let index = 0; index < entriesChunkCount; index++) {505    const isFinalEntries = index === entriesChunkCount - 1;506    frames.push({507      type: "ADOPT_SNAPSHOT",508      chunk_index: index,509      chunk_count: totalChunks,510      entries: chunks[index],511      // Tombstones ride the LAST wire frame: when duress follows the entries,512      // it goes on the duress frame; otherwise on the last entries chunk.513      tombstones: isFinalEntries && !sendDuress ? tombstones : {},514    });515  }516517  if (duressFrame) frames.push(duressFrame);518  return frames;519}520521/**522 * Receiver side: receive and reassemble every snapshot chunk. Adds raw frame523 * bytes to the aggregate counter before decoding (rejecting before524 * JSON.parse when the next frame exceeds the remaining budget), then enforces525 * the per-frame wire ceiling, sequential 0-based chunk_index ordering, a526 * stable chunk_count within MAX_SNAPSHOT_CHUNKS, and structural validity of527 * each frame. Merging goes through the shared aggregate helper: every528 * key/value and projected count is validated in one bounded pass before529 * anything lands in the accumulator, so a rejection leaves no partial530 * adopted state and the caller can fall back to the sync pipeline.531 */532export async function receiveVaultSnapshot(533  channel: PairingTransport,534): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {535  const acc = createAdoptSnapshotBudget();536  let chunkCount = -1;537  let received = 0;538  while (chunkCount === -1 || received < chunkCount) {539    const raw = await channel.receive();540    if (raw.length > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {541      throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");542    }543    if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {544      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");545    }546    const frame: unknown = JSON.parse(new TextDecoder().decode(raw));547    if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");548    if (chunkCount === -1) {549      chunkCount = frame.chunk_count;550      if (chunkCount > MAX_SNAPSHOT_CHUNKS) {551        throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");552      }553    } else if (frame.chunk_count !== chunkCount) {554      throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");555    }556    if (frame.chunk_index !== received) {557      throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");558    }559    applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duress_slot, raw.length);560    received++;561  }562  return { entries: acc.entries, tombstones: acc.tombstones, duressSlot: acc.duressSlot };563}564565export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {566  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);567}568569export interface AdoptionRequestResult {570  device: PairedDevice;571  /** True iff the source echoed snapshot_capable: true in its ADOPT_GRANT. */572  snapshotCapable: boolean;573}574575export async function requestAdoption(576  channel: PairingTransport,577  deps: AdoptionReceiverDeps,578): Promise<AdoptionRequestResult | null> {579  const receiverLicense = await deps.license?.();580  await sendFrame(channel, {581    type: "ADOPT_REQUEST",582    device_id: deps.generateDeviceId(),583    device_name: deps.deviceName,584    device_type: "extension",585    license: receiverLicense,586    snapshot_capable: true,587  });588589  const confirmed = await deps.confirmSas();590  await sendFrame(channel, {591    type: "ADOPT_CONFIRM",592    confirmed,593  });594  if (!confirmed) {595    deps.onError("ADOPT_SAS_REJECTED");596    return null;597  }598599  const frame = await receiveFrame(channel);600  if (isAdoptReject(frame)) {601    deps.onError(frame.reason);602    return null;603  }604  if (!isAdoptGrant(frame)) {605    deps.onError("ADOPT_UNEXPECTED_FRAME");606    return null;607  }608  const grantSnapshotCapable = frame.snapshot_capable === true;609610  const rootKeyBytes = hexToBytes(frame.root_key_hex);611  try {612    const computedVaultId = await computeVaultId(rootKeyBytes);613    if (computedVaultId !== frame.vault_id) {614      deps.onError("ADOPT_VAULT_ID_MISMATCH");615      return null;616    }617  } finally {618    rootKeyBytes.fill(0);619  }620  if (!receiverLicense && !frame.license) {621    deps.onError("ADOPT_PRO_REQUIRED");622    return null;623  }624625  await deps.validateSource?.(frame.source);626  const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);627  if (vaultId !== frame.vault_id) {628    deps.onError("ADOPT_VAULT_ID_MISMATCH");629    return null;630  }631632  if (!receiverLicense && frame.license) {633    await deps.applyLicense?.(frame.license);634  }635636  const identity = await deps.deriveLocalIdentity();637  await sendFrame(channel, {638    type: "ADOPT_IDENTITY",639    device_id: identity.deviceId,640    device_name: identity.deviceName,641    device_type: identity.deviceType,642    long_term_public_key_hex: identity.longTermPublicKeyHex,643    signing_public_key_hex: identity.signingPublicKeyHex,644  });645646  const completion = await receiveFrame(channel);647  if (!isAdoptComplete(completion)) {648    deps.onError("ADOPT_COMPLETION_MISSING");649    return null;650  }651652  const sourceDevice: PairedDevice = {653    deviceId: frame.source.device_id,654    deviceName: frame.source.device_name,655    deviceType: frame.source.device_type,656    longTermPublicKeyHex: frame.source.long_term_public_key_hex,657    signingPublicKeyHex: frame.source.signing_public_key_hex,658    trustSource: "direct",659    wsPort: frame.source.ws_port,660    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,661    relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,662    pairedAt: Date.now(),663  };664  await deps.upsertPairedDevice(sourceDevice);665  await sendFrame(channel, { type: "ADOPT_APPLIED" });666  return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };667}668669export async function grantAdoption(670  channel: PairingTransport,671  request: AdoptRequest,672  deps: AdoptionSourceDeps,673): Promise<PairedDevice | null> {674  const approved = await deps.onConsent(request);675  if (!approved) return null;676677  const confirmation = await receiveFrame(channel);678  if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;679680  let sourceLicense = await deps.license?.();681  if (!sourceLicense && request.license) {682    try {683      if (!deps.applyLicense) throw new Error("license_transfer_unavailable");684      await deps.applyLicense(request.license);685      sourceLicense = await deps.license?.();686    } catch {687      sourceLicense = undefined;688    }689  }690  if (!sourceLicense) {691    await sendFrame(channel, {692      type: "ADOPT_REJECT",693      reason: "ADOPT_PRO_REQUIRED",694    });695    throw new Error("ADOPT_PRO_REQUIRED");696  }697698  const rootKeyHex = await deps.rootKeyHex();699  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");700701  await sendFrame(channel, {702    type: "ADOPT_GRANT",703    root_key_hex: rootKeyHex,704    vault_id: await deps.vaultId(),705    license: sourceLicense,706    source: {707      device_id: deps.sourceIdentity.deviceId,708      device_name: deps.sourceIdentity.deviceName,709      device_type: deps.sourceIdentity.deviceType,710      long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,711      signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,712      ws_port: deps.sourceIdentity.wsPort,713      relay_url: deps.pairState?.relayUrl,714    },715    snapshot_capable: request.snapshot_capable === true,716  });717718  const identity = await receiveFrame(channel);719  if (!isAdoptIdentity(identity)) return null;720721  const device: PairedDevice = {722    deviceId: identity.device_id,723    deviceName: identity.device_name,724    deviceType: identity.device_type,725    longTermPublicKeyHex: identity.long_term_public_key_hex,726    signingPublicKeyHex: identity.signing_public_key_hex,727    trustSource: "direct",728    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,729    relayUrl: deps.pairState?.relayUrl,730    pairedAt: Date.now(),731  };732  await deps.upsertPairedDevice(device);733  await sendFrame(channel, { type: "ADOPT_COMPLETE" });734  if (!isAdoptApplied(await receiveFrame(channel))) return null;735  return device;736}737738export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {739  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));740}741742export async function receiveFrame(channel: PairingTransport): Promise<unknown> {743  return JSON.parse(new TextDecoder().decode(await channel.receive()));744}745746function isDeviceType(value: unknown): value is DeviceType {747  return value === "extension" || value === "android" || value === "ios";748}749750function isSecureRelayUrl(value: string): boolean {751  try {752    const url = new URL(value);753    return url.protocol === "wss:" && !url.username && !url.password &&754      !url.search && !url.hash;755  } catch {756    return false;757  }758}759760function hexToBytes(hex: string): Uint8Array {761  const out = new Uint8Array(hex.length / 2);762  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);763  return out;764}