chirp-sync-vault-adapter.ts

10.3 KB · raw

1// Production adapter that backs `runChirpSyncInitiator` with the live vault.2//3// The coordinator (chirp-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(key = HKDF(masterKey, salt="peach-sync-entry-v1"),10//                          nonce = random 12B, aad = "peach-sync-entry-v1").11// Both sides hold the same masterKey (same vault) so the symmetric key12// matches without prior negotiation. The Noise XX session encrypts the13// transport on top of this — the per-entry envelope is belt-and-braces so a14// transport-layer leak doesn't expose plaintext entries.15//16// Device-manifest exchange (v2 brief §4.3) is plumbed here. The extension is17// the trust anchor: manifests are signed with its vault-derived Ed25519 key,18// and receivers only apply manifests whose issuer is a paired extension.1920import { HKDF } from "@stablelib/hkdf";21import { SHA256 } from "@stablelib/sha256";22import type { ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";23import { getOrCreateDeviceId } from "./chirp-identity";24import {25  computeVaultId,26  deriveSyncSigningKeypair,27  hexToPubkey,28} from "../shared/sync-identity";29import {30  buildSignedDeviceManifest,31  verifyDeviceManifest,32} from "../shared/device-manifest";33import {34  applyMergedRemoteEntries,35  getDeletedEntries,36  getDerivedKeyHex,37  getEntries,38  getEntryById,39  getVaultVersion,40  isUnlocked,41} from "./vault";42import {43  findPairedDeviceById,44  listPairedDevices,45  upsertPairedDevice,46} from "./paired-devices";47import type { VaultEntry } from "../shared/types";48import type {49  ManifestDeviceEntry,50  PairedDevice,51  SignedDeviceManifest,52} from "../shared/sync-types";5354// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "sync-entry-v1").55// The info binding prevents a ciphertext from one entry slot being replayed into another.56const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";57const ENTRY_AAD = "peach-sync-entry-v1";5859export class VaultLockedError extends Error {60  constructor() {61    super("vault_locked");62    this.name = "VaultLockedError";63  }64}6566export function createChirpSyncVaultAdapter(): ChirpSyncVaultAdapter {67  return {68    async computeVaultId() {69      const master = requireMasterKey();70      return computeVaultId(master);71    },7273    async getOurDeviceId() {74      return getOrCreateDeviceId();75    },7677    async getLocalVersion() {78      return getVaultVersion();79    },8081    async buildEntryManifest() {82      requireUnlocked();83      const manifest: Record<string, number> = {};84      for (const entry of getEntries()) {85        manifest[entry.id] = entry.modifiedAt;86      }87      return manifest;88    },8990    async buildTombstoneManifest() {91      requireUnlocked();92      const manifest: Record<string, number> = {};93      for (const entry of getDeletedEntries()) {94        if (typeof entry.deletedAt === "number") {95          manifest[entry.id] = entry.deletedAt;96        }97      }98      return manifest;99    },100101    async getEncryptedEntries(entryIds) {102      requireUnlocked();103      const masterKey = requireMasterKey();104      const out: Record<string, string> = {};105      for (const id of entryIds) {106        const entry = getEntryById(id);107        if (!entry) continue;108        const key = await deriveEntryKey(masterKey, id);109        out[id] = await encryptEntry(key, entry);110      }111      return out;112    },113114    async applyEntries(entries) {115      requireUnlocked();116      const masterKey = requireMasterKey();117      const decrypted: VaultEntry[] = [];118      for (const [id, envelope] of Object.entries(entries)) {119        const key = await deriveEntryKey(masterKey, id);120        const entry = await decryptEntryOrNull(key, envelope);121        if (!entry) continue;122        // The map key is the authority on id — refuse mismatched envelopes123        // so a swapped envelope can't poison a different entry slot.124        if (entry.id !== id) continue;125        decrypted.push(entry);126      }127      if (decrypted.length > 0) {128        await applyMergedRemoteEntries(decrypted, {});129      }130    },131132    async applyTombstones(tombstones) {133      requireUnlocked();134      const cleaned: Record<string, number> = {};135      for (const [id, ts] of Object.entries(tombstones)) {136        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {137          cleaned[id] = ts;138        }139      }140      if (Object.keys(cleaned).length > 0) {141        await applyMergedRemoteEntries([], cleaned);142      }143    },144145    async buildOutgoingDeviceManifest() {146      requireUnlocked();147      const masterKey = requireMasterKey();148      const issuerDeviceId = await getOrCreateDeviceId();149      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);150      const devices = (await listPairedDevices())151        .map(toManifestEntry)152        .filter((d): d is ManifestDeviceEntry => d !== null);153      return buildSignedDeviceManifest(154        issuerDeviceId,155        devices,156        signing.privateKey,157      );158    },159160    async applyIncomingDeviceManifest(manifest) {161      requireUnlocked();162      await applyVerifiedExtensionManifest(manifest);163    },164  };165}166167function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {168  if (169    !device.deviceId ||170    !device.deviceName ||171    !device.deviceType ||172    !device.longTermPublicKeyHex ||173    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||174    !device.signingPublicKeyHex ||175    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)176  ) {177    return null;178  }179  return {180    deviceId: device.deviceId,181    deviceName: device.deviceName,182    deviceType: device.deviceType,183    longTermPublicKeyHex: device.longTermPublicKeyHex,184    signingPublicKeyHex: device.signingPublicKeyHex,185    wsPort: device.wsPort,186    pairedAt: device.pairedAt,187  };188}189190async function applyVerifiedExtensionManifest(191  manifest: SignedDeviceManifest,192): Promise<void> {193  const issuerId = manifest?.body?.issuerDeviceId;194  if (typeof issuerId !== "string" || !issuerId) return;195196  const issuer = await findPairedDeviceById(issuerId);197  if (198    !issuer ||199    issuer.deviceType !== "extension" ||200    !issuer.signingPublicKeyHex ||201    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)202  ) {203    return;204  }205206  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;207  try {208    body = await verifyDeviceManifest(209      manifest,210      hexToPubkey(issuer.signingPublicKeyHex),211    );212  } catch {213    return;214  }215  if (body.issuerDeviceId !== issuer.deviceId) return;216217  const ourDeviceId = await getOrCreateDeviceId();218  for (const entry of body.devices) {219    if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {220      continue;221    }222    const existing = await findPairedDeviceById(entry.deviceId);223    await upsertPairedDevice({224      ...(existing ?? {}),225      deviceId: entry.deviceId,226      deviceName: entry.deviceName,227      deviceType: entry.deviceType,228      longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),229      signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),230      pairedAt: existing?.pairedAt ?? entry.pairedAt,231      trustSource: existing?.trustSource ?? (existing ? "direct" : "vouched"),232      wsPort: existing?.wsPort ?? entry.wsPort,233      lastSyncedAt: existing?.lastSyncedAt,234    });235  }236}237238function requireUnlocked(): void {239  if (!isUnlocked()) throw new VaultLockedError();240}241242function requireMasterKey(): Uint8Array {243  if (!isUnlocked()) throw new VaultLockedError();244  const hex = getDerivedKeyHex();245  if (!hex) throw new VaultLockedError();246  return hexToBytes(hex);247}248249async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {250  const info = new TextEncoder().encode(entryId + ENTRY_KEY_INFO_SUFFIX);251  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);252  return crypto.subtle.importKey(253    "raw",254    raw.buffer as ArrayBuffer,255    { name: "AES-GCM" },256    false,257    ["encrypt", "decrypt"],258  );259}260261async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {262  const plaintext = new TextEncoder().encode(JSON.stringify(entry));263  const nonce = crypto.getRandomValues(new Uint8Array(12));264  const aad = new TextEncoder().encode(ENTRY_AAD);265  const ct = await crypto.subtle.encrypt(266    {267      name: "AES-GCM",268      iv: nonce.buffer as ArrayBuffer,269      additionalData: aad.buffer as ArrayBuffer,270      tagLength: 128,271    },272    key,273    plaintext.buffer as ArrayBuffer,274  );275  const ctBytes = new Uint8Array(ct);276  const envelope = new Uint8Array(nonce.length + ctBytes.length);277  envelope.set(nonce, 0);278  envelope.set(ctBytes, nonce.length);279  return bytesToBase64(envelope);280}281282async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {283  try {284    const bytes = base64ToBytes(envelopeB64);285    if (bytes.length < 12 + 16) return null;286    const nonce = bytes.subarray(0, 12);287    const ct = bytes.subarray(12);288    const aad = new TextEncoder().encode(ENTRY_AAD);289    const pt = await crypto.subtle.decrypt(290      {291        name: "AES-GCM",292        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,293        additionalData: aad.buffer as ArrayBuffer,294        tagLength: 128,295      },296      key,297      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,298    );299    const json = new TextDecoder().decode(pt);300    const parsed = JSON.parse(json);301    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;302    return parsed as VaultEntry;303  } catch {304    return null;305  }306}307308function hexToBytes(hex: string): Uint8Array {309  if (hex.length % 2 !== 0) throw new Error("hex length must be even");310  const out = new Uint8Array(hex.length / 2);311  for (let i = 0; i < out.length; i++) {312    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);313  }314  return out;315}316317function bytesToBase64(bytes: Uint8Array): string {318  let bin = "";319  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);320  return btoa(bin);321}322323function base64ToBytes(b64: string): Uint8Array {324  const bin = atob(b64);325  const out = new Uint8Array(bin.length);326  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);327  return out;328}