sync-vault-adapter.ts

16.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 NK 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 {38  applyMergedRemoteEntries,39  getDeletedEntries,40  getRootKeyHex,41  getEntries,42  getEntryById,43  getVaultVersion,44  isUnlocked,45} from "./vault";46import {47  findPairedDeviceById,48  findPairedDeviceByPubkey,49  listPairedDevices,50  upsertPairedDevice,51} from "./paired-devices";52import { addPendingVouchedDevice } from "./pending-vouched-devices";53import { recordSyncAlerts } from "./sync-alerts";54import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";55import type { VaultEntry } from "../shared/types";56import type {57  ManifestDeviceEntry,58  PairedDevice,59  SignedDeviceManifest,60} from "../shared/sync-types";6162// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1").63// The info binding prevents a ciphertext from one entry slot being replayed into another.64const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";65const ENTRY_AAD = "peach-sync-entry-v1";6667export class VaultLockedError extends Error {68  constructor() {69    super("vault_locked");70    this.name = "VaultLockedError";71  }72}7374export function createSyncVaultAdapter(75  context: { deviceName?: string } = {},76): SyncVaultAdapter {77  return {78    async computeVaultId() {79      const master = requireMasterKey();80      return computeVaultId(master);81    },8283    async getOurDeviceId() {84      return getOrCreateDeviceId();85    },8687    async getLocalVersion() {88      return getVaultVersion();89    },9091    async buildEntryManifest() {92      requireUnlocked();93      const manifest: Record<string, number> = {};94      for (const entry of getEntries()) {95        manifest[entry.id] = entry.modifiedAt;96      }97      return manifest;98    },99100    async buildTombstoneManifest() {101      // KNOWN LIMITATION: tombstones accumulate without bound. Every deleted102      // entry leaves a {id, deletedAt} record that persists indefinitely and is103      // sent in every sync session, so a long-lived vault's manifest grows with104      // total deletions, not live entries. Acceptable for now (records are tiny105      // and the trash-retention purge eventually splices the entry array).106      // Future: prune tombstones older than N months once every paired device107      // has confirmed it holds no entry with that id.108      requireUnlocked();109      const manifest: Record<string, number> = {};110      for (const entry of getDeletedEntries()) {111        if (typeof entry.deletedAt === "number") {112          manifest[entry.id] = entry.deletedAt;113        }114      }115      return manifest;116    },117118    async getEncryptedEntries(entryIds) {119      requireUnlocked();120      const masterKey = requireMasterKey();121      const out: Record<string, string> = {};122      for (const id of entryIds) {123        const entry = getEntryById(id);124        if (!entry) continue;125        const key = await deriveEntryKey(masterKey, id);126        out[id] = await encryptEntry(key, entry);127      }128      return out;129    },130131    async applyEntries(entries) {132      requireUnlocked();133      const entryList = Object.entries(entries);134      // MV-3: bound the batch before any per-entry crypto work.135      if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {136        throw new Error(137          `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,138        );139      }140      const masterKey = requireMasterKey();141      const decrypted: VaultEntry[] = [];142      for (const [id, envelope] of entryList) {143        // MV-3: per-entry ceiling, checked on the (base64) envelope before144        // decrypting. Base64 inflates by 4/3, so allow that factor.145        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {146          continue; // oversized envelope — skip, never decrypt147        }148        const key = await deriveEntryKey(masterKey, id);149        const entry = await decryptEntryOrNull(key, envelope);150        if (!entry) continue;151        // MV-3: defense in depth — re-measure the decrypted plaintext.152        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {153          continue;154        }155        // The map key is the authority on id — refuse mismatched envelopes156        // so a swapped envelope can't poison a different entry slot.157        if (entry.id !== id) continue;158        decrypted.push(entry);159      }160      if (decrypted.length > 0) {161        await applyMergedRemoteEntries(decrypted, {}, context);162      }163    },164165    async applyTombstones(tombstones) {166      requireUnlocked();167      const tombstoneList = Object.entries(tombstones);168      // MV-3: bound the tombstone batch before processing.169      if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {170        throw new Error(171          `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,172        );173      }174      const cleaned: Record<string, number> = {};175      for (const [id, ts] of tombstoneList) {176        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {177          // HV-1c: clamp before the value participates in any comparison.178          // vault.ts clamps again defensively; clamping here keeps the179          // invariant local to the boundary where peer data enters.180          cleaned[id] = clampSyncTimestamp(ts);181        }182      }183      if (Object.keys(cleaned).length > 0) {184        await applyMergedRemoteEntries([], cleaned, context);185      }186    },187188    async buildOutgoingDeviceManifest() {189      requireUnlocked();190      const masterKey = requireMasterKey();191      const issuerDeviceId = await getOrCreateDeviceId();192      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);193      const devices = (await listPairedDevices())194        .map(toManifestEntry)195        .filter((d): d is ManifestDeviceEntry => d !== null);196      return buildSignedDeviceManifest(197        issuerDeviceId,198        devices,199        signing.privateKey,200      );201    },202203    async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {204      requireUnlocked();205      await applyVerifiedPeerManifest(206        manifest,207        authenticatedRemoteStaticPublicKey,208      );209    },210  };211}212213function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {214  if (215    !device.deviceId ||216    !device.deviceName ||217    !device.deviceType ||218    !device.longTermPublicKeyHex ||219    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||220    !device.signingPublicKeyHex ||221    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)222  ) {223    return null;224  }225  return {226    deviceId: device.deviceId,227    deviceName: device.deviceName,228    deviceType: device.deviceType,229    longTermPublicKeyHex: device.longTermPublicKeyHex,230    signingPublicKeyHex: device.signingPublicKeyHex,231    wsPort: device.wsPort,232    pairedAt: device.pairedAt,233  };234}235236/**237 * Apply a device manifest issued by a peer we pair with directly.238 *239 * The issuer used to be required to be an "extension", on the assumption that240 * the extension was the mesh's trust anchor. It is not: the browser can never241 * listen for a connection, so the device it joins the mesh THROUGH is a native242 * peer (a phone), and that peer is the one holding the peer list to hand over.243 * Rejecting its manifest made mesh introduction impossible in the direction it244 * actually happens.245 *246 * Trust is not widened by that change. The issuer must still be a DIRECTLY247 * paired device — a vouched peer cannot vouch onward — and the MV-7 gate below248 * still routes any device we have never seen into the pending queue for249 * explicit user confirmation rather than trusting it on the signature alone.250 */251async function applyVerifiedPeerManifest(252  manifest: SignedDeviceManifest,253  authenticatedRemoteStaticPublicKey: Uint8Array,254): Promise<void> {255  const issuerId = manifest?.body?.issuerDeviceId;256  if (typeof issuerId !== "string" || !issuerId) return;257258  if (authenticatedRemoteStaticPublicKey.length !== 32) return;259  const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);260  const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);261  if (262    !issuer ||263    issuer.deviceId !== issuerId ||264    issuer.trustSource !== "direct" ||265    !issuer.signingPublicKeyHex ||266    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)267  ) {268    return;269  }270271  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;272  try {273    body = await verifyDeviceManifest(274      manifest,275      hexToPubkey(issuer.signingPublicKeyHex),276    );277  } catch {278    return;279  }280  if (body.issuerDeviceId !== issuer.deviceId) return;281282  const ourDeviceId = await getOrCreateDeviceId();283  for (const entry of body.devices) {284    if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {285      continue;286    }287    const existing = await findPairedDeviceById(entry.deviceId);288289    // MV-7: a device we have NEVER seen does not become a sync target on290    // the strength of the manifest signature alone. Queue it for explicit291    // user confirmation; until then it cannot push entries or tombstones292    // at us (it is absent from the paired-devices registry, so the runner293    // never opens a session to it and inbound static keys don't match).294    if (!existing) {295      await addPendingVouchedDevice({296        deviceId: entry.deviceId,297        deviceName: entry.deviceName,298        deviceType: entry.deviceType,299        longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),300        signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),301        wsPort: entry.wsPort,302        pairedAt: entry.pairedAt,303        receivedAt: Date.now(),304        vouchedByDeviceId: issuer.deviceId,305      });306      notifyPopup({307        type: "VOUCHED_DEVICE_PENDING",308        payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },309      });310      continue; // not trusted yet311    }312313    // MV-8d: a key changing under a stable deviceId is legitimate after a314    // reinstall, but it is exactly what a graph-poisoning attack looks like —315    // signal the user either way, durably (the alert queue survives a closed316    // popup) and live (the broadcast updates an open one).317    if (318      existing.longTermPublicKeyHex &&319      existing.longTermPublicKeyHex !== entry.longTermPublicKeyHex.toLowerCase()320    ) {321      notifyPopup({322        type: "DEVICE_KEY_ROTATED",323        payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },324      });325      void recordSyncAlerts([326        {327          type: "DEVICE_KEY_ROTATED",328          entryId: "",329          entryTitle: entry.deviceName,330          deviceName: entry.deviceName,331          timestamp: Date.now(),332        },333      ]);334    }335336    // Existing device — update in place as before.337    await upsertPairedDevice({338      ...existing,339      deviceId: entry.deviceId,340      deviceName: entry.deviceName,341      deviceType: entry.deviceType,342      longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),343      signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),344      pairedAt: existing.pairedAt ?? entry.pairedAt,345      trustSource: existing.trustSource ?? "direct",346      wsPort: existing.wsPort ?? entry.wsPort,347      lastSyncedAt: existing.lastSyncedAt,348    });349  }350}351352// MV-7: fire-and-forget broadcast to the popup. The popup may be closed,353// which is fine — the pending-vouched queue is the durable signal.354function notifyPopup(msg: { type: string; payload?: unknown }): void {355  try {356    void chrome.runtime.sendMessage(msg).catch(() => undefined);357  } catch {358    /* popup closed — pending queue is the durable signal */359  }360}361362function requireUnlocked(): void {363  if (!isUnlocked()) throw new VaultLockedError();364}365366// Sync derives everything — vault_id, the Noise NK static key, the Ed25519367// manifest signing key, and the per-entry envelope keys — from the vault's368// stable root key, NOT the password-derived key. This keeps paired devices'369// identities and envelope keys constant across a master-password change on370// either side (SEV-2-B); two paired devices with independently-rotated371// passwords still share this key and can decrypt each other's entries.372function requireMasterKey(): Uint8Array {373  if (!isUnlocked()) throw new VaultLockedError();374  const hex = getRootKeyHex();375  if (!hex) throw new VaultLockedError();376  return hexToBytes(hex);377}378379async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {380  // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this381  // makes the (entryId, suffix) boundary unambiguous regardless of future id382  // formats. Coordinated wire change — peach-android VaultSyncAdapter derives383  // the identical info; the two MUST stay byte-for-byte in lockstep.384  const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);385  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);386  return crypto.subtle.importKey(387    "raw",388    raw.buffer as ArrayBuffer,389    { name: "AES-GCM" },390    false,391    ["encrypt", "decrypt"],392  );393}394395async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {396  const plaintext = new TextEncoder().encode(JSON.stringify(entry));397  const nonce = crypto.getRandomValues(new Uint8Array(12));398  const aad = new TextEncoder().encode(ENTRY_AAD);399  const ct = await crypto.subtle.encrypt(400    {401      name: "AES-GCM",402      iv: nonce.buffer as ArrayBuffer,403      additionalData: aad.buffer as ArrayBuffer,404      tagLength: 128,405    },406    key,407    plaintext.buffer as ArrayBuffer,408  );409  const ctBytes = new Uint8Array(ct);410  const envelope = new Uint8Array(nonce.length + ctBytes.length);411  envelope.set(nonce, 0);412  envelope.set(ctBytes, nonce.length);413  return bytesToBase64(envelope);414}415416async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {417  try {418    const bytes = base64ToBytes(envelopeB64);419    if (bytes.length < 12 + 16) return null;420    const nonce = bytes.subarray(0, 12);421    const ct = bytes.subarray(12);422    const aad = new TextEncoder().encode(ENTRY_AAD);423    const pt = await crypto.subtle.decrypt(424      {425        name: "AES-GCM",426        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,427        additionalData: aad.buffer as ArrayBuffer,428        tagLength: 128,429      },430      key,431      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,432    );433    const json = new TextDecoder().decode(pt);434    const parsed = JSON.parse(json);435    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;436    return parsed as VaultEntry;437  } catch {438    return null;439  }440}441442function hexToBytes(hex: string): Uint8Array {443  if (hex.length % 2 !== 0) throw new Error("hex length must be even");444  const out = new Uint8Array(hex.length / 2);445  for (let i = 0; i < out.length; i++) {446    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);447  }448  return out;449}450451function bytesToBase64(bytes: Uint8Array): string {452  let bin = "";453  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);454  return btoa(bin);455}456457function base64ToBytes(b64: string): Uint8Array {458  const bin = atob(b64);459  const out = new Uint8Array(bin.length);460  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);461  return out;462}