sync-vault-adapter.ts

25.3 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 { DeviceManifestBody, DeviceRevocation } from "../shared/sync-types";28import type { SyncVaultAdapter } from "./sync-coordinator";29import { getDeviceName, getLocalPairingIdentity, getOrCreateDeviceId } from "./device-identity";30import {31  computeVaultId,32  hexToPubkey,33  pubkeyToHex,34} from "../shared/sync-identity";35import {36  buildSignedDeviceManifest,37  signDeviceRevocation,38  verifyDeviceManifest,39  verifyDeviceRevocation,40} from "../shared/device-manifest";41import { constantTimeEqualHex } from "../shared/constant-time";42import {43  applyMergedRemoteEntries,44  getTombstoneManifest,45  getRootKeyHex,46  getEntries,47  getEntryById,48  getVaultVersion,49  isUnlocked,50  isDecoyMode,51} from "./vault";52import {53  findPairedDeviceById,54  findPairedDeviceByPubkey,55  isDeviceRevoked,56  listDeviceRevocations,57  listPairedDevices,58  upsertPairedDevicesAndMergeRevocations,59} from "./paired-devices";60import { setPairSecret } from "./pair-secrets";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 {73  MAX_VOUCH_DEPTH,74  type ManifestDeviceEntry,75  type PairedDevice,76  type SignedDeviceManifest,77} from "../shared/sync-types";78import { replaceLockedSyncSpool, drainLockedSyncInbox, setLockedSyncPrivateKey } from "./locked-sync-spool";7980// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1").81// The info binding prevents a ciphertext from one entry slot being replayed into another.82const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";83const ENTRY_AAD = "peach-sync-entry-v1";8485export class VaultLockedError extends Error {86  constructor() {87    super("vault_locked");88    this.name = "VaultLockedError";89  }90}9192export function createSyncVaultAdapter(93  _context: { deviceName?: string } = {},94): SyncVaultAdapter {95  return {96    async computeVaultId() {97      const master = requireMasterKey();98      return computeVaultId(master);99    },100101    async getOurDeviceId() {102      return getOrCreateDeviceId();103    },104105    async getLocalVersion() {106      return getVaultVersion();107    },108109    async buildEntryManifest() {110      requireRealUnlocked();111      const manifest: Record<string, number> = {};112      for (const entry of getEntries()) {113        manifest[entry.id] = entry.modifiedAt;114      }115      return manifest;116    },117118    async buildTombstoneManifest() {119      requireRealUnlocked();120      return getTombstoneManifest();121    },122123    async getEncryptedEntries(entryIds) {124      requireRealUnlocked();125      const masterKey = requireMasterKey();126      const out: Record<string, string> = {};127      for (const id of entryIds) {128        const entry = getEntryById(id);129        if (!entry) continue;130        const key = await deriveEntryKey(masterKey, id);131        out[id] = await encryptEntry(key, entry);132      }133      return out;134    },135136    async applyEntries(entries) {137      requireRealUnlocked();138      const entryList = Object.entries(entries);139      if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {140        throw new Error(141          `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,142        );143      }144      const masterKey = requireMasterKey();145      const decrypted: VaultEntry[] = [];146      for (const [id, envelope] of entryList) {147        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) continue;148        const key = await deriveEntryKey(masterKey, id);149        const entry = await decryptEntryOrNull(key, envelope);150        if (!entry) continue;151        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) continue;152        if (entry.id !== id) continue;153        decrypted.push(entry);154      }155      if (decrypted.length > 0) {156        requireRealUnlocked();157        await applyMergedRemoteEntries(decrypted, {});158      }159    },160161    async applyTombstones(tombstones) {162      requireRealUnlocked();163      const tombstoneList = Object.entries(tombstones);164      if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {165        throw new Error(166          `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,167        );168      }169      const cleaned: Record<string, number> = {};170      for (const [id, ts] of tombstoneList) {171        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {172          cleaned[id] = clampSyncTimestamp(ts);173        }174      }175      if (Object.keys(cleaned).length > 0) {176        requireRealUnlocked();177        await applyMergedRemoteEntries([], cleaned);178      }179    },180181    async buildOutgoingDeviceManifest() {182      requireRealUnlocked();183      const local = await getLocalPairingIdentity();184      try {185        const issuerDeviceId = local.identity.deviceId;186        const revoked = await listDeviceRevocations();187        const revokedIds = new Set(revoked.map((r) => r.deviceId));188        const devices = (await listPairedDevices())189          .filter((d) => !revokedIds.has(d.deviceId))190          .map(toManifestEntry)191          .filter((d): d is ManifestDeviceEntry => d !== null);192        const signedRevoked = (193          await Promise.all(revoked.map(async (record) => {194            if (record.signatureHex) {195              // A terminal record may have been issued by another mesh member,196              // and is re-emitted only when one of its own issuer's trusted keys197              // verifies it. The record names no key of its own, so the issuer198              // is resolved here and nowhere else.199              const issuer = await findPairedDeviceById(record.revokedByDeviceId);200              for (const key of trustedSigningKeys(issuer)) {201                if (await verifyDeviceRevocation(record, hexToPubkey(key.publicKeyHex))) return record;202              }203              // Unverifiable — typically signed under a superseded canonical —204              // is dropped from the manifest rather than poisoning every205              // receiver. The local terminal revocation itself is retained.206              return null;207            }208            // Only this device may mint a new signature; never re-issue a209            // record attributed to a different issuer as our own.210            if (record.revokedByDeviceId !== issuerDeviceId) return null;211            return signDeviceRevocation(record, local.signingPrivateKey);212          }))213        ).filter((record): record is DeviceRevocation => record !== null);214        return buildSignedDeviceManifest(215          issuerDeviceId,216          devices,217          local.signingPrivateKey,218          { revoked: signedRevoked },219        );220      } finally {221        local.privateKey.fill(0);222        local.publicKey.fill(0);223      }224    },225226    async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {227      requireRealUnlocked();228      await applyVerifiedPeerManifest(229        manifest,230        authenticatedRemoteStaticPublicKey,231      );232    },233  };234}235236export async function refreshLockedSyncSpool(): Promise<void> {237  requireUnlocked();238  const masterKey = requireMasterKey();239  const deviceId = await getOrCreateDeviceId();240  const identity = await buildCachedIdentity(masterKey, deviceId);241  const cachedEntries: Record<string, { modifiedAt: number; ciphertext: string }> = {};242  try {243    for (const entry of getEntries()) {244      if (entry.deletedAt) continue;245      const key = await deriveEntryKey(masterKey, entry.id);246      cachedEntries[entry.id] = {247        modifiedAt: entry.modifiedAt,248        ciphertext: await encryptEntry(key, entry),249      };250    }251    const tombstones = getTombstoneManifest();252    setLockedSyncPrivateKey(identity.longTermPrivateKeyBytes);253    const { longTermPrivateKeyHex: _privateKeyHex, longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;254    await replaceLockedSyncSpool({255      ...storedIdentity,256      localVersion: await getVaultVersion(),257      entries: cachedEntries,258      tombstones,259    });260  } finally {261    masterKey.fill(0);262    identity.longTermPrivateKeyBytes.fill(0);263  }264}265266export async function drainAndRefreshLockedSyncSpool(): Promise<void> {267  const live = createSyncVaultAdapter();268  await drainLockedSyncInbox(live);269  await refreshLockedSyncSpool();270}271272async function buildCachedIdentity(masterKey: Uint8Array, _deviceId: string) {273  const vaultId = await computeVaultId(masterKey);274  const pairing = await getLocalPairingIdentity();275  const deviceName = await getDeviceName();276  return {277    vaultId,278    deviceId: pairing.identity.deviceId,279    deviceName,280    deviceType: "extension" as const,281    longTermPublicKeyHex: pubkeyToHex(pairing.publicKey),282    longTermPrivateKeyHex: bytesToHex(pairing.privateKey),283    longTermPrivateKeyBytes: pairing.privateKey,284  };285}286287function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {288  if (289    !device.deviceId ||290    !device.deviceName ||291    !device.deviceType ||292    !device.longTermPublicKeyHex ||293    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||294    !device.signingPublicKeyHex ||295    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)296  ) {297    return null;298  }299  return {300    deviceId: device.deviceId,301    deviceName: device.deviceName,302    deviceType: device.deviceType,303    longTermPublicKeyHex: device.longTermPublicKeyHex,304    signingPublicKeyHex: device.signingPublicKeyHex,305    wsPort: device.wsPort,306    relayUrl: device.relayUrl,307    pairedAt: device.pairedAt,308    vouchDepth: device.vouchDepth ?? 0,309  };310}311312/**313 * Every signing key we currently trust for a peer: its immutable history plus314 * its active key. A forwarded revocation is authorised only by one of these,315 * resolved from `revokedByDeviceId`. The record carries no key of its own, so a316 * peer that merely relays a manifest cannot authorise a revocation.317 */318function trustedSigningKeys(319  device:320    | {321        signingPublicKeyHex?: string;322        signingKeyHistory?: Array<{ version: number; publicKeyHex: string }>;323      }324    | null325    | undefined,326): Array<{ version: number; publicKeyHex: string }> {327  const historical = (device?.signingKeyHistory ?? [])328    .filter(329      (key) =>330        Number.isInteger(key.version) &&331        key.version > 0 &&332        /^[0-9a-f]{64}$/i.test(key.publicKeyHex),333    )334    .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() }));335  const current = device?.signingPublicKeyHex?.toLowerCase();336  if (!current || !/^[0-9a-f]{64}$/.test(current)) return historical;337  const currentVersion =338    historical.find((key) => key.publicKeyHex === current)?.version ??339    historical.reduce((max, key) => Math.max(max, key.version), 0) + 1;340  const all = [...historical, { version: currentVersion, publicKeyHex: current }];341  return all.filter(342    (key, index) =>343      all.findIndex(344        (candidate) =>345          candidate.version === key.version && candidate.publicKeyHex === key.publicKeyHex,346      ) === index,347  );348}349350/**351 * Apply a device manifest issued by a peer we pair with directly.352 *353 * The issuer used to be required to be an "extension", on the assumption that354 * the extension was the mesh's trust anchor. It is not: the device a fresh355 * install joins through is the one holding the peer list to hand over.356 *357 * The issuer must already be admitted locally as either direct or vouched.358 * That is the decentralisation boundary: an unknown key has no session and no359 * authority, while every authenticated member can propagate the same signed360 * pool without the original hub online. A v3 entry is admitted only when it361 * carries a valid relay route; both endpoints independently derive the same362 * pair-only capability from their vault root and public keys.363 */364async function applyVerifiedPeerManifest(365  manifest: SignedDeviceManifest,366  authenticatedRemoteStaticPublicKey: Uint8Array,367): Promise<void> {368  const issuerId = manifest?.body?.issuerDeviceId;369  if (typeof issuerId !== "string" || !issuerId) { return; }370371  if (authenticatedRemoteStaticPublicKey.length !== 32) {372    return;373  }374  const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);375  const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);376  if (377    !issuer ||378    issuer.deviceId !== issuerId ||379    (380      issuer.trustSource !== "direct" &&381      issuer.trustSource !== "vouched"382    ) ||383    (issuer.vouchDepth ?? 0) >= MAX_VOUCH_DEPTH ||384    !issuer.signingPublicKeyHex ||385    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)386  ) {387    return;388  }389390  let body: DeviceManifestBody | undefined;391  let selectedSigningKey: { version: number; publicKeyHex: string } | undefined;392  const issuerSigningPublicKeyHex = issuer.signingPublicKeyHex;393  const historicalKeys = (issuer.signingKeyHistory ?? [])394    .filter((key) => Number.isInteger(key.version) && key.version > 0 && /^[0-9a-f]{64}$/i.test(key.publicKeyHex))395    .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() }));396  const currentVersion = historicalKeys.find(397    (key) => key.publicKeyHex === issuerSigningPublicKeyHex.toLowerCase(),398  )?.version ?? (historicalKeys.reduce((max, key) => Math.max(max, key.version), 0) + 1);399  const signingKeys = [400    ...historicalKeys,401    { version: currentVersion, publicKeyHex: issuerSigningPublicKeyHex.toLowerCase() },402  ].filter((key, index, keys) =>403    keys.findIndex((candidate) => candidate.version === key.version && candidate.publicKeyHex === key.publicKeyHex) === index,404  );405  try {406    for (const key of signingKeys) {407      try {408        body = await verifyDeviceManifest(manifest, hexToPubkey(key.publicKeyHex));409        selectedSigningKey = key;410        break;411      } catch { /* try the next immutable trusted key */ }412    }413    if (!body || !selectedSigningKey) return;414  } catch {415    return;416  }417  if (body.issuerDeviceId !== issuer.deviceId) {418    return;419  }420  // Fail closed: an unresolvable or unverifiable issuer rejects the whole421  // manifest rather than dropping just that record, so a pruned history entry422  // can never silently un-revoke a device. Android enforces the same rule.423  const allRevocationsAuthorised = (424    await Promise.all(425      body.revoked.map(async (record) => {426        // The outer manifest is authenticated by the directly connected427        // forwarder, but a forwarded revocation retains its original issuer.428        // Resolve that issuer independently from our paired registry and429        // immutable signing-key history; never bind it to the forwarder's key.430        const originalIssuer = await findPairedDeviceById(record.revokedByDeviceId);431        for (const key of trustedSigningKeys(originalIssuer)) {432          if (await verifyDeviceRevocation(record, hexToPubkey(key.publicKeyHex))) return true;433        }434        return false;435      }),436    )437  ).every(Boolean);438  if (!allRevocationsAuthorised) return;439440  const ourDeviceId = await getOrCreateDeviceId();441  const masterKey = requireMasterKey();442  const localPairing = await getLocalPairingIdentity();443  const localKeys = { publicKey: localPairing.publicKey, secretKey: localPairing.privateKey };444  try {445    const pendingRegistry: PairedDevice[] = [];446    const pendingSecrets: { deviceId: string; secret: string }[] = [];447    const pendingVouchedIds: string[] = [];448    for (const entry of body.devices) {449      if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) continue;450      if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue;451      if ((entry.vouchDepth ?? 0) > (issuer.vouchDepth ?? 0) + 1) continue;452      const existing = await findPairedDeviceById(entry.deviceId);453      const meshRoute = deriveManifestMeshRoute(localKeys.secretKey, localKeys.publicKey, entry);454      if (!existing) {455        if (!meshRoute) continue;456        pendingRegistry.push({457          deviceId: entry.deviceId,458          deviceName: entry.deviceName,459          deviceType: entry.deviceType,460          longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),461          signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),462          wsPort: entry.wsPort,463          relayUrl: meshRoute.relayUrl,464          pairedAt: entry.pairedAt,465          trustSource: "vouched",466          vouchDepth: (issuer.vouchDepth ?? 0) + 1,467        });468        pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });469        pendingVouchedIds.push(entry.deviceId);470        continue;471      }472      const longTermChanged = existing.longTermPublicKeyHex &&473        !constantTimeEqualHex(existing.longTermPublicKeyHex, entry.longTermPublicKeyHex);474      const existingSigning = existing.signingPublicKeyHex;475      const entrySigning = entry.signingPublicKeyHex;476      const signingChanged =477        Boolean(existingSigning) !== Boolean(entrySigning) ||478        (existingSigning !== undefined &&479          entrySigning !== undefined &&480          !constantTimeEqualHex(existingSigning, entrySigning));481      if (longTermChanged || signingChanged) {482        const knownName = existing.deviceName ?? entry.deviceName;483        notifyPopup({ type: "DEVICE_KEY_ROTATED", payload: { deviceName: knownName, deviceId: entry.deviceId } });484        void recordSyncAlerts([{485          type: "DEVICE_KEY_ROTATED",486          entryId: "",487          entryTitle: knownName,488          deviceName: knownName,489          timestamp: Date.now(),490        }]);491        continue;492      }493      pendingRegistry.push({494        ...existing,495        deviceId: entry.deviceId,496        deviceName: existing.deviceName ?? entry.deviceName,497        deviceType: entry.deviceType,498        longTermPublicKeyHex: existing.longTermPublicKeyHex ?? entry.longTermPublicKeyHex.toLowerCase(),499        signingPublicKeyHex: existing.signingPublicKeyHex ?? entry.signingPublicKeyHex.toLowerCase(),500        pairedAt: existing.pairedAt ?? entry.pairedAt,501        trustSource: existing.trustSource,502        wsPort: existing.wsPort ?? entry.wsPort,503        relayUrl: existing.trustSource === "vouched" && meshRoute ? meshRoute.relayUrl : existing.relayUrl,504        lastSyncedAt: existing.lastSyncedAt,505      });506      if (existing.trustSource === "vouched" && meshRoute) {507        pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });508      }509    }510    const newlyRevoked = await upsertPairedDevicesAndMergeRevocations(511      pendingRegistry,512      body.revoked.filter((r) => r.deviceId !== ourDeviceId),513    );514    for (const deviceId of newlyRevoked) {515      await removePendingVouchedDevice(deviceId);516      void recordSyncAlerts([{517        type: "DEVICE_REVOKED",518        entryId: "",519        entryTitle: deviceId,520        deviceName: issuer.deviceName ?? "paired device",521        timestamp: Date.now(),522      }]);523    }524    for (const pending of pendingSecrets) {525      if (newlyRevoked.includes(pending.deviceId)) continue;526      await setPairSecret(pending.deviceId, pending.secret, masterKey);527    }528    for (const deviceId of pendingVouchedIds) {529      if (newlyRevoked.includes(deviceId)) continue;530      await removePendingVouchedDevice(deviceId);531    }532  } finally {533    masterKey.fill(0);534    localKeys.secretKey.fill(0);535    localKeys.publicKey.fill(0);536  }537}538539function deriveManifestMeshRoute(540  localPrivateKey: Uint8Array,541  localPublicKey: Uint8Array,542  entry: ManifestDeviceEntry,543): { rendezvousSecretHex: string; relayUrl: string } | null {544  try {545    if (!entry.relayUrl) return null;546    const relayUrl = normalizeRelayUrl(entry.relayUrl);547    const secret = deriveMeshRendezvousSecret(548      localPrivateKey,549      localPublicKey,550      hexToPubkey(entry.longTermPublicKeyHex),551    );552    try {553      return {554        rendezvousSecretHex: rendezvousBytesToHex(secret),555        relayUrl,556      };557    } finally {558      secret.fill(0);559    }560  } catch {561    return null;562  }563}564565function notifyPopup(msg: { type: string; payload?: unknown }): void {566  try {567    void chrome.runtime.sendMessage(msg).catch(() => undefined);568  } catch {569    /* popup closed — the durable sync-alert queue remains authoritative */570  }571}572573function requireUnlocked(): void {574  if (!isUnlocked()) throw new VaultLockedError();575}576577function requireRealUnlocked(): void {578  if (!isUnlocked() || isDecoyMode()) throw new VaultLockedError();579}580581// Sync derives everything — vault_id, the Noise static key, the Ed25519582// manifest signing key, and the per-entry envelope keys — from the vault's583// stable root key, NOT the password-derived key. This keeps paired devices'584// identities and envelope keys constant across a master-password change on585// either side (SEV-2-B); two paired devices with independently-rotated586// passwords still share this key and can decrypt each other's entries.587function requireMasterKey(): Uint8Array {588  if (!isUnlocked()) throw new VaultLockedError();589  const hex = getRootKeyHex();590  if (!hex) throw new VaultLockedError();591  return hexToBytes(hex);592}593594async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {595  // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this596  // makes the (entryId, suffix) boundary unambiguous regardless of future id597  // formats. Coordinated wire change — peach-android VaultSyncAdapter derives598  // the identical info; the two MUST stay byte-for-byte in lockstep.599  const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);600  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);601  return crypto.subtle.importKey(602    "raw",603    raw.buffer as ArrayBuffer,604    { name: "AES-GCM" },605    false,606    ["encrypt", "decrypt"],607  );608}609610async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {611  const plaintext = new TextEncoder().encode(JSON.stringify(entry));612  const nonce = crypto.getRandomValues(new Uint8Array(12));613  const aad = new TextEncoder().encode(ENTRY_AAD);614  const ct = await crypto.subtle.encrypt(615    {616      name: "AES-GCM",617      iv: nonce.buffer as ArrayBuffer,618      additionalData: aad.buffer as ArrayBuffer,619      tagLength: 128,620    },621    key,622    plaintext.buffer as ArrayBuffer,623  );624  const ctBytes = new Uint8Array(ct);625  const envelope = new Uint8Array(nonce.length + ctBytes.length);626  envelope.set(nonce, 0);627  envelope.set(ctBytes, nonce.length);628  return bytesToBase64(envelope);629}630631async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {632  try {633    const bytes = base64ToBytes(envelopeB64);634    if (bytes.length < 12 + 16) return null;635    const nonce = bytes.subarray(0, 12);636    const ct = bytes.subarray(12);637    const aad = new TextEncoder().encode(ENTRY_AAD);638    const pt = await crypto.subtle.decrypt(639      {640        name: "AES-GCM",641        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,642        additionalData: aad.buffer as ArrayBuffer,643        tagLength: 128,644      },645      key,646      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,647    );648    const json = new TextDecoder().decode(pt);649    const parsed = JSON.parse(json);650    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;651    return parsed as VaultEntry;652  } catch {653    return null;654  }655}656657function hexToBytes(hex: string): Uint8Array {658  if (hex.length % 2 !== 0) throw new Error("hex length must be even");659  const out = new Uint8Array(hex.length / 2);660  for (let i = 0; i < out.length; i++) {661    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);662  }663  return out;664}665666function bytesToBase64(bytes: Uint8Array): string {667  let bin = "";668  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);669  return btoa(bin);670}671672function bytesToHex(bytes: Uint8Array): string {673  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");674}675676function base64ToBytes(b64: string): Uint8Array {677  const bin = atob(b64);678  const out = new Uint8Array(bin.length);679  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);680  return out;681}