sync-vault-adapter.ts

25.5 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      console.info("[Peach diag] canonical len=" + canonicalStr.length + " head=" + canonicalStr.slice(0, 240));329    }330    // Stored key may be stale (older builds persisted the key derived at331    // first-pair and never refreshed it). The signing keypair is deterministic332    // from (masterKey, deviceId) on both sides, so retry once with the locally333    // re-derived pubkey. A genuinely forged signature throws here and is334    // silently rejected — only a stale-but-correct key survives the retry.335    const masterForIssuer = requireMasterKey();336    try {337      const derived = await deriveSyncSigningKeypair(338        masterForIssuer,339        issuer.deviceId,340      );341      // ponytail: temporary mesh-propagation diagnostic, strip before save342      const derivedHex = Array.from(derived.publicKeyRaw).map((b) => b.toString(16).padStart(2, "0")).join("");343      // ponytail: temporary mesh-propagation diagnostic, strip before save344      console.info("[Peach diag] retry with derived key: storedSigning=" + issuer!.signingPublicKeyHex!.slice(0,12) + " derivedSigning=" + derivedHex.slice(0,12));345      body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);346      console.info("[Peach diag] verifyDeviceManifest OK with derived key");347    } catch (derivedError) {348      // ponytail: temporary mesh-propagation diagnostic, strip before save349      console.info("[Peach diag] verifyDeviceManifest with derived key threw:", String(derivedError));350      return;351    } finally {352      masterForIssuer.fill(0);353    }354  }355  console.info("[Peach diag] body parsed: bodyIssuerId=" + body.issuerDeviceId + " issuerStoredId=" + issuer.deviceId);356  if (body.issuerDeviceId !== issuer.deviceId) { console.info("[Peach diag] early return: body issuer mismatch"); return; }357358  const ourDeviceId = await getOrCreateDeviceId();359  const masterKey = requireMasterKey();360  const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);361362  try {363    // Revocations are absorbed BEFORE any vouching, so a manifest that both364    // revokes and re-vouches the same device in one payload cannot race itself.365    // A peer may not revoke US — that would let one compromised device sever the366    // whole mesh — so our own id is filtered out first.367    const newlyRevoked = await mergeDeviceRevocations(368      body.revoked.filter((r) => r.deviceId !== ourDeviceId),369    );370    for (const deviceId of newlyRevoked) {371      await removePairedDeviceById(deviceId);372      await removePendingVouchedDevice(deviceId);373      void recordSyncAlerts([374        {375          type: "DEVICE_REVOKED",376          entryId: "",377          entryTitle: deviceId,378          deviceName: issuer.deviceName ?? "paired device",379          timestamp: Date.now(),380        },381      ]);382    }383384    for (const entry of body.devices) {385      if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {386        // ponytail: temporary mesh-propagation diagnostic, strip before save387        console.info("[Peach diag] skip entry (self or issuer):", entry.deviceId);388        continue;389      }390      // Terminal: no manifest re-admits a revoked device. Matched on the key as391      // well as the id, because the id is the issuer's to choose — checking only392      // the id let a revoked key walk back in under a fresh one. The check sits393      // ahead of the existing-device lookup so it also refuses rotating a device394      // we still trust onto a revoked key.395      if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {396        // ponytail: temporary mesh-propagation diagnostic, strip before save397        console.info("[Peach diag] skip entry (revoked):", entry.deviceId, "longTerm=" + entry.longTermPublicKeyHex?.slice(0, 12) + "...", "hasRelay=" + !!entry.relayUrl);398        continue;399      }400      const existing = await findPairedDeviceById(entry.deviceId);401      const meshRoute = deriveManifestMeshRoute(402        masterKey,403        localKeys.publicKey,404        entry,405      );406      // ponytail: temporary mesh-propagation diagnostic, strip before save407      if (existing) {408        console.info("[Peach diag] skip entry (existing):", entry.deviceId, "trustSource=" + existing.trustSource);409      } else if (!meshRoute) {410        console.info("[Peach diag] skip entry (no mesh route):", entry.deviceId, "hasRelay=" + !!entry.relayUrl);411      } else {412        console.info("[Peach diag] admit entry:", entry.deviceId, "relay=" + meshRoute.relayUrl);413      }414415      // A v3 manifest from an admitted, KK-authenticated issuer proves416      // same-vault membership and carries enough route material for the two417      // introduced endpoints to derive their own capability. Admit it418      // immediately so a six-device vault converges without keeping the419      // original pairing hub online.420      if (!existing) {421        if (!meshRoute) continue;422        await upsertPairedDevice({423          deviceId: entry.deviceId,424          deviceName: entry.deviceName,425          deviceType: entry.deviceType,426          longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),427          signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),428          wsPort: entry.wsPort,429          rendezvousSecretHex: meshRoute.rendezvousSecretHex,430          relayUrl: meshRoute.relayUrl,431          pairedAt: entry.pairedAt,432          trustSource: "vouched",433        });434        await removePendingVouchedDevice(entry.deviceId);435        continue;436      }437438      // MV-8d: a key changing under a stable deviceId is legitimate after a439      // reinstall, but it is exactly what a graph-poisoning attack looks like —440      // signal the user either way, durably (the alert queue survives a closed441      // popup) and live (the broadcast updates an open one).442      if (443        existing.longTermPublicKeyHex &&444        !constantTimeEqualHex(445          existing.longTermPublicKeyHex,446          entry.longTermPublicKeyHex,447        )448      ) {449        // S5: name the device from the LOCAL record, never from the manifest.450        // The issuer is a third party here (its own entry is skipped above), so451        // `entry.deviceName` is an unverified claim about someone else — an452        // attacker who could put it on screen could make its own key rotation453        // read as a rotation on a device the user trusts more.454        const knownName = existing.deviceName ?? entry.deviceName;455        notifyPopup({456          type: "DEVICE_KEY_ROTATED",457          payload: { deviceName: knownName, deviceId: entry.deviceId },458        });459        void recordSyncAlerts([460          {461            type: "DEVICE_KEY_ROTATED",462            entryId: "",463            entryTitle: knownName,464            deviceName: knownName,465            timestamp: Date.now(),466          },467        ]);468      }469470      // Existing device — update in place as before. The name is deliberately471      // NOT taken from the manifest: a device's name is set by that device at472      // pairing, and letting a third-party issuer rewrite it is the rename-then-473      // impersonate half of S5. Self-renames never arrive here anyway.474      await upsertPairedDevice({475        ...existing,476        deviceId: entry.deviceId,477        deviceName: existing.deviceName ?? entry.deviceName,478        deviceType: entry.deviceType,479        longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),480        signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),481        pairedAt: existing.pairedAt ?? entry.pairedAt,482        trustSource: existing.trustSource ?? "direct",483        wsPort: existing.wsPort ?? entry.wsPort,484        rendezvousSecretHex:485          existing.trustSource === "vouched" && meshRoute486            ? meshRoute.rendezvousSecretHex487            : existing.rendezvousSecretHex,488        relayUrl:489          existing.trustSource === "vouched" && meshRoute490            ? meshRoute.relayUrl491            : existing.relayUrl,492        lastSyncedAt: existing.lastSyncedAt,493      });494    }495  } finally {496    masterKey.fill(0);497    localKeys.secretKey.fill(0);498  }499}500501function deriveManifestMeshRoute(502  masterKey: Uint8Array,503  localPublicKey: Uint8Array,504  entry: ManifestDeviceEntry,505): { rendezvousSecretHex: string; relayUrl: string } | null {506  try {507    if (!entry.relayUrl) return null;508    const relayUrl = normalizeRelayUrl(entry.relayUrl);509    const secret = deriveMeshRendezvousSecret(510      masterKey,511      localPublicKey,512      hexToPubkey(entry.longTermPublicKeyHex),513    );514    try {515      return {516        rendezvousSecretHex: rendezvousBytesToHex(secret),517        relayUrl,518      };519    } finally {520      secret.fill(0);521    }522  } catch {523    return null;524  }525}526527function notifyPopup(msg: { type: string; payload?: unknown }): void {528  try {529    void chrome.runtime.sendMessage(msg).catch(() => undefined);530  } catch {531    /* popup closed — the durable sync-alert queue remains authoritative */532  }533}534535function requireUnlocked(): void {536  if (!isUnlocked()) throw new VaultLockedError();537}538539// Sync derives everything — vault_id, the Noise static key, the Ed25519540// manifest signing key, and the per-entry envelope keys — from the vault's541// stable root key, NOT the password-derived key. This keeps paired devices'542// identities and envelope keys constant across a master-password change on543// either side (SEV-2-B); two paired devices with independently-rotated544// passwords still share this key and can decrypt each other's entries.545function requireMasterKey(): Uint8Array {546  if (!isUnlocked()) throw new VaultLockedError();547  const hex = getRootKeyHex();548  if (!hex) throw new VaultLockedError();549  return hexToBytes(hex);550}551552async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {553  // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this554  // makes the (entryId, suffix) boundary unambiguous regardless of future id555  // formats. Coordinated wire change — peach-android VaultSyncAdapter derives556  // the identical info; the two MUST stay byte-for-byte in lockstep.557  const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);558  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);559  return crypto.subtle.importKey(560    "raw",561    raw.buffer as ArrayBuffer,562    { name: "AES-GCM" },563    false,564    ["encrypt", "decrypt"],565  );566}567568async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {569  const plaintext = new TextEncoder().encode(JSON.stringify(entry));570  const nonce = crypto.getRandomValues(new Uint8Array(12));571  const aad = new TextEncoder().encode(ENTRY_AAD);572  const ct = await crypto.subtle.encrypt(573    {574      name: "AES-GCM",575      iv: nonce.buffer as ArrayBuffer,576      additionalData: aad.buffer as ArrayBuffer,577      tagLength: 128,578    },579    key,580    plaintext.buffer as ArrayBuffer,581  );582  const ctBytes = new Uint8Array(ct);583  const envelope = new Uint8Array(nonce.length + ctBytes.length);584  envelope.set(nonce, 0);585  envelope.set(ctBytes, nonce.length);586  return bytesToBase64(envelope);587}588589async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {590  try {591    const bytes = base64ToBytes(envelopeB64);592    if (bytes.length < 12 + 16) return null;593    const nonce = bytes.subarray(0, 12);594    const ct = bytes.subarray(12);595    const aad = new TextEncoder().encode(ENTRY_AAD);596    const pt = await crypto.subtle.decrypt(597      {598        name: "AES-GCM",599        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,600        additionalData: aad.buffer as ArrayBuffer,601        tagLength: 128,602      },603      key,604      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,605    );606    const json = new TextDecoder().decode(pt);607    const parsed = JSON.parse(json);608    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;609    return parsed as VaultEntry;610  } catch {611    return null;612  }613}614615function hexToBytes(hex: string): Uint8Array {616  if (hex.length % 2 !== 0) throw new Error("hex length must be even");617  const out = new Uint8Array(hex.length / 2);618  for (let i = 0; i < out.length; i++) {619    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);620  }621  return out;622}623624function bytesToBase64(bytes: Uint8Array): string {625  let bin = "";626  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);627  return btoa(bin);628}629630function base64ToBytes(b64: string): Uint8Array {631  const bin = atob(b64);632  const out = new Uint8Array(bin.length);633  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);634  return out;635}