chirp-sync-vault-adapter.ts

14.5 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 { addPendingVouchedDevice } from "./pending-vouched-devices";51import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";52import type { VaultEntry } from "../shared/types";53import type {54  ManifestDeviceEntry,55  PairedDevice,56  SignedDeviceManifest,57} from "../shared/sync-types";5859// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "sync-entry-v1").60// The info binding prevents a ciphertext from one entry slot being replayed into another.61const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";62const ENTRY_AAD = "peach-sync-entry-v1";6364export class VaultLockedError extends Error {65  constructor() {66    super("vault_locked");67    this.name = "VaultLockedError";68  }69}7071export function createChirpSyncVaultAdapter(72  context: { deviceName?: string } = {},73): ChirpSyncVaultAdapter {74  return {75    async computeVaultId() {76      const master = requireMasterKey();77      return computeVaultId(master);78    },7980    async getOurDeviceId() {81      return getOrCreateDeviceId();82    },8384    async getLocalVersion() {85      return getVaultVersion();86    },8788    async buildEntryManifest() {89      requireUnlocked();90      const manifest: Record<string, number> = {};91      for (const entry of getEntries()) {92        manifest[entry.id] = entry.modifiedAt;93      }94      return manifest;95    },9697    async buildTombstoneManifest() {98      // KNOWN LIMITATION: tombstones accumulate without bound. Every deleted99      // entry leaves a {id, deletedAt} record that persists indefinitely and is100      // sent in every sync session, so a long-lived vault's manifest grows with101      // total deletions, not live entries. Acceptable for now (records are tiny102      // and the trash-retention purge eventually splices the entry array).103      // Future: prune tombstones older than N months once every paired device104      // has confirmed it holds no entry with that id.105      requireUnlocked();106      const manifest: Record<string, number> = {};107      for (const entry of getDeletedEntries()) {108        if (typeof entry.deletedAt === "number") {109          manifest[entry.id] = entry.deletedAt;110        }111      }112      return manifest;113    },114115    async getEncryptedEntries(entryIds) {116      requireUnlocked();117      const masterKey = requireMasterKey();118      const out: Record<string, string> = {};119      for (const id of entryIds) {120        const entry = getEntryById(id);121        if (!entry) continue;122        const key = await deriveEntryKey(masterKey, id);123        out[id] = await encryptEntry(key, entry);124      }125      return out;126    },127128    async applyEntries(entries) {129      requireUnlocked();130      const entryList = Object.entries(entries);131      // MV-3: bound the batch before any per-entry crypto work.132      if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {133        throw new Error(134          `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,135        );136      }137      const masterKey = requireMasterKey();138      const decrypted: VaultEntry[] = [];139      for (const [id, envelope] of entryList) {140        // MV-3: per-entry ceiling, checked on the (base64) envelope before141        // decrypting. Base64 inflates by 4/3, so allow that factor.142        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {143          continue; // oversized envelope — skip, never decrypt144        }145        const key = await deriveEntryKey(masterKey, id);146        const entry = await decryptEntryOrNull(key, envelope);147        if (!entry) continue;148        // MV-3: defense in depth — re-measure the decrypted plaintext.149        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {150          continue;151        }152        // The map key is the authority on id — refuse mismatched envelopes153        // so a swapped envelope can't poison a different entry slot.154        if (entry.id !== id) continue;155        decrypted.push(entry);156      }157      if (decrypted.length > 0) {158        await applyMergedRemoteEntries(decrypted, {}, context);159      }160    },161162    async applyTombstones(tombstones) {163      requireUnlocked();164      const tombstoneList = Object.entries(tombstones);165      // MV-3: bound the tombstone batch before processing.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          // HV-1c: clamp before the value participates in any comparison.175          // vault.ts clamps again defensively; clamping here keeps the176          // invariant local to the boundary where peer data enters.177          cleaned[id] = clampSyncTimestamp(ts);178        }179      }180      if (Object.keys(cleaned).length > 0) {181        await applyMergedRemoteEntries([], cleaned, context);182      }183    },184185    async buildOutgoingDeviceManifest() {186      requireUnlocked();187      const masterKey = requireMasterKey();188      const issuerDeviceId = await getOrCreateDeviceId();189      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);190      const devices = (await listPairedDevices())191        .map(toManifestEntry)192        .filter((d): d is ManifestDeviceEntry => d !== null);193      return buildSignedDeviceManifest(194        issuerDeviceId,195        devices,196        signing.privateKey,197      );198    },199200    async applyIncomingDeviceManifest(manifest) {201      requireUnlocked();202      await applyVerifiedExtensionManifest(manifest);203    },204  };205}206207function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {208  if (209    !device.deviceId ||210    !device.deviceName ||211    !device.deviceType ||212    !device.longTermPublicKeyHex ||213    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||214    !device.signingPublicKeyHex ||215    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)216  ) {217    return null;218  }219  return {220    deviceId: device.deviceId,221    deviceName: device.deviceName,222    deviceType: device.deviceType,223    longTermPublicKeyHex: device.longTermPublicKeyHex,224    signingPublicKeyHex: device.signingPublicKeyHex,225    wsPort: device.wsPort,226    pairedAt: device.pairedAt,227  };228}229230async function applyVerifiedExtensionManifest(231  manifest: SignedDeviceManifest,232): Promise<void> {233  const issuerId = manifest?.body?.issuerDeviceId;234  if (typeof issuerId !== "string" || !issuerId) return;235236  const issuer = await findPairedDeviceById(issuerId);237  if (238    !issuer ||239    issuer.deviceType !== "extension" ||240    !issuer.signingPublicKeyHex ||241    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)242  ) {243    return;244  }245246  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;247  try {248    body = await verifyDeviceManifest(249      manifest,250      hexToPubkey(issuer.signingPublicKeyHex),251    );252  } catch {253    return;254  }255  if (body.issuerDeviceId !== issuer.deviceId) return;256257  const ourDeviceId = await getOrCreateDeviceId();258  for (const entry of body.devices) {259    if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {260      continue;261    }262    const existing = await findPairedDeviceById(entry.deviceId);263264    // MV-7: a device we have NEVER seen does not become a sync target on265    // the strength of the manifest signature alone. Queue it for explicit266    // user confirmation; until then it cannot push entries or tombstones267    // at us (it is absent from the paired-devices registry, so the runner268    // never opens a session to it and inbound static keys don't match).269    if (!existing) {270      await addPendingVouchedDevice({271        deviceId: entry.deviceId,272        deviceName: entry.deviceName,273        deviceType: entry.deviceType,274        longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),275        signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),276        wsPort: entry.wsPort,277        pairedAt: entry.pairedAt,278        receivedAt: Date.now(),279        vouchedByDeviceId: issuer.deviceId,280      });281      notifyPopup({282        type: "VOUCHED_DEVICE_PENDING",283        payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },284      });285      continue; // not trusted yet286    }287288    // Existing device — update in place as before. (MV-8 fix 8d adds the289    // key-rotation notification here; see that patch.)290    await upsertPairedDevice({291      ...existing,292      deviceId: entry.deviceId,293      deviceName: entry.deviceName,294      deviceType: entry.deviceType,295      longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),296      signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),297      pairedAt: existing.pairedAt ?? entry.pairedAt,298      trustSource: existing.trustSource ?? "direct",299      wsPort: existing.wsPort ?? entry.wsPort,300      lastSyncedAt: existing.lastSyncedAt,301    });302  }303}304305// MV-7: fire-and-forget broadcast to the popup. The popup may be closed,306// which is fine — the pending-vouched queue is the durable signal.307function notifyPopup(msg: { type: string; payload?: unknown }): void {308  try {309    void chrome.runtime.sendMessage(msg).catch(() => undefined);310  } catch {311    /* popup closed — pending queue is the durable signal */312  }313}314315function requireUnlocked(): void {316  if (!isUnlocked()) throw new VaultLockedError();317}318319// Sync derives everything — vault_id, the Noise XX static key, the Ed25519320// manifest signing key, and the per-entry envelope keys — from the vault's321// stable root key, NOT the password-derived key. This keeps paired devices'322// identities and envelope keys constant across a master-password change on323// either side (SEV-2-B); two paired devices with independently-rotated324// passwords still share this key and can decrypt each other's entries.325function requireMasterKey(): Uint8Array {326  if (!isUnlocked()) throw new VaultLockedError();327  const hex = getRootKeyHex();328  if (!hex) throw new VaultLockedError();329  return hexToBytes(hex);330}331332async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {333  const info = new TextEncoder().encode(entryId + ENTRY_KEY_INFO_SUFFIX);334  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);335  return crypto.subtle.importKey(336    "raw",337    raw.buffer as ArrayBuffer,338    { name: "AES-GCM" },339    false,340    ["encrypt", "decrypt"],341  );342}343344async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {345  const plaintext = new TextEncoder().encode(JSON.stringify(entry));346  const nonce = crypto.getRandomValues(new Uint8Array(12));347  const aad = new TextEncoder().encode(ENTRY_AAD);348  const ct = await crypto.subtle.encrypt(349    {350      name: "AES-GCM",351      iv: nonce.buffer as ArrayBuffer,352      additionalData: aad.buffer as ArrayBuffer,353      tagLength: 128,354    },355    key,356    plaintext.buffer as ArrayBuffer,357  );358  const ctBytes = new Uint8Array(ct);359  const envelope = new Uint8Array(nonce.length + ctBytes.length);360  envelope.set(nonce, 0);361  envelope.set(ctBytes, nonce.length);362  return bytesToBase64(envelope);363}364365async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {366  try {367    const bytes = base64ToBytes(envelopeB64);368    if (bytes.length < 12 + 16) return null;369    const nonce = bytes.subarray(0, 12);370    const ct = bytes.subarray(12);371    const aad = new TextEncoder().encode(ENTRY_AAD);372    const pt = await crypto.subtle.decrypt(373      {374        name: "AES-GCM",375        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,376        additionalData: aad.buffer as ArrayBuffer,377        tagLength: 128,378      },379      key,380      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,381    );382    const json = new TextDecoder().decode(pt);383    const parsed = JSON.parse(json);384    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;385    return parsed as VaultEntry;386  } catch {387    return null;388  }389}390391function hexToBytes(hex: string): Uint8Array {392  if (hex.length % 2 !== 0) throw new Error("hex length must be even");393  const out = new Uint8Array(hex.length / 2);394  for (let i = 0; i < out.length; i++) {395    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);396  }397  return out;398}399400function bytesToBase64(bytes: Uint8Array): string {401  let bin = "";402  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);403  return btoa(bin);404}405406function base64ToBytes(b64: string): Uint8Array {407  const bin = atob(b64);408  const out = new Uint8Array(bin.length);409  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);410  return out;411}