sync-vault-adapter.ts

23.0 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 {73  MAX_VOUCH_DEPTH,74  type ManifestDeviceEntry,75  type PairedDevice,76  type SignedDeviceManifest,77} from "../shared/sync-types";78import { replaceLockedSyncSpool, drainLockedSyncInbox } from "./locked-sync-spool";7980// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1").81// The info binding prevents a ciphertext from one entry slot being replayed into another.82const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";83const ENTRY_AAD = "peach-sync-entry-v1";8485export class VaultLockedError extends Error {86  constructor() {87    super("vault_locked");88    this.name = "VaultLockedError";89  }90}9192export function createSyncVaultAdapter(93  _context: { deviceName?: string } = {},94): SyncVaultAdapter {95  return {96    async computeVaultId() {97      const master = requireMasterKey();98      return computeVaultId(master);99    },100101    async getOurDeviceId() {102      return getOrCreateDeviceId();103    },104105    async getLocalVersion() {106      return getVaultVersion();107    },108109    async buildEntryManifest() {110      requireUnlocked();111      const manifest: Record<string, number> = {};112      for (const entry of getEntries()) {113        manifest[entry.id] = entry.modifiedAt;114      }115      return manifest;116    },117118    async buildTombstoneManifest() {119      requireUnlocked();120      return getTombstoneManifest();121    },122123    async getEncryptedEntries(entryIds) {124      requireUnlocked();125      const masterKey = requireMasterKey();126      const out: Record<string, string> = {};127      for (const id of entryIds) {128        const entry = getEntryById(id);129        if (!entry) continue;130        const key = await deriveEntryKey(masterKey, id);131        out[id] = await encryptEntry(key, entry);132      }133      return out;134    },135136    async applyEntries(entries) {137      requireUnlocked();138      const entryList = Object.entries(entries);139      // MV-3: bound the batch before any per-entry crypto work.140      if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {141        throw new Error(142          `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,143        );144      }145      const masterKey = requireMasterKey();146      const decrypted: VaultEntry[] = [];147      for (const [id, envelope] of entryList) {148        // MV-3: per-entry ceiling, checked on the (base64) envelope before149        // decrypting. Base64 inflates by 4/3, so allow that factor.150        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {151          continue; // oversized envelope — skip, never decrypt152        }153        const key = await deriveEntryKey(masterKey, id);154        const entry = await decryptEntryOrNull(key, envelope);155        if (!entry) continue;156        // MV-3: defense in depth — re-measure the decrypted plaintext.157        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {158          continue;159        }160        // The map key is the authority on id — refuse mismatched envelopes161        // so a swapped envelope can't poison a different entry slot.162        if (entry.id !== id) continue;163        decrypted.push(entry);164      }165      if (decrypted.length > 0) {166        await applyMergedRemoteEntries(decrypted, {});167      }168    },169170    async applyTombstones(tombstones) {171      requireUnlocked();172      const tombstoneList = Object.entries(tombstones);173      // MV-3: bound the tombstone batch before processing.174      if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {175        throw new Error(176          `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,177        );178      }179      const cleaned: Record<string, number> = {};180      for (const [id, ts] of tombstoneList) {181        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {182          // HV-1c: clamp before the value participates in any comparison.183          // vault.ts clamps again defensively; clamping here keeps the184          // invariant local to the boundary where peer data enters.185          cleaned[id] = clampSyncTimestamp(ts);186        }187      }188      if (Object.keys(cleaned).length > 0) {189        await applyMergedRemoteEntries([], cleaned);190      }191    },192193    async buildOutgoingDeviceManifest() {194      requireUnlocked();195      const masterKey = requireMasterKey();196      const issuerDeviceId = await getOrCreateDeviceId();197      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);198      const revoked = await listDeviceRevocations();199      const revokedIds = new Set(revoked.map((r) => r.deviceId));200      const devices = (await listPairedDevices())201        .filter((d) => !revokedIds.has(d.deviceId))202        .map(toManifestEntry)203        .filter((d): d is ManifestDeviceEntry => d !== null);204      return buildSignedDeviceManifest(205        issuerDeviceId,206        devices,207        signing.privateKey,208        { revoked },209      );210    },211212    async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {213      requireUnlocked();214      await applyVerifiedPeerManifest(215        manifest,216        authenticatedRemoteStaticPublicKey,217      );218    },219  };220}221222export async function refreshLockedSyncSpool(): Promise<void> {223  requireUnlocked();224  const masterKey = requireMasterKey();225  const deviceId = await getOrCreateDeviceId();226  const identity = await buildCachedIdentity(masterKey, deviceId);227  const cachedEntries: Record<string, { modifiedAt: number; ciphertext: string }> = {};228  try {229    for (const entry of getEntries()) {230      if (entry.deletedAt) continue;231      const key = await deriveEntryKey(masterKey, entry.id);232      cachedEntries[entry.id] = {233        modifiedAt: entry.modifiedAt,234        ciphertext: await encryptEntry(key, entry),235      };236    }237    const tombstones = getTombstoneManifest();238    // PB-26-104: the private key rides along because locked sync needs it, but239    // the spool is written to chrome.storage.session — never to disk. See240    // locked-sync-spool.ts for why session storage is the only home that241    // satisfies both constraints.242    const { longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;243    await replaceLockedSyncSpool({244      ...storedIdentity,245      localVersion: await getVaultVersion(),246      entries: cachedEntries,247      tombstones,248    });249  } finally {250    masterKey.fill(0);251    identity.longTermPrivateKeyBytes.fill(0);252  }253}254255export async function drainAndRefreshLockedSyncSpool(): Promise<void> {256  const live = createSyncVaultAdapter();257  await drainLockedSyncInbox(live);258  await refreshLockedSyncSpool();259}260261async function buildCachedIdentity(masterKey: Uint8Array, deviceId: string) {262  const vaultId = await computeVaultId(masterKey);263  const keys = deriveSyncIdentityKeypair(masterKey, deviceId);264  const deviceName = await getDeviceName();265  return {266    vaultId,267    deviceId,268    deviceName,269    deviceType: "extension" as const,270    longTermPublicKeyHex: pubkeyToHex(keys.publicKey),271    longTermPrivateKeyHex: bytesToHex(keys.secretKey),272    longTermPrivateKeyBytes: keys.secretKey,273  };274}275276function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {277  if (278    !device.deviceId ||279    !device.deviceName ||280    !device.deviceType ||281    !device.longTermPublicKeyHex ||282    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||283    !device.signingPublicKeyHex ||284    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)285  ) {286    return null;287  }288  return {289    deviceId: device.deviceId,290    deviceName: device.deviceName,291    deviceType: device.deviceType,292    longTermPublicKeyHex: device.longTermPublicKeyHex,293    signingPublicKeyHex: device.signingPublicKeyHex,294    wsPort: device.wsPort,295    relayUrl: device.relayUrl,296    pairedAt: device.pairedAt,297    vouchDepth: device.vouchDepth ?? 0,298  };299}300301/**302 * Apply a device manifest issued by a peer we pair with directly.303 *304 * The issuer used to be required to be an "extension", on the assumption that305 * the extension was the mesh's trust anchor. It is not: the device a fresh306 * install joins through is the one holding the peer list to hand over.307 *308 * The issuer must already be admitted locally as either direct or vouched.309 * That is the decentralisation boundary: an unknown key has no session and no310 * authority, while every authenticated member can propagate the same signed311 * pool without the original hub online. A v3 entry is admitted only when it312 * carries a valid relay route; both endpoints independently derive the same313 * pair-only capability from their vault root and public keys.314 */315async function applyVerifiedPeerManifest(316  manifest: SignedDeviceManifest,317  authenticatedRemoteStaticPublicKey: Uint8Array,318): Promise<void> {319  const issuerId = manifest?.body?.issuerDeviceId;320  if (typeof issuerId !== "string" || !issuerId) { return; }321322  if (authenticatedRemoteStaticPublicKey.length !== 32) {323    return;324  }325  const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);326  const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);327  if (328    !issuer ||329    issuer.deviceId !== issuerId ||330    (331      issuer.trustSource !== "direct" &&332      issuer.trustSource !== "vouched"333    ) ||334    (issuer.vouchDepth ?? 0) >= MAX_VOUCH_DEPTH ||335    !issuer.signingPublicKeyHex ||336    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)337  ) {338    return;339  }340341  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;342  try {343    body = await verifyDeviceManifest(344      manifest,345      hexToPubkey(issuer!.signingPublicKeyHex!),346    );347  } catch {348    // Stored key may be stale (older builds persisted the key derived at349    // first-pair and never refreshed it). The signing keypair is deterministic350    // from (masterKey, deviceId) on both sides, so retry once with the locally351    // re-derived pubkey. A genuinely forged signature throws here and is352    // silently rejected — only a stale-but-correct key survives the retry.353    const masterForIssuer = requireMasterKey();354    try {355      const derived = await deriveSyncSigningKeypair(356        masterForIssuer,357        issuer.deviceId,358      );359      body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);360    } catch {361      return;362    } finally {363      masterForIssuer.fill(0);364    }365  }366  if (body.issuerDeviceId !== issuer.deviceId) {367    return;368  }369370  const ourDeviceId = await getOrCreateDeviceId();371  const masterKey = requireMasterKey();372  const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);373374  try {375    // Revocations are absorbed BEFORE any vouching, so a manifest that both376    // revokes and re-vouches the same device in one payload cannot race itself.377    // A peer may not revoke US — that would let one compromised device sever the378    // whole mesh — so our own id is filtered out first.379    const newlyRevoked = await mergeDeviceRevocations(380      body.revoked.filter((r) => r.deviceId !== ourDeviceId),381    );382    for (const deviceId of newlyRevoked) {383      await removePairedDeviceById(deviceId);384      await removePendingVouchedDevice(deviceId);385      void recordSyncAlerts([386        {387          type: "DEVICE_REVOKED",388          entryId: "",389          entryTitle: deviceId,390          deviceName: issuer.deviceName ?? "paired device",391          timestamp: Date.now(),392        },393      ]);394    }395396    for (const entry of body.devices) {397      if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {398        continue;399      }400      // Terminal: no manifest re-admits a revoked device. Matched on the key as401      // well as the id, because the id is the issuer's to choose — checking only402      // the id let a revoked key walk back in under a fresh one. The check sits403      // ahead of the existing-device lookup so it also refuses rotating a device404      // we still trust onto a revoked key.405      if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {406        continue;407      }408      if ((entry.vouchDepth ?? 0) > (issuer.vouchDepth ?? 0) + 1) {409        continue;410      }411      const existing = await findPairedDeviceById(entry.deviceId);412      const meshRoute = deriveManifestMeshRoute(413        masterKey,414        localKeys.publicKey,415        entry,416      );417418      // A v3 manifest from an admitted, KK-authenticated issuer proves419      // same-vault membership and carries enough route material for the two420      // introduced endpoints to derive their own capability. Admit it421      // immediately so a six-device vault converges without keeping the422      // original pairing hub online.423      if (!existing) {424        if (!meshRoute) continue;425        await upsertPairedDevice({426          deviceId: entry.deviceId,427          deviceName: entry.deviceName,428          deviceType: entry.deviceType,429          longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),430          signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),431          wsPort: entry.wsPort,432          relayUrl: meshRoute.relayUrl,433          pairedAt: entry.pairedAt,434          trustSource: "vouched",435          vouchDepth: (issuer.vouchDepth ?? 0) + 1,436        });437        // PB-26-213: the secret bypasses the public registry into the438        // encrypted pair-secret record.439        await setPairSecret(entry.deviceId, meshRoute.rendezvousSecretHex, masterKey);440        await removePendingVouchedDevice(entry.deviceId);441        continue;442      }443444      // MV-8d: a key changing under a stable deviceId is legitimate after a445      // reinstall, but it is exactly what a graph-poisoning attack looks like —446      // signal the user either way, durably (the alert queue survives a closed447      // popup) and live (the broadcast updates an open one).448      if (449        existing.longTermPublicKeyHex &&450        !constantTimeEqualHex(451          existing.longTermPublicKeyHex,452          entry.longTermPublicKeyHex,453        )454      ) {455        // S5: name the device from the LOCAL record, never from the manifest.456        // The issuer is a third party here (its own entry is skipped above), so457        // `entry.deviceName` is an unverified claim about someone else — an458        // attacker who could put it on screen could make its own key rotation459        // read as a rotation on a device the user trusts more.460        const knownName = existing.deviceName ?? entry.deviceName;461        notifyPopup({462          type: "DEVICE_KEY_ROTATED",463          payload: { deviceName: knownName, deviceId: entry.deviceId },464        });465        void recordSyncAlerts([466          {467            type: "DEVICE_KEY_ROTATED",468            entryId: "",469            entryTitle: knownName,470            deviceName: knownName,471            timestamp: Date.now(),472          },473        ]);474      }475476      // Existing device — update in place as before. The name is deliberately477      // NOT taken from the manifest: a device's name is set by that device at478      // pairing, and letting a third-party issuer rewrite it is the rename-then-479      // impersonate half of S5. Self-renames never arrive here anyway.480      await upsertPairedDevice({481        ...existing,482        deviceId: entry.deviceId,483        deviceName: existing.deviceName ?? entry.deviceName,484        deviceType: entry.deviceType,485        longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),486        signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),487        pairedAt: existing.pairedAt ?? entry.pairedAt,488        trustSource: existing.trustSource,489        wsPort: existing.wsPort ?? entry.wsPort,490        relayUrl:491          existing.trustSource === "vouched" && meshRoute492            ? meshRoute.relayUrl493            : existing.relayUrl,494        lastSyncedAt: existing.lastSyncedAt,495      });496      if (existing.trustSource === "vouched" && meshRoute) {497        await setPairSecret(entry.deviceId, meshRoute.rendezvousSecretHex, masterKey);498      }499    }500  } finally {501    masterKey.fill(0);502    localKeys.secretKey.fill(0);503  }504}505506function deriveManifestMeshRoute(507  masterKey: Uint8Array,508  localPublicKey: Uint8Array,509  entry: ManifestDeviceEntry,510): { rendezvousSecretHex: string; relayUrl: string } | null {511  try {512    if (!entry.relayUrl) return null;513    const relayUrl = normalizeRelayUrl(entry.relayUrl);514    const secret = deriveMeshRendezvousSecret(515      masterKey,516      localPublicKey,517      hexToPubkey(entry.longTermPublicKeyHex),518    );519    try {520      return {521        rendezvousSecretHex: rendezvousBytesToHex(secret),522        relayUrl,523      };524    } finally {525      secret.fill(0);526    }527  } catch {528    return null;529  }530}531532function notifyPopup(msg: { type: string; payload?: unknown }): void {533  try {534    void chrome.runtime.sendMessage(msg).catch(() => undefined);535  } catch {536    /* popup closed — the durable sync-alert queue remains authoritative */537  }538}539540function requireUnlocked(): void {541  if (!isUnlocked()) throw new VaultLockedError();542}543544// Sync derives everything — vault_id, the Noise static key, the Ed25519545// manifest signing key, and the per-entry envelope keys — from the vault's546// stable root key, NOT the password-derived key. This keeps paired devices'547// identities and envelope keys constant across a master-password change on548// either side (SEV-2-B); two paired devices with independently-rotated549// passwords still share this key and can decrypt each other's entries.550function requireMasterKey(): Uint8Array {551  if (!isUnlocked()) throw new VaultLockedError();552  const hex = getRootKeyHex();553  if (!hex) throw new VaultLockedError();554  return hexToBytes(hex);555}556557async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {558  // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this559  // makes the (entryId, suffix) boundary unambiguous regardless of future id560  // formats. Coordinated wire change — peach-android VaultSyncAdapter derives561  // the identical info; the two MUST stay byte-for-byte in lockstep.562  const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);563  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);564  return crypto.subtle.importKey(565    "raw",566    raw.buffer as ArrayBuffer,567    { name: "AES-GCM" },568    false,569    ["encrypt", "decrypt"],570  );571}572573async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {574  const plaintext = new TextEncoder().encode(JSON.stringify(entry));575  const nonce = crypto.getRandomValues(new Uint8Array(12));576  const aad = new TextEncoder().encode(ENTRY_AAD);577  const ct = await crypto.subtle.encrypt(578    {579      name: "AES-GCM",580      iv: nonce.buffer as ArrayBuffer,581      additionalData: aad.buffer as ArrayBuffer,582      tagLength: 128,583    },584    key,585    plaintext.buffer as ArrayBuffer,586  );587  const ctBytes = new Uint8Array(ct);588  const envelope = new Uint8Array(nonce.length + ctBytes.length);589  envelope.set(nonce, 0);590  envelope.set(ctBytes, nonce.length);591  return bytesToBase64(envelope);592}593594async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {595  try {596    const bytes = base64ToBytes(envelopeB64);597    if (bytes.length < 12 + 16) return null;598    const nonce = bytes.subarray(0, 12);599    const ct = bytes.subarray(12);600    const aad = new TextEncoder().encode(ENTRY_AAD);601    const pt = await crypto.subtle.decrypt(602      {603        name: "AES-GCM",604        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,605        additionalData: aad.buffer as ArrayBuffer,606        tagLength: 128,607      },608      key,609      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,610    );611    const json = new TextDecoder().decode(pt);612    const parsed = JSON.parse(json);613    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;614    return parsed as VaultEntry;615  } catch {616    return null;617  }618}619620function hexToBytes(hex: string): Uint8Array {621  if (hex.length % 2 !== 0) throw new Error("hex length must be even");622  const out = new Uint8Array(hex.length / 2);623  for (let i = 0; i < out.length; i++) {624    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);625  }626  return out;627}628629function bytesToBase64(bytes: Uint8Array): string {630  let bin = "";631  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);632  return btoa(bin);633}634635function bytesToHex(bytes: Uint8Array): string {636  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");637}638639function base64ToBytes(b64: string): Uint8Array {640  const bin = atob(b64);641  const out = new Uint8Array(bin.length);642  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);643  return out;644}