vault-adopt.ts

22.9 KB · raw

1import { computeVaultId } from "./sync-identity";2import type { DeviceType, PairedDevice, SignedDeviceManifest } from "./sync-types";3import type { LocalIdentity, PairingTransport } from "./pairing-handshake";45export interface AdoptRequest {6  type: "ADOPT_REQUEST";7  device_id: string;8  device_name: string;9  device_type: DeviceType;10  /** Signed Pro entitlement offered by the receiver, if it has one. */11  license?: string;12  /**13   * Receiver advertises inline-snapshot support. A source that understands it14   * sends the vault snapshot over this same channel after ADOPT_APPLIED;15   * older sources ignore it and the receiver falls back to the sync pipeline.16   */17  snapshot_capable?: boolean;18}1920export interface AdoptGrant {21  type: "ADOPT_GRANT";22  root_key_hex: string;23  vault_id: string;24  license?: string;25  source: {26    device_id: string;27    device_name: string;28    device_type: DeviceType;29    long_term_public_key_hex: string;30    signing_public_key_hex?: string;31    ws_port?: number;32    relay_url?: string;33  };34  /**35   * ECHO: source reflects receiver's ADOPT_REQUEST.snapshot_capable here so36   * the receiver can skip the inline-snapshot wait when the source will not37   * send one (pre-C source, or explicit opt-out). Additive; absent = old source.38   */39  snapshot_capable?: boolean;40}4142export interface AdoptConfirm {43  type: "ADOPT_CONFIRM";44  confirmed: boolean;45}4647export interface AdoptIdentity {48  type: "ADOPT_IDENTITY";49  device_id: string;50  device_name: string;51  device_type: DeviceType;52  long_term_public_key_hex: string;53  signing_public_key_hex?: string;54}5556/**57 * Encrypted source-to-receiver acknowledgement sent only after the source has58 * durably recorded the receiver. Without it the receiver could report success59 * after sending ADOPT_IDENTITY even though the source never received it.60 */61export interface AdoptComplete {62  type: "ADOPT_COMPLETE";63}6465/** Receiver-to-source acknowledgement sent after the source record is durable. */66export interface AdoptApplied {67  type: "ADOPT_APPLIED";68}6970/** Neither peer could prove a Pro entitlement, so no vault state may commit. */71export interface AdoptReject {72  type: "ADOPT_REJECT";73  reason: "ADOPT_PRO_REQUIRED";74}7576/**77 * One chunk of the inline vault snapshot sent by the source after78 * ADOPT_APPLIED, over the same authenticated Noise NK channel. Entries are79 * opaque base64 ciphertext envelopes; tombstones ride the final chunk only.80 */81export interface AdoptSnapshot {82  type: "ADOPT_SNAPSHOT";83  chunk_index: number;84  chunk_count: number;85  entries: Record<string, string>;86  /** entryId → deletion timestamp; populated on the final chunk only. */87  tombstones: Record<string, number>;88  /**89   * Sealed duress (slot B) StoredVault as JSON.stringify of the browser90   * shape — identical to the Codex vault_b field. Populated on the final91   * chunk only, and only when duress is actually configured; absent otherwise.92   */93  duress_slot?: string;94}9596/** Receiver acknowledgement sent after all snapshot chunks are applied. */97export interface AdoptSnapshotApplied {98  type: "ADOPT_SNAPSHOT_APPLIED";99  entries_received: number;100  tombstones_received: number;101}102103/** Optional signed device graph appended after the snapshot (manifest v3+). */104export interface AdoptDeviceManifestFrame {105  type: "ADOPT_DEVICE_MANIFEST";106  manifest: SignedDeviceManifest;107}108109/** Receiver acknowledgement for an applied device manifest. */110export interface AdoptDeviceManifestApplied {111  type: "ADOPT_DEVICE_MANIFEST_APPLIED";112}113114export type AdoptionFrame =115  | AdoptRequest116  | AdoptConfirm117  | AdoptGrant118  | AdoptIdentity119  | AdoptComplete120  | AdoptApplied121  | AdoptReject122  | AdoptSnapshot123  | AdoptSnapshotApplied124  | AdoptDeviceManifestFrame125  | AdoptDeviceManifestApplied;126127/**128 * Wire guardrails for the inline snapshot. A chunk's serialized entries map129 * stays under MAX_SNAPSHOT_CHUNK_BYTES; the whole transfer is capped at130 * MAX_SNAPSHOT_CHUNKS frames so a compromised source cannot exhaust receiver131 * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator).132 */133export const MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024; // 512 KiB134export const MAX_SNAPSHOT_CHUNKS = 200;135// Headroom added to the per-chunk entries budget when validating an encoded136// frame on the wire: JSON envelope plus the final chunk's tombstone map.137const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;138139export interface AdoptionPairState {140  rendezvousSecretHex: string;141  relayUrl?: string;142}143144export interface AdoptionReceiverDeps {145  deviceName: string;146  generateDeviceId(): string;147  /** Root-key transfer cannot begin until this device confirms the SAS. */148  confirmSas(): Promise<boolean>;149  /**150   * Optional local safety policy evaluated after the authenticated grant is151   * validated but before any vault state changes.152   */153  validateSource?(source: AdoptGrant["source"]): Promise<void>;154  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;155  deriveLocalIdentity(): Promise<LocalIdentity>;156  upsertPairedDevice(device: PairedDevice): Promise<void>;157  applyLicense?(license: string): Promise<void>;158  /** Return this receiver's locally validated Pro key, if present. */159  license?(): Promise<string | undefined>;160  /** Bound to the same completed Noise session, never taken from frame claims. */161  pairState?: AdoptionPairState;162  onError(error: string): void;163}164165export interface AdoptionSourceDeps {166  sourceIdentity: LocalIdentity;167  rootKeyHex(): Promise<string>;168  vaultId(): Promise<string>;169  license?(): Promise<string | undefined>;170  /** Validate and durably store a Pro key offered by the receiver. */171  applyLicense?(license: string): Promise<void>;172  onConsent(request: AdoptRequest): Promise<boolean>;173  upsertPairedDevice(device: PairedDevice): Promise<void>;174  /** Bound to the same completed Noise session, never taken from frame claims. */175  pairState?: AdoptionPairState;176}177178export function isAdoptRequest(frame: unknown): frame is AdoptRequest {179  const value = frame as Partial<AdoptRequest> | null;180  return value?.type === "ADOPT_REQUEST" &&181    typeof value.device_id === "string" &&182    value.device_id.length > 0 &&183    typeof value.device_name === "string" &&184    isDeviceType(value.device_type) &&185    (value.license === undefined || typeof value.license === "string");186}187188export function isAdoptGrant(frame: unknown): frame is AdoptGrant {189  const value = frame as Partial<AdoptGrant> | null;190  return value?.type === "ADOPT_GRANT" &&191    validateRootKeyHex(value.root_key_hex) &&192    typeof value.vault_id === "string" &&193    /^[0-9a-f]{64}$/.test(value.vault_id) &&194    typeof value.source === "object" &&195    value.source !== null &&196    typeof value.source.device_id === "string" &&197    typeof value.source.device_name === "string" &&198    isDeviceType(value.source.device_type) &&199    typeof value.source.long_term_public_key_hex === "string" &&200    /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&201    (value.source.relay_url === undefined ||202      (typeof value.source.relay_url === "string" &&203        isSecureRelayUrl(value.source.relay_url))) &&204    (value.snapshot_capable === undefined ||205      typeof value.snapshot_capable === "boolean");206}207208export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {209  const value = frame as Partial<AdoptConfirm> | null;210  return value?.type === "ADOPT_CONFIRM" &&211    typeof value.confirmed === "boolean";212}213214export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {215  const value = frame as Partial<AdoptIdentity> | null;216  return value?.type === "ADOPT_IDENTITY" &&217    typeof value.device_id === "string" &&218    value.device_id.length > 0 &&219    typeof value.device_name === "string" &&220    isDeviceType(value.device_type) &&221    typeof value.long_term_public_key_hex === "string" &&222    /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);223}224225export function isAdoptComplete(frame: unknown): frame is AdoptComplete {226  return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";227}228229export function isAdoptApplied(frame: unknown): frame is AdoptApplied {230  return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";231}232233export function isAdoptReject(frame: unknown): frame is AdoptReject {234  const value = frame as Partial<AdoptReject> | null;235  return value?.type === "ADOPT_REJECT" &&236    value.reason === "ADOPT_PRO_REQUIRED";237}238239export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {240  const value = frame as Partial<AdoptSnapshot> | null;241  if (value?.type !== "ADOPT_SNAPSHOT") return false;242  if (243    typeof value.chunk_index !== "number" ||244    !Number.isInteger(value.chunk_index) ||245    value.chunk_index < 0246  ) return false;247  if (248    typeof value.chunk_count !== "number" ||249    !Number.isInteger(value.chunk_count) ||250    value.chunk_count < 1251  ) return false;252  if (!isStringRecord(value.entries)) return false;253  if (!isNumberRecord(value.tombstones)) return false;254  if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;255  return true;256}257258export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {259  const value = frame as Partial<AdoptSnapshotApplied> | null;260  return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&261    typeof value.entries_received === "number" &&262    typeof value.tombstones_received === "number";263}264265export function isAdoptDeviceManifestFrame(266  frame: unknown,267): frame is AdoptDeviceManifestFrame {268  const value = frame as Partial<AdoptDeviceManifestFrame> | null;269  const manifest = value?.manifest as270    | { body?: unknown; signatureHex?: unknown }271    | null;272  return value?.type === "ADOPT_DEVICE_MANIFEST" &&273    typeof manifest === "object" &&274    manifest !== null &&275    typeof manifest.body === "object" &&276    manifest.body !== null &&277    typeof manifest.signatureHex === "string";278}279280export function isAdoptDeviceManifestApplied(281  frame: unknown,282): frame is AdoptDeviceManifestApplied {283  return (frame as Partial<AdoptDeviceManifestApplied> | null)?.type ===284    "ADOPT_DEVICE_MANIFEST_APPLIED";285}286287function isStringRecord(value: unknown): value is Record<string, string> {288  if (typeof value !== "object" || value === null) return false;289  return Object.values(value).every((v) => typeof v === "string");290}291292function isNumberRecord(value: unknown): value is Record<string, number> {293  if (typeof value !== "object" || value === null) return false;294  return Object.values(value).every((v) => typeof v === "number");295}296297/**298 * Source side: chunk the vault snapshot and send it over the pairing channel.299 *300 * Wire shape (v4.6.4+, duress on its own frame):301 *   - N entries frames, each ≤ MAX_SNAPSHOT_CHUNK_BYTES serialized entries.302 *     Tombstones ride the last entries frame when no duress follows.303 *   - If duressSlot is set, one extra frame after the entries carrying only304 *     the duress slot (entries: empty, tombstones: tombstone map,305 *     duress_slot: duressSlot). This moves duress off the entries frame so a306 *     sizeable sealed duress vault can no longer push the entries frame raw307 *     bytes over SNAPSHOT_FRAME_WIRE_CEILING (768 KiB).308 *309 * Frame budget (MAX_SNAPSHOT_CHUNKS = 200):310 *   - When duress is configured, ONE of the 200 frames is reserved for the311 *     duress slot. The entries cap is MAX_SNAPSHOT_CHUNKS - 1 = 199 entries312 *     chunks; excess entries are deferred to the sync fallback pipeline (sync313 *     carries entries and tombstones, so this is a graceful truncation).314 *     This reservation is REQUIRED: ordinary sync does NOT carry duress315 *     (vault.test.ts:158-179 test-locks duress as non-syncing), so silently316 *     dropping a duress frame at the cap would leave the adopted device317 *     permanently without its duress vault. We prioritize duress delivery318 *     over completeness of entries (the latter is recoverable via sync).319 *320 * Oversized duress: duress that itself exceeds SNAPSHOT_FRAME_WIRE_CEILING321 * (e.g. a ≥768 KiB sealed duress) is rejected with a thrown error BEFORE the322 * first duress frame is sent. This is a hard stop — the caller must split323 * the duress payload out of band. We do not silently truncate duress; sync324 * cannot restore it.325 *326 * Backward compat: pre-v4.6.4 senders put `duress_slot` on the entries327 * final chunk. Receivers tolerate both shapes because `receiveVaultSnapshot`328 * reads `duress_slot` off any chunk (a duress-only frame has empty329 * entries/tombstones which merge as a no-op).330 */331export async function sendVaultSnapshot(332  channel: PairingTransport,333  entries: Record<string, string>,334  tombstones: Record<string, number>,335  duressSlot: string | null = null,336): Promise<void> {337  const includeDuress = !!duressSlot;338  // Reserve one frame for duress when configured so duress is never silently339  // dropped at the cap; ordinary sync does NOT carry duress, so dropping a340  // duress frame would orphan the adopted device's duress vault.341  const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS;342343  const chunks: Array<Record<string, string>> = [];344  let current: Record<string, string> = {};345  let currentBytes = 2; // "{}"346  for (const [id, envelope] of Object.entries(entries)) {347    // +1 for the separating comma; an overestimate only shrinks chunks.348    const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;349    if (350      Object.keys(current).length > 0 &&351      currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES352    ) {353      chunks.push(current);354      current = {};355      currentBytes = 2;356    }357    current[id] = envelope;358    currentBytes += entryBytes;359  }360  chunks.push(current);361362  const entriesChunkCount = Math.min(chunks.length, entryCap);363  const sendDuress = includeDuress;364  const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);365366  for (let index = 0; index < entriesChunkCount; index++) {367    const isFinalEntries = index === entriesChunkCount - 1;368    const frame: AdoptSnapshot = {369      type: "ADOPT_SNAPSHOT",370      chunk_index: index,371      chunk_count: totalChunks,372      entries: chunks[index],373      // Tombstones ride the LAST wire frame: when duress follows the entries,374      // it goes on the duress frame; otherwise on the last entries chunk.375      tombstones: isFinalEntries && !sendDuress ? tombstones : {},376    };377    await sendFrame(channel, frame);378  }379380  if (sendDuress) {381    // Pre-check: a single duress frame that exceeds the wire ceiling cannot be382    // sent and would fail the receiver's size check. Throw loudly so the383    // caller can split duress out of band instead of triggering a silent384    // "adoption succeeded but duress vanished" path. Sync cannot recover385    // duress — the spec is a hard stop.386    const duressEnvelope = JSON.stringify({387      type: "ADOPT_SNAPSHOT",388      chunk_index: entriesChunkCount,389      chunk_count: totalChunks,390      entries: {},391      tombstones,392      duress_slot: duressSlot,393    });394    if (new TextEncoder().encode(duressEnvelope).length > SNAPSHOT_FRAME_WIRE_CEILING) {395      throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");396    }397    await sendFrame(channel, {398      type: "ADOPT_SNAPSHOT",399      chunk_index: entriesChunkCount,400      chunk_count: totalChunks,401      entries: {},402      tombstones,403      duress_slot: duressSlot as string,404    });405  }406}407408/**409 * Receiver side: receive and reassemble every snapshot chunk. Validates the410 * encoded frame size before parsing, then enforces sequential 0-based411 * chunk_index ordering, a stable chunk_count within MAX_SNAPSHOT_CHUNKS, and412 * structural validity of each frame. Throws on any validation failure so the413 * caller can fall back to the sync pipeline.414 */415export async function receiveVaultSnapshot(416  channel: PairingTransport,417): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {418  const entries: Record<string, string> = {};419  const tombstones: Record<string, number> = {};420  let duressSlot: string | null = null;421  let chunkCount = -1;422  let received = 0;423  while (chunkCount === -1 || received < chunkCount) {424    const raw = await channel.receive();425    if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {426      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");427    }428    const frame: unknown = JSON.parse(new TextDecoder().decode(raw));429    if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");430    if (chunkCount === -1) {431      chunkCount = frame.chunk_count;432      if (chunkCount > MAX_SNAPSHOT_CHUNKS) {433        throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");434      }435    } else if (frame.chunk_count !== chunkCount) {436      throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");437    }438    if (frame.chunk_index !== received) {439      throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");440    }441    Object.assign(entries, frame.entries);442    Object.assign(tombstones, frame.tombstones);443    if (frame.duress_slot) duressSlot = frame.duress_slot;444    received++;445  }446  return { entries, tombstones, duressSlot };447}448449export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {450  return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);451}452453export interface AdoptionRequestResult {454  device: PairedDevice;455  /** True iff the source echoed snapshot_capable: true in its ADOPT_GRANT. */456  snapshotCapable: boolean;457}458459export async function requestAdoption(460  channel: PairingTransport,461  deps: AdoptionReceiverDeps,462): Promise<AdoptionRequestResult | null> {463  const receiverLicense = await deps.license?.();464  await sendFrame(channel, {465    type: "ADOPT_REQUEST",466    device_id: deps.generateDeviceId(),467    device_name: deps.deviceName,468    device_type: "extension",469    license: receiverLicense,470    snapshot_capable: true,471  });472473  const confirmed = await deps.confirmSas();474  await sendFrame(channel, {475    type: "ADOPT_CONFIRM",476    confirmed,477  });478  if (!confirmed) {479    deps.onError("ADOPT_SAS_REJECTED");480    return null;481  }482483  const frame = await receiveFrame(channel);484  if (isAdoptReject(frame)) {485    deps.onError(frame.reason);486    return null;487  }488  if (!isAdoptGrant(frame)) {489    deps.onError("ADOPT_UNEXPECTED_FRAME");490    return null;491  }492  const grantSnapshotCapable = frame.snapshot_capable === true;493494  const rootKeyBytes = hexToBytes(frame.root_key_hex);495  try {496    const computedVaultId = await computeVaultId(rootKeyBytes);497    if (computedVaultId !== frame.vault_id) {498      deps.onError("ADOPT_VAULT_ID_MISMATCH");499      return null;500    }501  } finally {502    rootKeyBytes.fill(0);503  }504  if (!receiverLicense && !frame.license) {505    deps.onError("ADOPT_PRO_REQUIRED");506    return null;507  }508509  await deps.validateSource?.(frame.source);510  const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);511  if (vaultId !== frame.vault_id) {512    deps.onError("ADOPT_VAULT_ID_MISMATCH");513    return null;514  }515516  if (!receiverLicense && frame.license) {517    await deps.applyLicense?.(frame.license);518  }519520  const identity = await deps.deriveLocalIdentity();521  await sendFrame(channel, {522    type: "ADOPT_IDENTITY",523    device_id: identity.deviceId,524    device_name: identity.deviceName,525    device_type: identity.deviceType,526    long_term_public_key_hex: identity.longTermPublicKeyHex,527    signing_public_key_hex: identity.signingPublicKeyHex,528  });529530  const completion = await receiveFrame(channel);531  if (!isAdoptComplete(completion)) {532    deps.onError("ADOPT_COMPLETION_MISSING");533    return null;534  }535536  const sourceDevice: PairedDevice = {537    deviceId: frame.source.device_id,538    deviceName: frame.source.device_name,539    deviceType: frame.source.device_type,540    longTermPublicKeyHex: frame.source.long_term_public_key_hex,541    signingPublicKeyHex: frame.source.signing_public_key_hex,542    trustSource: "direct",543    wsPort: frame.source.ws_port,544    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,545    relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,546    pairedAt: Date.now(),547  };548  await deps.upsertPairedDevice(sourceDevice);549  await sendFrame(channel, { type: "ADOPT_APPLIED" });550  return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };551}552553export async function grantAdoption(554  channel: PairingTransport,555  request: AdoptRequest,556  deps: AdoptionSourceDeps,557): Promise<PairedDevice | null> {558  const approved = await deps.onConsent(request);559  if (!approved) return null;560561  const confirmation = await receiveFrame(channel);562  if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;563564  let sourceLicense = await deps.license?.();565  if (!sourceLicense && request.license) {566    try {567      if (!deps.applyLicense) throw new Error("license_transfer_unavailable");568      await deps.applyLicense(request.license);569      sourceLicense = await deps.license?.();570    } catch {571      sourceLicense = undefined;572    }573  }574  if (!sourceLicense) {575    await sendFrame(channel, {576      type: "ADOPT_REJECT",577      reason: "ADOPT_PRO_REQUIRED",578    });579    throw new Error("ADOPT_PRO_REQUIRED");580  }581582  const rootKeyHex = await deps.rootKeyHex();583  if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");584585  await sendFrame(channel, {586    type: "ADOPT_GRANT",587    root_key_hex: rootKeyHex,588    vault_id: await deps.vaultId(),589    license: sourceLicense,590    source: {591      device_id: deps.sourceIdentity.deviceId,592      device_name: deps.sourceIdentity.deviceName,593      device_type: deps.sourceIdentity.deviceType,594      long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,595      signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,596      ws_port: deps.sourceIdentity.wsPort,597      relay_url: deps.pairState?.relayUrl,598    },599    snapshot_capable: request.snapshot_capable === true,600  });601602  const identity = await receiveFrame(channel);603  if (!isAdoptIdentity(identity)) return null;604605  const device: PairedDevice = {606    deviceId: identity.device_id,607    deviceName: identity.device_name,608    deviceType: identity.device_type,609    longTermPublicKeyHex: identity.long_term_public_key_hex,610    signingPublicKeyHex: identity.signing_public_key_hex,611    trustSource: "direct",612    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,613    relayUrl: deps.pairState?.relayUrl,614    pairedAt: Date.now(),615  };616  await deps.upsertPairedDevice(device);617  await sendFrame(channel, { type: "ADOPT_COMPLETE" });618  if (!isAdoptApplied(await receiveFrame(channel))) return null;619  return device;620}621622export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {623  await channel.send(new TextEncoder().encode(JSON.stringify(frame)));624}625626export async function receiveFrame(channel: PairingTransport): Promise<unknown> {627  return JSON.parse(new TextDecoder().decode(await channel.receive()));628}629630function isDeviceType(value: unknown): value is DeviceType {631  return value === "extension" || value === "android" || value === "ios";632}633634function isSecureRelayUrl(value: string): boolean {635  try {636    const url = new URL(value);637    return url.protocol === "wss:" && !url.username && !url.password &&638      !url.search && !url.hash;639  } catch {640    return false;641  }642}643644function hexToBytes(hex: string): Uint8Array {645  const out = new Uint8Array(hex.length / 2);646  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);647  return out;648}