chirp-sync-vault-adapter.ts

12.9 KB · raw

1// Production adapter that backs `runChirpSyncInitiator` with the live vault.2//3// The coordinator (chirp-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 + "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 XX 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. The extension is20// the trust anchor: manifests are signed with its vault-derived Ed25519 key,21// and receivers only apply manifests whose issuer is a paired extension.2223import { HKDF } from "@stablelib/hkdf";24import { SHA256 } from "@stablelib/sha256";25import type { ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";26import { getOrCreateDeviceId } from "./chirp-identity";27import {28  computeVaultId,29  deriveSyncSigningKeypair,30  hexToPubkey,31} from "../shared/sync-identity";32import {33  buildSignedDeviceManifest,34  verifyDeviceManifest,35} from "../shared/device-manifest";36import {37  applyMergedRemoteEntries,38  getDeletedEntries,39  getRootKeyHex,40  getEntries,41  getEntryById,42  getVaultVersion,43  isUnlocked,44} from "./vault";45import {46  findPairedDeviceById,47  listPairedDevices,48  upsertPairedDevice,49} from "./paired-devices";50import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";51import type { VaultEntry } from "../shared/types";52import type {53  ManifestDeviceEntry,54  PairedDevice,55  SignedDeviceManifest,56} from "../shared/sync-types";5758// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "sync-entry-v1").59// The info binding prevents a ciphertext from one entry slot being replayed into another.60const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";61const ENTRY_AAD = "peach-sync-entry-v1";6263export class VaultLockedError extends Error {64  constructor() {65    super("vault_locked");66    this.name = "VaultLockedError";67  }68}6970export function createChirpSyncVaultAdapter(71  context: { deviceName?: string } = {},72): ChirpSyncVaultAdapter {73  return {74    async computeVaultId() {75      const master = requireMasterKey();76      return computeVaultId(master);77    },7879    async getOurDeviceId() {80      return getOrCreateDeviceId();81    },8283    async getLocalVersion() {84      return getVaultVersion();85    },8687    async buildEntryManifest() {88      requireUnlocked();89      const manifest: Record<string, number> = {};90      for (const entry of getEntries()) {91        manifest[entry.id] = entry.modifiedAt;92      }93      return manifest;94    },9596    async buildTombstoneManifest() {97      // KNOWN LIMITATION: tombstones accumulate without bound. Every deleted98      // entry leaves a {id, deletedAt} record that persists indefinitely and is99      // sent in every sync session, so a long-lived vault's manifest grows with100      // total deletions, not live entries. Acceptable for now (records are tiny101      // and the trash-retention purge eventually splices the entry array).102      // Future: prune tombstones older than N months once every paired device103      // has confirmed it holds no entry with that id.104      requireUnlocked();105      const manifest: Record<string, number> = {};106      for (const entry of getDeletedEntries()) {107        if (typeof entry.deletedAt === "number") {108          manifest[entry.id] = entry.deletedAt;109        }110      }111      return manifest;112    },113114    async getEncryptedEntries(entryIds) {115      requireUnlocked();116      const masterKey = requireMasterKey();117      const out: Record<string, string> = {};118      for (const id of entryIds) {119        const entry = getEntryById(id);120        if (!entry) continue;121        const key = await deriveEntryKey(masterKey, id);122        out[id] = await encryptEntry(key, entry);123      }124      return out;125    },126127    async applyEntries(entries) {128      requireUnlocked();129      const entryList = Object.entries(entries);130      // MV-3: bound the batch before any per-entry crypto work.131      if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {132        throw new Error(133          `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,134        );135      }136      const masterKey = requireMasterKey();137      const decrypted: VaultEntry[] = [];138      for (const [id, envelope] of entryList) {139        // MV-3: per-entry ceiling, checked on the (base64) envelope before140        // decrypting. Base64 inflates by 4/3, so allow that factor.141        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {142          continue; // oversized envelope — skip, never decrypt143        }144        const key = await deriveEntryKey(masterKey, id);145        const entry = await decryptEntryOrNull(key, envelope);146        if (!entry) continue;147        // MV-3: defense in depth — re-measure the decrypted plaintext.148        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {149          continue;150        }151        // The map key is the authority on id — refuse mismatched envelopes152        // so a swapped envelope can't poison a different entry slot.153        if (entry.id !== id) continue;154        decrypted.push(entry);155      }156      if (decrypted.length > 0) {157        await applyMergedRemoteEntries(decrypted, {}, context);158      }159    },160161    async applyTombstones(tombstones) {162      requireUnlocked();163      const tombstoneList = Object.entries(tombstones);164      // MV-3: bound the tombstone batch before processing.165      if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {166        throw new Error(167          `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,168        );169      }170      const cleaned: Record<string, number> = {};171      for (const [id, ts] of tombstoneList) {172        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {173          // HV-1c: clamp before the value participates in any comparison.174          // vault.ts clamps again defensively; clamping here keeps the175          // invariant local to the boundary where peer data enters.176          cleaned[id] = clampSyncTimestamp(ts);177        }178      }179      if (Object.keys(cleaned).length > 0) {180        await applyMergedRemoteEntries([], cleaned, context);181      }182    },183184    async buildOutgoingDeviceManifest() {185      requireUnlocked();186      const masterKey = requireMasterKey();187      const issuerDeviceId = await getOrCreateDeviceId();188      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);189      const devices = (await listPairedDevices())190        .map(toManifestEntry)191        .filter((d): d is ManifestDeviceEntry => d !== null);192      return buildSignedDeviceManifest(193        issuerDeviceId,194        devices,195        signing.privateKey,196      );197    },198199    async applyIncomingDeviceManifest(manifest) {200      requireUnlocked();201      await applyVerifiedExtensionManifest(manifest);202    },203  };204}205206function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {207  if (208    !device.deviceId ||209    !device.deviceName ||210    !device.deviceType ||211    !device.longTermPublicKeyHex ||212    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||213    !device.signingPublicKeyHex ||214    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)215  ) {216    return null;217  }218  return {219    deviceId: device.deviceId,220    deviceName: device.deviceName,221    deviceType: device.deviceType,222    longTermPublicKeyHex: device.longTermPublicKeyHex,223    signingPublicKeyHex: device.signingPublicKeyHex,224    wsPort: device.wsPort,225    pairedAt: device.pairedAt,226  };227}228229async function applyVerifiedExtensionManifest(230  manifest: SignedDeviceManifest,231): Promise<void> {232  const issuerId = manifest?.body?.issuerDeviceId;233  if (typeof issuerId !== "string" || !issuerId) return;234235  const issuer = await findPairedDeviceById(issuerId);236  if (237    !issuer ||238    issuer.deviceType !== "extension" ||239    !issuer.signingPublicKeyHex ||240    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)241  ) {242    return;243  }244245  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;246  try {247    body = await verifyDeviceManifest(248      manifest,249      hexToPubkey(issuer.signingPublicKeyHex),250    );251  } catch {252    return;253  }254  if (body.issuerDeviceId !== issuer.deviceId) return;255256  const ourDeviceId = await getOrCreateDeviceId();257  for (const entry of body.devices) {258    if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {259      continue;260    }261    const existing = await findPairedDeviceById(entry.deviceId);262    await upsertPairedDevice({263      ...(existing ?? {}),264      deviceId: entry.deviceId,265      deviceName: entry.deviceName,266      deviceType: entry.deviceType,267      longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),268      signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),269      pairedAt: existing?.pairedAt ?? entry.pairedAt,270      trustSource: existing?.trustSource ?? (existing ? "direct" : "vouched"),271      wsPort: existing?.wsPort ?? entry.wsPort,272      lastSyncedAt: existing?.lastSyncedAt,273    });274  }275}276277function requireUnlocked(): void {278  if (!isUnlocked()) throw new VaultLockedError();279}280281// Sync derives everything — vault_id, the Noise XX static key, the Ed25519282// manifest signing key, and the per-entry envelope keys — from the vault's283// stable root key, NOT the password-derived key. This keeps paired devices'284// identities and envelope keys constant across a master-password change on285// either side (SEV-2-B); two paired devices with independently-rotated286// passwords still share this key and can decrypt each other's entries.287function requireMasterKey(): Uint8Array {288  if (!isUnlocked()) throw new VaultLockedError();289  const hex = getRootKeyHex();290  if (!hex) throw new VaultLockedError();291  return hexToBytes(hex);292}293294async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {295  const info = new TextEncoder().encode(entryId + ENTRY_KEY_INFO_SUFFIX);296  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);297  return crypto.subtle.importKey(298    "raw",299    raw.buffer as ArrayBuffer,300    { name: "AES-GCM" },301    false,302    ["encrypt", "decrypt"],303  );304}305306async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {307  const plaintext = new TextEncoder().encode(JSON.stringify(entry));308  const nonce = crypto.getRandomValues(new Uint8Array(12));309  const aad = new TextEncoder().encode(ENTRY_AAD);310  const ct = await crypto.subtle.encrypt(311    {312      name: "AES-GCM",313      iv: nonce.buffer as ArrayBuffer,314      additionalData: aad.buffer as ArrayBuffer,315      tagLength: 128,316    },317    key,318    plaintext.buffer as ArrayBuffer,319  );320  const ctBytes = new Uint8Array(ct);321  const envelope = new Uint8Array(nonce.length + ctBytes.length);322  envelope.set(nonce, 0);323  envelope.set(ctBytes, nonce.length);324  return bytesToBase64(envelope);325}326327async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {328  try {329    const bytes = base64ToBytes(envelopeB64);330    if (bytes.length < 12 + 16) return null;331    const nonce = bytes.subarray(0, 12);332    const ct = bytes.subarray(12);333    const aad = new TextEncoder().encode(ENTRY_AAD);334    const pt = await crypto.subtle.decrypt(335      {336        name: "AES-GCM",337        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,338        additionalData: aad.buffer as ArrayBuffer,339        tagLength: 128,340      },341      key,342      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,343    );344    const json = new TextDecoder().decode(pt);345    const parsed = JSON.parse(json);346    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;347    return parsed as VaultEntry;348  } catch {349    return null;350  }351}352353function hexToBytes(hex: string): Uint8Array {354  if (hex.length % 2 !== 0) throw new Error("hex length must be even");355  const out = new Uint8Array(hex.length / 2);356  for (let i = 0; i < out.length; i++) {357    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);358  }359  return out;360}361362function bytesToBase64(bytes: Uint8Array): string {363  let bin = "";364  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);365  return btoa(bin);366}367368function base64ToBytes(b64: string): Uint8Array {369  const bin = atob(b64);370  const out = new Uint8Array(bin.length);371  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);372  return out;373}