sync-vault-adapter.ts

22.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 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  getTombstoneManifest,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 { setPairSecret } from "./pair-secrets";61import {62  removePendingVouchedDevice,63} from "./pending-vouched-devices";64import {65  bytesToHex as rendezvousBytesToHex,66  deriveMeshRendezvousSecret,67  normalizeRelayUrl,68} from "../shared/rendezvous";69import { recordSyncAlerts } from "./sync-alerts";70import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";71import type { VaultEntry } from "../shared/types";72import type {73  ManifestDeviceEntry,74  PairedDevice,75  SignedDeviceManifest,76} from "../shared/sync-types";77import { replaceLockedSyncSpool, drainLockedSyncInbox } from "./locked-sync-spool";7879// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1").80// The info binding prevents a ciphertext from one entry slot being replayed into another.81const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";82const ENTRY_AAD = "peach-sync-entry-v1";8384export class VaultLockedError extends Error {85  constructor() {86    super("vault_locked");87    this.name = "VaultLockedError";88  }89}9091export function createSyncVaultAdapter(92  _context: { deviceName?: string } = {},93): SyncVaultAdapter {94  return {95    async computeVaultId() {96      const master = requireMasterKey();97      return computeVaultId(master);98    },99100    async getOurDeviceId() {101      return getOrCreateDeviceId();102    },103104    async getLocalVersion() {105      return getVaultVersion();106    },107108    async buildEntryManifest() {109      requireUnlocked();110      const manifest: Record<string, number> = {};111      for (const entry of getEntries()) {112        manifest[entry.id] = entry.modifiedAt;113      }114      return manifest;115    },116117    async buildTombstoneManifest() {118      requireUnlocked();119      return getTombstoneManifest();120    },121122    async getEncryptedEntries(entryIds) {123      requireUnlocked();124      const masterKey = requireMasterKey();125      const out: Record<string, string> = {};126      for (const id of entryIds) {127        const entry = getEntryById(id);128        if (!entry) continue;129        const key = await deriveEntryKey(masterKey, id);130        out[id] = await encryptEntry(key, entry);131      }132      return out;133    },134135    async applyEntries(entries) {136      requireUnlocked();137      const entryList = Object.entries(entries);138      // MV-3: bound the batch before any per-entry crypto work.139      if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {140        throw new Error(141          `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,142        );143      }144      const masterKey = requireMasterKey();145      const decrypted: VaultEntry[] = [];146      for (const [id, envelope] of entryList) {147        // MV-3: per-entry ceiling, checked on the (base64) envelope before148        // decrypting. Base64 inflates by 4/3, so allow that factor.149        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {150          continue; // oversized envelope — skip, never decrypt151        }152        const key = await deriveEntryKey(masterKey, id);153        const entry = await decryptEntryOrNull(key, envelope);154        if (!entry) continue;155        // MV-3: defense in depth — re-measure the decrypted plaintext.156        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {157          continue;158        }159        // The map key is the authority on id — refuse mismatched envelopes160        // so a swapped envelope can't poison a different entry slot.161        if (entry.id !== id) continue;162        decrypted.push(entry);163      }164      if (decrypted.length > 0) {165        await applyMergedRemoteEntries(decrypted, {});166      }167    },168169    async applyTombstones(tombstones) {170      requireUnlocked();171      const tombstoneList = Object.entries(tombstones);172      // MV-3: bound the tombstone batch before processing.173      if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {174        throw new Error(175          `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,176        );177      }178      const cleaned: Record<string, number> = {};179      for (const [id, ts] of tombstoneList) {180        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {181          // HV-1c: clamp before the value participates in any comparison.182          // vault.ts clamps again defensively; clamping here keeps the183          // invariant local to the boundary where peer data enters.184          cleaned[id] = clampSyncTimestamp(ts);185        }186      }187      if (Object.keys(cleaned).length > 0) {188        await applyMergedRemoteEntries([], cleaned);189      }190    },191192    async buildOutgoingDeviceManifest() {193      requireUnlocked();194      const masterKey = requireMasterKey();195      const issuerDeviceId = await getOrCreateDeviceId();196      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);197      const revoked = await listDeviceRevocations();198      const revokedIds = new Set(revoked.map((r) => r.deviceId));199      const devices = (await listPairedDevices())200        .filter((d) => !revokedIds.has(d.deviceId))201        .map(toManifestEntry)202        .filter((d): d is ManifestDeviceEntry => d !== null);203      return buildSignedDeviceManifest(204        issuerDeviceId,205        devices,206        signing.privateKey,207        { revoked },208      );209    },210211    async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {212      requireUnlocked();213      await applyVerifiedPeerManifest(214        manifest,215        authenticatedRemoteStaticPublicKey,216      );217    },218  };219}220221export async function refreshLockedSyncSpool(): Promise<void> {222  requireUnlocked();223  const masterKey = requireMasterKey();224  const deviceId = await getOrCreateDeviceId();225  const identity = await buildCachedIdentity(masterKey, deviceId);226  const cachedEntries: Record<string, { modifiedAt: number; ciphertext: string }> = {};227  try {228    for (const entry of getEntries()) {229      if (entry.deletedAt) continue;230      const key = await deriveEntryKey(masterKey, entry.id);231      cachedEntries[entry.id] = {232        modifiedAt: entry.modifiedAt,233        ciphertext: await encryptEntry(key, entry),234      };235    }236    const tombstones = getTombstoneManifest();237    // PB-26-104: the private key rides along because locked sync needs it, but238    // the spool is written to chrome.storage.session — never to disk. See239    // locked-sync-spool.ts for why session storage is the only home that240    // satisfies both constraints.241    const { longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;242    await replaceLockedSyncSpool({243      ...storedIdentity,244      localVersion: await getVaultVersion(),245      entries: cachedEntries,246      tombstones,247    });248  } finally {249    masterKey.fill(0);250    identity.longTermPrivateKeyBytes.fill(0);251  }252}253254export async function drainAndRefreshLockedSyncSpool(): Promise<void> {255  const live = createSyncVaultAdapter();256  await drainLockedSyncInbox(live);257  await refreshLockedSyncSpool();258}259260async function buildCachedIdentity(masterKey: Uint8Array, deviceId: string) {261  const vaultId = await computeVaultId(masterKey);262  const keys = deriveSyncIdentityKeypair(masterKey, deviceId);263  const deviceName = await getDeviceName();264  return {265    vaultId,266    deviceId,267    deviceName,268    deviceType: "extension" as const,269    longTermPublicKeyHex: pubkeyToHex(keys.publicKey),270    longTermPrivateKeyHex: bytesToHex(keys.secretKey),271    longTermPrivateKeyBytes: keys.secretKey,272  };273}274275function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {276  if (277    !device.deviceId ||278    !device.deviceName ||279    !device.deviceType ||280    !device.longTermPublicKeyHex ||281    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||282    !device.signingPublicKeyHex ||283    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)284  ) {285    return null;286  }287  return {288    deviceId: device.deviceId,289    deviceName: device.deviceName,290    deviceType: device.deviceType,291    longTermPublicKeyHex: device.longTermPublicKeyHex,292    signingPublicKeyHex: device.signingPublicKeyHex,293    wsPort: device.wsPort,294    relayUrl: device.relayUrl,295    pairedAt: device.pairedAt,296  };297}298299/**300 * Apply a device manifest issued by a peer we pair with directly.301 *302 * The issuer used to be required to be an "extension", on the assumption that303 * the extension was the mesh's trust anchor. It is not: the device a fresh304 * install joins through is the one holding the peer list to hand over.305 *306 * The issuer must already be admitted locally as either direct or vouched.307 * That is the decentralisation boundary: an unknown key has no session and no308 * authority, while every authenticated member can propagate the same signed309 * pool without the original hub online. A v3 entry is admitted only when it310 * carries a valid relay route; both endpoints independently derive the same311 * pair-only capability from their vault root and public keys.312 */313async function applyVerifiedPeerManifest(314  manifest: SignedDeviceManifest,315  authenticatedRemoteStaticPublicKey: Uint8Array,316): Promise<void> {317  const issuerId = manifest?.body?.issuerDeviceId;318  if (typeof issuerId !== "string" || !issuerId) { return; }319320  if (authenticatedRemoteStaticPublicKey.length !== 32) {321    return;322  }323  const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);324  const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);325  if (326    !issuer ||327    issuer.deviceId !== issuerId ||328    (329      issuer.trustSource !== "direct" &&330      issuer.trustSource !== "vouched"331    ) ||332    !issuer.signingPublicKeyHex ||333    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)334  ) {335    return;336  }337338  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;339  try {340    body = await verifyDeviceManifest(341      manifest,342      hexToPubkey(issuer!.signingPublicKeyHex!),343    );344  } catch {345    // Stored key may be stale (older builds persisted the key derived at346    // first-pair and never refreshed it). The signing keypair is deterministic347    // from (masterKey, deviceId) on both sides, so retry once with the locally348    // re-derived pubkey. A genuinely forged signature throws here and is349    // silently rejected — only a stale-but-correct key survives the retry.350    const masterForIssuer = requireMasterKey();351    try {352      const derived = await deriveSyncSigningKeypair(353        masterForIssuer,354        issuer.deviceId,355      );356      body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);357    } catch {358      return;359    } finally {360      masterForIssuer.fill(0);361    }362  }363  if (body.issuerDeviceId !== issuer.deviceId) {364    return;365  }366367  const ourDeviceId = await getOrCreateDeviceId();368  const masterKey = requireMasterKey();369  const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);370371  try {372    // Revocations are absorbed BEFORE any vouching, so a manifest that both373    // revokes and re-vouches the same device in one payload cannot race itself.374    // A peer may not revoke US — that would let one compromised device sever the375    // whole mesh — so our own id is filtered out first.376    const newlyRevoked = await mergeDeviceRevocations(377      body.revoked.filter((r) => r.deviceId !== ourDeviceId),378    );379    for (const deviceId of newlyRevoked) {380      await removePairedDeviceById(deviceId);381      await removePendingVouchedDevice(deviceId);382      void recordSyncAlerts([383        {384          type: "DEVICE_REVOKED",385          entryId: "",386          entryTitle: deviceId,387          deviceName: issuer.deviceName ?? "paired device",388          timestamp: Date.now(),389        },390      ]);391    }392393    for (const entry of body.devices) {394      if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {395        continue;396      }397      // Terminal: no manifest re-admits a revoked device. Matched on the key as398      // well as the id, because the id is the issuer's to choose — checking only399      // the id let a revoked key walk back in under a fresh one. The check sits400      // ahead of the existing-device lookup so it also refuses rotating a device401      // we still trust onto a revoked key.402      if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {403        continue;404      }405      const existing = await findPairedDeviceById(entry.deviceId);406      const meshRoute = deriveManifestMeshRoute(407        masterKey,408        localKeys.publicKey,409        entry,410      );411412      // A v3 manifest from an admitted, KK-authenticated issuer proves413      // same-vault membership and carries enough route material for the two414      // introduced endpoints to derive their own capability. Admit it415      // immediately so a six-device vault converges without keeping the416      // original pairing hub online.417      if (!existing) {418        if (!meshRoute) continue;419        await upsertPairedDevice({420          deviceId: entry.deviceId,421          deviceName: entry.deviceName,422          deviceType: entry.deviceType,423          longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),424          signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),425          wsPort: entry.wsPort,426          relayUrl: meshRoute.relayUrl,427          pairedAt: entry.pairedAt,428          trustSource: "vouched",429        });430        // PB-26-213: the secret bypasses the public registry into the431        // encrypted pair-secret record.432        await setPairSecret(entry.deviceId, meshRoute.rendezvousSecretHex, masterKey);433        await removePendingVouchedDevice(entry.deviceId);434        continue;435      }436437      // MV-8d: a key changing under a stable deviceId is legitimate after a438      // reinstall, but it is exactly what a graph-poisoning attack looks like —439      // signal the user either way, durably (the alert queue survives a closed440      // popup) and live (the broadcast updates an open one).441      if (442        existing.longTermPublicKeyHex &&443        !constantTimeEqualHex(444          existing.longTermPublicKeyHex,445          entry.longTermPublicKeyHex,446        )447      ) {448        // S5: name the device from the LOCAL record, never from the manifest.449        // The issuer is a third party here (its own entry is skipped above), so450        // `entry.deviceName` is an unverified claim about someone else — an451        // attacker who could put it on screen could make its own key rotation452        // read as a rotation on a device the user trusts more.453        const knownName = existing.deviceName ?? entry.deviceName;454        notifyPopup({455          type: "DEVICE_KEY_ROTATED",456          payload: { deviceName: knownName, deviceId: entry.deviceId },457        });458        void recordSyncAlerts([459          {460            type: "DEVICE_KEY_ROTATED",461            entryId: "",462            entryTitle: knownName,463            deviceName: knownName,464            timestamp: Date.now(),465          },466        ]);467      }468469      // Existing device — update in place as before. The name is deliberately470      // NOT taken from the manifest: a device's name is set by that device at471      // pairing, and letting a third-party issuer rewrite it is the rename-then-472      // impersonate half of S5. Self-renames never arrive here anyway.473      await upsertPairedDevice({474        ...existing,475        deviceId: entry.deviceId,476        deviceName: existing.deviceName ?? entry.deviceName,477        deviceType: entry.deviceType,478        longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),479        signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),480        pairedAt: existing.pairedAt ?? entry.pairedAt,481        trustSource: existing.trustSource,482        wsPort: existing.wsPort ?? entry.wsPort,483        relayUrl:484          existing.trustSource === "vouched" && meshRoute485            ? meshRoute.relayUrl486            : existing.relayUrl,487        lastSyncedAt: existing.lastSyncedAt,488      });489      if (existing.trustSource === "vouched" && meshRoute) {490        await setPairSecret(entry.deviceId, meshRoute.rendezvousSecretHex, masterKey);491      }492    }493  } finally {494    masterKey.fill(0);495    localKeys.secretKey.fill(0);496  }497}498499function deriveManifestMeshRoute(500  masterKey: Uint8Array,501  localPublicKey: Uint8Array,502  entry: ManifestDeviceEntry,503): { rendezvousSecretHex: string; relayUrl: string } | null {504  try {505    if (!entry.relayUrl) return null;506    const relayUrl = normalizeRelayUrl(entry.relayUrl);507    const secret = deriveMeshRendezvousSecret(508      masterKey,509      localPublicKey,510      hexToPubkey(entry.longTermPublicKeyHex),511    );512    try {513      return {514        rendezvousSecretHex: rendezvousBytesToHex(secret),515        relayUrl,516      };517    } finally {518      secret.fill(0);519    }520  } catch {521    return null;522  }523}524525function notifyPopup(msg: { type: string; payload?: unknown }): void {526  try {527    void chrome.runtime.sendMessage(msg).catch(() => undefined);528  } catch {529    /* popup closed — the durable sync-alert queue remains authoritative */530  }531}532533function requireUnlocked(): void {534  if (!isUnlocked()) throw new VaultLockedError();535}536537// Sync derives everything — vault_id, the Noise static key, the Ed25519538// manifest signing key, and the per-entry envelope keys — from the vault's539// stable root key, NOT the password-derived key. This keeps paired devices'540// identities and envelope keys constant across a master-password change on541// either side (SEV-2-B); two paired devices with independently-rotated542// passwords still share this key and can decrypt each other's entries.543function requireMasterKey(): Uint8Array {544  if (!isUnlocked()) throw new VaultLockedError();545  const hex = getRootKeyHex();546  if (!hex) throw new VaultLockedError();547  return hexToBytes(hex);548}549550async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {551  // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this552  // makes the (entryId, suffix) boundary unambiguous regardless of future id553  // formats. Coordinated wire change — peach-android VaultSyncAdapter derives554  // the identical info; the two MUST stay byte-for-byte in lockstep.555  const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);556  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);557  return crypto.subtle.importKey(558    "raw",559    raw.buffer as ArrayBuffer,560    { name: "AES-GCM" },561    false,562    ["encrypt", "decrypt"],563  );564}565566async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {567  const plaintext = new TextEncoder().encode(JSON.stringify(entry));568  const nonce = crypto.getRandomValues(new Uint8Array(12));569  const aad = new TextEncoder().encode(ENTRY_AAD);570  const ct = await crypto.subtle.encrypt(571    {572      name: "AES-GCM",573      iv: nonce.buffer as ArrayBuffer,574      additionalData: aad.buffer as ArrayBuffer,575      tagLength: 128,576    },577    key,578    plaintext.buffer as ArrayBuffer,579  );580  const ctBytes = new Uint8Array(ct);581  const envelope = new Uint8Array(nonce.length + ctBytes.length);582  envelope.set(nonce, 0);583  envelope.set(ctBytes, nonce.length);584  return bytesToBase64(envelope);585}586587async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {588  try {589    const bytes = base64ToBytes(envelopeB64);590    if (bytes.length < 12 + 16) return null;591    const nonce = bytes.subarray(0, 12);592    const ct = bytes.subarray(12);593    const aad = new TextEncoder().encode(ENTRY_AAD);594    const pt = await crypto.subtle.decrypt(595      {596        name: "AES-GCM",597        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,598        additionalData: aad.buffer as ArrayBuffer,599        tagLength: 128,600      },601      key,602      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,603    );604    const json = new TextDecoder().decode(pt);605    const parsed = JSON.parse(json);606    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;607    return parsed as VaultEntry;608  } catch {609    return null;610  }611}612613function hexToBytes(hex: string): Uint8Array {614  if (hex.length % 2 !== 0) throw new Error("hex length must be even");615  const out = new Uint8Array(hex.length / 2);616  for (let i = 0; i < out.length; i++) {617    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);618  }619  return out;620}621622function bytesToBase64(bytes: Uint8Array): string {623  let bin = "";624  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);625  return btoa(bin);626}627628function bytesToHex(bytes: Uint8Array): string {629  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");630}631632function base64ToBytes(b64: string): Uint8Array {633  const bin = atob(b64);634  const out = new Uint8Array(bin.length);635  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);636  return out;637}