chirp-sync-vault-adapter.ts

7.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 intentionally NOT plumbed here.17// The coordinator's optional `buildOutgoingDeviceManifest` /18// `applyIncomingDeviceManifest` hooks are left undefined so the coordinator19// skips that protocol step. Adding manifest support is a separate concern20// that requires verified-issuer registry plumbing.2122import { HKDF } from "@stablelib/hkdf";23import { SHA256 } from "@stablelib/sha256";24import type { ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";25import { getOrCreateDeviceId } from "./chirp-identity";26import { computeVaultId } from "../shared/sync-identity";27import {28  applyMergedRemoteEntries,29  getDeletedEntries,30  getDerivedKeyHex,31  getEntries,32  getEntryById,33  isUnlocked,34} from "./vault";35import type { VaultEntry } from "../shared/types";3637const ENTRY_KEY_SALT = new TextEncoder().encode("peach-sync-entry-v1");38const ENTRY_AAD = "peach-sync-entry-v1";3940export class VaultLockedError extends Error {41  constructor() {42    super("vault_locked");43    this.name = "VaultLockedError";44  }45}4647export function createChirpSyncVaultAdapter(): ChirpSyncVaultAdapter {48  return {49    async computeVaultId() {50      const master = requireMasterKey();51      return computeVaultId(master);52    },5354    async getOurDeviceId() {55      return getOrCreateDeviceId();56    },5758    async buildEntryManifest() {59      requireUnlocked();60      const manifest: Record<string, number> = {};61      for (const entry of getEntries()) {62        manifest[entry.id] = entry.modifiedAt;63      }64      return manifest;65    },6667    async buildTombstoneManifest() {68      requireUnlocked();69      const manifest: Record<string, number> = {};70      for (const entry of getDeletedEntries()) {71        if (typeof entry.deletedAt === "number") {72          manifest[entry.id] = entry.deletedAt;73        }74      }75      return manifest;76    },7778    async getEncryptedEntries(entryIds) {79      requireUnlocked();80      const masterKey = requireMasterKey();81      const entryKey = await deriveEntryKey(masterKey);82      const out: Record<string, string> = {};83      for (const id of entryIds) {84        const entry = getEntryById(id);85        if (!entry) continue;86        out[id] = await encryptEntry(entryKey, entry);87      }88      return out;89    },9091    async applyEntries(entries) {92      requireUnlocked();93      const masterKey = requireMasterKey();94      const entryKey = await deriveEntryKey(masterKey);95      const decrypted: VaultEntry[] = [];96      for (const [id, envelope] of Object.entries(entries)) {97        const entry = await decryptEntryOrNull(entryKey, envelope);98        if (!entry) continue;99        // The map key is the authority on id — refuse mismatched envelopes100        // so a swapped envelope can't poison a different entry slot.101        if (entry.id !== id) continue;102        decrypted.push(entry);103      }104      if (decrypted.length > 0) {105        await applyMergedRemoteEntries(decrypted, {});106      }107    },108109    async applyTombstones(tombstones) {110      requireUnlocked();111      const cleaned: Record<string, number> = {};112      for (const [id, ts] of Object.entries(tombstones)) {113        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {114          cleaned[id] = ts;115        }116      }117      if (Object.keys(cleaned).length > 0) {118        await applyMergedRemoteEntries([], cleaned);119      }120    },121  };122}123124function requireUnlocked(): void {125  if (!isUnlocked()) throw new VaultLockedError();126}127128function requireMasterKey(): Uint8Array {129  if (!isUnlocked()) throw new VaultLockedError();130  const hex = getDerivedKeyHex();131  if (!hex) throw new VaultLockedError();132  return hexToBytes(hex);133}134135async function deriveEntryKey(masterKey: Uint8Array): Promise<CryptoKey> {136  const raw = new HKDF(SHA256, masterKey, ENTRY_KEY_SALT, new Uint8Array(0)).expand(32);137  return crypto.subtle.importKey(138    "raw",139    raw.buffer as ArrayBuffer,140    { name: "AES-GCM" },141    false,142    ["encrypt", "decrypt"],143  );144}145146async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {147  const plaintext = new TextEncoder().encode(JSON.stringify(entry));148  const nonce = crypto.getRandomValues(new Uint8Array(12));149  const aad = new TextEncoder().encode(ENTRY_AAD);150  const ct = await crypto.subtle.encrypt(151    {152      name: "AES-GCM",153      iv: nonce.buffer as ArrayBuffer,154      additionalData: aad.buffer as ArrayBuffer,155      tagLength: 128,156    },157    key,158    plaintext.buffer as ArrayBuffer,159  );160  const ctBytes = new Uint8Array(ct);161  const envelope = new Uint8Array(nonce.length + ctBytes.length);162  envelope.set(nonce, 0);163  envelope.set(ctBytes, nonce.length);164  return bytesToBase64(envelope);165}166167async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {168  try {169    const bytes = base64ToBytes(envelopeB64);170    if (bytes.length < 12 + 16) return null;171    const nonce = bytes.subarray(0, 12);172    const ct = bytes.subarray(12);173    const aad = new TextEncoder().encode(ENTRY_AAD);174    const pt = await crypto.subtle.decrypt(175      {176        name: "AES-GCM",177        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,178        additionalData: aad.buffer as ArrayBuffer,179        tagLength: 128,180      },181      key,182      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,183    );184    const json = new TextDecoder().decode(pt);185    const parsed = JSON.parse(json);186    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;187    return parsed as VaultEntry;188  } catch {189    return null;190  }191}192193function hexToBytes(hex: string): Uint8Array {194  if (hex.length % 2 !== 0) throw new Error("hex length must be even");195  const out = new Uint8Array(hex.length / 2);196  for (let i = 0; i < out.length; i++) {197    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);198  }199  return out;200}201202function bytesToBase64(bytes: Uint8Array): string {203  let bin = "";204  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);205  return btoa(bin);206}207208function base64ToBytes(b64: string): Uint8Array {209  const bin = atob(b64);210  const out = new Uint8Array(bin.length);211  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);212  return out;213}