sync-vault-adapter.ts

23.4 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 admitted20// mesh peer may issue one — see applyVerifiedPeerManifest for why the issuer21// is not restricted by device type or first-pair topology, 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 { getDeviceName, 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";76import { replaceLockedSyncSpool, drainLockedSyncInbox } from "./locked-sync-spool";7778// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1").79// The info binding prevents a ciphertext from one entry slot being replayed into another.80const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";81const ENTRY_AAD = "peach-sync-entry-v1";8283export class VaultLockedError extends Error {84  constructor() {85    super("vault_locked");86    this.name = "VaultLockedError";87  }88}8990export function createSyncVaultAdapter(91  context: { deviceName?: string } = {},92): SyncVaultAdapter {93  return {94    async computeVaultId() {95      const master = requireMasterKey();96      return computeVaultId(master);97    },9899    async getOurDeviceId() {100      return getOrCreateDeviceId();101    },102103    async getLocalVersion() {104      return getVaultVersion();105    },106107    async buildEntryManifest() {108      requireUnlocked();109      const manifest: Record<string, number> = {};110      for (const entry of getEntries()) {111        manifest[entry.id] = entry.modifiedAt;112      }113      return manifest;114    },115116    async buildTombstoneManifest() {117      // KNOWN LIMITATION: tombstones accumulate without bound. Every deleted118      // entry leaves a {id, deletedAt} record that persists indefinitely and is119      // sent in every sync session, so a long-lived vault's manifest grows with120      // total deletions, not live entries. Acceptable for now (records are tiny121      // and the trash-retention purge eventually splices the entry array).122      // TODO(tombstone-gc): prune tombstones older than N months once every paired device123      // Algorithm: (1) After SYNC_DONE, record the peer-ack timestamp per tombstone.124      // (2) Once ALL paired devices have ack-ed a tombstone, splice it from the125      // deleted-entries array. (3) Gate on all-devices to prevent resurrection126      // from an offline peer that still holds the entry.127      requireUnlocked();128      const manifest: Record<string, number> = {};129      for (const entry of getDeletedEntries()) {130        if (typeof entry.deletedAt === "number") {131          manifest[entry.id] = entry.deletedAt;132        }133      }134      return manifest;135    },136137    async getEncryptedEntries(entryIds) {138      requireUnlocked();139      const masterKey = requireMasterKey();140      const out: Record<string, string> = {};141      for (const id of entryIds) {142        const entry = getEntryById(id);143        if (!entry) continue;144        const key = await deriveEntryKey(masterKey, id);145        out[id] = await encryptEntry(key, entry);146      }147      return out;148    },149150    async applyEntries(entries) {151      requireUnlocked();152      const entryList = Object.entries(entries);153      // MV-3: bound the batch before any per-entry crypto work.154      if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {155        throw new Error(156          `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,157        );158      }159      const masterKey = requireMasterKey();160      const decrypted: VaultEntry[] = [];161      for (const [id, envelope] of entryList) {162        // MV-3: per-entry ceiling, checked on the (base64) envelope before163        // decrypting. Base64 inflates by 4/3, so allow that factor.164        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {165          continue; // oversized envelope — skip, never decrypt166        }167        const key = await deriveEntryKey(masterKey, id);168        const entry = await decryptEntryOrNull(key, envelope);169        if (!entry) continue;170        // MV-3: defense in depth — re-measure the decrypted plaintext.171        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {172          continue;173        }174        // The map key is the authority on id — refuse mismatched envelopes175        // so a swapped envelope can't poison a different entry slot.176        if (entry.id !== id) continue;177        decrypted.push(entry);178      }179      if (decrypted.length > 0) {180        await applyMergedRemoteEntries(decrypted, {}, context);181      }182    },183184    async applyTombstones(tombstones) {185      requireUnlocked();186      const tombstoneList = Object.entries(tombstones);187      // MV-3: bound the tombstone batch before processing.188      if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {189        throw new Error(190          `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,191        );192      }193      const cleaned: Record<string, number> = {};194      for (const [id, ts] of tombstoneList) {195        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {196          // HV-1c: clamp before the value participates in any comparison.197          // vault.ts clamps again defensively; clamping here keeps the198          // invariant local to the boundary where peer data enters.199          cleaned[id] = clampSyncTimestamp(ts);200        }201      }202      if (Object.keys(cleaned).length > 0) {203        await applyMergedRemoteEntries([], cleaned, context);204      }205    },206207    async buildOutgoingDeviceManifest() {208      requireUnlocked();209      const masterKey = requireMasterKey();210      const issuerDeviceId = await getOrCreateDeviceId();211      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);212      const revoked = await listDeviceRevocations();213      const revokedIds = new Set(revoked.map((r) => r.deviceId));214      const devices = (await listPairedDevices())215        .filter((d) => !revokedIds.has(d.deviceId))216        .map(toManifestEntry)217        .filter((d): d is ManifestDeviceEntry => d !== null);218      return buildSignedDeviceManifest(219        issuerDeviceId,220        devices,221        signing.privateKey,222        { revoked },223      );224    },225226    async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {227      requireUnlocked();228      await applyVerifiedPeerManifest(229        manifest,230        authenticatedRemoteStaticPublicKey,231      );232    },233  };234}235236export async function refreshLockedSyncSpool(): Promise<void> {237  requireUnlocked();238  const masterKey = requireMasterKey();239  const deviceId = await getOrCreateDeviceId();240  const identity = await buildCachedIdentity(masterKey, deviceId);241  const cachedEntries: Record<string, { modifiedAt: number; ciphertext: string }> = {};242  try {243    for (const entry of getEntries()) {244      if (entry.deletedAt) continue;245      const key = await deriveEntryKey(masterKey, entry.id);246      cachedEntries[entry.id] = {247        modifiedAt: entry.modifiedAt,248        ciphertext: await encryptEntry(key, entry),249      };250    }251    const tombstones: Record<string, number> = {};252    for (const entry of getDeletedEntries()) {253      if (typeof entry.deletedAt === "number") tombstones[entry.id] = entry.deletedAt;254    }255    const { longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;256    await replaceLockedSyncSpool({257      ...storedIdentity,258      localVersion: await getVaultVersion(),259      entries: cachedEntries,260      tombstones,261    });262  } finally {263    masterKey.fill(0);264    identity.longTermPrivateKeyBytes.fill(0);265  }266}267268export async function drainAndRefreshLockedSyncSpool(): Promise<void> {269  const live = createSyncVaultAdapter();270  await drainLockedSyncInbox(live);271  await refreshLockedSyncSpool();272}273274async function buildCachedIdentity(masterKey: Uint8Array, deviceId: string) {275  const vaultId = await computeVaultId(masterKey);276  const keys = deriveSyncIdentityKeypair(masterKey, deviceId);277  const deviceName = await getDeviceName();278  return {279    vaultId,280    deviceId,281    deviceName,282    deviceType: "extension" as const,283    longTermPublicKeyHex: pubkeyToHex(keys.publicKey),284    longTermPrivateKeyHex: bytesToHex(keys.secretKey),285    longTermPrivateKeyBytes: keys.secretKey,286  };287}288289function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {290  if (291    !device.deviceId ||292    !device.deviceName ||293    !device.deviceType ||294    !device.longTermPublicKeyHex ||295    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||296    !device.signingPublicKeyHex ||297    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)298  ) {299    return null;300  }301  return {302    deviceId: device.deviceId,303    deviceName: device.deviceName,304    deviceType: device.deviceType,305    longTermPublicKeyHex: device.longTermPublicKeyHex,306    signingPublicKeyHex: device.signingPublicKeyHex,307    wsPort: device.wsPort,308    relayUrl: device.relayUrl,309    pairedAt: device.pairedAt,310  };311}312313/**314 * Apply a device manifest issued by a peer we pair with directly.315 *316 * The issuer used to be required to be an "extension", on the assumption that317 * the extension was the mesh's trust anchor. It is not: the device a fresh318 * install joins through is the one holding the peer list to hand over.319 *320 * The issuer must already be admitted locally as either direct or vouched.321 * That is the decentralisation boundary: an unknown key has no session and no322 * authority, while every authenticated member can propagate the same signed323 * pool without the original hub online. A v3 entry is admitted only when it324 * carries a valid relay route; both endpoints independently derive the same325 * pair-only capability from their vault root and public keys.326 */327async function applyVerifiedPeerManifest(328  manifest: SignedDeviceManifest,329  authenticatedRemoteStaticPublicKey: Uint8Array,330): Promise<void> {331  const issuerId = manifest?.body?.issuerDeviceId;332  if (typeof issuerId !== "string" || !issuerId) { return; }333334  if (authenticatedRemoteStaticPublicKey.length !== 32) {335    return;336  }337  const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);338  const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);339  if (340    !issuer ||341    issuer.deviceId !== issuerId ||342    (343      issuer.trustSource !== "direct" &&344      issuer.trustSource !== "vouched"345    ) ||346    !issuer.signingPublicKeyHex ||347    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)348  ) {349    return;350  }351352  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;353  try {354    body = await verifyDeviceManifest(355      manifest,356      hexToPubkey(issuer!.signingPublicKeyHex!),357    );358  } catch {359    // Stored key may be stale (older builds persisted the key derived at360    // first-pair and never refreshed it). The signing keypair is deterministic361    // from (masterKey, deviceId) on both sides, so retry once with the locally362    // re-derived pubkey. A genuinely forged signature throws here and is363    // silently rejected — only a stale-but-correct key survives the retry.364    const masterForIssuer = requireMasterKey();365    try {366      const derived = await deriveSyncSigningKeypair(367        masterForIssuer,368        issuer.deviceId,369      );370      body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);371    } catch {372      return;373    } finally {374      masterForIssuer.fill(0);375    }376  }377  if (body.issuerDeviceId !== issuer.deviceId) {378    return;379  }380381  const ourDeviceId = await getOrCreateDeviceId();382  const masterKey = requireMasterKey();383  const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);384385  try {386    // Revocations are absorbed BEFORE any vouching, so a manifest that both387    // revokes and re-vouches the same device in one payload cannot race itself.388    // A peer may not revoke US — that would let one compromised device sever the389    // whole mesh — so our own id is filtered out first.390    const newlyRevoked = await mergeDeviceRevocations(391      body.revoked.filter((r) => r.deviceId !== ourDeviceId),392    );393    for (const deviceId of newlyRevoked) {394      await removePairedDeviceById(deviceId);395      await removePendingVouchedDevice(deviceId);396      void recordSyncAlerts([397        {398          type: "DEVICE_REVOKED",399          entryId: "",400          entryTitle: deviceId,401          deviceName: issuer.deviceName ?? "paired device",402          timestamp: Date.now(),403        },404      ]);405    }406407    for (const entry of body.devices) {408      if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {409        continue;410      }411      // Terminal: no manifest re-admits a revoked device. Matched on the key as412      // well as the id, because the id is the issuer's to choose — checking only413      // the id let a revoked key walk back in under a fresh one. The check sits414      // ahead of the existing-device lookup so it also refuses rotating a device415      // we still trust onto a revoked key.416      if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {417        continue;418      }419      const existing = await findPairedDeviceById(entry.deviceId);420      const meshRoute = deriveManifestMeshRoute(421        masterKey,422        localKeys.publicKey,423        entry,424      );425426      // A v3 manifest from an admitted, KK-authenticated issuer proves427      // same-vault membership and carries enough route material for the two428      // introduced endpoints to derive their own capability. Admit it429      // immediately so a six-device vault converges without keeping the430      // original pairing hub online.431      if (!existing) {432        if (!meshRoute) continue;433        await upsertPairedDevice({434          deviceId: entry.deviceId,435          deviceName: entry.deviceName,436          deviceType: entry.deviceType,437          longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),438          signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),439          wsPort: entry.wsPort,440          rendezvousSecretHex: meshRoute.rendezvousSecretHex,441          relayUrl: meshRoute.relayUrl,442          pairedAt: entry.pairedAt,443          trustSource: "vouched",444        });445        await removePendingVouchedDevice(entry.deviceId);446        continue;447      }448449      // MV-8d: a key changing under a stable deviceId is legitimate after a450      // reinstall, but it is exactly what a graph-poisoning attack looks like —451      // signal the user either way, durably (the alert queue survives a closed452      // popup) and live (the broadcast updates an open one).453      if (454        existing.longTermPublicKeyHex &&455        !constantTimeEqualHex(456          existing.longTermPublicKeyHex,457          entry.longTermPublicKeyHex,458        )459      ) {460        // S5: name the device from the LOCAL record, never from the manifest.461        // The issuer is a third party here (its own entry is skipped above), so462        // `entry.deviceName` is an unverified claim about someone else — an463        // attacker who could put it on screen could make its own key rotation464        // read as a rotation on a device the user trusts more.465        const knownName = existing.deviceName ?? entry.deviceName;466        notifyPopup({467          type: "DEVICE_KEY_ROTATED",468          payload: { deviceName: knownName, deviceId: entry.deviceId },469        });470        void recordSyncAlerts([471          {472            type: "DEVICE_KEY_ROTATED",473            entryId: "",474            entryTitle: knownName,475            deviceName: knownName,476            timestamp: Date.now(),477          },478        ]);479      }480481      // Existing device — update in place as before. The name is deliberately482      // NOT taken from the manifest: a device's name is set by that device at483      // pairing, and letting a third-party issuer rewrite it is the rename-then-484      // impersonate half of S5. Self-renames never arrive here anyway.485      await upsertPairedDevice({486        ...existing,487        deviceId: entry.deviceId,488        deviceName: existing.deviceName ?? entry.deviceName,489        deviceType: entry.deviceType,490        longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),491        signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),492        pairedAt: existing.pairedAt ?? entry.pairedAt,493        trustSource: existing.trustSource ?? "direct",494        wsPort: existing.wsPort ?? entry.wsPort,495        rendezvousSecretHex:496          existing.trustSource === "vouched" && meshRoute497            ? meshRoute.rendezvousSecretHex498            : existing.rendezvousSecretHex,499        relayUrl:500          existing.trustSource === "vouched" && meshRoute501            ? meshRoute.relayUrl502            : existing.relayUrl,503        lastSyncedAt: existing.lastSyncedAt,504      });505    }506  } finally {507    masterKey.fill(0);508    localKeys.secretKey.fill(0);509  }510}511512function deriveManifestMeshRoute(513  masterKey: Uint8Array,514  localPublicKey: Uint8Array,515  entry: ManifestDeviceEntry,516): { rendezvousSecretHex: string; relayUrl: string } | null {517  try {518    if (!entry.relayUrl) return null;519    const relayUrl = normalizeRelayUrl(entry.relayUrl);520    const secret = deriveMeshRendezvousSecret(521      masterKey,522      localPublicKey,523      hexToPubkey(entry.longTermPublicKeyHex),524    );525    try {526      return {527        rendezvousSecretHex: rendezvousBytesToHex(secret),528        relayUrl,529      };530    } finally {531      secret.fill(0);532    }533  } catch {534    return null;535  }536}537538function notifyPopup(msg: { type: string; payload?: unknown }): void {539  try {540    void chrome.runtime.sendMessage(msg).catch(() => undefined);541  } catch {542    /* popup closed — the durable sync-alert queue remains authoritative */543  }544}545546function requireUnlocked(): void {547  if (!isUnlocked()) throw new VaultLockedError();548}549550// Sync derives everything — vault_id, the Noise static key, the Ed25519551// manifest signing key, and the per-entry envelope keys — from the vault's552// stable root key, NOT the password-derived key. This keeps paired devices'553// identities and envelope keys constant across a master-password change on554// either side (SEV-2-B); two paired devices with independently-rotated555// passwords still share this key and can decrypt each other's entries.556function requireMasterKey(): Uint8Array {557  if (!isUnlocked()) throw new VaultLockedError();558  const hex = getRootKeyHex();559  if (!hex) throw new VaultLockedError();560  return hexToBytes(hex);561}562563async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {564  // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this565  // makes the (entryId, suffix) boundary unambiguous regardless of future id566  // formats. Coordinated wire change — peach-android VaultSyncAdapter derives567  // the identical info; the two MUST stay byte-for-byte in lockstep.568  const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);569  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);570  return crypto.subtle.importKey(571    "raw",572    raw.buffer as ArrayBuffer,573    { name: "AES-GCM" },574    false,575    ["encrypt", "decrypt"],576  );577}578579async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {580  const plaintext = new TextEncoder().encode(JSON.stringify(entry));581  const nonce = crypto.getRandomValues(new Uint8Array(12));582  const aad = new TextEncoder().encode(ENTRY_AAD);583  const ct = await crypto.subtle.encrypt(584    {585      name: "AES-GCM",586      iv: nonce.buffer as ArrayBuffer,587      additionalData: aad.buffer as ArrayBuffer,588      tagLength: 128,589    },590    key,591    plaintext.buffer as ArrayBuffer,592  );593  const ctBytes = new Uint8Array(ct);594  const envelope = new Uint8Array(nonce.length + ctBytes.length);595  envelope.set(nonce, 0);596  envelope.set(ctBytes, nonce.length);597  return bytesToBase64(envelope);598}599600async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {601  try {602    const bytes = base64ToBytes(envelopeB64);603    if (bytes.length < 12 + 16) return null;604    const nonce = bytes.subarray(0, 12);605    const ct = bytes.subarray(12);606    const aad = new TextEncoder().encode(ENTRY_AAD);607    const pt = await crypto.subtle.decrypt(608      {609        name: "AES-GCM",610        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,611        additionalData: aad.buffer as ArrayBuffer,612        tagLength: 128,613      },614      key,615      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,616    );617    const json = new TextDecoder().decode(pt);618    const parsed = JSON.parse(json);619    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;620    return parsed as VaultEntry;621  } catch {622    return null;623  }624}625626function hexToBytes(hex: string): Uint8Array {627  if (hex.length % 2 !== 0) throw new Error("hex length must be even");628  const out = new Uint8Array(hex.length / 2);629  for (let i = 0; i < out.length; i++) {630    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);631  }632  return out;633}634635function bytesToBase64(bytes: Uint8Array): string {636  let bin = "";637  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);638  return btoa(bin);639}640641function bytesToHex(bytes: Uint8Array): string {642  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");643}644645function base64ToBytes(b64: string): Uint8Array {646  const bin = atob(b64);647  const out = new Uint8Array(bin.length);648  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);649  return out;650}