sync-vault-adapter.ts

19.3 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 DIRECTLY20// paired device may issue one — see applyVerifiedPeerManifest for why the21// issuer is not restricted to the extension, 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 { SyncVaultAdapter } from "./sync-coordinator";28import { getOrCreateDeviceId } from "./device-identity";29import {30  computeVaultId,31  deriveSyncSigningKeypair,32  hexToPubkey,33  pubkeyToHex,34} from "../shared/sync-identity";35import {36  buildSignedDeviceManifest,37  verifyDeviceManifest,38} from "../shared/device-manifest";39import { constantTimeEqualHex } from "../shared/constant-time";40import {41  applyMergedRemoteEntries,42  getDeletedEntries,43  getRootKeyHex,44  getEntries,45  getEntryById,46  getVaultVersion,47  isUnlocked,48} from "./vault";49import {50  findPairedDeviceById,51  findPairedDeviceByPubkey,52  isDeviceRevoked,53  listDeviceRevocations,54  listPairedDevices,55  mergeDeviceRevocations,56  removePairedDeviceById,57  upsertPairedDevice,58} from "./paired-devices";59import {60  addPendingVouchedDevice,61  removePendingVouchedDevice,62} from "./pending-vouched-devices";63import { recordSyncAlerts } from "./sync-alerts";64import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";65import type { VaultEntry } from "../shared/types";66import type {67  ManifestDeviceEntry,68  PairedDevice,69  SignedDeviceManifest,70} from "../shared/sync-types";7172// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1").73// The info binding prevents a ciphertext from one entry slot being replayed into another.74const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";75const ENTRY_AAD = "peach-sync-entry-v1";7677export class VaultLockedError extends Error {78  constructor() {79    super("vault_locked");80    this.name = "VaultLockedError";81  }82}8384export function createSyncVaultAdapter(85  context: { deviceName?: string } = {},86): SyncVaultAdapter {87  return {88    async computeVaultId() {89      const master = requireMasterKey();90      return computeVaultId(master);91    },9293    async getOurDeviceId() {94      return getOrCreateDeviceId();95    },9697    async getLocalVersion() {98      return getVaultVersion();99    },100101    async buildEntryManifest() {102      requireUnlocked();103      const manifest: Record<string, number> = {};104      for (const entry of getEntries()) {105        manifest[entry.id] = entry.modifiedAt;106      }107      return manifest;108    },109110    async buildTombstoneManifest() {111      // KNOWN LIMITATION: tombstones accumulate without bound. Every deleted112      // entry leaves a {id, deletedAt} record that persists indefinitely and is113      // sent in every sync session, so a long-lived vault's manifest grows with114      // total deletions, not live entries. Acceptable for now (records are tiny115      // and the trash-retention purge eventually splices the entry array).116      // Future: prune tombstones older than N months once every paired device117      // has confirmed it holds no entry with that id.118      requireUnlocked();119      const manifest: Record<string, number> = {};120      for (const entry of getDeletedEntries()) {121        if (typeof entry.deletedAt === "number") {122          manifest[entry.id] = entry.deletedAt;123        }124      }125      return manifest;126    },127128    async getEncryptedEntries(entryIds) {129      requireUnlocked();130      const masterKey = requireMasterKey();131      const out: Record<string, string> = {};132      for (const id of entryIds) {133        const entry = getEntryById(id);134        if (!entry) continue;135        const key = await deriveEntryKey(masterKey, id);136        out[id] = await encryptEntry(key, entry);137      }138      return out;139    },140141    async applyEntries(entries) {142      requireUnlocked();143      const entryList = Object.entries(entries);144      // MV-3: bound the batch before any per-entry crypto work.145      if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {146        throw new Error(147          `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,148        );149      }150      const masterKey = requireMasterKey();151      const decrypted: VaultEntry[] = [];152      for (const [id, envelope] of entryList) {153        // MV-3: per-entry ceiling, checked on the (base64) envelope before154        // decrypting. Base64 inflates by 4/3, so allow that factor.155        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {156          continue; // oversized envelope — skip, never decrypt157        }158        const key = await deriveEntryKey(masterKey, id);159        const entry = await decryptEntryOrNull(key, envelope);160        if (!entry) continue;161        // MV-3: defense in depth — re-measure the decrypted plaintext.162        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {163          continue;164        }165        // The map key is the authority on id — refuse mismatched envelopes166        // so a swapped envelope can't poison a different entry slot.167        if (entry.id !== id) continue;168        decrypted.push(entry);169      }170      if (decrypted.length > 0) {171        await applyMergedRemoteEntries(decrypted, {}, context);172      }173    },174175    async applyTombstones(tombstones) {176      requireUnlocked();177      const tombstoneList = Object.entries(tombstones);178      // MV-3: bound the tombstone batch before processing.179      if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {180        throw new Error(181          `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,182        );183      }184      const cleaned: Record<string, number> = {};185      for (const [id, ts] of tombstoneList) {186        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {187          // HV-1c: clamp before the value participates in any comparison.188          // vault.ts clamps again defensively; clamping here keeps the189          // invariant local to the boundary where peer data enters.190          cleaned[id] = clampSyncTimestamp(ts);191        }192      }193      if (Object.keys(cleaned).length > 0) {194        await applyMergedRemoteEntries([], cleaned, context);195      }196    },197198    async buildOutgoingDeviceManifest() {199      requireUnlocked();200      const masterKey = requireMasterKey();201      const issuerDeviceId = await getOrCreateDeviceId();202      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);203      const revoked = await listDeviceRevocations();204      const revokedIds = new Set(revoked.map((r) => r.deviceId));205      const devices = (await listPairedDevices())206        .filter((d) => !revokedIds.has(d.deviceId))207        .map(toManifestEntry)208        .filter((d): d is ManifestDeviceEntry => d !== null);209      return buildSignedDeviceManifest(210        issuerDeviceId,211        devices,212        signing.privateKey,213        { revoked },214      );215    },216217    async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {218      requireUnlocked();219      await applyVerifiedPeerManifest(220        manifest,221        authenticatedRemoteStaticPublicKey,222      );223    },224  };225}226227function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {228  if (229    !device.deviceId ||230    !device.deviceName ||231    !device.deviceType ||232    !device.longTermPublicKeyHex ||233    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||234    !device.signingPublicKeyHex ||235    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)236  ) {237    return null;238  }239  return {240    deviceId: device.deviceId,241    deviceName: device.deviceName,242    deviceType: device.deviceType,243    longTermPublicKeyHex: device.longTermPublicKeyHex,244    signingPublicKeyHex: device.signingPublicKeyHex,245    wsPort: device.wsPort,246    pairedAt: device.pairedAt,247  };248}249250/**251 * Apply a device manifest issued by a peer we pair with directly.252 *253 * The issuer used to be required to be an "extension", on the assumption that254 * the extension was the mesh's trust anchor. It is not: the browser can never255 * listen for a connection, so the device it joins the mesh THROUGH is a native256 * peer (a phone), and that peer is the one holding the peer list to hand over.257 * Rejecting its manifest made mesh introduction impossible in the direction it258 * actually happens.259 *260 * Trust is not widened by that change. The issuer must still be a DIRECTLY261 * paired device — a vouched peer cannot vouch onward — and the MV-7 gate below262 * still routes any device we have never seen into the pending queue for263 * explicit user confirmation rather than trusting it on the signature alone.264 */265async function applyVerifiedPeerManifest(266  manifest: SignedDeviceManifest,267  authenticatedRemoteStaticPublicKey: Uint8Array,268): Promise<void> {269  const issuerId = manifest?.body?.issuerDeviceId;270  if (typeof issuerId !== "string" || !issuerId) return;271272  if (authenticatedRemoteStaticPublicKey.length !== 32) return;273  const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);274  const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);275  if (276    !issuer ||277    issuer.deviceId !== issuerId ||278    issuer.trustSource !== "direct" ||279    !issuer.signingPublicKeyHex ||280    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)281  ) {282    return;283  }284285  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;286  try {287    body = await verifyDeviceManifest(288      manifest,289      hexToPubkey(issuer.signingPublicKeyHex),290    );291  } catch {292    return;293  }294  if (body.issuerDeviceId !== issuer.deviceId) return;295296  const ourDeviceId = await getOrCreateDeviceId();297298  // Revocations are absorbed BEFORE any vouching, so a manifest that both299  // revokes and re-vouches the same device in one payload cannot race itself.300  // A peer may not revoke US — that would let one compromised device sever the301  // whole mesh — so our own id is filtered out first.302  const newlyRevoked = await mergeDeviceRevocations(303    body.revoked.filter((r) => r.deviceId !== ourDeviceId),304  );305  for (const deviceId of newlyRevoked) {306    await removePairedDeviceById(deviceId);307    await removePendingVouchedDevice(deviceId);308    void recordSyncAlerts([309      {310        type: "DEVICE_REVOKED",311        entryId: "",312        entryTitle: deviceId,313        deviceName: issuer.deviceName ?? "paired device",314        timestamp: Date.now(),315      },316    ]);317  }318319  for (const entry of body.devices) {320    if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {321      continue;322    }323    // Terminal: no manifest re-admits a revoked device. Matched on the key as324    // well as the id, because the id is the issuer's to choose — checking only325    // the id let a revoked key walk back in under a fresh one. The check sits326    // ahead of the existing-device lookup so it also refuses rotating a device327    // we still trust onto a revoked key.328    if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {329      continue;330    }331    const existing = await findPairedDeviceById(entry.deviceId);332333    // MV-7: a device we have NEVER seen does not become a sync target on334    // the strength of the manifest signature alone. Queue it for explicit335    // user confirmation; until then it cannot push entries or tombstones336    // at us (it is absent from the paired-devices registry, so the runner337    // never opens a session to it and inbound static keys don't match).338    if (!existing) {339      await addPendingVouchedDevice({340        deviceId: entry.deviceId,341        deviceName: entry.deviceName,342        deviceType: entry.deviceType,343        longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),344        signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),345        wsPort: entry.wsPort,346        pairedAt: entry.pairedAt,347        receivedAt: Date.now(),348        vouchedByDeviceId: issuer.deviceId,349      });350      // No local record exists yet, so this name is the issuer's unverified351      // claim and nothing better is available. It reaches a screen the user352      // must explicitly confirm — treat it as untrusted display text there.353      notifyPopup({354        type: "VOUCHED_DEVICE_PENDING",355        payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },356      });357      continue; // not trusted yet358    }359360    // MV-8d: a key changing under a stable deviceId is legitimate after a361    // reinstall, but it is exactly what a graph-poisoning attack looks like —362    // signal the user either way, durably (the alert queue survives a closed363    // popup) and live (the broadcast updates an open one).364    if (365      existing.longTermPublicKeyHex &&366      !constantTimeEqualHex(367        existing.longTermPublicKeyHex,368        entry.longTermPublicKeyHex,369      )370    ) {371      // S5: name the device from the LOCAL record, never from the manifest.372      // The issuer is a third party here (its own entry is skipped above), so373      // `entry.deviceName` is an unverified claim about someone else — an374      // attacker who could put it on screen could make its own key rotation375      // read as a rotation on a device the user trusts more.376      const knownName = existing.deviceName ?? entry.deviceName;377      notifyPopup({378        type: "DEVICE_KEY_ROTATED",379        payload: { deviceName: knownName, deviceId: entry.deviceId },380      });381      void recordSyncAlerts([382        {383          type: "DEVICE_KEY_ROTATED",384          entryId: "",385          entryTitle: knownName,386          deviceName: knownName,387          timestamp: Date.now(),388        },389      ]);390    }391392    // Existing device — update in place as before. The name is deliberately393    // NOT taken from the manifest: a device's name is set by that device at394    // pairing, and letting a third-party issuer rewrite it is the rename-then-395    // impersonate half of S5. Self-renames never arrive here anyway.396    await upsertPairedDevice({397      ...existing,398      deviceId: entry.deviceId,399      deviceName: existing.deviceName ?? entry.deviceName,400      deviceType: entry.deviceType,401      longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),402      signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),403      pairedAt: existing.pairedAt ?? entry.pairedAt,404      trustSource: existing.trustSource ?? "direct",405      wsPort: existing.wsPort ?? entry.wsPort,406      lastSyncedAt: existing.lastSyncedAt,407    });408  }409}410411// MV-7: fire-and-forget broadcast to the popup. The popup may be closed,412// which is fine — the pending-vouched queue is the durable signal.413function notifyPopup(msg: { type: string; payload?: unknown }): void {414  try {415    void chrome.runtime.sendMessage(msg).catch(() => undefined);416  } catch {417    /* popup closed — pending queue is the durable signal */418  }419}420421function requireUnlocked(): void {422  if (!isUnlocked()) throw new VaultLockedError();423}424425// Sync derives everything — vault_id, the Noise static key, the Ed25519426// manifest signing key, and the per-entry envelope keys — from the vault's427// stable root key, NOT the password-derived key. This keeps paired devices'428// identities and envelope keys constant across a master-password change on429// either side (SEV-2-B); two paired devices with independently-rotated430// passwords still share this key and can decrypt each other's entries.431function requireMasterKey(): Uint8Array {432  if (!isUnlocked()) throw new VaultLockedError();433  const hex = getRootKeyHex();434  if (!hex) throw new VaultLockedError();435  return hexToBytes(hex);436}437438async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {439  // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this440  // makes the (entryId, suffix) boundary unambiguous regardless of future id441  // formats. Coordinated wire change — peach-android VaultSyncAdapter derives442  // the identical info; the two MUST stay byte-for-byte in lockstep.443  const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);444  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);445  return crypto.subtle.importKey(446    "raw",447    raw.buffer as ArrayBuffer,448    { name: "AES-GCM" },449    false,450    ["encrypt", "decrypt"],451  );452}453454async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {455  const plaintext = new TextEncoder().encode(JSON.stringify(entry));456  const nonce = crypto.getRandomValues(new Uint8Array(12));457  const aad = new TextEncoder().encode(ENTRY_AAD);458  const ct = await crypto.subtle.encrypt(459    {460      name: "AES-GCM",461      iv: nonce.buffer as ArrayBuffer,462      additionalData: aad.buffer as ArrayBuffer,463      tagLength: 128,464    },465    key,466    plaintext.buffer as ArrayBuffer,467  );468  const ctBytes = new Uint8Array(ct);469  const envelope = new Uint8Array(nonce.length + ctBytes.length);470  envelope.set(nonce, 0);471  envelope.set(ctBytes, nonce.length);472  return bytesToBase64(envelope);473}474475async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {476  try {477    const bytes = base64ToBytes(envelopeB64);478    if (bytes.length < 12 + 16) return null;479    const nonce = bytes.subarray(0, 12);480    const ct = bytes.subarray(12);481    const aad = new TextEncoder().encode(ENTRY_AAD);482    const pt = await crypto.subtle.decrypt(483      {484        name: "AES-GCM",485        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,486        additionalData: aad.buffer as ArrayBuffer,487        tagLength: 128,488      },489      key,490      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,491    );492    const json = new TextDecoder().decode(pt);493    const parsed = JSON.parse(json);494    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;495    return parsed as VaultEntry;496  } catch {497    return null;498  }499}500501function hexToBytes(hex: string): Uint8Array {502  if (hex.length % 2 !== 0) throw new Error("hex length must be even");503  const out = new Uint8Array(hex.length / 2);504  for (let i = 0; i < out.length; i++) {505    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);506  }507  return out;508}509510function bytesToBase64(bytes: Uint8Array): string {511  let bin = "";512  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);513  return btoa(bin);514}515516function base64ToBytes(b64: string): Uint8Array {517  const bin = atob(b64);518  const out = new Uint8Array(bin.length);519  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);520  return out;521}