sync-vault-adapter.ts

25.4 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  ensureSettingsLoaded,52  lockedSyncRequiresUnlock,53} from "./vault";54import {55  findPairedDeviceById,56  findPairedDeviceByPubkey,57  isDeviceRevoked,58  listDeviceRevocations,59  listPairedDevices,60  upsertPairedDevicesAndMergeRevocations,61} from "./paired-devices";62import { setPairSecret } from "./pair-secrets";63import {64  removePendingVouchedDevice,65} from "./pending-vouched-devices";66import {67  bytesToHex as rendezvousBytesToHex,68  deriveMeshRendezvousSecret,69  normalizeRelayUrl,70} from "../shared/rendezvous";71import { recordSyncAlerts } from "./sync-alerts";72import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";73import type { VaultEntry } from "../shared/types";74import {75  MAX_VOUCH_DEPTH,76  type ManifestDeviceEntry,77  type PairedDevice,78  type SignedDeviceManifest,79} from "../shared/sync-types";80import { replaceLockedSyncSpool, drainLockedSyncInbox, setLockedSyncPrivateKey } from "./locked-sync-spool";8182// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1").83// The info binding prevents a ciphertext from one entry slot being replayed into another.84const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";85const ENTRY_AAD = "peach-sync-entry-v1";8687export class VaultLockedError extends Error {88  constructor() {89    super("vault_locked");90    this.name = "VaultLockedError";91  }92}9394export function createSyncVaultAdapter(95  _context: { deviceName?: string } = {},96): SyncVaultAdapter {97  return {98    async computeVaultId() {99      const master = requireMasterKey();100      return computeVaultId(master);101    },102103    async getOurDeviceId() {104      return getOrCreateDeviceId();105    },106107    async getLocalVersion() {108      return getVaultVersion();109    },110111    async buildEntryManifest() {112      requireRealUnlocked();113      const manifest: Record<string, number> = {};114      for (const entry of getEntries()) {115        manifest[entry.id] = entry.modifiedAt;116      }117      return manifest;118    },119120    async buildTombstoneManifest() {121      requireRealUnlocked();122      return getTombstoneManifest();123    },124125    async getEncryptedEntries(entryIds) {126      requireRealUnlocked();127      const masterKey = requireMasterKey();128      const out: Record<string, string> = {};129      for (const id of entryIds) {130        const entry = getEntryById(id);131        if (!entry) continue;132        const key = await deriveEntryKey(masterKey, id);133        out[id] = await encryptEntry(key, entry);134      }135      return out;136    },137138    async applyEntries(entries) {139      requireRealUnlocked();140      const entryList = Object.entries(entries);141      if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {142        throw new Error(143          `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,144        );145      }146      const masterKey = requireMasterKey();147      const decrypted: VaultEntry[] = [];148      for (const [id, envelope] of entryList) {149        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) continue;150        const key = await deriveEntryKey(masterKey, id);151        const entry = await decryptEntryOrNull(key, envelope);152        if (!entry) continue;153        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) continue;154        if (entry.id !== id) continue;155        decrypted.push(entry);156      }157      if (decrypted.length > 0) {158        requireRealUnlocked();159        await applyMergedRemoteEntries(decrypted, {});160      }161    },162163    async applyTombstones(tombstones) {164      requireRealUnlocked();165      const tombstoneList = Object.entries(tombstones);166      if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {167        throw new Error(168          `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,169        );170      }171      const cleaned: Record<string, number> = {};172      for (const [id, ts] of tombstoneList) {173        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {174          cleaned[id] = clampSyncTimestamp(ts);175        }176      }177      if (Object.keys(cleaned).length > 0) {178        requireRealUnlocked();179        await applyMergedRemoteEntries([], cleaned);180      }181    },182183    async buildOutgoingDeviceManifest() {184      requireRealUnlocked();185      const local = await getLocalPairingIdentity();186      try {187        const issuerDeviceId = local.identity.deviceId;188        const revoked = await listDeviceRevocations();189        const revokedIds = new Set(revoked.map((r) => r.deviceId));190        const devices = (await listPairedDevices())191          .filter((d) => !revokedIds.has(d.deviceId))192          .map(toManifestEntry)193          .filter((d): d is ManifestDeviceEntry => d !== null);194        const signedRevoked = (195          await Promise.all(revoked.map(async (record) => {196            if (record.signatureHex) {197              // A terminal record may have been issued by another mesh member,198              // and is re-emitted only when one of its own issuer's trusted keys199              // verifies it. The record names no key of its own, so the issuer200              // is resolved here and nowhere else.201              const issuer = await findPairedDeviceById(record.revokedByDeviceId);202              for (const key of trustedSigningKeys(issuer)) {203                if (await verifyDeviceRevocation(record, hexToPubkey(key.publicKeyHex))) return record;204              }205              // Unverifiable — typically signed under a superseded canonical —206              // is dropped from the manifest rather than poisoning every207              // receiver. The local terminal revocation itself is retained.208              return null;209            }210            // Only this device may mint a new signature; never re-issue a211            // record attributed to a different issuer as our own.212            if (record.revokedByDeviceId !== issuerDeviceId) return null;213            return signDeviceRevocation(record, local.signingPrivateKey);214          }))215        ).filter((record): record is DeviceRevocation => record !== null);216        return buildSignedDeviceManifest(217          issuerDeviceId,218          devices,219          local.signingPrivateKey,220          { revoked: signedRevoked },221        );222      } finally {223        local.privateKey.fill(0);224        local.publicKey.fill(0);225      }226    },227228    async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {229      requireRealUnlocked();230      await applyVerifiedPeerManifest(231        manifest,232        authenticatedRemoteStaticPublicKey,233      );234    },235  };236}237238export async function refreshLockedSyncSpool(): Promise<void> {239  requireUnlocked();240  const masterKey = requireMasterKey();241  const deviceId = await getOrCreateDeviceId();242  const identity = await buildCachedIdentity(masterKey, deviceId);243  const cachedEntries: Record<string, { modifiedAt: number; ciphertext: string }> = {};244  try {245    for (const entry of getEntries()) {246      if (entry.deletedAt) continue;247      const key = await deriveEntryKey(masterKey, entry.id);248      cachedEntries[entry.id] = {249        modifiedAt: entry.modifiedAt,250        ciphertext: await encryptEntry(key, entry),251      };252    }253    const tombstones = getTombstoneManifest();254    await ensureSettingsLoaded();255    await setLockedSyncPrivateKey(identity.longTermPrivateKeyBytes, !lockedSyncRequiresUnlock());256    const { longTermPrivateKeyHex: _privateKeyHex, longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;257    await replaceLockedSyncSpool({258      ...storedIdentity,259      localVersion: await getVaultVersion(),260      entries: cachedEntries,261      tombstones,262    });263  } finally {264    masterKey.fill(0);265    identity.longTermPrivateKeyBytes.fill(0);266  }267}268269export async function drainAndRefreshLockedSyncSpool(): Promise<void> {270  const live = createSyncVaultAdapter();271  await drainLockedSyncInbox(live);272  await refreshLockedSyncSpool();273}274275async function buildCachedIdentity(masterKey: Uint8Array, _deviceId: string) {276  const vaultId = await computeVaultId(masterKey);277  const pairing = await getLocalPairingIdentity();278  const deviceName = await getDeviceName();279  return {280    vaultId,281    deviceId: pairing.identity.deviceId,282    deviceName,283    deviceType: "extension" as const,284    longTermPublicKeyHex: pubkeyToHex(pairing.publicKey),285    longTermPrivateKeyHex: bytesToHex(pairing.privateKey),286    longTermPrivateKeyBytes: pairing.privateKey,287  };288}289290function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {291  if (292    !device.deviceId ||293    !device.deviceName ||294    !device.deviceType ||295    !device.longTermPublicKeyHex ||296    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||297    !device.signingPublicKeyHex ||298    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)299  ) {300    return null;301  }302  return {303    deviceId: device.deviceId,304    deviceName: device.deviceName,305    deviceType: device.deviceType,306    longTermPublicKeyHex: device.longTermPublicKeyHex,307    signingPublicKeyHex: device.signingPublicKeyHex,308    wsPort: device.wsPort,309    relayUrl: device.relayUrl,310    pairedAt: device.pairedAt,311    vouchDepth: device.vouchDepth ?? 0,312  };313}314315/**316 * Every signing key we currently trust for a peer: its immutable history plus317 * its active key. A forwarded revocation is authorised only by one of these,318 * resolved from `revokedByDeviceId`. The record carries no key of its own, so a319 * peer that merely relays a manifest cannot authorise a revocation.320 */321function trustedSigningKeys(322  device:323    | {324        signingPublicKeyHex?: string;325        signingKeyHistory?: Array<{ version: number; publicKeyHex: string }>;326      }327    | null328    | undefined,329): Array<{ version: number; publicKeyHex: string }> {330  const historical = (device?.signingKeyHistory ?? [])331    .filter(332      (key) =>333        Number.isInteger(key.version) &&334        key.version > 0 &&335        /^[0-9a-f]{64}$/i.test(key.publicKeyHex),336    )337    .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() }));338  const current = device?.signingPublicKeyHex?.toLowerCase();339  if (!current || !/^[0-9a-f]{64}$/.test(current)) return historical;340  const currentVersion =341    historical.find((key) => key.publicKeyHex === current)?.version ??342    historical.reduce((max, key) => Math.max(max, key.version), 0) + 1;343  const all = [...historical, { version: currentVersion, publicKeyHex: current }];344  return all.filter(345    (key, index) =>346      all.findIndex(347        (candidate) =>348          candidate.version === key.version && candidate.publicKeyHex === key.publicKeyHex,349      ) === index,350  );351}352353/**354 * Apply a device manifest issued by a peer we pair with directly.355 *356 * The issuer used to be required to be an "extension", on the assumption that357 * the extension was the mesh's trust anchor. It is not: the device a fresh358 * install joins through is the one holding the peer list to hand over.359 *360 * The issuer must already be admitted locally as either direct or vouched.361 * That is the decentralisation boundary: an unknown key has no session and no362 * authority, while every authenticated member can propagate the same signed363 * pool without the original hub online. A v3 entry is admitted only when it364 * carries a valid relay route; both endpoints independently derive the same365 * pair-only capability from their vault root and public keys.366 */367async function applyVerifiedPeerManifest(368  manifest: SignedDeviceManifest,369  authenticatedRemoteStaticPublicKey: Uint8Array,370): Promise<void> {371  const issuerId = manifest?.body?.issuerDeviceId;372  if (typeof issuerId !== "string" || !issuerId) { return; }373374  if (authenticatedRemoteStaticPublicKey.length !== 32) {375    return;376  }377  const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);378  const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);379  if (380    !issuer ||381    issuer.deviceId !== issuerId ||382    (383      issuer.trustSource !== "direct" &&384      issuer.trustSource !== "vouched"385    ) ||386    (issuer.vouchDepth ?? 0) >= MAX_VOUCH_DEPTH ||387    !issuer.signingPublicKeyHex ||388    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)389  ) {390    return;391  }392393  let body: DeviceManifestBody | undefined;394  let selectedSigningKey: { version: number; publicKeyHex: string } | undefined;395  const issuerSigningPublicKeyHex = issuer.signingPublicKeyHex;396  const historicalKeys = (issuer.signingKeyHistory ?? [])397    .filter((key) => Number.isInteger(key.version) && key.version > 0 && /^[0-9a-f]{64}$/i.test(key.publicKeyHex))398    .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() }));399  const currentVersion = historicalKeys.find(400    (key) => key.publicKeyHex === issuerSigningPublicKeyHex.toLowerCase(),401  )?.version ?? (historicalKeys.reduce((max, key) => Math.max(max, key.version), 0) + 1);402  const signingKeys = [403    ...historicalKeys,404    { version: currentVersion, publicKeyHex: issuerSigningPublicKeyHex.toLowerCase() },405  ].filter((key, index, keys) =>406    keys.findIndex((candidate) => candidate.version === key.version && candidate.publicKeyHex === key.publicKeyHex) === index,407  );408  try {409    for (const key of signingKeys) {410      try {411        body = await verifyDeviceManifest(manifest, hexToPubkey(key.publicKeyHex));412        selectedSigningKey = key;413        break;414      } catch { /* try the next immutable trusted key */ }415    }416    if (!body || !selectedSigningKey) return;417  } catch {418    return;419  }420  if (body.issuerDeviceId !== issuer.deviceId) {421    return;422  }423  // Fail closed: an unresolvable or unverifiable issuer rejects the whole424  // manifest rather than dropping just that record, so a pruned history entry425  // can never silently un-revoke a device. Android enforces the same rule.426  const allRevocationsAuthorised = (427    await Promise.all(428      body.revoked.map(async (record) => {429        // The outer manifest is authenticated by the directly connected430        // forwarder, but a forwarded revocation retains its original issuer.431        // Resolve that issuer independently from our paired registry and432        // immutable signing-key history; never bind it to the forwarder's key.433        const originalIssuer = await findPairedDeviceById(record.revokedByDeviceId);434        for (const key of trustedSigningKeys(originalIssuer)) {435          if (await verifyDeviceRevocation(record, hexToPubkey(key.publicKeyHex))) return true;436        }437        return false;438      }),439    )440  ).every(Boolean);441  if (!allRevocationsAuthorised) return;442443  const ourDeviceId = await getOrCreateDeviceId();444  const masterKey = requireMasterKey();445  const localPairing = await getLocalPairingIdentity();446  const localKeys = { publicKey: localPairing.publicKey, secretKey: localPairing.privateKey };447  try {448    const pendingRegistry: PairedDevice[] = [];449    const pendingSecrets: { deviceId: string; secret: string }[] = [];450    const pendingVouchedIds: string[] = [];451    for (const entry of body.devices) {452      if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) continue;453      if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue;454      if ((entry.vouchDepth ?? 0) > (issuer.vouchDepth ?? 0) + 1) continue;455      const existing = await findPairedDeviceById(entry.deviceId);456      const meshRoute = deriveManifestMeshRoute(localKeys.secretKey, localKeys.publicKey, entry);457      if (!existing) {458        if (!meshRoute) continue;459        pendingRegistry.push({460          deviceId: entry.deviceId,461          deviceName: entry.deviceName,462          deviceType: entry.deviceType,463          longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),464          signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),465          wsPort: entry.wsPort,466          relayUrl: meshRoute.relayUrl,467          pairedAt: entry.pairedAt,468          trustSource: "vouched",469          vouchDepth: (issuer.vouchDepth ?? 0) + 1,470        });471        pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });472        pendingVouchedIds.push(entry.deviceId);473        continue;474      }475      const longTermChanged = existing.longTermPublicKeyHex &&476        !constantTimeEqualHex(existing.longTermPublicKeyHex, entry.longTermPublicKeyHex);477      const existingSigning = existing.signingPublicKeyHex;478      const entrySigning = entry.signingPublicKeyHex;479      const signingChanged =480        Boolean(existingSigning) !== Boolean(entrySigning) ||481        (existingSigning !== undefined &&482          entrySigning !== undefined &&483          !constantTimeEqualHex(existingSigning, entrySigning));484      if (longTermChanged || signingChanged) {485        const knownName = existing.deviceName ?? entry.deviceName;486        notifyPopup({ type: "DEVICE_KEY_ROTATED", payload: { deviceName: knownName, deviceId: entry.deviceId } });487        void recordSyncAlerts([{488          type: "DEVICE_KEY_ROTATED",489          entryId: "",490          entryTitle: knownName,491          deviceName: knownName,492          timestamp: Date.now(),493        }]);494        continue;495      }496      pendingRegistry.push({497        ...existing,498        deviceId: entry.deviceId,499        deviceName: existing.deviceName ?? entry.deviceName,500        deviceType: entry.deviceType,501        longTermPublicKeyHex: existing.longTermPublicKeyHex ?? entry.longTermPublicKeyHex.toLowerCase(),502        signingPublicKeyHex: existing.signingPublicKeyHex ?? entry.signingPublicKeyHex.toLowerCase(),503        pairedAt: existing.pairedAt ?? entry.pairedAt,504        trustSource: existing.trustSource,505        wsPort: existing.wsPort ?? entry.wsPort,506        relayUrl: existing.trustSource === "vouched" && meshRoute ? meshRoute.relayUrl : existing.relayUrl,507        lastSyncedAt: existing.lastSyncedAt,508      });509      if (existing.trustSource === "vouched" && meshRoute) {510        pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });511      }512    }513    const newlyRevoked = await upsertPairedDevicesAndMergeRevocations(514      pendingRegistry,515      body.revoked.filter((r) => r.deviceId !== ourDeviceId),516    );517    for (const deviceId of newlyRevoked) {518      await removePendingVouchedDevice(deviceId);519      void recordSyncAlerts([{520        type: "DEVICE_REVOKED",521        entryId: "",522        entryTitle: deviceId,523        deviceName: issuer.deviceName ?? "paired device",524        timestamp: Date.now(),525      }]);526    }527    for (const pending of pendingSecrets) {528      if (newlyRevoked.includes(pending.deviceId)) continue;529      await setPairSecret(pending.deviceId, pending.secret, masterKey);530    }531    for (const deviceId of pendingVouchedIds) {532      if (newlyRevoked.includes(deviceId)) continue;533      await removePendingVouchedDevice(deviceId);534    }535  } finally {536    masterKey.fill(0);537    localKeys.secretKey.fill(0);538    localKeys.publicKey.fill(0);539  }540}541542function deriveManifestMeshRoute(543  localPrivateKey: Uint8Array,544  localPublicKey: Uint8Array,545  entry: ManifestDeviceEntry,546): { rendezvousSecretHex: string; relayUrl: string } | null {547  try {548    if (!entry.relayUrl) return null;549    const relayUrl = normalizeRelayUrl(entry.relayUrl);550    const secret = deriveMeshRendezvousSecret(551      localPrivateKey,552      localPublicKey,553      hexToPubkey(entry.longTermPublicKeyHex),554    );555    try {556      return {557        rendezvousSecretHex: rendezvousBytesToHex(secret),558        relayUrl,559      };560    } finally {561      secret.fill(0);562    }563  } catch {564    return null;565  }566}567568function notifyPopup(msg: { type: string; payload?: unknown }): void {569  try {570    void chrome.runtime.sendMessage(msg).catch(() => undefined);571  } catch {572    /* popup closed — the durable sync-alert queue remains authoritative */573  }574}575576function requireUnlocked(): void {577  if (!isUnlocked()) throw new VaultLockedError();578}579580function requireRealUnlocked(): void {581  if (!isUnlocked() || isDecoyMode()) throw new VaultLockedError();582}583584// Sync derives everything — vault_id, the Noise static key, the Ed25519585// manifest signing key, and the per-entry envelope keys — from the vault's586// stable root key, NOT the password-derived key. This keeps paired devices'587// identities and envelope keys constant across a master-password change on588// either side (SEV-2-B); two paired devices with independently-rotated589// passwords still share this key and can decrypt each other's entries.590function requireMasterKey(): Uint8Array {591  if (!isUnlocked()) throw new VaultLockedError();592  const hex = getRootKeyHex();593  if (!hex) throw new VaultLockedError();594  return hexToBytes(hex);595}596597async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {598  // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this599  // makes the (entryId, suffix) boundary unambiguous regardless of future id600  // formats. Coordinated wire change — peach-android VaultSyncAdapter derives601  // the identical info; the two MUST stay byte-for-byte in lockstep.602  const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);603  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);604  return crypto.subtle.importKey(605    "raw",606    raw.buffer as ArrayBuffer,607    { name: "AES-GCM" },608    false,609    ["encrypt", "decrypt"],610  );611}612613async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {614  const plaintext = new TextEncoder().encode(JSON.stringify(entry));615  const nonce = crypto.getRandomValues(new Uint8Array(12));616  const aad = new TextEncoder().encode(ENTRY_AAD);617  const ct = await crypto.subtle.encrypt(618    {619      name: "AES-GCM",620      iv: nonce.buffer as ArrayBuffer,621      additionalData: aad.buffer as ArrayBuffer,622      tagLength: 128,623    },624    key,625    plaintext.buffer as ArrayBuffer,626  );627  const ctBytes = new Uint8Array(ct);628  const envelope = new Uint8Array(nonce.length + ctBytes.length);629  envelope.set(nonce, 0);630  envelope.set(ctBytes, nonce.length);631  return bytesToBase64(envelope);632}633634async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {635  try {636    const bytes = base64ToBytes(envelopeB64);637    if (bytes.length < 12 + 16) return null;638    const nonce = bytes.subarray(0, 12);639    const ct = bytes.subarray(12);640    const aad = new TextEncoder().encode(ENTRY_AAD);641    const pt = await crypto.subtle.decrypt(642      {643        name: "AES-GCM",644        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,645        additionalData: aad.buffer as ArrayBuffer,646        tagLength: 128,647      },648      key,649      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,650    );651    const json = new TextDecoder().decode(pt);652    const parsed = JSON.parse(json);653    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;654    return parsed as VaultEntry;655  } catch {656    return null;657  }658}659660function hexToBytes(hex: string): Uint8Array {661  if (hex.length % 2 !== 0) throw new Error("hex length must be even");662  const out = new Uint8Array(hex.length / 2);663  for (let i = 0; i < out.length; i++) {664    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);665  }666  return out;667}668669function bytesToBase64(bytes: Uint8Array): string {670  let bin = "";671  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);672  return btoa(bin);673}674675function bytesToHex(bytes: Uint8Array): string {676  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");677}678679function base64ToBytes(b64: string): Uint8Array {680  const bin = atob(b64);681  const out = new Uint8Array(bin.length);682  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);683  return out;684}