sync-vault-adapter.ts

25.9 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  canonicalise,39  verifyDeviceManifest,40} from "../shared/device-manifest";41import { constantTimeEqualHex } from "../shared/constant-time";42import {43  applyMergedRemoteEntries,44  getDeletedEntries,45  getRootKeyHex,46  getEntries,47  getEntryById,48  getVaultVersion,49  isUnlocked,50} from "./vault";51import {52  findPairedDeviceById,53  findPairedDeviceByPubkey,54  isDeviceRevoked,55  listDeviceRevocations,56  listPairedDevices,57  mergeDeviceRevocations,58  removePairedDeviceById,59  upsertPairedDevice,60} from "./paired-devices";61import {62  removePendingVouchedDevice,63} from "./pending-vouched-devices";64import {65  bytesToHex as rendezvousBytesToHex,66  deriveMeshRendezvousSecret,67  normalizeRelayUrl,68} from "../shared/rendezvous";69import { recordSyncAlerts } from "./sync-alerts";70import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";71import type { VaultEntry } from "../shared/types";72import type {73  ManifestDeviceEntry,74  PairedDevice,75  SignedDeviceManifest,76} from "../shared/sync-types";7778// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1").79// The info binding prevents a ciphertext from one entry slot being replayed into another.80const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";81const ENTRY_AAD = "peach-sync-entry-v1";8283export class VaultLockedError extends Error {84  constructor() {85    super("vault_locked");86    this.name = "VaultLockedError";87  }88}8990export function createSyncVaultAdapter(91  context: { deviceName?: string } = {},92): SyncVaultAdapter {93  return {94    async computeVaultId() {95      const master = requireMasterKey();96      return computeVaultId(master);97    },9899    async getOurDeviceId() {100      return getOrCreateDeviceId();101    },102103    async getLocalVersion() {104      return getVaultVersion();105    },106107    async buildEntryManifest() {108      requireUnlocked();109      const manifest: Record<string, number> = {};110      for (const entry of getEntries()) {111        manifest[entry.id] = entry.modifiedAt;112      }113      return manifest;114    },115116    async buildTombstoneManifest() {117      // KNOWN LIMITATION: tombstones accumulate without bound. Every deleted118      // entry leaves a {id, deletedAt} record that persists indefinitely and is119      // sent in every sync session, so a long-lived vault's manifest grows with120      // total deletions, not live entries. Acceptable for now (records are tiny121      // and the trash-retention purge eventually splices the entry array).122      // TODO(tombstone-gc): prune tombstones older than N months once every paired device123      // Algorithm: (1) After SYNC_DONE, record the peer-ack timestamp per tombstone.124      // (2) Once ALL paired devices have ack-ed a tombstone, splice it from the125      // deleted-entries array. (3) Gate on all-devices to prevent resurrection126      // from an offline peer that still holds the entry.127      requireUnlocked();128      const manifest: Record<string, number> = {};129      for (const entry of getDeletedEntries()) {130        if (typeof entry.deletedAt === "number") {131          manifest[entry.id] = entry.deletedAt;132        }133      }134      return manifest;135    },136137    async getEncryptedEntries(entryIds) {138      requireUnlocked();139      const masterKey = requireMasterKey();140      const out: Record<string, string> = {};141      for (const id of entryIds) {142        const entry = getEntryById(id);143        if (!entry) continue;144        const key = await deriveEntryKey(masterKey, id);145        out[id] = await encryptEntry(key, entry);146      }147      return out;148    },149150    async applyEntries(entries) {151      requireUnlocked();152      const entryList = Object.entries(entries);153      // MV-3: bound the batch before any per-entry crypto work.154      if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {155        throw new Error(156          `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,157        );158      }159      const masterKey = requireMasterKey();160      const decrypted: VaultEntry[] = [];161      for (const [id, envelope] of entryList) {162        // MV-3: per-entry ceiling, checked on the (base64) envelope before163        // decrypting. Base64 inflates by 4/3, so allow that factor.164        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {165          continue; // oversized envelope — skip, never decrypt166        }167        const key = await deriveEntryKey(masterKey, id);168        const entry = await decryptEntryOrNull(key, envelope);169        if (!entry) continue;170        // MV-3: defense in depth — re-measure the decrypted plaintext.171        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {172          continue;173        }174        // The map key is the authority on id — refuse mismatched envelopes175        // so a swapped envelope can't poison a different entry slot.176        if (entry.id !== id) continue;177        decrypted.push(entry);178      }179      if (decrypted.length > 0) {180        await applyMergedRemoteEntries(decrypted, {}, context);181      }182    },183184    async applyTombstones(tombstones) {185      requireUnlocked();186      const tombstoneList = Object.entries(tombstones);187      // MV-3: bound the tombstone batch before processing.188      if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {189        throw new Error(190          `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,191        );192      }193      const cleaned: Record<string, number> = {};194      for (const [id, ts] of tombstoneList) {195        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {196          // HV-1c: clamp before the value participates in any comparison.197          // vault.ts clamps again defensively; clamping here keeps the198          // invariant local to the boundary where peer data enters.199          cleaned[id] = clampSyncTimestamp(ts);200        }201      }202      if (Object.keys(cleaned).length > 0) {203        await applyMergedRemoteEntries([], cleaned, context);204      }205    },206207    async buildOutgoingDeviceManifest() {208      requireUnlocked();209      const masterKey = requireMasterKey();210      const issuerDeviceId = await getOrCreateDeviceId();211      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);212      const revoked = await listDeviceRevocations();213      const revokedIds = new Set(revoked.map((r) => r.deviceId));214      const devices = (await listPairedDevices())215        .filter((d) => !revokedIds.has(d.deviceId))216        .map(toManifestEntry)217        .filter((d): d is ManifestDeviceEntry => d !== null);218      return buildSignedDeviceManifest(219        issuerDeviceId,220        devices,221        signing.privateKey,222        { revoked },223      );224    },225226    async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {227      requireUnlocked();228      // ponytail: temporary mesh-propagation diagnostic, strip before save229      console.info("[Peach diag] applyIncomingDeviceManifest: issuerDeviceId=" + manifest?.body?.issuerDeviceId + " devices=" + (manifest?.body?.devices ?? []).map((d) => d.deviceId).join(",") + " revoked=" + (manifest?.body?.revoked ?? []).length);230      try {231        await applyVerifiedPeerManifest(232          manifest,233          authenticatedRemoteStaticPublicKey,234        );235      } catch (diagError) {236        console.info("[Peach diag] applyVerifiedPeerManifest threw:", diagError);237      }238      const all = await listPairedDevices();239      console.info("[Peach diag] post-manifest paired-devices:", all.map((d) => d.deviceId + "/" + d.trustSource).join(","));240    },241  };242}243244function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {245  if (246    !device.deviceId ||247    !device.deviceName ||248    !device.deviceType ||249    !device.longTermPublicKeyHex ||250    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||251    !device.signingPublicKeyHex ||252    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)253  ) {254    return null;255  }256  return {257    deviceId: device.deviceId,258    deviceName: device.deviceName,259    deviceType: device.deviceType,260    longTermPublicKeyHex: device.longTermPublicKeyHex,261    signingPublicKeyHex: device.signingPublicKeyHex,262    wsPort: device.wsPort,263    relayUrl: device.relayUrl,264    pairedAt: device.pairedAt,265  };266}267268/**269 * Apply a device manifest issued by a peer we pair with directly.270 *271 * The issuer used to be required to be an "extension", on the assumption that272 * the extension was the mesh's trust anchor. It is not: the device a fresh273 * install joins through is the one holding the peer list to hand over.274 *275 * The issuer must already be admitted locally as either direct or vouched.276 * That is the decentralisation boundary: an unknown key has no session and no277 * authority, while every authenticated member can propagate the same signed278 * pool without the original hub online. A v3 entry is admitted only when it279 * carries a valid relay route; both endpoints independently derive the same280 * pair-only capability from their vault root and public keys.281 */282async function applyVerifiedPeerManifest(283  manifest: SignedDeviceManifest,284  authenticatedRemoteStaticPublicKey: Uint8Array,285): Promise<void> {286  // ponytail: temporary mesh-propagation diagnostic, strip before save287  console.info("[Peach diag] applyVerifiedPeerManifest enter: authPubKeyLen=" + authenticatedRemoteStaticPublicKey.length + " manifestIssuer=" + manifest?.body?.issuerDeviceId);288  const issuerId = manifest?.body?.issuerDeviceId;289  if (typeof issuerId !== "string" || !issuerId) { console.info("[Peach diag] early return: missing issuerId"); return; }290291  if (authenticatedRemoteStaticPublicKey.length !== 32) {292    console.info("[Peach diag] early return: auth key not 32 bytes");293    return;294  }295  const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);296  const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);297  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));298  if (299    !issuer ||300    issuer.deviceId !== issuerId ||301    (302      issuer.trustSource !== "direct" &&303      issuer.trustSource !== "vouched"304    ) ||305    !issuer.signingPublicKeyHex ||306    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)307  ) {308    console.info("[Peach diag] early return: issuer gate");309    return;310  }311312  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;313  // ponytail: temporary mesh-propagation diagnostic, strip before save314  console.info("[Peach diag] verifyDeviceManifest sigHex=" + manifest.signatureHex?.slice(0, 16) + "... bodyIssuerId=" + manifest.body?.issuerDeviceId + " bodyIssuedAt=" + manifest.body?.issuedAt + " bodyDeviceCount=" + (manifest.body?.devices?.length ?? -1) + " deviceIds=" + (manifest.body?.devices ?? []).map((d) => d.deviceId).join(","));315  try {316    body = await verifyDeviceManifest(317      manifest,318      hexToPubkey(issuer!.signingPublicKeyHex!),319    );320    // ponytail: temporary mesh-propagation diagnostic, strip before save321    console.info("[Peach diag] verifyDeviceManifest OK with stored key");322  } catch (storedError) {323    // ponytail: temporary mesh-propagation diagnostic, strip before save324    console.info("[Peach diag] verifyDeviceManifest with stored key threw:", String(storedError));325    // ponytail: temporary mesh-propagation diagnostic, strip before save326    {327      const canonicalStr = canonicalise(manifest.body);328      // ponytail: dump the first device entry in full + head of revoked329      const firstDevice = manifest.body?.devices?.[0];330      const firstDeviceStr = firstDevice ? JSON.stringify(firstDevice) : "<none>";331      console.info("[Peach diag] canonical len=" + canonicalStr.length + " firstDevice=" + firstDeviceStr);332      const revokedArr = manifest.body?.revoked ?? [];333      console.info("[Peach diag] revoked count=" + revokedArr.length + " firstRevoked=" + (revokedArr[0] ? JSON.stringify(revokedArr[0]) : "<none>"));334    }335    // Stored key may be stale (older builds persisted the key derived at336    // first-pair and never refreshed it). The signing keypair is deterministic337    // from (masterKey, deviceId) on both sides, so retry once with the locally338    // re-derived pubkey. A genuinely forged signature throws here and is339    // silently rejected — only a stale-but-correct key survives the retry.340    const masterForIssuer = requireMasterKey();341    try {342      const derived = await deriveSyncSigningKeypair(343        masterForIssuer,344        issuer.deviceId,345      );346      // ponytail: temporary mesh-propagation diagnostic, strip before save347      const derivedHex = Array.from(derived.publicKeyRaw).map((b) => b.toString(16).padStart(2, "0")).join("");348      // ponytail: temporary mesh-propagation diagnostic, strip before save349      console.info("[Peach diag] retry with derived key: storedSigning=" + issuer!.signingPublicKeyHex!.slice(0,12) + " derivedSigning=" + derivedHex.slice(0,12));350      body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);351      console.info("[Peach diag] verifyDeviceManifest OK with derived key");352    } catch (derivedError) {353      // ponytail: temporary mesh-propagation diagnostic, strip before save354      console.info("[Peach diag] verifyDeviceManifest with derived key threw:", String(derivedError));355      return;356    } finally {357      masterForIssuer.fill(0);358    }359  }360  console.info("[Peach diag] body parsed: bodyIssuerId=" + body.issuerDeviceId + " issuerStoredId=" + issuer.deviceId);361  if (body.issuerDeviceId !== issuer.deviceId) { console.info("[Peach diag] early return: body issuer mismatch"); return; }362363  const ourDeviceId = await getOrCreateDeviceId();364  const masterKey = requireMasterKey();365  const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);366367  try {368    // Revocations are absorbed BEFORE any vouching, so a manifest that both369    // revokes and re-vouches the same device in one payload cannot race itself.370    // A peer may not revoke US — that would let one compromised device sever the371    // whole mesh — so our own id is filtered out first.372    const newlyRevoked = await mergeDeviceRevocations(373      body.revoked.filter((r) => r.deviceId !== ourDeviceId),374    );375    for (const deviceId of newlyRevoked) {376      await removePairedDeviceById(deviceId);377      await removePendingVouchedDevice(deviceId);378      void recordSyncAlerts([379        {380          type: "DEVICE_REVOKED",381          entryId: "",382          entryTitle: deviceId,383          deviceName: issuer.deviceName ?? "paired device",384          timestamp: Date.now(),385        },386      ]);387    }388389    for (const entry of body.devices) {390      if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {391        // ponytail: temporary mesh-propagation diagnostic, strip before save392        console.info("[Peach diag] skip entry (self or issuer):", entry.deviceId);393        continue;394      }395      // Terminal: no manifest re-admits a revoked device. Matched on the key as396      // well as the id, because the id is the issuer's to choose — checking only397      // the id let a revoked key walk back in under a fresh one. The check sits398      // ahead of the existing-device lookup so it also refuses rotating a device399      // we still trust onto a revoked key.400      if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {401        // ponytail: temporary mesh-propagation diagnostic, strip before save402        console.info("[Peach diag] skip entry (revoked):", entry.deviceId, "longTerm=" + entry.longTermPublicKeyHex?.slice(0, 12) + "...", "hasRelay=" + !!entry.relayUrl);403        continue;404      }405      const existing = await findPairedDeviceById(entry.deviceId);406      const meshRoute = deriveManifestMeshRoute(407        masterKey,408        localKeys.publicKey,409        entry,410      );411      // ponytail: temporary mesh-propagation diagnostic, strip before save412      if (existing) {413        console.info("[Peach diag] skip entry (existing):", entry.deviceId, "trustSource=" + existing.trustSource);414      } else if (!meshRoute) {415        console.info("[Peach diag] skip entry (no mesh route):", entry.deviceId, "hasRelay=" + !!entry.relayUrl);416      } else {417        console.info("[Peach diag] admit entry:", entry.deviceId, "relay=" + meshRoute.relayUrl);418      }419420      // A v3 manifest from an admitted, KK-authenticated issuer proves421      // same-vault membership and carries enough route material for the two422      // introduced endpoints to derive their own capability. Admit it423      // immediately so a six-device vault converges without keeping the424      // original pairing hub online.425      if (!existing) {426        if (!meshRoute) continue;427        await upsertPairedDevice({428          deviceId: entry.deviceId,429          deviceName: entry.deviceName,430          deviceType: entry.deviceType,431          longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),432          signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),433          wsPort: entry.wsPort,434          rendezvousSecretHex: meshRoute.rendezvousSecretHex,435          relayUrl: meshRoute.relayUrl,436          pairedAt: entry.pairedAt,437          trustSource: "vouched",438        });439        await removePendingVouchedDevice(entry.deviceId);440        continue;441      }442443      // MV-8d: a key changing under a stable deviceId is legitimate after a444      // reinstall, but it is exactly what a graph-poisoning attack looks like —445      // signal the user either way, durably (the alert queue survives a closed446      // popup) and live (the broadcast updates an open one).447      if (448        existing.longTermPublicKeyHex &&449        !constantTimeEqualHex(450          existing.longTermPublicKeyHex,451          entry.longTermPublicKeyHex,452        )453      ) {454        // S5: name the device from the LOCAL record, never from the manifest.455        // The issuer is a third party here (its own entry is skipped above), so456        // `entry.deviceName` is an unverified claim about someone else — an457        // attacker who could put it on screen could make its own key rotation458        // read as a rotation on a device the user trusts more.459        const knownName = existing.deviceName ?? entry.deviceName;460        notifyPopup({461          type: "DEVICE_KEY_ROTATED",462          payload: { deviceName: knownName, deviceId: entry.deviceId },463        });464        void recordSyncAlerts([465          {466            type: "DEVICE_KEY_ROTATED",467            entryId: "",468            entryTitle: knownName,469            deviceName: knownName,470            timestamp: Date.now(),471          },472        ]);473      }474475      // Existing device — update in place as before. The name is deliberately476      // NOT taken from the manifest: a device's name is set by that device at477      // pairing, and letting a third-party issuer rewrite it is the rename-then-478      // impersonate half of S5. Self-renames never arrive here anyway.479      await upsertPairedDevice({480        ...existing,481        deviceId: entry.deviceId,482        deviceName: existing.deviceName ?? entry.deviceName,483        deviceType: entry.deviceType,484        longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),485        signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),486        pairedAt: existing.pairedAt ?? entry.pairedAt,487        trustSource: existing.trustSource ?? "direct",488        wsPort: existing.wsPort ?? entry.wsPort,489        rendezvousSecretHex:490          existing.trustSource === "vouched" && meshRoute491            ? meshRoute.rendezvousSecretHex492            : existing.rendezvousSecretHex,493        relayUrl:494          existing.trustSource === "vouched" && meshRoute495            ? meshRoute.relayUrl496            : existing.relayUrl,497        lastSyncedAt: existing.lastSyncedAt,498      });499    }500  } finally {501    masterKey.fill(0);502    localKeys.secretKey.fill(0);503  }504}505506function deriveManifestMeshRoute(507  masterKey: Uint8Array,508  localPublicKey: Uint8Array,509  entry: ManifestDeviceEntry,510): { rendezvousSecretHex: string; relayUrl: string } | null {511  try {512    if (!entry.relayUrl) return null;513    const relayUrl = normalizeRelayUrl(entry.relayUrl);514    const secret = deriveMeshRendezvousSecret(515      masterKey,516      localPublicKey,517      hexToPubkey(entry.longTermPublicKeyHex),518    );519    try {520      return {521        rendezvousSecretHex: rendezvousBytesToHex(secret),522        relayUrl,523      };524    } finally {525      secret.fill(0);526    }527  } catch {528    return null;529  }530}531532function notifyPopup(msg: { type: string; payload?: unknown }): void {533  try {534    void chrome.runtime.sendMessage(msg).catch(() => undefined);535  } catch {536    /* popup closed — the durable sync-alert queue remains authoritative */537  }538}539540function requireUnlocked(): void {541  if (!isUnlocked()) throw new VaultLockedError();542}543544// Sync derives everything — vault_id, the Noise static key, the Ed25519545// manifest signing key, and the per-entry envelope keys — from the vault's546// stable root key, NOT the password-derived key. This keeps paired devices'547// identities and envelope keys constant across a master-password change on548// either side (SEV-2-B); two paired devices with independently-rotated549// passwords still share this key and can decrypt each other's entries.550function requireMasterKey(): Uint8Array {551  if (!isUnlocked()) throw new VaultLockedError();552  const hex = getRootKeyHex();553  if (!hex) throw new VaultLockedError();554  return hexToBytes(hex);555}556557async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {558  // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this559  // makes the (entryId, suffix) boundary unambiguous regardless of future id560  // formats. Coordinated wire change — peach-android VaultSyncAdapter derives561  // the identical info; the two MUST stay byte-for-byte in lockstep.562  const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);563  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);564  return crypto.subtle.importKey(565    "raw",566    raw.buffer as ArrayBuffer,567    { name: "AES-GCM" },568    false,569    ["encrypt", "decrypt"],570  );571}572573async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {574  const plaintext = new TextEncoder().encode(JSON.stringify(entry));575  const nonce = crypto.getRandomValues(new Uint8Array(12));576  const aad = new TextEncoder().encode(ENTRY_AAD);577  const ct = await crypto.subtle.encrypt(578    {579      name: "AES-GCM",580      iv: nonce.buffer as ArrayBuffer,581      additionalData: aad.buffer as ArrayBuffer,582      tagLength: 128,583    },584    key,585    plaintext.buffer as ArrayBuffer,586  );587  const ctBytes = new Uint8Array(ct);588  const envelope = new Uint8Array(nonce.length + ctBytes.length);589  envelope.set(nonce, 0);590  envelope.set(ctBytes, nonce.length);591  return bytesToBase64(envelope);592}593594async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {595  try {596    const bytes = base64ToBytes(envelopeB64);597    if (bytes.length < 12 + 16) return null;598    const nonce = bytes.subarray(0, 12);599    const ct = bytes.subarray(12);600    const aad = new TextEncoder().encode(ENTRY_AAD);601    const pt = await crypto.subtle.decrypt(602      {603        name: "AES-GCM",604        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,605        additionalData: aad.buffer as ArrayBuffer,606        tagLength: 128,607      },608      key,609      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,610    );611    const json = new TextDecoder().decode(pt);612    const parsed = JSON.parse(json);613    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;614    return parsed as VaultEntry;615  } catch {616    return null;617  }618}619620function hexToBytes(hex: string): Uint8Array {621  if (hex.length % 2 !== 0) throw new Error("hex length must be even");622  const out = new Uint8Array(hex.length / 2);623  for (let i = 0; i < out.length; i++) {624    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);625  }626  return out;627}628629function bytesToBase64(bytes: Uint8Array): string {630  let bin = "";631  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);632  return btoa(bin);633}634635function base64ToBytes(b64: string): Uint8Array {636  const bin = atob(b64);637  const out = new Uint8Array(bin.length);638  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);639  return out;640}