sync-vault-adapter.ts

18.9 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  listDeviceRevocations,51  listPairedDevices,52  mergeDeviceRevocations,53  removePairedDeviceById,54  upsertPairedDevice,55} from "./paired-devices";56import {57  addPendingVouchedDevice,58  removePendingVouchedDevice,59} from "./pending-vouched-devices";60import { recordSyncAlerts } from "./sync-alerts";61import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";62import type { VaultEntry } from "../shared/types";63import type {64  ManifestDeviceEntry,65  PairedDevice,66  SignedDeviceManifest,67} from "../shared/sync-types";6869// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1").70// The info binding prevents a ciphertext from one entry slot being replayed into another.71const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";72const ENTRY_AAD = "peach-sync-entry-v1";7374export class VaultLockedError extends Error {75  constructor() {76    super("vault_locked");77    this.name = "VaultLockedError";78  }79}8081export function createSyncVaultAdapter(82  context: { deviceName?: string } = {},83): SyncVaultAdapter {84  return {85    async computeVaultId() {86      const master = requireMasterKey();87      return computeVaultId(master);88    },8990    async getOurDeviceId() {91      return getOrCreateDeviceId();92    },9394    async getLocalVersion() {95      return getVaultVersion();96    },9798    async buildEntryManifest() {99      requireUnlocked();100      const manifest: Record<string, number> = {};101      for (const entry of getEntries()) {102        manifest[entry.id] = entry.modifiedAt;103      }104      return manifest;105    },106107    async buildTombstoneManifest() {108      // KNOWN LIMITATION: tombstones accumulate without bound. Every deleted109      // entry leaves a {id, deletedAt} record that persists indefinitely and is110      // sent in every sync session, so a long-lived vault's manifest grows with111      // total deletions, not live entries. Acceptable for now (records are tiny112      // and the trash-retention purge eventually splices the entry array).113      // Future: prune tombstones older than N months once every paired device114      // has confirmed it holds no entry with that id.115      requireUnlocked();116      const manifest: Record<string, number> = {};117      for (const entry of getDeletedEntries()) {118        if (typeof entry.deletedAt === "number") {119          manifest[entry.id] = entry.deletedAt;120        }121      }122      return manifest;123    },124125    async getEncryptedEntries(entryIds) {126      requireUnlocked();127      const masterKey = requireMasterKey();128      const out: Record<string, string> = {};129      for (const id of entryIds) {130        const entry = getEntryById(id);131        if (!entry) continue;132        const key = await deriveEntryKey(masterKey, id);133        out[id] = await encryptEntry(key, entry);134      }135      return out;136    },137138    async applyEntries(entries) {139      requireUnlocked();140      const entryList = Object.entries(entries);141      // MV-3: bound the batch before any per-entry crypto work.142      if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {143        throw new Error(144          `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,145        );146      }147      const masterKey = requireMasterKey();148      const decrypted: VaultEntry[] = [];149      for (const [id, envelope] of entryList) {150        // MV-3: per-entry ceiling, checked on the (base64) envelope before151        // decrypting. Base64 inflates by 4/3, so allow that factor.152        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {153          continue; // oversized envelope — skip, never decrypt154        }155        const key = await deriveEntryKey(masterKey, id);156        const entry = await decryptEntryOrNull(key, envelope);157        if (!entry) continue;158        // MV-3: defense in depth — re-measure the decrypted plaintext.159        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {160          continue;161        }162        // The map key is the authority on id — refuse mismatched envelopes163        // so a swapped envelope can't poison a different entry slot.164        if (entry.id !== id) continue;165        decrypted.push(entry);166      }167      if (decrypted.length > 0) {168        await applyMergedRemoteEntries(decrypted, {}, context);169      }170    },171172    async applyTombstones(tombstones) {173      requireUnlocked();174      const tombstoneList = Object.entries(tombstones);175      // MV-3: bound the tombstone batch before processing.176      if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {177        throw new Error(178          `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,179        );180      }181      const cleaned: Record<string, number> = {};182      for (const [id, ts] of tombstoneList) {183        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {184          // HV-1c: clamp before the value participates in any comparison.185          // vault.ts clamps again defensively; clamping here keeps the186          // invariant local to the boundary where peer data enters.187          cleaned[id] = clampSyncTimestamp(ts);188        }189      }190      if (Object.keys(cleaned).length > 0) {191        await applyMergedRemoteEntries([], cleaned, context);192      }193    },194195    async buildOutgoingDeviceManifest() {196      requireUnlocked();197      const masterKey = requireMasterKey();198      const issuerDeviceId = await getOrCreateDeviceId();199      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);200      const revoked = await listDeviceRevocations();201      const revokedIds = new Set(revoked.map((r) => r.deviceId));202      const devices = (await listPairedDevices())203        .filter((d) => !revokedIds.has(d.deviceId))204        .map(toManifestEntry)205        .filter((d): d is ManifestDeviceEntry => d !== null);206      return buildSignedDeviceManifest(207        issuerDeviceId,208        devices,209        signing.privateKey,210        { revoked },211      );212    },213214    async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {215      requireUnlocked();216      await applyVerifiedPeerManifest(217        manifest,218        authenticatedRemoteStaticPublicKey,219      );220    },221  };222}223224function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {225  if (226    !device.deviceId ||227    !device.deviceName ||228    !device.deviceType ||229    !device.longTermPublicKeyHex ||230    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||231    !device.signingPublicKeyHex ||232    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)233  ) {234    return null;235  }236  return {237    deviceId: device.deviceId,238    deviceName: device.deviceName,239    deviceType: device.deviceType,240    longTermPublicKeyHex: device.longTermPublicKeyHex,241    signingPublicKeyHex: device.signingPublicKeyHex,242    wsPort: device.wsPort,243    pairedAt: device.pairedAt,244  };245}246247/**248 * Apply a device manifest issued by a peer we pair with directly.249 *250 * The issuer used to be required to be an "extension", on the assumption that251 * the extension was the mesh's trust anchor. It is not: the browser can never252 * listen for a connection, so the device it joins the mesh THROUGH is a native253 * peer (a phone), and that peer is the one holding the peer list to hand over.254 * Rejecting its manifest made mesh introduction impossible in the direction it255 * actually happens.256 *257 * Trust is not widened by that change. The issuer must still be a DIRECTLY258 * paired device — a vouched peer cannot vouch onward — and the MV-7 gate below259 * still routes any device we have never seen into the pending queue for260 * explicit user confirmation rather than trusting it on the signature alone.261 */262async function applyVerifiedPeerManifest(263  manifest: SignedDeviceManifest,264  authenticatedRemoteStaticPublicKey: Uint8Array,265): Promise<void> {266  const issuerId = manifest?.body?.issuerDeviceId;267  if (typeof issuerId !== "string" || !issuerId) return;268269  if (authenticatedRemoteStaticPublicKey.length !== 32) return;270  const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);271  const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);272  if (273    !issuer ||274    issuer.deviceId !== issuerId ||275    issuer.trustSource !== "direct" ||276    !issuer.signingPublicKeyHex ||277    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)278  ) {279    return;280  }281282  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;283  try {284    body = await verifyDeviceManifest(285      manifest,286      hexToPubkey(issuer.signingPublicKeyHex),287    );288  } catch {289    return;290  }291  if (body.issuerDeviceId !== issuer.deviceId) return;292293  const ourDeviceId = await getOrCreateDeviceId();294295  // Revocations are absorbed BEFORE any vouching, so a manifest that both296  // revokes and re-vouches the same device in one payload cannot race itself.297  // A peer may not revoke US — that would let one compromised device sever the298  // whole mesh — so our own id is filtered out first.299  const newlyRevoked = await mergeDeviceRevocations(300    body.revoked.filter((r) => r.deviceId !== ourDeviceId),301  );302  for (const deviceId of newlyRevoked) {303    await removePairedDeviceById(deviceId);304    await removePendingVouchedDevice(deviceId);305    void recordSyncAlerts([306      {307        type: "DEVICE_REVOKED",308        entryId: "",309        entryTitle: deviceId,310        deviceName: issuer.deviceName ?? "paired device",311        timestamp: Date.now(),312      },313    ]);314  }315316  const revokedIds = new Set(317    (await listDeviceRevocations()).map((r) => r.deviceId),318  );319320  for (const entry of body.devices) {321    if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {322      continue;323    }324    // Terminal: no manifest re-admits a revoked device.325    if (revokedIds.has(entry.deviceId)) continue;326    const existing = await findPairedDeviceById(entry.deviceId);327328    // MV-7: a device we have NEVER seen does not become a sync target on329    // the strength of the manifest signature alone. Queue it for explicit330    // user confirmation; until then it cannot push entries or tombstones331    // at us (it is absent from the paired-devices registry, so the runner332    // never opens a session to it and inbound static keys don't match).333    if (!existing) {334      await addPendingVouchedDevice({335        deviceId: entry.deviceId,336        deviceName: entry.deviceName,337        deviceType: entry.deviceType,338        longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),339        signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),340        wsPort: entry.wsPort,341        pairedAt: entry.pairedAt,342        receivedAt: Date.now(),343        vouchedByDeviceId: issuer.deviceId,344      });345      // No local record exists yet, so this name is the issuer's unverified346      // claim and nothing better is available. It reaches a screen the user347      // must explicitly confirm — treat it as untrusted display text there.348      notifyPopup({349        type: "VOUCHED_DEVICE_PENDING",350        payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },351      });352      continue; // not trusted yet353    }354355    // MV-8d: a key changing under a stable deviceId is legitimate after a356    // reinstall, but it is exactly what a graph-poisoning attack looks like —357    // signal the user either way, durably (the alert queue survives a closed358    // popup) and live (the broadcast updates an open one).359    if (360      existing.longTermPublicKeyHex &&361      !constantTimeEqualHex(362        existing.longTermPublicKeyHex,363        entry.longTermPublicKeyHex,364      )365    ) {366      // S5: name the device from the LOCAL record, never from the manifest.367      // The issuer is a third party here (its own entry is skipped above), so368      // `entry.deviceName` is an unverified claim about someone else — an369      // attacker who could put it on screen could make its own key rotation370      // read as a rotation on a device the user trusts more.371      const knownName = existing.deviceName ?? entry.deviceName;372      notifyPopup({373        type: "DEVICE_KEY_ROTATED",374        payload: { deviceName: knownName, deviceId: entry.deviceId },375      });376      void recordSyncAlerts([377        {378          type: "DEVICE_KEY_ROTATED",379          entryId: "",380          entryTitle: knownName,381          deviceName: knownName,382          timestamp: Date.now(),383        },384      ]);385    }386387    // Existing device — update in place as before. The name is deliberately388    // NOT taken from the manifest: a device's name is set by that device at389    // pairing, and letting a third-party issuer rewrite it is the rename-then-390    // impersonate half of S5. Self-renames never arrive here anyway.391    await upsertPairedDevice({392      ...existing,393      deviceId: entry.deviceId,394      deviceName: existing.deviceName ?? entry.deviceName,395      deviceType: entry.deviceType,396      longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),397      signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),398      pairedAt: existing.pairedAt ?? entry.pairedAt,399      trustSource: existing.trustSource ?? "direct",400      wsPort: existing.wsPort ?? entry.wsPort,401      lastSyncedAt: existing.lastSyncedAt,402    });403  }404}405406// MV-7: fire-and-forget broadcast to the popup. The popup may be closed,407// which is fine — the pending-vouched queue is the durable signal.408function notifyPopup(msg: { type: string; payload?: unknown }): void {409  try {410    void chrome.runtime.sendMessage(msg).catch(() => undefined);411  } catch {412    /* popup closed — pending queue is the durable signal */413  }414}415416function requireUnlocked(): void {417  if (!isUnlocked()) throw new VaultLockedError();418}419420// Sync derives everything — vault_id, the Noise static key, the Ed25519421// manifest signing key, and the per-entry envelope keys — from the vault's422// stable root key, NOT the password-derived key. This keeps paired devices'423// identities and envelope keys constant across a master-password change on424// either side (SEV-2-B); two paired devices with independently-rotated425// passwords still share this key and can decrypt each other's entries.426function requireMasterKey(): Uint8Array {427  if (!isUnlocked()) throw new VaultLockedError();428  const hex = getRootKeyHex();429  if (!hex) throw new VaultLockedError();430  return hexToBytes(hex);431}432433async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {434  // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this435  // makes the (entryId, suffix) boundary unambiguous regardless of future id436  // formats. Coordinated wire change — peach-android VaultSyncAdapter derives437  // the identical info; the two MUST stay byte-for-byte in lockstep.438  const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);439  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);440  return crypto.subtle.importKey(441    "raw",442    raw.buffer as ArrayBuffer,443    { name: "AES-GCM" },444    false,445    ["encrypt", "decrypt"],446  );447}448449async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {450  const plaintext = new TextEncoder().encode(JSON.stringify(entry));451  const nonce = crypto.getRandomValues(new Uint8Array(12));452  const aad = new TextEncoder().encode(ENTRY_AAD);453  const ct = await crypto.subtle.encrypt(454    {455      name: "AES-GCM",456      iv: nonce.buffer as ArrayBuffer,457      additionalData: aad.buffer as ArrayBuffer,458      tagLength: 128,459    },460    key,461    plaintext.buffer as ArrayBuffer,462  );463  const ctBytes = new Uint8Array(ct);464  const envelope = new Uint8Array(nonce.length + ctBytes.length);465  envelope.set(nonce, 0);466  envelope.set(ctBytes, nonce.length);467  return bytesToBase64(envelope);468}469470async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {471  try {472    const bytes = base64ToBytes(envelopeB64);473    if (bytes.length < 12 + 16) return null;474    const nonce = bytes.subarray(0, 12);475    const ct = bytes.subarray(12);476    const aad = new TextEncoder().encode(ENTRY_AAD);477    const pt = await crypto.subtle.decrypt(478      {479        name: "AES-GCM",480        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,481        additionalData: aad.buffer as ArrayBuffer,482        tagLength: 128,483      },484      key,485      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,486    );487    const json = new TextDecoder().decode(pt);488    const parsed = JSON.parse(json);489    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;490    return parsed as VaultEntry;491  } catch {492    return null;493  }494}495496function hexToBytes(hex: string): Uint8Array {497  if (hex.length % 2 !== 0) throw new Error("hex length must be even");498  const out = new Uint8Array(hex.length / 2);499  for (let i = 0; i < out.length; i++) {500    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);501  }502  return out;503}504505function bytesToBase64(bytes: Uint8Array): string {506  let bin = "";507  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);508  return btoa(bin);509}510511function base64ToBytes(b64: string): Uint8Array {512  const bin = atob(b64);513  const out = new Uint8Array(bin.length);514  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);515  return out;516}