sync-vault-adapter.ts

20.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  deriveSyncIdentityKeypair,32  deriveSyncSigningKeypair,33  hexToPubkey,34  pubkeyToHex,35} from "../shared/sync-identity";36import {37  buildSignedDeviceManifest,38  verifyDeviceManifest,39} from "../shared/device-manifest";40import { constantTimeEqualHex } from "../shared/constant-time";41import {42  applyMergedRemoteEntries,43  getDeletedEntries,44  getRootKeyHex,45  getEntries,46  getEntryById,47  getVaultVersion,48  isUnlocked,49} from "./vault";50import {51  findPairedDeviceById,52  findPairedDeviceByPubkey,53  isDeviceRevoked,54  listDeviceRevocations,55  listPairedDevices,56  mergeDeviceRevocations,57  removePairedDeviceById,58  upsertPairedDevice,59} from "./paired-devices";60import {61  removePendingVouchedDevice,62} from "./pending-vouched-devices";63import {64  bytesToHex as rendezvousBytesToHex,65  deriveMeshRendezvousSecret,66  normalizeRelayUrl,67} from "../shared/rendezvous";68import { recordSyncAlerts } from "./sync-alerts";69import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";70import type { VaultEntry } from "../shared/types";71import type {72  ManifestDeviceEntry,73  PairedDevice,74  SignedDeviceManifest,75} from "../shared/sync-types";7677// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1").78// The info binding prevents a ciphertext from one entry slot being replayed into another.79const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";80const ENTRY_AAD = "peach-sync-entry-v1";8182export class VaultLockedError extends Error {83  constructor() {84    super("vault_locked");85    this.name = "VaultLockedError";86  }87}8889export function createSyncVaultAdapter(90  context: { deviceName?: string } = {},91): SyncVaultAdapter {92  return {93    async computeVaultId() {94      const master = requireMasterKey();95      return computeVaultId(master);96    },9798    async getOurDeviceId() {99      return getOrCreateDeviceId();100    },101102    async getLocalVersion() {103      return getVaultVersion();104    },105106    async buildEntryManifest() {107      requireUnlocked();108      const manifest: Record<string, number> = {};109      for (const entry of getEntries()) {110        manifest[entry.id] = entry.modifiedAt;111      }112      return manifest;113    },114115    async buildTombstoneManifest() {116      // KNOWN LIMITATION: tombstones accumulate without bound. Every deleted117      // entry leaves a {id, deletedAt} record that persists indefinitely and is118      // sent in every sync session, so a long-lived vault's manifest grows with119      // total deletions, not live entries. Acceptable for now (records are tiny120      // and the trash-retention purge eventually splices the entry array).121      // Future: prune tombstones older than N months once every paired device122      // has confirmed it holds no entry with that id.123      requireUnlocked();124      const manifest: Record<string, number> = {};125      for (const entry of getDeletedEntries()) {126        if (typeof entry.deletedAt === "number") {127          manifest[entry.id] = entry.deletedAt;128        }129      }130      return manifest;131    },132133    async getEncryptedEntries(entryIds) {134      requireUnlocked();135      const masterKey = requireMasterKey();136      const out: Record<string, string> = {};137      for (const id of entryIds) {138        const entry = getEntryById(id);139        if (!entry) continue;140        const key = await deriveEntryKey(masterKey, id);141        out[id] = await encryptEntry(key, entry);142      }143      return out;144    },145146    async applyEntries(entries) {147      requireUnlocked();148      const entryList = Object.entries(entries);149      // MV-3: bound the batch before any per-entry crypto work.150      if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {151        throw new Error(152          `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,153        );154      }155      const masterKey = requireMasterKey();156      const decrypted: VaultEntry[] = [];157      for (const [id, envelope] of entryList) {158        // MV-3: per-entry ceiling, checked on the (base64) envelope before159        // decrypting. Base64 inflates by 4/3, so allow that factor.160        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {161          continue; // oversized envelope — skip, never decrypt162        }163        const key = await deriveEntryKey(masterKey, id);164        const entry = await decryptEntryOrNull(key, envelope);165        if (!entry) continue;166        // MV-3: defense in depth — re-measure the decrypted plaintext.167        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {168          continue;169        }170        // The map key is the authority on id — refuse mismatched envelopes171        // so a swapped envelope can't poison a different entry slot.172        if (entry.id !== id) continue;173        decrypted.push(entry);174      }175      if (decrypted.length > 0) {176        await applyMergedRemoteEntries(decrypted, {}, context);177      }178    },179180    async applyTombstones(tombstones) {181      requireUnlocked();182      const tombstoneList = Object.entries(tombstones);183      // MV-3: bound the tombstone batch before processing.184      if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {185        throw new Error(186          `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,187        );188      }189      const cleaned: Record<string, number> = {};190      for (const [id, ts] of tombstoneList) {191        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {192          // HV-1c: clamp before the value participates in any comparison.193          // vault.ts clamps again defensively; clamping here keeps the194          // invariant local to the boundary where peer data enters.195          cleaned[id] = clampSyncTimestamp(ts);196        }197      }198      if (Object.keys(cleaned).length > 0) {199        await applyMergedRemoteEntries([], cleaned, context);200      }201    },202203    async buildOutgoingDeviceManifest() {204      requireUnlocked();205      const masterKey = requireMasterKey();206      const issuerDeviceId = await getOrCreateDeviceId();207      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);208      const revoked = await listDeviceRevocations();209      const revokedIds = new Set(revoked.map((r) => r.deviceId));210      const devices = (await listPairedDevices())211        .filter((d) => !revokedIds.has(d.deviceId))212        .map(toManifestEntry)213        .filter((d): d is ManifestDeviceEntry => d !== null);214      return buildSignedDeviceManifest(215        issuerDeviceId,216        devices,217        signing.privateKey,218        { revoked },219      );220    },221222    async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {223      requireUnlocked();224      await applyVerifiedPeerManifest(225        manifest,226        authenticatedRemoteStaticPublicKey,227      );228    },229  };230}231232function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {233  if (234    !device.deviceId ||235    !device.deviceName ||236    !device.deviceType ||237    !device.longTermPublicKeyHex ||238    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||239    !device.signingPublicKeyHex ||240    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)241  ) {242    return null;243  }244  return {245    deviceId: device.deviceId,246    deviceName: device.deviceName,247    deviceType: device.deviceType,248    longTermPublicKeyHex: device.longTermPublicKeyHex,249    signingPublicKeyHex: device.signingPublicKeyHex,250    wsPort: device.wsPort,251    relayUrl: device.relayUrl,252    pairedAt: device.pairedAt,253  };254}255256/**257 * Apply a device manifest issued by a peer we pair with directly.258 *259 * The issuer used to be required to be an "extension", on the assumption that260 * the extension was the mesh's trust anchor. It is not: the device a fresh261 * install joins through is the one holding the peer list to hand over.262 *263 * Trust is not widened by that change. The issuer must still be a DIRECTLY264 * paired device and a vouched peer cannot vouch onward. A v3 entry is admitted265 * only when it also carries a valid relay route; both endpoints independently266 * derive the same pair-only capability from their vault root and public keys.267 */268async function applyVerifiedPeerManifest(269  manifest: SignedDeviceManifest,270  authenticatedRemoteStaticPublicKey: Uint8Array,271): Promise<void> {272  const issuerId = manifest?.body?.issuerDeviceId;273  if (typeof issuerId !== "string" || !issuerId) return;274275  if (authenticatedRemoteStaticPublicKey.length !== 32) return;276  const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);277  const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);278  if (279    !issuer ||280    issuer.deviceId !== issuerId ||281    issuer.trustSource !== "direct" ||282    !issuer.signingPublicKeyHex ||283    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)284  ) {285    return;286  }287288  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;289  try {290    body = await verifyDeviceManifest(291      manifest,292      hexToPubkey(issuer.signingPublicKeyHex),293    );294  } catch {295    return;296  }297  if (body.issuerDeviceId !== issuer.deviceId) return;298299  const ourDeviceId = await getOrCreateDeviceId();300  const masterKey = requireMasterKey();301  const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);302303  try {304    // Revocations are absorbed BEFORE any vouching, so a manifest that both305    // revokes and re-vouches the same device in one payload cannot race itself.306    // A peer may not revoke US — that would let one compromised device sever the307    // whole mesh — so our own id is filtered out first.308    const newlyRevoked = await mergeDeviceRevocations(309      body.revoked.filter((r) => r.deviceId !== ourDeviceId),310    );311    for (const deviceId of newlyRevoked) {312      await removePairedDeviceById(deviceId);313      await removePendingVouchedDevice(deviceId);314      void recordSyncAlerts([315        {316          type: "DEVICE_REVOKED",317          entryId: "",318          entryTitle: deviceId,319          deviceName: issuer.deviceName ?? "paired device",320          timestamp: Date.now(),321        },322      ]);323    }324325    for (const entry of body.devices) {326      if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {327        continue;328      }329      // Terminal: no manifest re-admits a revoked device. Matched on the key as330      // well as the id, because the id is the issuer's to choose — checking only331      // the id let a revoked key walk back in under a fresh one. The check sits332      // ahead of the existing-device lookup so it also refuses rotating a device333      // we still trust onto a revoked key.334      if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {335        continue;336      }337      const existing = await findPairedDeviceById(entry.deviceId);338      const meshRoute = deriveManifestMeshRoute(339        masterKey,340        localKeys.publicKey,341        entry,342      );343344      // A v3 manifest from a DIRECT, KK-authenticated issuer proves same-vault345      // membership and carries enough route material for the two introduced346      // endpoints to derive their own capability. Admit it immediately so a347      // six-device vault converges to one graph instead of requiring ten manual348      // confirmations on every fresh install.349      if (!existing) {350        if (!meshRoute) continue;351        await upsertPairedDevice({352          deviceId: entry.deviceId,353          deviceName: entry.deviceName,354          deviceType: entry.deviceType,355          longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),356          signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),357          wsPort: entry.wsPort,358          rendezvousSecretHex: meshRoute.rendezvousSecretHex,359          relayUrl: meshRoute.relayUrl,360          pairedAt: entry.pairedAt,361          trustSource: "vouched",362        });363        await removePendingVouchedDevice(entry.deviceId);364        continue;365      }366367      // MV-8d: a key changing under a stable deviceId is legitimate after a368      // reinstall, but it is exactly what a graph-poisoning attack looks like —369      // signal the user either way, durably (the alert queue survives a closed370      // popup) and live (the broadcast updates an open one).371      if (372        existing.longTermPublicKeyHex &&373        !constantTimeEqualHex(374          existing.longTermPublicKeyHex,375          entry.longTermPublicKeyHex,376        )377      ) {378        // S5: name the device from the LOCAL record, never from the manifest.379        // The issuer is a third party here (its own entry is skipped above), so380        // `entry.deviceName` is an unverified claim about someone else — an381        // attacker who could put it on screen could make its own key rotation382        // read as a rotation on a device the user trusts more.383        const knownName = existing.deviceName ?? entry.deviceName;384        notifyPopup({385          type: "DEVICE_KEY_ROTATED",386          payload: { deviceName: knownName, deviceId: entry.deviceId },387        });388        void recordSyncAlerts([389          {390            type: "DEVICE_KEY_ROTATED",391            entryId: "",392            entryTitle: knownName,393            deviceName: knownName,394            timestamp: Date.now(),395          },396        ]);397      }398399      // Existing device — update in place as before. The name is deliberately400      // NOT taken from the manifest: a device's name is set by that device at401      // pairing, and letting a third-party issuer rewrite it is the rename-then-402      // impersonate half of S5. Self-renames never arrive here anyway.403      await upsertPairedDevice({404        ...existing,405        deviceId: entry.deviceId,406        deviceName: existing.deviceName ?? entry.deviceName,407        deviceType: entry.deviceType,408        longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),409        signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),410        pairedAt: existing.pairedAt ?? entry.pairedAt,411        trustSource: existing.trustSource ?? "direct",412        wsPort: existing.wsPort ?? entry.wsPort,413        rendezvousSecretHex:414          existing.trustSource === "vouched" && meshRoute415            ? meshRoute.rendezvousSecretHex416            : existing.rendezvousSecretHex,417        relayUrl:418          existing.trustSource === "vouched" && meshRoute419            ? meshRoute.relayUrl420            : existing.relayUrl,421        lastSyncedAt: existing.lastSyncedAt,422      });423    }424  } finally {425    masterKey.fill(0);426    localKeys.secretKey.fill(0);427  }428}429430function deriveManifestMeshRoute(431  masterKey: Uint8Array,432  localPublicKey: Uint8Array,433  entry: ManifestDeviceEntry,434): { rendezvousSecretHex: string; relayUrl: string } | null {435  try {436    if (!entry.relayUrl) return null;437    const relayUrl = normalizeRelayUrl(entry.relayUrl);438    const secret = deriveMeshRendezvousSecret(439      masterKey,440      localPublicKey,441      hexToPubkey(entry.longTermPublicKeyHex),442    );443    try {444      return {445        rendezvousSecretHex: rendezvousBytesToHex(secret),446        relayUrl,447      };448    } finally {449      secret.fill(0);450    }451  } catch {452    return null;453  }454}455456function notifyPopup(msg: { type: string; payload?: unknown }): void {457  try {458    void chrome.runtime.sendMessage(msg).catch(() => undefined);459  } catch {460    /* popup closed — the durable sync-alert queue remains authoritative */461  }462}463464function requireUnlocked(): void {465  if (!isUnlocked()) throw new VaultLockedError();466}467468// Sync derives everything — vault_id, the Noise static key, the Ed25519469// manifest signing key, and the per-entry envelope keys — from the vault's470// stable root key, NOT the password-derived key. This keeps paired devices'471// identities and envelope keys constant across a master-password change on472// either side (SEV-2-B); two paired devices with independently-rotated473// passwords still share this key and can decrypt each other's entries.474function requireMasterKey(): Uint8Array {475  if (!isUnlocked()) throw new VaultLockedError();476  const hex = getRootKeyHex();477  if (!hex) throw new VaultLockedError();478  return hexToBytes(hex);479}480481async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {482  // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this483  // makes the (entryId, suffix) boundary unambiguous regardless of future id484  // formats. Coordinated wire change — peach-android VaultSyncAdapter derives485  // the identical info; the two MUST stay byte-for-byte in lockstep.486  const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);487  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);488  return crypto.subtle.importKey(489    "raw",490    raw.buffer as ArrayBuffer,491    { name: "AES-GCM" },492    false,493    ["encrypt", "decrypt"],494  );495}496497async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {498  const plaintext = new TextEncoder().encode(JSON.stringify(entry));499  const nonce = crypto.getRandomValues(new Uint8Array(12));500  const aad = new TextEncoder().encode(ENTRY_AAD);501  const ct = await crypto.subtle.encrypt(502    {503      name: "AES-GCM",504      iv: nonce.buffer as ArrayBuffer,505      additionalData: aad.buffer as ArrayBuffer,506      tagLength: 128,507    },508    key,509    plaintext.buffer as ArrayBuffer,510  );511  const ctBytes = new Uint8Array(ct);512  const envelope = new Uint8Array(nonce.length + ctBytes.length);513  envelope.set(nonce, 0);514  envelope.set(ctBytes, nonce.length);515  return bytesToBase64(envelope);516}517518async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {519  try {520    const bytes = base64ToBytes(envelopeB64);521    if (bytes.length < 12 + 16) return null;522    const nonce = bytes.subarray(0, 12);523    const ct = bytes.subarray(12);524    const aad = new TextEncoder().encode(ENTRY_AAD);525    const pt = await crypto.subtle.decrypt(526      {527        name: "AES-GCM",528        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,529        additionalData: aad.buffer as ArrayBuffer,530        tagLength: 128,531      },532      key,533      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,534    );535    const json = new TextDecoder().decode(pt);536    const parsed = JSON.parse(json);537    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;538    return parsed as VaultEntry;539  } catch {540    return null;541  }542}543544function hexToBytes(hex: string): Uint8Array {545  if (hex.length % 2 !== 0) throw new Error("hex length must be even");546  const out = new Uint8Array(hex.length / 2);547  for (let i = 0; i < out.length; i++) {548    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);549  }550  return out;551}552553function bytesToBase64(bytes: Uint8Array): string {554  let bin = "";555  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);556  return btoa(bin);557}558559function base64ToBytes(b64: string): Uint8Array {560  const bin = atob(b64);561  const out = new Uint8Array(bin.length);562  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);563  return out;564}