chirp-sync-vault-adapter.ts

10.0 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  isUnlocked,40} from "./vault";41import {42  findPairedDeviceById,43  listPairedDevices,44  upsertPairedDevice,45} from "./paired-devices";46import type { VaultEntry } from "../shared/types";47import type {48  ManifestDeviceEntry,49  PairedDevice,50  SignedDeviceManifest,51} from "../shared/sync-types";5253const ENTRY_KEY_SALT = new TextEncoder().encode("peach-sync-entry-v1");54const ENTRY_AAD = "peach-sync-entry-v1";5556export class VaultLockedError extends Error {57  constructor() {58    super("vault_locked");59    this.name = "VaultLockedError";60  }61}6263export function createChirpSyncVaultAdapter(): ChirpSyncVaultAdapter {64  return {65    async computeVaultId() {66      const master = requireMasterKey();67      return computeVaultId(master);68    },6970    async getOurDeviceId() {71      return getOrCreateDeviceId();72    },7374    async buildEntryManifest() {75      requireUnlocked();76      const manifest: Record<string, number> = {};77      for (const entry of getEntries()) {78        manifest[entry.id] = entry.modifiedAt;79      }80      return manifest;81    },8283    async buildTombstoneManifest() {84      requireUnlocked();85      const manifest: Record<string, number> = {};86      for (const entry of getDeletedEntries()) {87        if (typeof entry.deletedAt === "number") {88          manifest[entry.id] = entry.deletedAt;89        }90      }91      return manifest;92    },9394    async getEncryptedEntries(entryIds) {95      requireUnlocked();96      const masterKey = requireMasterKey();97      const entryKey = await deriveEntryKey(masterKey);98      const out: Record<string, string> = {};99      for (const id of entryIds) {100        const entry = getEntryById(id);101        if (!entry) continue;102        out[id] = await encryptEntry(entryKey, entry);103      }104      return out;105    },106107    async applyEntries(entries) {108      requireUnlocked();109      const masterKey = requireMasterKey();110      const entryKey = await deriveEntryKey(masterKey);111      const decrypted: VaultEntry[] = [];112      for (const [id, envelope] of Object.entries(entries)) {113        const entry = await decryptEntryOrNull(entryKey, envelope);114        if (!entry) continue;115        // The map key is the authority on id — refuse mismatched envelopes116        // so a swapped envelope can't poison a different entry slot.117        if (entry.id !== id) continue;118        decrypted.push(entry);119      }120      if (decrypted.length > 0) {121        await applyMergedRemoteEntries(decrypted, {});122      }123    },124125    async applyTombstones(tombstones) {126      requireUnlocked();127      const cleaned: Record<string, number> = {};128      for (const [id, ts] of Object.entries(tombstones)) {129        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {130          cleaned[id] = ts;131        }132      }133      if (Object.keys(cleaned).length > 0) {134        await applyMergedRemoteEntries([], cleaned);135      }136    },137138    async buildOutgoingDeviceManifest() {139      requireUnlocked();140      const masterKey = requireMasterKey();141      const issuerDeviceId = await getOrCreateDeviceId();142      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);143      const devices = (await listPairedDevices())144        .map(toManifestEntry)145        .filter((d): d is ManifestDeviceEntry => d !== null);146      return buildSignedDeviceManifest(147        issuerDeviceId,148        devices,149        signing.privateKey,150      );151    },152153    async applyIncomingDeviceManifest(manifest) {154      requireUnlocked();155      await applyVerifiedExtensionManifest(manifest);156    },157  };158}159160function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {161  if (162    !device.deviceId ||163    !device.deviceName ||164    !device.deviceType ||165    !device.longTermPublicKeyHex ||166    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||167    !device.signingPublicKeyHex ||168    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)169  ) {170    return null;171  }172  return {173    deviceId: device.deviceId,174    deviceName: device.deviceName,175    deviceType: device.deviceType,176    longTermPublicKeyHex: device.longTermPublicKeyHex,177    signingPublicKeyHex: device.signingPublicKeyHex,178    wsPort: device.wsPort,179    pairedAt: device.pairedAt,180  };181}182183async function applyVerifiedExtensionManifest(184  manifest: SignedDeviceManifest,185): Promise<void> {186  const issuerId = manifest?.body?.issuerDeviceId;187  if (typeof issuerId !== "string" || !issuerId) return;188189  const issuer = await findPairedDeviceById(issuerId);190  if (191    !issuer ||192    issuer.deviceType !== "extension" ||193    !issuer.signingPublicKeyHex ||194    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)195  ) {196    return;197  }198199  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;200  try {201    body = await verifyDeviceManifest(202      manifest,203      hexToPubkey(issuer.signingPublicKeyHex),204    );205  } catch {206    return;207  }208  if (body.issuerDeviceId !== issuer.deviceId) return;209210  const ourDeviceId = await getOrCreateDeviceId();211  for (const entry of body.devices) {212    if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {213      continue;214    }215    const existing = await findPairedDeviceById(entry.deviceId);216    await upsertPairedDevice({217      ...(existing ?? {}),218      deviceId: entry.deviceId,219      deviceName: entry.deviceName,220      deviceType: entry.deviceType,221      longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),222      signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),223      pairedAt: existing?.pairedAt ?? entry.pairedAt,224      trustSource: existing?.trustSource ?? (existing ? "direct" : "vouched"),225      wsPort: existing?.wsPort ?? entry.wsPort,226      lastSyncedAt: existing?.lastSyncedAt,227    });228  }229}230231function requireUnlocked(): void {232  if (!isUnlocked()) throw new VaultLockedError();233}234235function requireMasterKey(): Uint8Array {236  if (!isUnlocked()) throw new VaultLockedError();237  const hex = getDerivedKeyHex();238  if (!hex) throw new VaultLockedError();239  return hexToBytes(hex);240}241242async function deriveEntryKey(masterKey: Uint8Array): Promise<CryptoKey> {243  const raw = new HKDF(SHA256, masterKey, ENTRY_KEY_SALT, new Uint8Array(0)).expand(32);244  return crypto.subtle.importKey(245    "raw",246    raw.buffer as ArrayBuffer,247    { name: "AES-GCM" },248    false,249    ["encrypt", "decrypt"],250  );251}252253async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {254  const plaintext = new TextEncoder().encode(JSON.stringify(entry));255  const nonce = crypto.getRandomValues(new Uint8Array(12));256  const aad = new TextEncoder().encode(ENTRY_AAD);257  const ct = await crypto.subtle.encrypt(258    {259      name: "AES-GCM",260      iv: nonce.buffer as ArrayBuffer,261      additionalData: aad.buffer as ArrayBuffer,262      tagLength: 128,263    },264    key,265    plaintext.buffer as ArrayBuffer,266  );267  const ctBytes = new Uint8Array(ct);268  const envelope = new Uint8Array(nonce.length + ctBytes.length);269  envelope.set(nonce, 0);270  envelope.set(ctBytes, nonce.length);271  return bytesToBase64(envelope);272}273274async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {275  try {276    const bytes = base64ToBytes(envelopeB64);277    if (bytes.length < 12 + 16) return null;278    const nonce = bytes.subarray(0, 12);279    const ct = bytes.subarray(12);280    const aad = new TextEncoder().encode(ENTRY_AAD);281    const pt = await crypto.subtle.decrypt(282      {283        name: "AES-GCM",284        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,285        additionalData: aad.buffer as ArrayBuffer,286        tagLength: 128,287      },288      key,289      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,290    );291    const json = new TextDecoder().decode(pt);292    const parsed = JSON.parse(json);293    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;294    return parsed as VaultEntry;295  } catch {296    return null;297  }298}299300function hexToBytes(hex: string): Uint8Array {301  if (hex.length % 2 !== 0) throw new Error("hex length must be even");302  const out = new Uint8Array(hex.length / 2);303  for (let i = 0; i < out.length; i++) {304    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);305  }306  return out;307}308309function bytesToBase64(bytes: Uint8Array): string {310  let bin = "";311  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);312  return btoa(bin);313}314315function base64ToBytes(b64: string): Uint8Array {316  const bin = atob(b64);317  const out = new Uint8Array(bin.length);318  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);319  return out;320}