sync-vault-adapter.ts

20.5 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  isDecoyMode,50} from "./vault";51import {52  findPairedDeviceById,53  findPairedDeviceByPubkey,54  isDeviceRevoked,55  listDeviceRevocations,56  listPairedDevices,57  upsertPairedDevicesAndMergeRevocations,58} from "./paired-devices";59import { setPairSecret } from "./pair-secrets";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 {72  MAX_VOUCH_DEPTH,73  type ManifestDeviceEntry,74  type PairedDevice,75  type 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      requireRealUnlocked();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      requireRealUnlocked();119      return getTombstoneManifest();120    },121122    async getEncryptedEntries(entryIds) {123      requireRealUnlocked();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      requireRealUnlocked();137      const entryList = Object.entries(entries);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        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) continue;147        const key = await deriveEntryKey(masterKey, id);148        const entry = await decryptEntryOrNull(key, envelope);149        if (!entry) continue;150        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) continue;151        if (entry.id !== id) continue;152        decrypted.push(entry);153      }154      if (decrypted.length > 0) {155        requireRealUnlocked();156        await applyMergedRemoteEntries(decrypted, {});157      }158    },159160    async applyTombstones(tombstones) {161      requireRealUnlocked();162      const tombstoneList = Object.entries(tombstones);163      if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {164        throw new Error(165          `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,166        );167      }168      const cleaned: Record<string, number> = {};169      for (const [id, ts] of tombstoneList) {170        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {171          cleaned[id] = clampSyncTimestamp(ts);172        }173      }174      if (Object.keys(cleaned).length > 0) {175        requireRealUnlocked();176        await applyMergedRemoteEntries([], cleaned);177      }178    },179180    async buildOutgoingDeviceManifest() {181      requireRealUnlocked();182      const masterKey = requireMasterKey();183      const issuerDeviceId = await getOrCreateDeviceId();184      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);185      const revoked = await listDeviceRevocations();186      const revokedIds = new Set(revoked.map((r) => r.deviceId));187      const devices = (await listPairedDevices())188        .filter((d) => !revokedIds.has(d.deviceId))189        .map(toManifestEntry)190        .filter((d): d is ManifestDeviceEntry => d !== null);191      return buildSignedDeviceManifest(192        issuerDeviceId,193        devices,194        signing.privateKey,195        { revoked },196      );197    },198199    async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {200      requireRealUnlocked();201      await applyVerifiedPeerManifest(202        manifest,203        authenticatedRemoteStaticPublicKey,204      );205    },206  };207}208209export async function refreshLockedSyncSpool(): Promise<void> {210  requireUnlocked();211  const masterKey = requireMasterKey();212  const deviceId = await getOrCreateDeviceId();213  const identity = await buildCachedIdentity(masterKey, deviceId);214  const cachedEntries: Record<string, { modifiedAt: number; ciphertext: string }> = {};215  try {216    for (const entry of getEntries()) {217      if (entry.deletedAt) continue;218      const key = await deriveEntryKey(masterKey, entry.id);219      cachedEntries[entry.id] = {220        modifiedAt: entry.modifiedAt,221        ciphertext: await encryptEntry(key, entry),222      };223    }224    const tombstones = getTombstoneManifest();225    // PB-26-104: the private key rides along because locked sync needs it, but226    // the spool is written to chrome.storage.session — never to disk. See227    // locked-sync-spool.ts for why session storage is the only home that228    // satisfies both constraints.229    const { longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;230    await replaceLockedSyncSpool({231      ...storedIdentity,232      localVersion: await getVaultVersion(),233      entries: cachedEntries,234      tombstones,235    });236  } finally {237    masterKey.fill(0);238    identity.longTermPrivateKeyBytes.fill(0);239  }240}241242export async function drainAndRefreshLockedSyncSpool(): Promise<void> {243  const live = createSyncVaultAdapter();244  await drainLockedSyncInbox(live);245  await refreshLockedSyncSpool();246}247248async function buildCachedIdentity(masterKey: Uint8Array, deviceId: string) {249  const vaultId = await computeVaultId(masterKey);250  const keys = deriveSyncIdentityKeypair(masterKey, deviceId);251  const deviceName = await getDeviceName();252  return {253    vaultId,254    deviceId,255    deviceName,256    deviceType: "extension" as const,257    longTermPublicKeyHex: pubkeyToHex(keys.publicKey),258    longTermPrivateKeyHex: bytesToHex(keys.secretKey),259    longTermPrivateKeyBytes: keys.secretKey,260  };261}262263function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {264  if (265    !device.deviceId ||266    !device.deviceName ||267    !device.deviceType ||268    !device.longTermPublicKeyHex ||269    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||270    !device.signingPublicKeyHex ||271    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)272  ) {273    return null;274  }275  return {276    deviceId: device.deviceId,277    deviceName: device.deviceName,278    deviceType: device.deviceType,279    longTermPublicKeyHex: device.longTermPublicKeyHex,280    signingPublicKeyHex: device.signingPublicKeyHex,281    wsPort: device.wsPort,282    relayUrl: device.relayUrl,283    pairedAt: device.pairedAt,284    vouchDepth: device.vouchDepth ?? 0,285  };286}287288/**289 * Apply a device manifest issued by a peer we pair with directly.290 *291 * The issuer used to be required to be an "extension", on the assumption that292 * the extension was the mesh's trust anchor. It is not: the device a fresh293 * install joins through is the one holding the peer list to hand over.294 *295 * The issuer must already be admitted locally as either direct or vouched.296 * That is the decentralisation boundary: an unknown key has no session and no297 * authority, while every authenticated member can propagate the same signed298 * pool without the original hub online. A v3 entry is admitted only when it299 * carries a valid relay route; both endpoints independently derive the same300 * pair-only capability from their vault root and public keys.301 */302async function applyVerifiedPeerManifest(303  manifest: SignedDeviceManifest,304  authenticatedRemoteStaticPublicKey: Uint8Array,305): Promise<void> {306  const issuerId = manifest?.body?.issuerDeviceId;307  if (typeof issuerId !== "string" || !issuerId) { return; }308309  if (authenticatedRemoteStaticPublicKey.length !== 32) {310    return;311  }312  const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);313  const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);314  if (315    !issuer ||316    issuer.deviceId !== issuerId ||317    (318      issuer.trustSource !== "direct" &&319      issuer.trustSource !== "vouched"320    ) ||321    (issuer.vouchDepth ?? 0) >= MAX_VOUCH_DEPTH ||322    !issuer.signingPublicKeyHex ||323    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)324  ) {325    return;326  }327328  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;329  try {330    body = await verifyDeviceManifest(331      manifest,332      hexToPubkey(issuer!.signingPublicKeyHex!),333    );334  } catch {335    // Stored key may be stale (older builds persisted the key derived at336    // first-pair and never refreshed it). The signing keypair is deterministic337    // from (masterKey, deviceId) on both sides, so retry once with the locally338    // re-derived pubkey. A genuinely forged signature throws here and is339    // silently rejected — only a stale-but-correct key survives the retry.340    const masterForIssuer = requireMasterKey();341    try {342      const derived = await deriveSyncSigningKeypair(343        masterForIssuer,344        issuer.deviceId,345      );346      body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);347    } catch {348      return;349    } finally {350      masterForIssuer.fill(0);351    }352  }353  if (body.issuerDeviceId !== issuer.deviceId) {354    return;355  }356357  const ourDeviceId = await getOrCreateDeviceId();358  const masterKey = requireMasterKey();359  const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);360  try {361    const pendingRegistry: PairedDevice[] = [];362    const pendingSecrets: { deviceId: string; secret: string }[] = [];363    const pendingVouchedIds: string[] = [];364    for (const entry of body.devices) {365      if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) continue;366      if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue;367      if ((entry.vouchDepth ?? 0) > (issuer.vouchDepth ?? 0) + 1) continue;368      const existing = await findPairedDeviceById(entry.deviceId);369      const meshRoute = deriveManifestMeshRoute(masterKey, localKeys.publicKey, entry);370      if (!existing) {371        if (!meshRoute) continue;372        pendingRegistry.push({373          deviceId: entry.deviceId,374          deviceName: entry.deviceName,375          deviceType: entry.deviceType,376          longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),377          signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),378          wsPort: entry.wsPort,379          relayUrl: meshRoute.relayUrl,380          pairedAt: entry.pairedAt,381          trustSource: "vouched",382          vouchDepth: (issuer.vouchDepth ?? 0) + 1,383        });384        pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });385        pendingVouchedIds.push(entry.deviceId);386        continue;387      }388      if (existing.longTermPublicKeyHex && !constantTimeEqualHex(existing.longTermPublicKeyHex, entry.longTermPublicKeyHex)) {389        const knownName = existing.deviceName ?? entry.deviceName;390        notifyPopup({ type: "DEVICE_KEY_ROTATED", payload: { deviceName: knownName, deviceId: entry.deviceId } });391        void recordSyncAlerts([{392          type: "DEVICE_KEY_ROTATED",393          entryId: "",394          entryTitle: knownName,395          deviceName: knownName,396          timestamp: Date.now(),397        }]);398      }399      pendingRegistry.push({400        ...existing,401        deviceId: entry.deviceId,402        deviceName: existing.deviceName ?? entry.deviceName,403        deviceType: entry.deviceType,404        longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),405        signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),406        pairedAt: existing.pairedAt ?? entry.pairedAt,407        trustSource: existing.trustSource,408        wsPort: existing.wsPort ?? entry.wsPort,409        relayUrl: existing.trustSource === "vouched" && meshRoute ? meshRoute.relayUrl : existing.relayUrl,410        lastSyncedAt: existing.lastSyncedAt,411      });412      if (existing.trustSource === "vouched" && meshRoute) {413        pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });414      }415    }416    const newlyRevoked = await upsertPairedDevicesAndMergeRevocations(417      pendingRegistry,418      body.revoked.filter((r) => r.deviceId !== ourDeviceId),419    );420    for (const deviceId of newlyRevoked) {421      await removePendingVouchedDevice(deviceId);422      void recordSyncAlerts([{423        type: "DEVICE_REVOKED",424        entryId: "",425        entryTitle: deviceId,426        deviceName: issuer.deviceName ?? "paired device",427        timestamp: Date.now(),428      }]);429    }430    for (const pending of pendingSecrets) {431      if (newlyRevoked.includes(pending.deviceId)) continue;432      await setPairSecret(pending.deviceId, pending.secret, masterKey);433    }434    for (const deviceId of pendingVouchedIds) {435      if (newlyRevoked.includes(deviceId)) continue;436      await removePendingVouchedDevice(deviceId);437    }438  } finally {439    masterKey.fill(0);440    localKeys.secretKey.fill(0);441  }442}443444function deriveManifestMeshRoute(445  masterKey: Uint8Array,446  localPublicKey: Uint8Array,447  entry: ManifestDeviceEntry,448): { rendezvousSecretHex: string; relayUrl: string } | null {449  try {450    if (!entry.relayUrl) return null;451    const relayUrl = normalizeRelayUrl(entry.relayUrl);452    const secret = deriveMeshRendezvousSecret(453      masterKey,454      localPublicKey,455      hexToPubkey(entry.longTermPublicKeyHex),456    );457    try {458      return {459        rendezvousSecretHex: rendezvousBytesToHex(secret),460        relayUrl,461      };462    } finally {463      secret.fill(0);464    }465  } catch {466    return null;467  }468}469470function notifyPopup(msg: { type: string; payload?: unknown }): void {471  try {472    void chrome.runtime.sendMessage(msg).catch(() => undefined);473  } catch {474    /* popup closed — the durable sync-alert queue remains authoritative */475  }476}477478function requireUnlocked(): void {479  if (!isUnlocked()) throw new VaultLockedError();480}481482function requireRealUnlocked(): void {483  if (!isUnlocked() || isDecoyMode()) throw new VaultLockedError();484}485486// Sync derives everything — vault_id, the Noise static key, the Ed25519487// manifest signing key, and the per-entry envelope keys — from the vault's488// stable root key, NOT the password-derived key. This keeps paired devices'489// identities and envelope keys constant across a master-password change on490// either side (SEV-2-B); two paired devices with independently-rotated491// passwords still share this key and can decrypt each other's entries.492function requireMasterKey(): Uint8Array {493  if (!isUnlocked()) throw new VaultLockedError();494  const hex = getRootKeyHex();495  if (!hex) throw new VaultLockedError();496  return hexToBytes(hex);497}498499async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {500  // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this501  // makes the (entryId, suffix) boundary unambiguous regardless of future id502  // formats. Coordinated wire change — peach-android VaultSyncAdapter derives503  // the identical info; the two MUST stay byte-for-byte in lockstep.504  const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);505  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);506  return crypto.subtle.importKey(507    "raw",508    raw.buffer as ArrayBuffer,509    { name: "AES-GCM" },510    false,511    ["encrypt", "decrypt"],512  );513}514515async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {516  const plaintext = new TextEncoder().encode(JSON.stringify(entry));517  const nonce = crypto.getRandomValues(new Uint8Array(12));518  const aad = new TextEncoder().encode(ENTRY_AAD);519  const ct = await crypto.subtle.encrypt(520    {521      name: "AES-GCM",522      iv: nonce.buffer as ArrayBuffer,523      additionalData: aad.buffer as ArrayBuffer,524      tagLength: 128,525    },526    key,527    plaintext.buffer as ArrayBuffer,528  );529  const ctBytes = new Uint8Array(ct);530  const envelope = new Uint8Array(nonce.length + ctBytes.length);531  envelope.set(nonce, 0);532  envelope.set(ctBytes, nonce.length);533  return bytesToBase64(envelope);534}535536async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {537  try {538    const bytes = base64ToBytes(envelopeB64);539    if (bytes.length < 12 + 16) return null;540    const nonce = bytes.subarray(0, 12);541    const ct = bytes.subarray(12);542    const aad = new TextEncoder().encode(ENTRY_AAD);543    const pt = await crypto.subtle.decrypt(544      {545        name: "AES-GCM",546        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,547        additionalData: aad.buffer as ArrayBuffer,548        tagLength: 128,549      },550      key,551      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,552    );553    const json = new TextDecoder().decode(pt);554    const parsed = JSON.parse(json);555    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;556    return parsed as VaultEntry;557  } catch {558    return null;559  }560}561562function hexToBytes(hex: string): Uint8Array {563  if (hex.length % 2 !== 0) throw new Error("hex length must be even");564  const out = new Uint8Array(hex.length / 2);565  for (let i = 0; i < out.length; i++) {566    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);567  }568  return out;569}570571function bytesToBase64(bytes: Uint8Array): string {572  let bin = "";573  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);574  return btoa(bin);575}576577function bytesToHex(bytes: Uint8Array): string {578  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");579}580581function base64ToBytes(b64: string): Uint8Array {582  const bin = atob(b64);583  const out = new Uint8Array(bin.length);584  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);585  return out;586}