sync-vault-adapter.ts

17.7 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. 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 { SyncVaultAdapter } from "./sync-coordinator";26import { getOrCreateDeviceId } from "./device-identity";27import {28  computeVaultId,29  deriveSyncSigningKeypair,30  hexToPubkey,31  pubkeyToHex,32} from "../shared/sync-identity";33import {34  buildSignedDeviceManifest,35  verifyDeviceManifest,36} from "../shared/device-manifest";37import { constantTimeEqualHex } from "../shared/constant-time";38import {39  applyMergedRemoteEntries,40  getDeletedEntries,41  getRootKeyHex,42  getEntries,43  getEntryById,44  getVaultVersion,45  isUnlocked,46} from "./vault";47import {48  findPairedDeviceById,49  findPairedDeviceByPubkey,50  listPairedDevices,51  upsertPairedDevice,52} from "./paired-devices";53import { addPendingVouchedDevice } from "./pending-vouched-devices";54import { recordSyncAlerts } from "./sync-alerts";55import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";56import type { VaultEntry } from "../shared/types";57import type {58  ManifestDeviceEntry,59  PairedDevice,60  SignedDeviceManifest,61} from "../shared/sync-types";6263// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1").64// The info binding prevents a ciphertext from one entry slot being replayed into another.65const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";66const ENTRY_AAD = "peach-sync-entry-v1";6768export class VaultLockedError extends Error {69  constructor() {70    super("vault_locked");71    this.name = "VaultLockedError";72  }73}7475export function createSyncVaultAdapter(76  context: { deviceName?: string } = {},77): SyncVaultAdapter {78  return {79    async computeVaultId() {80      const master = requireMasterKey();81      return computeVaultId(master);82    },8384    async getOurDeviceId() {85      return getOrCreateDeviceId();86    },8788    async getLocalVersion() {89      return getVaultVersion();90    },9192    async buildEntryManifest() {93      requireUnlocked();94      const manifest: Record<string, number> = {};95      for (const entry of getEntries()) {96        manifest[entry.id] = entry.modifiedAt;97      }98      return manifest;99    },100101    async buildTombstoneManifest() {102      // KNOWN LIMITATION: tombstones accumulate without bound. Every deleted103      // entry leaves a {id, deletedAt} record that persists indefinitely and is104      // sent in every sync session, so a long-lived vault's manifest grows with105      // total deletions, not live entries. Acceptable for now (records are tiny106      // and the trash-retention purge eventually splices the entry array).107      // Future: prune tombstones older than N months once every paired device108      // has confirmed it holds no entry with that id.109      requireUnlocked();110      const manifest: Record<string, number> = {};111      for (const entry of getDeletedEntries()) {112        if (typeof entry.deletedAt === "number") {113          manifest[entry.id] = entry.deletedAt;114        }115      }116      return manifest;117    },118119    async getEncryptedEntries(entryIds) {120      requireUnlocked();121      const masterKey = requireMasterKey();122      const out: Record<string, string> = {};123      for (const id of entryIds) {124        const entry = getEntryById(id);125        if (!entry) continue;126        const key = await deriveEntryKey(masterKey, id);127        out[id] = await encryptEntry(key, entry);128      }129      return out;130    },131132    async applyEntries(entries) {133      requireUnlocked();134      const entryList = Object.entries(entries);135      // MV-3: bound the batch before any per-entry crypto work.136      if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {137        throw new Error(138          `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,139        );140      }141      const masterKey = requireMasterKey();142      const decrypted: VaultEntry[] = [];143      for (const [id, envelope] of entryList) {144        // MV-3: per-entry ceiling, checked on the (base64) envelope before145        // decrypting. Base64 inflates by 4/3, so allow that factor.146        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {147          continue; // oversized envelope — skip, never decrypt148        }149        const key = await deriveEntryKey(masterKey, id);150        const entry = await decryptEntryOrNull(key, envelope);151        if (!entry) continue;152        // MV-3: defense in depth — re-measure the decrypted plaintext.153        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {154          continue;155        }156        // The map key is the authority on id — refuse mismatched envelopes157        // so a swapped envelope can't poison a different entry slot.158        if (entry.id !== id) continue;159        decrypted.push(entry);160      }161      if (decrypted.length > 0) {162        await applyMergedRemoteEntries(decrypted, {}, context);163      }164    },165166    async applyTombstones(tombstones) {167      requireUnlocked();168      const tombstoneList = Object.entries(tombstones);169      // MV-3: bound the tombstone batch before processing.170      if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {171        throw new Error(172          `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,173        );174      }175      const cleaned: Record<string, number> = {};176      for (const [id, ts] of tombstoneList) {177        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {178          // HV-1c: clamp before the value participates in any comparison.179          // vault.ts clamps again defensively; clamping here keeps the180          // invariant local to the boundary where peer data enters.181          cleaned[id] = clampSyncTimestamp(ts);182        }183      }184      if (Object.keys(cleaned).length > 0) {185        await applyMergedRemoteEntries([], cleaned, context);186      }187    },188189    async buildOutgoingDeviceManifest() {190      requireUnlocked();191      const masterKey = requireMasterKey();192      const issuerDeviceId = await getOrCreateDeviceId();193      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);194      const devices = (await listPairedDevices())195        .map(toManifestEntry)196        .filter((d): d is ManifestDeviceEntry => d !== null);197      return buildSignedDeviceManifest(198        issuerDeviceId,199        devices,200        signing.privateKey,201      );202    },203204    async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {205      requireUnlocked();206      await applyVerifiedPeerManifest(207        manifest,208        authenticatedRemoteStaticPublicKey,209      );210    },211  };212}213214function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {215  if (216    !device.deviceId ||217    !device.deviceName ||218    !device.deviceType ||219    !device.longTermPublicKeyHex ||220    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||221    !device.signingPublicKeyHex ||222    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)223  ) {224    return null;225  }226  return {227    deviceId: device.deviceId,228    deviceName: device.deviceName,229    deviceType: device.deviceType,230    longTermPublicKeyHex: device.longTermPublicKeyHex,231    signingPublicKeyHex: device.signingPublicKeyHex,232    wsPort: device.wsPort,233    pairedAt: device.pairedAt,234  };235}236237/**238 * Apply a device manifest issued by a peer we pair with directly.239 *240 * The issuer used to be required to be an "extension", on the assumption that241 * the extension was the mesh's trust anchor. It is not: the browser can never242 * listen for a connection, so the device it joins the mesh THROUGH is a native243 * peer (a phone), and that peer is the one holding the peer list to hand over.244 * Rejecting its manifest made mesh introduction impossible in the direction it245 * actually happens.246 *247 * Trust is not widened by that change. The issuer must still be a DIRECTLY248 * paired device — a vouched peer cannot vouch onward — and the MV-7 gate below249 * still routes any device we have never seen into the pending queue for250 * explicit user confirmation rather than trusting it on the signature alone.251 */252async function applyVerifiedPeerManifest(253  manifest: SignedDeviceManifest,254  authenticatedRemoteStaticPublicKey: Uint8Array,255): Promise<void> {256  const issuerId = manifest?.body?.issuerDeviceId;257  if (typeof issuerId !== "string" || !issuerId) return;258259  if (authenticatedRemoteStaticPublicKey.length !== 32) return;260  const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);261  const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);262  if (263    !issuer ||264    issuer.deviceId !== issuerId ||265    issuer.trustSource !== "direct" ||266    !issuer.signingPublicKeyHex ||267    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)268  ) {269    return;270  }271272  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;273  try {274    body = await verifyDeviceManifest(275      manifest,276      hexToPubkey(issuer.signingPublicKeyHex),277    );278  } catch {279    return;280  }281  if (body.issuerDeviceId !== issuer.deviceId) return;282283  const ourDeviceId = await getOrCreateDeviceId();284  for (const entry of body.devices) {285    if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {286      continue;287    }288    const existing = await findPairedDeviceById(entry.deviceId);289290    // MV-7: a device we have NEVER seen does not become a sync target on291    // the strength of the manifest signature alone. Queue it for explicit292    // user confirmation; until then it cannot push entries or tombstones293    // at us (it is absent from the paired-devices registry, so the runner294    // never opens a session to it and inbound static keys don't match).295    if (!existing) {296      await addPendingVouchedDevice({297        deviceId: entry.deviceId,298        deviceName: entry.deviceName,299        deviceType: entry.deviceType,300        longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),301        signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),302        wsPort: entry.wsPort,303        pairedAt: entry.pairedAt,304        receivedAt: Date.now(),305        vouchedByDeviceId: issuer.deviceId,306      });307      // No local record exists yet, so this name is the issuer's unverified308      // claim and nothing better is available. It reaches a screen the user309      // must explicitly confirm — treat it as untrusted display text there.310      notifyPopup({311        type: "VOUCHED_DEVICE_PENDING",312        payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },313      });314      continue; // not trusted yet315    }316317    // MV-8d: a key changing under a stable deviceId is legitimate after a318    // reinstall, but it is exactly what a graph-poisoning attack looks like —319    // signal the user either way, durably (the alert queue survives a closed320    // popup) and live (the broadcast updates an open one).321    if (322      existing.longTermPublicKeyHex &&323      !constantTimeEqualHex(324        existing.longTermPublicKeyHex,325        entry.longTermPublicKeyHex,326      )327    ) {328      // S5: name the device from the LOCAL record, never from the manifest.329      // The issuer is a third party here (its own entry is skipped above), so330      // `entry.deviceName` is an unverified claim about someone else — an331      // attacker who could put it on screen could make its own key rotation332      // read as a rotation on a device the user trusts more.333      const knownName = existing.deviceName ?? entry.deviceName;334      notifyPopup({335        type: "DEVICE_KEY_ROTATED",336        payload: { deviceName: knownName, deviceId: entry.deviceId },337      });338      void recordSyncAlerts([339        {340          type: "DEVICE_KEY_ROTATED",341          entryId: "",342          entryTitle: knownName,343          deviceName: knownName,344          timestamp: Date.now(),345        },346      ]);347    }348349    // Existing device — update in place as before. The name is deliberately350    // NOT taken from the manifest: a device's name is set by that device at351    // pairing, and letting a third-party issuer rewrite it is the rename-then-352    // impersonate half of S5. Self-renames never arrive here anyway.353    await upsertPairedDevice({354      ...existing,355      deviceId: entry.deviceId,356      deviceName: existing.deviceName ?? entry.deviceName,357      deviceType: entry.deviceType,358      longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),359      signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),360      pairedAt: existing.pairedAt ?? entry.pairedAt,361      trustSource: existing.trustSource ?? "direct",362      wsPort: existing.wsPort ?? entry.wsPort,363      lastSyncedAt: existing.lastSyncedAt,364    });365  }366}367368// MV-7: fire-and-forget broadcast to the popup. The popup may be closed,369// which is fine — the pending-vouched queue is the durable signal.370function notifyPopup(msg: { type: string; payload?: unknown }): void {371  try {372    void chrome.runtime.sendMessage(msg).catch(() => undefined);373  } catch {374    /* popup closed — pending queue is the durable signal */375  }376}377378function requireUnlocked(): void {379  if (!isUnlocked()) throw new VaultLockedError();380}381382// Sync derives everything — vault_id, the Noise static key, the Ed25519383// manifest signing key, and the per-entry envelope keys — from the vault's384// stable root key, NOT the password-derived key. This keeps paired devices'385// identities and envelope keys constant across a master-password change on386// either side (SEV-2-B); two paired devices with independently-rotated387// passwords still share this key and can decrypt each other's entries.388function requireMasterKey(): Uint8Array {389  if (!isUnlocked()) throw new VaultLockedError();390  const hex = getRootKeyHex();391  if (!hex) throw new VaultLockedError();392  return hexToBytes(hex);393}394395async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {396  // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this397  // makes the (entryId, suffix) boundary unambiguous regardless of future id398  // formats. Coordinated wire change — peach-android VaultSyncAdapter derives399  // the identical info; the two MUST stay byte-for-byte in lockstep.400  const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);401  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);402  return crypto.subtle.importKey(403    "raw",404    raw.buffer as ArrayBuffer,405    { name: "AES-GCM" },406    false,407    ["encrypt", "decrypt"],408  );409}410411async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {412  const plaintext = new TextEncoder().encode(JSON.stringify(entry));413  const nonce = crypto.getRandomValues(new Uint8Array(12));414  const aad = new TextEncoder().encode(ENTRY_AAD);415  const ct = await crypto.subtle.encrypt(416    {417      name: "AES-GCM",418      iv: nonce.buffer as ArrayBuffer,419      additionalData: aad.buffer as ArrayBuffer,420      tagLength: 128,421    },422    key,423    plaintext.buffer as ArrayBuffer,424  );425  const ctBytes = new Uint8Array(ct);426  const envelope = new Uint8Array(nonce.length + ctBytes.length);427  envelope.set(nonce, 0);428  envelope.set(ctBytes, nonce.length);429  return bytesToBase64(envelope);430}431432async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {433  try {434    const bytes = base64ToBytes(envelopeB64);435    if (bytes.length < 12 + 16) return null;436    const nonce = bytes.subarray(0, 12);437    const ct = bytes.subarray(12);438    const aad = new TextEncoder().encode(ENTRY_AAD);439    const pt = await crypto.subtle.decrypt(440      {441        name: "AES-GCM",442        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,443        additionalData: aad.buffer as ArrayBuffer,444        tagLength: 128,445      },446      key,447      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,448    );449    const json = new TextDecoder().decode(pt);450    const parsed = JSON.parse(json);451    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;452    return parsed as VaultEntry;453  } catch {454    return null;455  }456}457458function hexToBytes(hex: string): Uint8Array {459  if (hex.length % 2 !== 0) throw new Error("hex length must be even");460  const out = new Uint8Array(hex.length / 2);461  for (let i = 0; i < out.length; i++) {462    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);463  }464  return out;465}466467function bytesToBase64(bytes: Uint8Array): string {468  let bin = "";469  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);470  return btoa(bin);471}472473function base64ToBytes(b64: string): Uint8Array {474  const bin = atob(b64);475  const out = new Uint8Array(bin.length);476  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);477  return out;478}