sync-vault-adapter.ts

24.7 KB · raw

1// Production adapter that backs `runSyncInitiator` with the live vault.2//3// The coordinator (sync-coordinator.ts) never decrypts vault data; it4// only moves opaque base64 entry blobs and metadata. This adapter is the5// boundary where vault plaintext meets the sync layer — entries leave the6// vault as encrypted envelopes here, and incoming envelopes are decrypted7// here before being merged via `applyMergedRemoteEntries`.8//9// Envelope cipher: AES-GCM with a PER-ENTRY key derived as10//   HKDF-SHA256(ikm = syncMasterKey, salt = "", info = entryId + "\x00" + "sync-entry-v1")11// (random 12B nonce, aad = "peach-sync-entry-v1"). Binding the key to the12// entry id means a ciphertext minted for one entry slot cannot be replayed13// into another: decryptEntryOrNull rejects any envelope whose decrypted14// entry.id does not match the map key it arrived under. Both sides hold the15// same syncMasterKey (same vault) so keys match without prior negotiation. The16// Noise KK reconnect session encrypts the transport on top — the envelope is17// belt-and-braces so a transport-layer leak doesn't expose plaintext entries.18//19// Device-manifest exchange (v2 brief §4.3) is plumbed here. Any admitted20// mesh peer may issue one — see applyVerifiedPeerManifest for why the issuer21// is not restricted by device type or first-pair topology, and for what the22// Noise-authenticated static key gates. docs/mesh-vouching-authority.md is the23// end-to-end trace.2425import { HKDF } from "@stablelib/hkdf";26import { SHA256 } from "@stablelib/sha256";27import type { SyncVaultAdapter } from "./sync-coordinator";28import { getOrCreateDeviceId } from "./device-identity";29import {30  computeVaultId,31  deriveSyncIdentityKeypair,32  deriveSyncSigningKeypair,33  hexToPubkey,34  pubkeyToHex,35} from "../shared/sync-identity";36import {37  buildSignedDeviceManifest,38  verifyDeviceManifest,39} from "../shared/device-manifest";40import { constantTimeEqualHex } from "../shared/constant-time";41import {42  applyMergedRemoteEntries,43  getDeletedEntries,44  getRootKeyHex,45  getEntries,46  getEntryById,47  getVaultVersion,48  isUnlocked,49} from "./vault";50import {51  findPairedDeviceById,52  findPairedDeviceByPubkey,53  isDeviceRevoked,54  listDeviceRevocations,55  listPairedDevices,56  mergeDeviceRevocations,57  removePairedDeviceById,58  upsertPairedDevice,59} from "./paired-devices";60import {61  removePendingVouchedDevice,62} from "./pending-vouched-devices";63import {64  bytesToHex as rendezvousBytesToHex,65  deriveMeshRendezvousSecret,66  normalizeRelayUrl,67} from "../shared/rendezvous";68import { recordSyncAlerts } from "./sync-alerts";69import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";70import type { VaultEntry } from "../shared/types";71import type {72  ManifestDeviceEntry,73  PairedDevice,74  SignedDeviceManifest,75} from "../shared/sync-types";7677// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1").78// The info binding prevents a ciphertext from one entry slot being replayed into another.79const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";80const ENTRY_AAD = "peach-sync-entry-v1";8182export class VaultLockedError extends Error {83  constructor() {84    super("vault_locked");85    this.name = "VaultLockedError";86  }87}8889export function createSyncVaultAdapter(90  context: { deviceName?: string } = {},91): SyncVaultAdapter {92  return {93    async computeVaultId() {94      const master = requireMasterKey();95      return computeVaultId(master);96    },9798    async getOurDeviceId() {99      return getOrCreateDeviceId();100    },101102    async getLocalVersion() {103      return getVaultVersion();104    },105106    async buildEntryManifest() {107      requireUnlocked();108      const manifest: Record<string, number> = {};109      for (const entry of getEntries()) {110        manifest[entry.id] = entry.modifiedAt;111      }112      return manifest;113    },114115    async buildTombstoneManifest() {116      // KNOWN LIMITATION: tombstones accumulate without bound. Every deleted117      // entry leaves a {id, deletedAt} record that persists indefinitely and is118      // sent in every sync session, so a long-lived vault's manifest grows with119      // total deletions, not live entries. Acceptable for now (records are tiny120      // and the trash-retention purge eventually splices the entry array).121      // TODO(tombstone-gc): prune tombstones older than N months once every paired device122      // Algorithm: (1) After SYNC_DONE, record the peer-ack timestamp per tombstone.123      // (2) Once ALL paired devices have ack-ed a tombstone, splice it from the124      // deleted-entries array. (3) Gate on all-devices to prevent resurrection125      // from an offline peer that still holds the entry.126      requireUnlocked();127      const manifest: Record<string, number> = {};128      for (const entry of getDeletedEntries()) {129        if (typeof entry.deletedAt === "number") {130          manifest[entry.id] = entry.deletedAt;131        }132      }133      return manifest;134    },135136    async getEncryptedEntries(entryIds) {137      requireUnlocked();138      const masterKey = requireMasterKey();139      const out: Record<string, string> = {};140      for (const id of entryIds) {141        const entry = getEntryById(id);142        if (!entry) continue;143        const key = await deriveEntryKey(masterKey, id);144        out[id] = await encryptEntry(key, entry);145      }146      return out;147    },148149    async applyEntries(entries) {150      requireUnlocked();151      const entryList = Object.entries(entries);152      // MV-3: bound the batch before any per-entry crypto work.153      if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {154        throw new Error(155          `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,156        );157      }158      const masterKey = requireMasterKey();159      const decrypted: VaultEntry[] = [];160      for (const [id, envelope] of entryList) {161        // MV-3: per-entry ceiling, checked on the (base64) envelope before162        // decrypting. Base64 inflates by 4/3, so allow that factor.163        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {164          continue; // oversized envelope — skip, never decrypt165        }166        const key = await deriveEntryKey(masterKey, id);167        const entry = await decryptEntryOrNull(key, envelope);168        if (!entry) continue;169        // MV-3: defense in depth — re-measure the decrypted plaintext.170        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {171          continue;172        }173        // The map key is the authority on id — refuse mismatched envelopes174        // so a swapped envelope can't poison a different entry slot.175        if (entry.id !== id) continue;176        decrypted.push(entry);177      }178      if (decrypted.length > 0) {179        await applyMergedRemoteEntries(decrypted, {}, context);180      }181    },182183    async applyTombstones(tombstones) {184      requireUnlocked();185      const tombstoneList = Object.entries(tombstones);186      // MV-3: bound the tombstone batch before processing.187      if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {188        throw new Error(189          `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,190        );191      }192      const cleaned: Record<string, number> = {};193      for (const [id, ts] of tombstoneList) {194        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {195          // HV-1c: clamp before the value participates in any comparison.196          // vault.ts clamps again defensively; clamping here keeps the197          // invariant local to the boundary where peer data enters.198          cleaned[id] = clampSyncTimestamp(ts);199        }200      }201      if (Object.keys(cleaned).length > 0) {202        await applyMergedRemoteEntries([], cleaned, context);203      }204    },205206    async buildOutgoingDeviceManifest() {207      requireUnlocked();208      const masterKey = requireMasterKey();209      const issuerDeviceId = await getOrCreateDeviceId();210      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);211      const revoked = await listDeviceRevocations();212      const revokedIds = new Set(revoked.map((r) => r.deviceId));213      const devices = (await listPairedDevices())214        .filter((d) => !revokedIds.has(d.deviceId))215        .map(toManifestEntry)216        .filter((d): d is ManifestDeviceEntry => d !== null);217      return buildSignedDeviceManifest(218        issuerDeviceId,219        devices,220        signing.privateKey,221        { revoked },222      );223    },224225    async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {226      requireUnlocked();227      // ponytail: temporary mesh-propagation diagnostic, strip before save228      console.info("[Peach diag] applyIncomingDeviceManifest: issuerDeviceId=" + manifest?.body?.issuerDeviceId + " devices=" + (manifest?.body?.devices ?? []).map((d) => d.deviceId).join(",") + " revoked=" + (manifest?.body?.revoked ?? []).length);229      try {230        await applyVerifiedPeerManifest(231          manifest,232          authenticatedRemoteStaticPublicKey,233        );234      } catch (diagError) {235        console.info("[Peach diag] applyVerifiedPeerManifest threw:", diagError);236      }237      const all = await listPairedDevices();238      console.info("[Peach diag] post-manifest paired-devices:", all.map((d) => d.deviceId + "/" + d.trustSource).join(","));239    },240  };241}242243function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {244  if (245    !device.deviceId ||246    !device.deviceName ||247    !device.deviceType ||248    !device.longTermPublicKeyHex ||249    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||250    !device.signingPublicKeyHex ||251    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)252  ) {253    return null;254  }255  return {256    deviceId: device.deviceId,257    deviceName: device.deviceName,258    deviceType: device.deviceType,259    longTermPublicKeyHex: device.longTermPublicKeyHex,260    signingPublicKeyHex: device.signingPublicKeyHex,261    wsPort: device.wsPort,262    relayUrl: device.relayUrl,263    pairedAt: device.pairedAt,264  };265}266267/**268 * Apply a device manifest issued by a peer we pair with directly.269 *270 * The issuer used to be required to be an "extension", on the assumption that271 * the extension was the mesh's trust anchor. It is not: the device a fresh272 * install joins through is the one holding the peer list to hand over.273 *274 * The issuer must already be admitted locally as either direct or vouched.275 * That is the decentralisation boundary: an unknown key has no session and no276 * authority, while every authenticated member can propagate the same signed277 * pool without the original hub online. A v3 entry is admitted only when it278 * carries a valid relay route; both endpoints independently derive the same279 * pair-only capability from their vault root and public keys.280 */281async function applyVerifiedPeerManifest(282  manifest: SignedDeviceManifest,283  authenticatedRemoteStaticPublicKey: Uint8Array,284): Promise<void> {285  // ponytail: temporary mesh-propagation diagnostic, strip before save286  console.info("[Peach diag] applyVerifiedPeerManifest enter: authPubKeyLen=" + authenticatedRemoteStaticPublicKey.length + " manifestIssuer=" + manifest?.body?.issuerDeviceId);287  const issuerId = manifest?.body?.issuerDeviceId;288  if (typeof issuerId !== "string" || !issuerId) { console.info("[Peach diag] early return: missing issuerId"); return; }289290  if (authenticatedRemoteStaticPublicKey.length !== 32) {291    console.info("[Peach diag] early return: auth key not 32 bytes");292    return;293  }294  const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);295  const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);296  console.info("[Peach diag] lookup issuer: found=" + (issuer !== null) + " storedDeviceId=" + (issuer === null ? "<none>" : issuer.deviceId) + " manifestIssuerId=" + issuerId + " trustSource=" + (issuer === null ? "<none>" : issuer.trustSource) + " hasSigningKey=" + (issuer !== null && issuer.signingPublicKeyHex !== undefined));297  if (298    !issuer ||299    issuer.deviceId !== issuerId ||300    (301      issuer.trustSource !== "direct" &&302      issuer.trustSource !== "vouched"303    ) ||304    !issuer.signingPublicKeyHex ||305    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)306  ) {307    console.info("[Peach diag] early return: issuer gate");308    return;309  }310311  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;312  try {313    body = await verifyDeviceManifest(314      manifest,315      hexToPubkey(issuer!.signingPublicKeyHex!),316    );317    // ponytail: temporary mesh-propagation diagnostic, strip before save318    console.info("[Peach diag] verifyDeviceManifest OK with stored key");319  } catch (storedError) {320    // ponytail: temporary mesh-propagation diagnostic, strip before save321    console.info("[Peach diag] verifyDeviceManifest with stored key threw:", String(storedError));322    // Stored key may be stale (older builds persisted the key derived at323    // first-pair and never refreshed it). The signing keypair is deterministic324    // from (masterKey, deviceId) on both sides, so retry once with the locally325    // re-derived pubkey. A genuinely forged signature throws here and is326    // silently rejected — only a stale-but-correct key survives the retry.327    const masterForIssuer = requireMasterKey();328    try {329      const derived = await deriveSyncSigningKeypair(330        masterForIssuer,331        issuer.deviceId,332      );333      // ponytail: temporary mesh-propagation diagnostic, strip before save334      console.info("[Peach diag] retry with derived key: storedSigning=" + issuer!.signingPublicKeyHex!.slice(0,12) + " derivedSigning=" + Buffer.from(derived.publicKeyRaw).toString("hex").slice(0,12));335      body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);336      console.info("[Peach diag] verifyDeviceManifest OK with derived key");337    } catch (derivedError) {338      // ponytail: temporary mesh-propagation diagnostic, strip before save339      console.info("[Peach diag] verifyDeviceManifest with derived key threw:", String(derivedError));340      return;341    } finally {342      masterForIssuer.fill(0);343    }344  }345  console.info("[Peach diag] body parsed: bodyIssuerId=" + body.issuerDeviceId + " issuerStoredId=" + issuer.deviceId);346  if (body.issuerDeviceId !== issuer.deviceId) { console.info("[Peach diag] early return: body issuer mismatch"); return; }347348  const ourDeviceId = await getOrCreateDeviceId();349  const masterKey = requireMasterKey();350  const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);351352  try {353    // Revocations are absorbed BEFORE any vouching, so a manifest that both354    // revokes and re-vouches the same device in one payload cannot race itself.355    // A peer may not revoke US — that would let one compromised device sever the356    // whole mesh — so our own id is filtered out first.357    const newlyRevoked = await mergeDeviceRevocations(358      body.revoked.filter((r) => r.deviceId !== ourDeviceId),359    );360    for (const deviceId of newlyRevoked) {361      await removePairedDeviceById(deviceId);362      await removePendingVouchedDevice(deviceId);363      void recordSyncAlerts([364        {365          type: "DEVICE_REVOKED",366          entryId: "",367          entryTitle: deviceId,368          deviceName: issuer.deviceName ?? "paired device",369          timestamp: Date.now(),370        },371      ]);372    }373374    for (const entry of body.devices) {375      if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {376        // ponytail: temporary mesh-propagation diagnostic, strip before save377        console.info("[Peach diag] skip entry (self or issuer):", entry.deviceId);378        continue;379      }380      // Terminal: no manifest re-admits a revoked device. Matched on the key as381      // well as the id, because the id is the issuer's to choose — checking only382      // the id let a revoked key walk back in under a fresh one. The check sits383      // ahead of the existing-device lookup so it also refuses rotating a device384      // we still trust onto a revoked key.385      if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {386        // ponytail: temporary mesh-propagation diagnostic, strip before save387        console.info("[Peach diag] skip entry (revoked):", entry.deviceId, "longTerm=" + entry.longTermPublicKeyHex?.slice(0, 12) + "...", "hasRelay=" + !!entry.relayUrl);388        continue;389      }390      const existing = await findPairedDeviceById(entry.deviceId);391      const meshRoute = deriveManifestMeshRoute(392        masterKey,393        localKeys.publicKey,394        entry,395      );396      // ponytail: temporary mesh-propagation diagnostic, strip before save397      if (existing) {398        console.info("[Peach diag] skip entry (existing):", entry.deviceId, "trustSource=" + existing.trustSource);399      } else if (!meshRoute) {400        console.info("[Peach diag] skip entry (no mesh route):", entry.deviceId, "hasRelay=" + !!entry.relayUrl);401      } else {402        console.info("[Peach diag] admit entry:", entry.deviceId, "relay=" + meshRoute.relayUrl);403      }404405      // A v3 manifest from an admitted, KK-authenticated issuer proves406      // same-vault membership and carries enough route material for the two407      // introduced endpoints to derive their own capability. Admit it408      // immediately so a six-device vault converges without keeping the409      // original pairing hub online.410      if (!existing) {411        if (!meshRoute) continue;412        await upsertPairedDevice({413          deviceId: entry.deviceId,414          deviceName: entry.deviceName,415          deviceType: entry.deviceType,416          longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),417          signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),418          wsPort: entry.wsPort,419          rendezvousSecretHex: meshRoute.rendezvousSecretHex,420          relayUrl: meshRoute.relayUrl,421          pairedAt: entry.pairedAt,422          trustSource: "vouched",423        });424        await removePendingVouchedDevice(entry.deviceId);425        continue;426      }427428      // MV-8d: a key changing under a stable deviceId is legitimate after a429      // reinstall, but it is exactly what a graph-poisoning attack looks like —430      // signal the user either way, durably (the alert queue survives a closed431      // popup) and live (the broadcast updates an open one).432      if (433        existing.longTermPublicKeyHex &&434        !constantTimeEqualHex(435          existing.longTermPublicKeyHex,436          entry.longTermPublicKeyHex,437        )438      ) {439        // S5: name the device from the LOCAL record, never from the manifest.440        // The issuer is a third party here (its own entry is skipped above), so441        // `entry.deviceName` is an unverified claim about someone else — an442        // attacker who could put it on screen could make its own key rotation443        // read as a rotation on a device the user trusts more.444        const knownName = existing.deviceName ?? entry.deviceName;445        notifyPopup({446          type: "DEVICE_KEY_ROTATED",447          payload: { deviceName: knownName, deviceId: entry.deviceId },448        });449        void recordSyncAlerts([450          {451            type: "DEVICE_KEY_ROTATED",452            entryId: "",453            entryTitle: knownName,454            deviceName: knownName,455            timestamp: Date.now(),456          },457        ]);458      }459460      // Existing device — update in place as before. The name is deliberately461      // NOT taken from the manifest: a device's name is set by that device at462      // pairing, and letting a third-party issuer rewrite it is the rename-then-463      // impersonate half of S5. Self-renames never arrive here anyway.464      await upsertPairedDevice({465        ...existing,466        deviceId: entry.deviceId,467        deviceName: existing.deviceName ?? entry.deviceName,468        deviceType: entry.deviceType,469        longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),470        signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),471        pairedAt: existing.pairedAt ?? entry.pairedAt,472        trustSource: existing.trustSource ?? "direct",473        wsPort: existing.wsPort ?? entry.wsPort,474        rendezvousSecretHex:475          existing.trustSource === "vouched" && meshRoute476            ? meshRoute.rendezvousSecretHex477            : existing.rendezvousSecretHex,478        relayUrl:479          existing.trustSource === "vouched" && meshRoute480            ? meshRoute.relayUrl481            : existing.relayUrl,482        lastSyncedAt: existing.lastSyncedAt,483      });484    }485  } finally {486    masterKey.fill(0);487    localKeys.secretKey.fill(0);488  }489}490491function deriveManifestMeshRoute(492  masterKey: Uint8Array,493  localPublicKey: Uint8Array,494  entry: ManifestDeviceEntry,495): { rendezvousSecretHex: string; relayUrl: string } | null {496  try {497    if (!entry.relayUrl) return null;498    const relayUrl = normalizeRelayUrl(entry.relayUrl);499    const secret = deriveMeshRendezvousSecret(500      masterKey,501      localPublicKey,502      hexToPubkey(entry.longTermPublicKeyHex),503    );504    try {505      return {506        rendezvousSecretHex: rendezvousBytesToHex(secret),507        relayUrl,508      };509    } finally {510      secret.fill(0);511    }512  } catch {513    return null;514  }515}516517function notifyPopup(msg: { type: string; payload?: unknown }): void {518  try {519    void chrome.runtime.sendMessage(msg).catch(() => undefined);520  } catch {521    /* popup closed — the durable sync-alert queue remains authoritative */522  }523}524525function requireUnlocked(): void {526  if (!isUnlocked()) throw new VaultLockedError();527}528529// Sync derives everything — vault_id, the Noise static key, the Ed25519530// manifest signing key, and the per-entry envelope keys — from the vault's531// stable root key, NOT the password-derived key. This keeps paired devices'532// identities and envelope keys constant across a master-password change on533// either side (SEV-2-B); two paired devices with independently-rotated534// passwords still share this key and can decrypt each other's entries.535function requireMasterKey(): Uint8Array {536  if (!isUnlocked()) throw new VaultLockedError();537  const hex = getRootKeyHex();538  if (!hex) throw new VaultLockedError();539  return hexToBytes(hex);540}541542async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {543  // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this544  // makes the (entryId, suffix) boundary unambiguous regardless of future id545  // formats. Coordinated wire change — peach-android VaultSyncAdapter derives546  // the identical info; the two MUST stay byte-for-byte in lockstep.547  const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);548  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);549  return crypto.subtle.importKey(550    "raw",551    raw.buffer as ArrayBuffer,552    { name: "AES-GCM" },553    false,554    ["encrypt", "decrypt"],555  );556}557558async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {559  const plaintext = new TextEncoder().encode(JSON.stringify(entry));560  const nonce = crypto.getRandomValues(new Uint8Array(12));561  const aad = new TextEncoder().encode(ENTRY_AAD);562  const ct = await crypto.subtle.encrypt(563    {564      name: "AES-GCM",565      iv: nonce.buffer as ArrayBuffer,566      additionalData: aad.buffer as ArrayBuffer,567      tagLength: 128,568    },569    key,570    plaintext.buffer as ArrayBuffer,571  );572  const ctBytes = new Uint8Array(ct);573  const envelope = new Uint8Array(nonce.length + ctBytes.length);574  envelope.set(nonce, 0);575  envelope.set(ctBytes, nonce.length);576  return bytesToBase64(envelope);577}578579async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {580  try {581    const bytes = base64ToBytes(envelopeB64);582    if (bytes.length < 12 + 16) return null;583    const nonce = bytes.subarray(0, 12);584    const ct = bytes.subarray(12);585    const aad = new TextEncoder().encode(ENTRY_AAD);586    const pt = await crypto.subtle.decrypt(587      {588        name: "AES-GCM",589        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,590        additionalData: aad.buffer as ArrayBuffer,591        tagLength: 128,592      },593      key,594      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,595    );596    const json = new TextDecoder().decode(pt);597    const parsed = JSON.parse(json);598    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;599    return parsed as VaultEntry;600  } catch {601    return null;602  }603}604605function hexToBytes(hex: string): Uint8Array {606  if (hex.length % 2 !== 0) throw new Error("hex length must be even");607  const out = new Uint8Array(hex.length / 2);608  for (let i = 0; i < out.length; i++) {609    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);610  }611  return out;612}613614function bytesToBase64(bytes: Uint8Array): string {615  let bin = "";616  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);617  return btoa(bin);618}619620function base64ToBytes(b64: string): Uint8Array {621  const bin = atob(b64);622  const out = new Uint8Array(bin.length);623  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);624  return out;625}