chirp-sync-vault-adapter.ts

10.5 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 with a PER-ENTRY key derived as10//   HKDF-SHA256(ikm = syncMasterKey, salt = "", info = entryId + "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 XX 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. The extension is20// the trust anchor: manifests are signed with its vault-derived Ed25519 key,21// and receivers only apply manifests whose issuer is a paired extension.2223import { HKDF } from "@stablelib/hkdf";24import { SHA256 } from "@stablelib/sha256";25import type { ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";26import { getOrCreateDeviceId } from "./chirp-identity";27import {28  computeVaultId,29  deriveSyncSigningKeypair,30  hexToPubkey,31} from "../shared/sync-identity";32import {33  buildSignedDeviceManifest,34  verifyDeviceManifest,35} from "../shared/device-manifest";36import {37  applyMergedRemoteEntries,38  getDeletedEntries,39  getDerivedKeyHex,40  getEntries,41  getEntryById,42  getVaultVersion,43  isUnlocked,44} from "./vault";45import {46  findPairedDeviceById,47  listPairedDevices,48  upsertPairedDevice,49} from "./paired-devices";50import type { VaultEntry } from "../shared/types";51import type {52  ManifestDeviceEntry,53  PairedDevice,54  SignedDeviceManifest,55} from "../shared/sync-types";5657// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "sync-entry-v1").58// The info binding prevents a ciphertext from one entry slot being replayed into another.59const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";60const ENTRY_AAD = "peach-sync-entry-v1";6162export class VaultLockedError extends Error {63  constructor() {64    super("vault_locked");65    this.name = "VaultLockedError";66  }67}6869export function createChirpSyncVaultAdapter(): ChirpSyncVaultAdapter {70  return {71    async computeVaultId() {72      const master = requireMasterKey();73      return computeVaultId(master);74    },7576    async getOurDeviceId() {77      return getOrCreateDeviceId();78    },7980    async getLocalVersion() {81      return getVaultVersion();82    },8384    async buildEntryManifest() {85      requireUnlocked();86      const manifest: Record<string, number> = {};87      for (const entry of getEntries()) {88        manifest[entry.id] = entry.modifiedAt;89      }90      return manifest;91    },9293    async buildTombstoneManifest() {94      requireUnlocked();95      const manifest: Record<string, number> = {};96      for (const entry of getDeletedEntries()) {97        if (typeof entry.deletedAt === "number") {98          manifest[entry.id] = entry.deletedAt;99        }100      }101      return manifest;102    },103104    async getEncryptedEntries(entryIds) {105      requireUnlocked();106      const masterKey = requireMasterKey();107      const out: Record<string, string> = {};108      for (const id of entryIds) {109        const entry = getEntryById(id);110        if (!entry) continue;111        const key = await deriveEntryKey(masterKey, id);112        out[id] = await encryptEntry(key, entry);113      }114      return out;115    },116117    async applyEntries(entries) {118      requireUnlocked();119      const masterKey = requireMasterKey();120      const decrypted: VaultEntry[] = [];121      for (const [id, envelope] of Object.entries(entries)) {122        const key = await deriveEntryKey(masterKey, id);123        const entry = await decryptEntryOrNull(key, envelope);124        if (!entry) continue;125        // The map key is the authority on id — refuse mismatched envelopes126        // so a swapped envelope can't poison a different entry slot.127        if (entry.id !== id) continue;128        decrypted.push(entry);129      }130      if (decrypted.length > 0) {131        await applyMergedRemoteEntries(decrypted, {});132      }133    },134135    async applyTombstones(tombstones) {136      requireUnlocked();137      const cleaned: Record<string, number> = {};138      for (const [id, ts] of Object.entries(tombstones)) {139        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {140          cleaned[id] = ts;141        }142      }143      if (Object.keys(cleaned).length > 0) {144        await applyMergedRemoteEntries([], cleaned);145      }146    },147148    async buildOutgoingDeviceManifest() {149      requireUnlocked();150      const masterKey = requireMasterKey();151      const issuerDeviceId = await getOrCreateDeviceId();152      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);153      const devices = (await listPairedDevices())154        .map(toManifestEntry)155        .filter((d): d is ManifestDeviceEntry => d !== null);156      return buildSignedDeviceManifest(157        issuerDeviceId,158        devices,159        signing.privateKey,160      );161    },162163    async applyIncomingDeviceManifest(manifest) {164      requireUnlocked();165      await applyVerifiedExtensionManifest(manifest);166    },167  };168}169170function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {171  if (172    !device.deviceId ||173    !device.deviceName ||174    !device.deviceType ||175    !device.longTermPublicKeyHex ||176    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||177    !device.signingPublicKeyHex ||178    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)179  ) {180    return null;181  }182  return {183    deviceId: device.deviceId,184    deviceName: device.deviceName,185    deviceType: device.deviceType,186    longTermPublicKeyHex: device.longTermPublicKeyHex,187    signingPublicKeyHex: device.signingPublicKeyHex,188    wsPort: device.wsPort,189    pairedAt: device.pairedAt,190  };191}192193async function applyVerifiedExtensionManifest(194  manifest: SignedDeviceManifest,195): Promise<void> {196  const issuerId = manifest?.body?.issuerDeviceId;197  if (typeof issuerId !== "string" || !issuerId) return;198199  const issuer = await findPairedDeviceById(issuerId);200  if (201    !issuer ||202    issuer.deviceType !== "extension" ||203    !issuer.signingPublicKeyHex ||204    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)205  ) {206    return;207  }208209  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;210  try {211    body = await verifyDeviceManifest(212      manifest,213      hexToPubkey(issuer.signingPublicKeyHex),214    );215  } catch {216    return;217  }218  if (body.issuerDeviceId !== issuer.deviceId) return;219220  const ourDeviceId = await getOrCreateDeviceId();221  for (const entry of body.devices) {222    if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {223      continue;224    }225    const existing = await findPairedDeviceById(entry.deviceId);226    await upsertPairedDevice({227      ...(existing ?? {}),228      deviceId: entry.deviceId,229      deviceName: entry.deviceName,230      deviceType: entry.deviceType,231      longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),232      signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),233      pairedAt: existing?.pairedAt ?? entry.pairedAt,234      trustSource: existing?.trustSource ?? (existing ? "direct" : "vouched"),235      wsPort: existing?.wsPort ?? entry.wsPort,236      lastSyncedAt: existing?.lastSyncedAt,237    });238  }239}240241function requireUnlocked(): void {242  if (!isUnlocked()) throw new VaultLockedError();243}244245function requireMasterKey(): Uint8Array {246  if (!isUnlocked()) throw new VaultLockedError();247  const hex = getDerivedKeyHex();248  if (!hex) throw new VaultLockedError();249  return hexToBytes(hex);250}251252async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {253  const info = new TextEncoder().encode(entryId + ENTRY_KEY_INFO_SUFFIX);254  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);255  return crypto.subtle.importKey(256    "raw",257    raw.buffer as ArrayBuffer,258    { name: "AES-GCM" },259    false,260    ["encrypt", "decrypt"],261  );262}263264async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {265  const plaintext = new TextEncoder().encode(JSON.stringify(entry));266  const nonce = crypto.getRandomValues(new Uint8Array(12));267  const aad = new TextEncoder().encode(ENTRY_AAD);268  const ct = await crypto.subtle.encrypt(269    {270      name: "AES-GCM",271      iv: nonce.buffer as ArrayBuffer,272      additionalData: aad.buffer as ArrayBuffer,273      tagLength: 128,274    },275    key,276    plaintext.buffer as ArrayBuffer,277  );278  const ctBytes = new Uint8Array(ct);279  const envelope = new Uint8Array(nonce.length + ctBytes.length);280  envelope.set(nonce, 0);281  envelope.set(ctBytes, nonce.length);282  return bytesToBase64(envelope);283}284285async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {286  try {287    const bytes = base64ToBytes(envelopeB64);288    if (bytes.length < 12 + 16) return null;289    const nonce = bytes.subarray(0, 12);290    const ct = bytes.subarray(12);291    const aad = new TextEncoder().encode(ENTRY_AAD);292    const pt = await crypto.subtle.decrypt(293      {294        name: "AES-GCM",295        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,296        additionalData: aad.buffer as ArrayBuffer,297        tagLength: 128,298      },299      key,300      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,301    );302    const json = new TextDecoder().decode(pt);303    const parsed = JSON.parse(json);304    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;305    return parsed as VaultEntry;306  } catch {307    return null;308  }309}310311function hexToBytes(hex: string): Uint8Array {312  if (hex.length % 2 !== 0) throw new Error("hex length must be even");313  const out = new Uint8Array(hex.length / 2);314  for (let i = 0; i < out.length; i++) {315    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);316  }317  return out;318}319320function bytesToBase64(bytes: Uint8Array): string {321  let bin = "";322  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);323  return btoa(bin);324}325326function base64ToBytes(b64: string): Uint8Array {327  const bin = atob(b64);328  const out = new Uint8Array(bin.length);329  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);330  return out;331}