chirp-sync-vault-adapter.ts

10.7 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";5253// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "sync-entry-v1").54// The info binding prevents a ciphertext from one entry slot being replayed into another.55const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";56const ENTRY_AAD = "peach-sync-entry-v1";5758export class VaultLockedError extends Error {59  constructor() {60    super("vault_locked");61    this.name = "VaultLockedError";62  }63}6465export function createChirpSyncVaultAdapter(): ChirpSyncVaultAdapter {66  return {67    async computeVaultId() {68      const master = requireMasterKey();69      return computeVaultId(master);70    },7172    async getOurDeviceId() {73      return getOrCreateDeviceId();74    },7576    async getLocalVersion() {77      try {78        const raw = await chrome.storage.local.get("peach_last_modified_at");79        const v = (raw as Record<string, unknown>).peach_last_modified_at;80        if (typeof v === "number") return v;81        if (typeof v === "string" && v !== "") {82          const n = Number.parseInt(v, 10);83          return Number.isFinite(n) ? n : 0;84        }85        return 0;86      } catch {87        return 0;88      }89    },9091    async buildEntryManifest() {92      requireUnlocked();93      const manifest: Record<string, number> = {};94      for (const entry of getEntries()) {95        manifest[entry.id] = entry.modifiedAt;96      }97      return manifest;98    },99100    async buildTombstoneManifest() {101      requireUnlocked();102      const manifest: Record<string, number> = {};103      for (const entry of getDeletedEntries()) {104        if (typeof entry.deletedAt === "number") {105          manifest[entry.id] = entry.deletedAt;106        }107      }108      return manifest;109    },110111    async getEncryptedEntries(entryIds) {112      requireUnlocked();113      const masterKey = requireMasterKey();114      const out: Record<string, string> = {};115      for (const id of entryIds) {116        const entry = getEntryById(id);117        if (!entry) continue;118        const key = await deriveEntryKey(masterKey, id);119        out[id] = await encryptEntry(key, entry);120      }121      return out;122    },123124    async applyEntries(entries) {125      requireUnlocked();126      const masterKey = requireMasterKey();127      const decrypted: VaultEntry[] = [];128      for (const [id, envelope] of Object.entries(entries)) {129        const key = await deriveEntryKey(masterKey, id);130        const entry = await decryptEntryOrNull(key, envelope);131        if (!entry) continue;132        // The map key is the authority on id — refuse mismatched envelopes133        // so a swapped envelope can't poison a different entry slot.134        if (entry.id !== id) continue;135        decrypted.push(entry);136      }137      if (decrypted.length > 0) {138        await applyMergedRemoteEntries(decrypted, {});139      }140    },141142    async applyTombstones(tombstones) {143      requireUnlocked();144      const cleaned: Record<string, number> = {};145      for (const [id, ts] of Object.entries(tombstones)) {146        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {147          cleaned[id] = ts;148        }149      }150      if (Object.keys(cleaned).length > 0) {151        await applyMergedRemoteEntries([], cleaned);152      }153    },154155    async buildOutgoingDeviceManifest() {156      requireUnlocked();157      const masterKey = requireMasterKey();158      const issuerDeviceId = await getOrCreateDeviceId();159      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);160      const devices = (await listPairedDevices())161        .map(toManifestEntry)162        .filter((d): d is ManifestDeviceEntry => d !== null);163      return buildSignedDeviceManifest(164        issuerDeviceId,165        devices,166        signing.privateKey,167      );168    },169170    async applyIncomingDeviceManifest(manifest) {171      requireUnlocked();172      await applyVerifiedExtensionManifest(manifest);173    },174  };175}176177function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {178  if (179    !device.deviceId ||180    !device.deviceName ||181    !device.deviceType ||182    !device.longTermPublicKeyHex ||183    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||184    !device.signingPublicKeyHex ||185    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)186  ) {187    return null;188  }189  return {190    deviceId: device.deviceId,191    deviceName: device.deviceName,192    deviceType: device.deviceType,193    longTermPublicKeyHex: device.longTermPublicKeyHex,194    signingPublicKeyHex: device.signingPublicKeyHex,195    wsPort: device.wsPort,196    pairedAt: device.pairedAt,197  };198}199200async function applyVerifiedExtensionManifest(201  manifest: SignedDeviceManifest,202): Promise<void> {203  const issuerId = manifest?.body?.issuerDeviceId;204  if (typeof issuerId !== "string" || !issuerId) return;205206  const issuer = await findPairedDeviceById(issuerId);207  if (208    !issuer ||209    issuer.deviceType !== "extension" ||210    !issuer.signingPublicKeyHex ||211    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)212  ) {213    return;214  }215216  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;217  try {218    body = await verifyDeviceManifest(219      manifest,220      hexToPubkey(issuer.signingPublicKeyHex),221    );222  } catch {223    return;224  }225  if (body.issuerDeviceId !== issuer.deviceId) return;226227  const ourDeviceId = await getOrCreateDeviceId();228  for (const entry of body.devices) {229    if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {230      continue;231    }232    const existing = await findPairedDeviceById(entry.deviceId);233    await upsertPairedDevice({234      ...(existing ?? {}),235      deviceId: entry.deviceId,236      deviceName: entry.deviceName,237      deviceType: entry.deviceType,238      longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),239      signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),240      pairedAt: existing?.pairedAt ?? entry.pairedAt,241      trustSource: existing?.trustSource ?? (existing ? "direct" : "vouched"),242      wsPort: existing?.wsPort ?? entry.wsPort,243      lastSyncedAt: existing?.lastSyncedAt,244    });245  }246}247248function requireUnlocked(): void {249  if (!isUnlocked()) throw new VaultLockedError();250}251252function requireMasterKey(): Uint8Array {253  if (!isUnlocked()) throw new VaultLockedError();254  const hex = getDerivedKeyHex();255  if (!hex) throw new VaultLockedError();256  return hexToBytes(hex);257}258259async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {260  const info = new TextEncoder().encode(entryId + ENTRY_KEY_INFO_SUFFIX);261  const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);262  return crypto.subtle.importKey(263    "raw",264    raw.buffer as ArrayBuffer,265    { name: "AES-GCM" },266    false,267    ["encrypt", "decrypt"],268  );269}270271async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {272  const plaintext = new TextEncoder().encode(JSON.stringify(entry));273  const nonce = crypto.getRandomValues(new Uint8Array(12));274  const aad = new TextEncoder().encode(ENTRY_AAD);275  const ct = await crypto.subtle.encrypt(276    {277      name: "AES-GCM",278      iv: nonce.buffer as ArrayBuffer,279      additionalData: aad.buffer as ArrayBuffer,280      tagLength: 128,281    },282    key,283    plaintext.buffer as ArrayBuffer,284  );285  const ctBytes = new Uint8Array(ct);286  const envelope = new Uint8Array(nonce.length + ctBytes.length);287  envelope.set(nonce, 0);288  envelope.set(ctBytes, nonce.length);289  return bytesToBase64(envelope);290}291292async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {293  try {294    const bytes = base64ToBytes(envelopeB64);295    if (bytes.length < 12 + 16) return null;296    const nonce = bytes.subarray(0, 12);297    const ct = bytes.subarray(12);298    const aad = new TextEncoder().encode(ENTRY_AAD);299    const pt = await crypto.subtle.decrypt(300      {301        name: "AES-GCM",302        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,303        additionalData: aad.buffer as ArrayBuffer,304        tagLength: 128,305      },306      key,307      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,308    );309    const json = new TextDecoder().decode(pt);310    const parsed = JSON.parse(json);311    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;312    return parsed as VaultEntry;313  } catch {314    return null;315  }316}317318function hexToBytes(hex: string): Uint8Array {319  if (hex.length % 2 !== 0) throw new Error("hex length must be even");320  const out = new Uint8Array(hex.length / 2);321  for (let i = 0; i < out.length; i++) {322    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);323  }324  return out;325}326327function bytesToBase64(bytes: Uint8Array): string {328  let bin = "";329  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);330  return btoa(bin);331}332333function base64ToBytes(b64: string): Uint8Array {334  const bin = atob(b64);335  const out = new Uint8Array(bin.length);336  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);337  return out;338}