chirp-sync-vault-adapter.ts

16.3 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 + "\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 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 { recordSyncAlerts } from "./sync-alerts";52import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";53import type { VaultEntry } from "../shared/types";54import type {55  ManifestDeviceEntry,56  PairedDevice,57  SignedDeviceManifest,58} from "../shared/sync-types";5960// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1").61// The info binding prevents a ciphertext from one entry slot being replayed into another.62const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";63const ENTRY_AAD = "peach-sync-entry-v1";6465export class VaultLockedError extends Error {66  constructor() {67    super("vault_locked");68    this.name = "VaultLockedError";69  }70}7172export function createChirpSyncVaultAdapter(73  context: { deviceName?: string } = {},74): ChirpSyncVaultAdapter {75  return {76    async computeVaultId() {77      const master = requireMasterKey();78      return computeVaultId(master);79    },8081    async getOurDeviceId() {82      return getOrCreateDeviceId();83    },8485    async getLocalVersion() {86      return getVaultVersion();87    },8889    async buildEntryManifest() {90      requireUnlocked();91      const manifest: Record<string, number> = {};92      for (const entry of getEntries()) {93        manifest[entry.id] = entry.modifiedAt;94      }95      return manifest;96    },9798    async buildTombstoneManifest() {99      // KNOWN LIMITATION: tombstones accumulate without bound. Every deleted100      // entry leaves a {id, deletedAt} record that persists indefinitely and is101      // sent in every sync session, so a long-lived vault's manifest grows with102      // total deletions, not live entries. Acceptable for now (records are tiny103      // and the trash-retention purge eventually splices the entry array).104      // Future: prune tombstones older than N months once every paired device105      // has confirmed it holds no entry with that id.106      requireUnlocked();107      const manifest: Record<string, number> = {};108      for (const entry of getDeletedEntries()) {109        if (typeof entry.deletedAt === "number") {110          manifest[entry.id] = entry.deletedAt;111        }112      }113      return manifest;114    },115116    async getEncryptedEntries(entryIds) {117      requireUnlocked();118      const masterKey = requireMasterKey();119      const out: Record<string, string> = {};120      for (const id of entryIds) {121        const entry = getEntryById(id);122        if (!entry) continue;123        const key = await deriveEntryKey(masterKey, id);124        out[id] = await encryptEntry(key, entry);125      }126      return out;127    },128129    async applyEntries(entries) {130      requireUnlocked();131      const entryList = Object.entries(entries);132      // MV-3: bound the batch before any per-entry crypto work.133      if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {134        throw new Error(135          `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,136        );137      }138      const masterKey = requireMasterKey();139      const decrypted: VaultEntry[] = [];140      for (const [id, envelope] of entryList) {141        // MV-3: per-entry ceiling, checked on the (base64) envelope before142        // decrypting. Base64 inflates by 4/3, so allow that factor.143        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {144          continue; // oversized envelope — skip, never decrypt145        }146        const key = await deriveEntryKey(masterKey, id);147        const entry = await decryptEntryOrNull(key, envelope);148        if (!entry) continue;149        // MV-3: defense in depth — re-measure the decrypted plaintext.150        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {151          continue;152        }153        // The map key is the authority on id — refuse mismatched envelopes154        // so a swapped envelope can't poison a different entry slot.155        if (entry.id !== id) continue;156        decrypted.push(entry);157      }158      if (decrypted.length > 0) {159        await applyMergedRemoteEntries(decrypted, {}, context);160      }161    },162163    async applyTombstones(tombstones) {164      requireUnlocked();165      const tombstoneList = Object.entries(tombstones);166      // MV-3: bound the tombstone batch before processing.167      if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {168        throw new Error(169          `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,170        );171      }172      const cleaned: Record<string, number> = {};173      for (const [id, ts] of tombstoneList) {174        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {175          // HV-1c: clamp before the value participates in any comparison.176          // vault.ts clamps again defensively; clamping here keeps the177          // invariant local to the boundary where peer data enters.178          cleaned[id] = clampSyncTimestamp(ts);179        }180      }181      if (Object.keys(cleaned).length > 0) {182        await applyMergedRemoteEntries([], cleaned, context);183      }184    },185186    async buildOutgoingDeviceManifest() {187      requireUnlocked();188      const masterKey = requireMasterKey();189      const issuerDeviceId = await getOrCreateDeviceId();190      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);191      const devices = (await listPairedDevices())192        .map(toManifestEntry)193        .filter((d): d is ManifestDeviceEntry => d !== null);194      return buildSignedDeviceManifest(195        issuerDeviceId,196        devices,197        signing.privateKey,198      );199    },200201    async applyIncomingDeviceManifest(manifest) {202      requireUnlocked();203      await applyVerifiedPeerManifest(manifest);204    },205  };206}207208function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {209  if (210    !device.deviceId ||211    !device.deviceName ||212    !device.deviceType ||213    !device.longTermPublicKeyHex ||214    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||215    !device.signingPublicKeyHex ||216    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)217  ) {218    return null;219  }220  return {221    deviceId: device.deviceId,222    deviceName: device.deviceName,223    deviceType: device.deviceType,224    longTermPublicKeyHex: device.longTermPublicKeyHex,225    signingPublicKeyHex: device.signingPublicKeyHex,226    wsPort: device.wsPort,227    pairedAt: device.pairedAt,228  };229}230231/**232 * Apply a device manifest issued by a peer we pair with directly.233 *234 * The issuer used to be required to be an "extension", on the assumption that235 * the extension was the mesh's trust anchor. It is not: the browser can never236 * listen for a connection, so the device it joins the mesh THROUGH is a native237 * peer (a phone), and that peer is the one holding the peer list to hand over.238 * Rejecting its manifest made mesh introduction impossible in the direction it239 * actually happens.240 *241 * Trust is not widened by that change. The issuer must still be a DIRECTLY242 * paired device — a vouched peer cannot vouch onward — and the MV-7 gate below243 * still routes any device we have never seen into the pending queue for244 * explicit user confirmation rather than trusting it on the signature alone.245 */246async function applyVerifiedPeerManifest(247  manifest: SignedDeviceManifest,248): Promise<void> {249  const issuerId = manifest?.body?.issuerDeviceId;250  if (typeof issuerId !== "string" || !issuerId) return;251252  const issuer = await findPairedDeviceById(issuerId);253  if (254    !issuer ||255    issuer.trustSource !== "direct" ||256    !issuer.signingPublicKeyHex ||257    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)258  ) {259    return;260  }261262  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;263  try {264    body = await verifyDeviceManifest(265      manifest,266      hexToPubkey(issuer.signingPublicKeyHex),267    );268  } catch {269    return;270  }271  if (body.issuerDeviceId !== issuer.deviceId) return;272273  const ourDeviceId = await getOrCreateDeviceId();274  for (const entry of body.devices) {275    if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {276      continue;277    }278    const existing = await findPairedDeviceById(entry.deviceId);279280    // MV-7: a device we have NEVER seen does not become a sync target on281    // the strength of the manifest signature alone. Queue it for explicit282    // user confirmation; until then it cannot push entries or tombstones283    // at us (it is absent from the paired-devices registry, so the runner284    // never opens a session to it and inbound static keys don't match).285    if (!existing) {286      await addPendingVouchedDevice({287        deviceId: entry.deviceId,288        deviceName: entry.deviceName,289        deviceType: entry.deviceType,290        longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),291        signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),292        wsPort: entry.wsPort,293        pairedAt: entry.pairedAt,294        receivedAt: Date.now(),295        vouchedByDeviceId: issuer.deviceId,296      });297      notifyPopup({298        type: "VOUCHED_DEVICE_PENDING",299        payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },300      });301      continue; // not trusted yet302    }303304    // MV-8d: a key changing under a stable deviceId is legitimate after a305    // reinstall, but it is exactly what a graph-poisoning attack looks like —306    // signal the user either way, durably (the alert queue survives a closed307    // popup) and live (the broadcast updates an open one).308    if (309      existing.longTermPublicKeyHex &&310      existing.longTermPublicKeyHex !== entry.longTermPublicKeyHex.toLowerCase()311    ) {312      notifyPopup({313        type: "DEVICE_KEY_ROTATED",314        payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },315      });316      void recordSyncAlerts([317        {318          type: "DEVICE_KEY_ROTATED",319          entryId: "",320          entryTitle: entry.deviceName,321          deviceName: entry.deviceName,322          timestamp: Date.now(),323        },324      ]);325    }326327    // Existing device — update in place as before.328    await upsertPairedDevice({329      ...existing,330      deviceId: entry.deviceId,331      deviceName: entry.deviceName,332      deviceType: entry.deviceType,333      longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),334      signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),335      pairedAt: existing.pairedAt ?? entry.pairedAt,336      trustSource: existing.trustSource ?? "direct",337      wsPort: existing.wsPort ?? entry.wsPort,338      lastSyncedAt: existing.lastSyncedAt,339    });340  }341}342343// MV-7: fire-and-forget broadcast to the popup. The popup may be closed,344// which is fine — the pending-vouched queue is the durable signal.345function notifyPopup(msg: { type: string; payload?: unknown }): void {346  try {347    void chrome.runtime.sendMessage(msg).catch(() => undefined);348  } catch {349    /* popup closed — pending queue is the durable signal */350  }351}352353function requireUnlocked(): void {354  if (!isUnlocked()) throw new VaultLockedError();355}356357// Sync derives everything — vault_id, the Noise XX static key, the Ed25519358// manifest signing key, and the per-entry envelope keys — from the vault's359// stable root key, NOT the password-derived key. This keeps paired devices'360// identities and envelope keys constant across a master-password change on361// either side (SEV-2-B); two paired devices with independently-rotated362// passwords still share this key and can decrypt each other's entries.363function requireMasterKey(): Uint8Array {364  if (!isUnlocked()) throw new VaultLockedError();365  const hex = getRootKeyHex();366  if (!hex) throw new VaultLockedError();367  return hexToBytes(hex);368}369370async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {371  // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this372  // makes the (entryId, suffix) boundary unambiguous regardless of future id373  // formats. Coordinated wire change — peach-android VaultSyncAdapter derives374  // the identical info; the two MUST stay byte-for-byte in lockstep.375  const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);376  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);377  return crypto.subtle.importKey(378    "raw",379    raw.buffer as ArrayBuffer,380    { name: "AES-GCM" },381    false,382    ["encrypt", "decrypt"],383  );384}385386async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {387  const plaintext = new TextEncoder().encode(JSON.stringify(entry));388  const nonce = crypto.getRandomValues(new Uint8Array(12));389  const aad = new TextEncoder().encode(ENTRY_AAD);390  const ct = await crypto.subtle.encrypt(391    {392      name: "AES-GCM",393      iv: nonce.buffer as ArrayBuffer,394      additionalData: aad.buffer as ArrayBuffer,395      tagLength: 128,396    },397    key,398    plaintext.buffer as ArrayBuffer,399  );400  const ctBytes = new Uint8Array(ct);401  const envelope = new Uint8Array(nonce.length + ctBytes.length);402  envelope.set(nonce, 0);403  envelope.set(ctBytes, nonce.length);404  return bytesToBase64(envelope);405}406407async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {408  try {409    const bytes = base64ToBytes(envelopeB64);410    if (bytes.length < 12 + 16) return null;411    const nonce = bytes.subarray(0, 12);412    const ct = bytes.subarray(12);413    const aad = new TextEncoder().encode(ENTRY_AAD);414    const pt = await crypto.subtle.decrypt(415      {416        name: "AES-GCM",417        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,418        additionalData: aad.buffer as ArrayBuffer,419        tagLength: 128,420      },421      key,422      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,423    );424    const json = new TextDecoder().decode(pt);425    const parsed = JSON.parse(json);426    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;427    return parsed as VaultEntry;428  } catch {429    return null;430  }431}432433function hexToBytes(hex: string): Uint8Array {434  if (hex.length % 2 !== 0) throw new Error("hex length must be even");435  const out = new Uint8Array(hex.length / 2);436  for (let i = 0; i < out.length; i++) {437    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);438  }439  return out;440}441442function bytesToBase64(bytes: Uint8Array): string {443  let bin = "";444  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);445  return btoa(bin);446}447448function base64ToBytes(b64: string): Uint8Array {449  const bin = atob(b64);450  const out = new Uint8Array(bin.length);451  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);452  return out;453}