sync-vault-adapter.ts

22.6 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 {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      requireUnlocked();118      return getTombstoneManifest();119    },120121    async getEncryptedEntries(entryIds) {122      requireUnlocked();123      const masterKey = requireMasterKey();124      const out: Record<string, string> = {};125      for (const id of entryIds) {126        const entry = getEntryById(id);127        if (!entry) continue;128        const key = await deriveEntryKey(masterKey, id);129        out[id] = await encryptEntry(key, entry);130      }131      return out;132    },133134    async applyEntries(entries) {135      requireUnlocked();136      const entryList = Object.entries(entries);137      // MV-3: bound the batch before any per-entry crypto work.138      if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {139        throw new Error(140          `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,141        );142      }143      const masterKey = requireMasterKey();144      const decrypted: VaultEntry[] = [];145      for (const [id, envelope] of entryList) {146        // MV-3: per-entry ceiling, checked on the (base64) envelope before147        // decrypting. Base64 inflates by 4/3, so allow that factor.148        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {149          continue; // oversized envelope — skip, never decrypt150        }151        const key = await deriveEntryKey(masterKey, id);152        const entry = await decryptEntryOrNull(key, envelope);153        if (!entry) continue;154        // MV-3: defense in depth — re-measure the decrypted plaintext.155        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {156          continue;157        }158        // The map key is the authority on id — refuse mismatched envelopes159        // so a swapped envelope can't poison a different entry slot.160        if (entry.id !== id) continue;161        decrypted.push(entry);162      }163      if (decrypted.length > 0) {164        await applyMergedRemoteEntries(decrypted, {}, context);165      }166    },167168    async applyTombstones(tombstones) {169      requireUnlocked();170      const tombstoneList = Object.entries(tombstones);171      // MV-3: bound the tombstone batch before processing.172      if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {173        throw new Error(174          `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,175        );176      }177      const cleaned: Record<string, number> = {};178      for (const [id, ts] of tombstoneList) {179        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {180          // HV-1c: clamp before the value participates in any comparison.181          // vault.ts clamps again defensively; clamping here keeps the182          // invariant local to the boundary where peer data enters.183          cleaned[id] = clampSyncTimestamp(ts);184        }185      }186      if (Object.keys(cleaned).length > 0) {187        await applyMergedRemoteEntries([], cleaned, context);188      }189    },190191    async buildOutgoingDeviceManifest() {192      requireUnlocked();193      const masterKey = requireMasterKey();194      const issuerDeviceId = await getOrCreateDeviceId();195      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);196      const revoked = await listDeviceRevocations();197      const revokedIds = new Set(revoked.map((r) => r.deviceId));198      const devices = (await listPairedDevices())199        .filter((d) => !revokedIds.has(d.deviceId))200        .map(toManifestEntry)201        .filter((d): d is ManifestDeviceEntry => d !== null);202      return buildSignedDeviceManifest(203        issuerDeviceId,204        devices,205        signing.privateKey,206        { revoked },207      );208    },209210    async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {211      requireUnlocked();212      await applyVerifiedPeerManifest(213        manifest,214        authenticatedRemoteStaticPublicKey,215      );216    },217  };218}219220export async function refreshLockedSyncSpool(): Promise<void> {221  requireUnlocked();222  const masterKey = requireMasterKey();223  const deviceId = await getOrCreateDeviceId();224  const identity = await buildCachedIdentity(masterKey, deviceId);225  const cachedEntries: Record<string, { modifiedAt: number; ciphertext: string }> = {};226  try {227    for (const entry of getEntries()) {228      if (entry.deletedAt) continue;229      const key = await deriveEntryKey(masterKey, entry.id);230      cachedEntries[entry.id] = {231        modifiedAt: entry.modifiedAt,232        ciphertext: await encryptEntry(key, entry),233      };234    }235    const tombstones = getTombstoneManifest();236    // PB-26-104: the private key rides along because locked sync needs it, but237    // the spool is written to chrome.storage.session — never to disk. See238    // locked-sync-spool.ts for why session storage is the only home that239    // satisfies both constraints.240    const { longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;241    await replaceLockedSyncSpool({242      ...storedIdentity,243      localVersion: await getVaultVersion(),244      entries: cachedEntries,245      tombstones,246    });247  } finally {248    masterKey.fill(0);249    identity.longTermPrivateKeyBytes.fill(0);250  }251}252253export async function drainAndRefreshLockedSyncSpool(): Promise<void> {254  const live = createSyncVaultAdapter();255  await drainLockedSyncInbox(live);256  await refreshLockedSyncSpool();257}258259async function buildCachedIdentity(masterKey: Uint8Array, deviceId: string) {260  const vaultId = await computeVaultId(masterKey);261  const keys = deriveSyncIdentityKeypair(masterKey, deviceId);262  const deviceName = await getDeviceName();263  return {264    vaultId,265    deviceId,266    deviceName,267    deviceType: "extension" as const,268    longTermPublicKeyHex: pubkeyToHex(keys.publicKey),269    longTermPrivateKeyHex: bytesToHex(keys.secretKey),270    longTermPrivateKeyBytes: keys.secretKey,271  };272}273274function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {275  if (276    !device.deviceId ||277    !device.deviceName ||278    !device.deviceType ||279    !device.longTermPublicKeyHex ||280    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||281    !device.signingPublicKeyHex ||282    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)283  ) {284    return null;285  }286  return {287    deviceId: device.deviceId,288    deviceName: device.deviceName,289    deviceType: device.deviceType,290    longTermPublicKeyHex: device.longTermPublicKeyHex,291    signingPublicKeyHex: device.signingPublicKeyHex,292    wsPort: device.wsPort,293    relayUrl: device.relayUrl,294    pairedAt: device.pairedAt,295  };296}297298/**299 * Apply a device manifest issued by a peer we pair with directly.300 *301 * The issuer used to be required to be an "extension", on the assumption that302 * the extension was the mesh's trust anchor. It is not: the device a fresh303 * install joins through is the one holding the peer list to hand over.304 *305 * The issuer must already be admitted locally as either direct or vouched.306 * That is the decentralisation boundary: an unknown key has no session and no307 * authority, while every authenticated member can propagate the same signed308 * pool without the original hub online. A v3 entry is admitted only when it309 * carries a valid relay route; both endpoints independently derive the same310 * pair-only capability from their vault root and public keys.311 */312async function applyVerifiedPeerManifest(313  manifest: SignedDeviceManifest,314  authenticatedRemoteStaticPublicKey: Uint8Array,315): Promise<void> {316  const issuerId = manifest?.body?.issuerDeviceId;317  if (typeof issuerId !== "string" || !issuerId) { return; }318319  if (authenticatedRemoteStaticPublicKey.length !== 32) {320    return;321  }322  const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);323  const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);324  if (325    !issuer ||326    issuer.deviceId !== issuerId ||327    (328      issuer.trustSource !== "direct" &&329      issuer.trustSource !== "vouched"330    ) ||331    !issuer.signingPublicKeyHex ||332    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)333  ) {334    return;335  }336337  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;338  try {339    body = await verifyDeviceManifest(340      manifest,341      hexToPubkey(issuer!.signingPublicKeyHex!),342    );343  } catch {344    // Stored key may be stale (older builds persisted the key derived at345    // first-pair and never refreshed it). The signing keypair is deterministic346    // from (masterKey, deviceId) on both sides, so retry once with the locally347    // re-derived pubkey. A genuinely forged signature throws here and is348    // silently rejected — only a stale-but-correct key survives the retry.349    const masterForIssuer = requireMasterKey();350    try {351      const derived = await deriveSyncSigningKeypair(352        masterForIssuer,353        issuer.deviceId,354      );355      body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);356    } catch {357      return;358    } finally {359      masterForIssuer.fill(0);360    }361  }362  if (body.issuerDeviceId !== issuer.deviceId) {363    return;364  }365366  const ourDeviceId = await getOrCreateDeviceId();367  const masterKey = requireMasterKey();368  const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);369370  try {371    // Revocations are absorbed BEFORE any vouching, so a manifest that both372    // revokes and re-vouches the same device in one payload cannot race itself.373    // A peer may not revoke US — that would let one compromised device sever the374    // whole mesh — so our own id is filtered out first.375    const newlyRevoked = await mergeDeviceRevocations(376      body.revoked.filter((r) => r.deviceId !== ourDeviceId),377    );378    for (const deviceId of newlyRevoked) {379      await removePairedDeviceById(deviceId);380      await removePendingVouchedDevice(deviceId);381      void recordSyncAlerts([382        {383          type: "DEVICE_REVOKED",384          entryId: "",385          entryTitle: deviceId,386          deviceName: issuer.deviceName ?? "paired device",387          timestamp: Date.now(),388        },389      ]);390    }391392    for (const entry of body.devices) {393      if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {394        continue;395      }396      // Terminal: no manifest re-admits a revoked device. Matched on the key as397      // well as the id, because the id is the issuer's to choose — checking only398      // the id let a revoked key walk back in under a fresh one. The check sits399      // ahead of the existing-device lookup so it also refuses rotating a device400      // we still trust onto a revoked key.401      if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {402        continue;403      }404      const existing = await findPairedDeviceById(entry.deviceId);405      const meshRoute = deriveManifestMeshRoute(406        masterKey,407        localKeys.publicKey,408        entry,409      );410411      // A v3 manifest from an admitted, KK-authenticated issuer proves412      // same-vault membership and carries enough route material for the two413      // introduced endpoints to derive their own capability. Admit it414      // immediately so a six-device vault converges without keeping the415      // original pairing hub online.416      if (!existing) {417        if (!meshRoute) continue;418        await upsertPairedDevice({419          deviceId: entry.deviceId,420          deviceName: entry.deviceName,421          deviceType: entry.deviceType,422          longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),423          signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),424          wsPort: entry.wsPort,425          rendezvousSecretHex: meshRoute.rendezvousSecretHex,426          relayUrl: meshRoute.relayUrl,427          pairedAt: entry.pairedAt,428          trustSource: "vouched",429        });430        await removePendingVouchedDevice(entry.deviceId);431        continue;432      }433434      // MV-8d: a key changing under a stable deviceId is legitimate after a435      // reinstall, but it is exactly what a graph-poisoning attack looks like —436      // signal the user either way, durably (the alert queue survives a closed437      // popup) and live (the broadcast updates an open one).438      if (439        existing.longTermPublicKeyHex &&440        !constantTimeEqualHex(441          existing.longTermPublicKeyHex,442          entry.longTermPublicKeyHex,443        )444      ) {445        // S5: name the device from the LOCAL record, never from the manifest.446        // The issuer is a third party here (its own entry is skipped above), so447        // `entry.deviceName` is an unverified claim about someone else — an448        // attacker who could put it on screen could make its own key rotation449        // read as a rotation on a device the user trusts more.450        const knownName = existing.deviceName ?? entry.deviceName;451        notifyPopup({452          type: "DEVICE_KEY_ROTATED",453          payload: { deviceName: knownName, deviceId: entry.deviceId },454        });455        void recordSyncAlerts([456          {457            type: "DEVICE_KEY_ROTATED",458            entryId: "",459            entryTitle: knownName,460            deviceName: knownName,461            timestamp: Date.now(),462          },463        ]);464      }465466      // Existing device — update in place as before. The name is deliberately467      // NOT taken from the manifest: a device's name is set by that device at468      // pairing, and letting a third-party issuer rewrite it is the rename-then-469      // impersonate half of S5. Self-renames never arrive here anyway.470      await upsertPairedDevice({471        ...existing,472        deviceId: entry.deviceId,473        deviceName: existing.deviceName ?? entry.deviceName,474        deviceType: entry.deviceType,475        longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),476        signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),477        pairedAt: existing.pairedAt ?? entry.pairedAt,478        trustSource: existing.trustSource,479        wsPort: existing.wsPort ?? entry.wsPort,480        rendezvousSecretHex:481          existing.trustSource === "vouched" && meshRoute482            ? meshRoute.rendezvousSecretHex483            : existing.rendezvousSecretHex,484        relayUrl:485          existing.trustSource === "vouched" && meshRoute486            ? meshRoute.relayUrl487            : existing.relayUrl,488        lastSyncedAt: existing.lastSyncedAt,489      });490    }491  } finally {492    masterKey.fill(0);493    localKeys.secretKey.fill(0);494  }495}496497function deriveManifestMeshRoute(498  masterKey: Uint8Array,499  localPublicKey: Uint8Array,500  entry: ManifestDeviceEntry,501): { rendezvousSecretHex: string; relayUrl: string } | null {502  try {503    if (!entry.relayUrl) return null;504    const relayUrl = normalizeRelayUrl(entry.relayUrl);505    const secret = deriveMeshRendezvousSecret(506      masterKey,507      localPublicKey,508      hexToPubkey(entry.longTermPublicKeyHex),509    );510    try {511      return {512        rendezvousSecretHex: rendezvousBytesToHex(secret),513        relayUrl,514      };515    } finally {516      secret.fill(0);517    }518  } catch {519    return null;520  }521}522523function notifyPopup(msg: { type: string; payload?: unknown }): void {524  try {525    void chrome.runtime.sendMessage(msg).catch(() => undefined);526  } catch {527    /* popup closed — the durable sync-alert queue remains authoritative */528  }529}530531function requireUnlocked(): void {532  if (!isUnlocked()) throw new VaultLockedError();533}534535// Sync derives everything — vault_id, the Noise static key, the Ed25519536// manifest signing key, and the per-entry envelope keys — from the vault's537// stable root key, NOT the password-derived key. This keeps paired devices'538// identities and envelope keys constant across a master-password change on539// either side (SEV-2-B); two paired devices with independently-rotated540// passwords still share this key and can decrypt each other's entries.541function requireMasterKey(): Uint8Array {542  if (!isUnlocked()) throw new VaultLockedError();543  const hex = getRootKeyHex();544  if (!hex) throw new VaultLockedError();545  return hexToBytes(hex);546}547548async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {549  // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this550  // makes the (entryId, suffix) boundary unambiguous regardless of future id551  // formats. Coordinated wire change — peach-android VaultSyncAdapter derives552  // the identical info; the two MUST stay byte-for-byte in lockstep.553  const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);554  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);555  return crypto.subtle.importKey(556    "raw",557    raw.buffer as ArrayBuffer,558    { name: "AES-GCM" },559    false,560    ["encrypt", "decrypt"],561  );562}563564async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {565  const plaintext = new TextEncoder().encode(JSON.stringify(entry));566  const nonce = crypto.getRandomValues(new Uint8Array(12));567  const aad = new TextEncoder().encode(ENTRY_AAD);568  const ct = await crypto.subtle.encrypt(569    {570      name: "AES-GCM",571      iv: nonce.buffer as ArrayBuffer,572      additionalData: aad.buffer as ArrayBuffer,573      tagLength: 128,574    },575    key,576    plaintext.buffer as ArrayBuffer,577  );578  const ctBytes = new Uint8Array(ct);579  const envelope = new Uint8Array(nonce.length + ctBytes.length);580  envelope.set(nonce, 0);581  envelope.set(ctBytes, nonce.length);582  return bytesToBase64(envelope);583}584585async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {586  try {587    const bytes = base64ToBytes(envelopeB64);588    if (bytes.length < 12 + 16) return null;589    const nonce = bytes.subarray(0, 12);590    const ct = bytes.subarray(12);591    const aad = new TextEncoder().encode(ENTRY_AAD);592    const pt = await crypto.subtle.decrypt(593      {594        name: "AES-GCM",595        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,596        additionalData: aad.buffer as ArrayBuffer,597        tagLength: 128,598      },599      key,600      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,601    );602    const json = new TextDecoder().decode(pt);603    const parsed = JSON.parse(json);604    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;605    return parsed as VaultEntry;606  } catch {607    return null;608  }609}610611function hexToBytes(hex: string): Uint8Array {612  if (hex.length % 2 !== 0) throw new Error("hex length must be even");613  const out = new Uint8Array(hex.length / 2);614  for (let i = 0; i < out.length; i++) {615    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);616  }617  return out;618}619620function bytesToBase64(bytes: Uint8Array): string {621  let bin = "";622  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);623  return btoa(bin);624}625626function bytesToHex(bytes: Uint8Array): string {627  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");628}629630function base64ToBytes(b64: string): Uint8Array {631  const bin = atob(b64);632  const out = new Uint8Array(bin.length);633  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);634  return out;635}