chirp-sync-vault-adapter.ts

10.4 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 getLocalVersion() {75      try {76        const raw = await chrome.storage.local.get("peach_last_modified_at");77        const v = (raw as Record<string, unknown>).peach_last_modified_at;78        if (typeof v === "number") return v;79        if (typeof v === "string" && v !== "") {80          const n = Number.parseInt(v, 10);81          return Number.isFinite(n) ? n : 0;82        }83        return 0;84      } catch {85        return 0;86      }87    },8889    async buildEntryManifest() {90      requireUnlocked();91      const manifest: Record<string, number> = {};92      for (const entry of getEntries()) {93        manifest[entry.id] = entry.modifiedAt;94      }95      return manifest;96    },9798    async buildTombstoneManifest() {99      requireUnlocked();100      const manifest: Record<string, number> = {};101      for (const entry of getDeletedEntries()) {102        if (typeof entry.deletedAt === "number") {103          manifest[entry.id] = entry.deletedAt;104        }105      }106      return manifest;107    },108109    async getEncryptedEntries(entryIds) {110      requireUnlocked();111      const masterKey = requireMasterKey();112      const entryKey = await deriveEntryKey(masterKey);113      const out: Record<string, string> = {};114      for (const id of entryIds) {115        const entry = getEntryById(id);116        if (!entry) continue;117        out[id] = await encryptEntry(entryKey, entry);118      }119      return out;120    },121122    async applyEntries(entries) {123      requireUnlocked();124      const masterKey = requireMasterKey();125      const entryKey = await deriveEntryKey(masterKey);126      const decrypted: VaultEntry[] = [];127      for (const [id, envelope] of Object.entries(entries)) {128        const entry = await decryptEntryOrNull(entryKey, envelope);129        if (!entry) continue;130        // The map key is the authority on id — refuse mismatched envelopes131        // so a swapped envelope can't poison a different entry slot.132        if (entry.id !== id) continue;133        decrypted.push(entry);134      }135      if (decrypted.length > 0) {136        await applyMergedRemoteEntries(decrypted, {});137      }138    },139140    async applyTombstones(tombstones) {141      requireUnlocked();142      const cleaned: Record<string, number> = {};143      for (const [id, ts] of Object.entries(tombstones)) {144        if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {145          cleaned[id] = ts;146        }147      }148      if (Object.keys(cleaned).length > 0) {149        await applyMergedRemoteEntries([], cleaned);150      }151    },152153    async buildOutgoingDeviceManifest() {154      requireUnlocked();155      const masterKey = requireMasterKey();156      const issuerDeviceId = await getOrCreateDeviceId();157      const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);158      const devices = (await listPairedDevices())159        .map(toManifestEntry)160        .filter((d): d is ManifestDeviceEntry => d !== null);161      return buildSignedDeviceManifest(162        issuerDeviceId,163        devices,164        signing.privateKey,165      );166    },167168    async applyIncomingDeviceManifest(manifest) {169      requireUnlocked();170      await applyVerifiedExtensionManifest(manifest);171    },172  };173}174175function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {176  if (177    !device.deviceId ||178    !device.deviceName ||179    !device.deviceType ||180    !device.longTermPublicKeyHex ||181    !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||182    !device.signingPublicKeyHex ||183    !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)184  ) {185    return null;186  }187  return {188    deviceId: device.deviceId,189    deviceName: device.deviceName,190    deviceType: device.deviceType,191    longTermPublicKeyHex: device.longTermPublicKeyHex,192    signingPublicKeyHex: device.signingPublicKeyHex,193    wsPort: device.wsPort,194    pairedAt: device.pairedAt,195  };196}197198async function applyVerifiedExtensionManifest(199  manifest: SignedDeviceManifest,200): Promise<void> {201  const issuerId = manifest?.body?.issuerDeviceId;202  if (typeof issuerId !== "string" || !issuerId) return;203204  const issuer = await findPairedDeviceById(issuerId);205  if (206    !issuer ||207    issuer.deviceType !== "extension" ||208    !issuer.signingPublicKeyHex ||209    !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)210  ) {211    return;212  }213214  let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;215  try {216    body = await verifyDeviceManifest(217      manifest,218      hexToPubkey(issuer.signingPublicKeyHex),219    );220  } catch {221    return;222  }223  if (body.issuerDeviceId !== issuer.deviceId) return;224225  const ourDeviceId = await getOrCreateDeviceId();226  for (const entry of body.devices) {227    if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {228      continue;229    }230    const existing = await findPairedDeviceById(entry.deviceId);231    await upsertPairedDevice({232      ...(existing ?? {}),233      deviceId: entry.deviceId,234      deviceName: entry.deviceName,235      deviceType: entry.deviceType,236      longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),237      signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),238      pairedAt: existing?.pairedAt ?? entry.pairedAt,239      trustSource: existing?.trustSource ?? (existing ? "direct" : "vouched"),240      wsPort: existing?.wsPort ?? entry.wsPort,241      lastSyncedAt: existing?.lastSyncedAt,242    });243  }244}245246function requireUnlocked(): void {247  if (!isUnlocked()) throw new VaultLockedError();248}249250function requireMasterKey(): Uint8Array {251  if (!isUnlocked()) throw new VaultLockedError();252  const hex = getDerivedKeyHex();253  if (!hex) throw new VaultLockedError();254  return hexToBytes(hex);255}256257async function deriveEntryKey(masterKey: Uint8Array): Promise<CryptoKey> {258  const raw = new HKDF(SHA256, masterKey, ENTRY_KEY_SALT, new Uint8Array(0)).expand(32);259  return crypto.subtle.importKey(260    "raw",261    raw.buffer as ArrayBuffer,262    { name: "AES-GCM" },263    false,264    ["encrypt", "decrypt"],265  );266}267268async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {269  const plaintext = new TextEncoder().encode(JSON.stringify(entry));270  const nonce = crypto.getRandomValues(new Uint8Array(12));271  const aad = new TextEncoder().encode(ENTRY_AAD);272  const ct = await crypto.subtle.encrypt(273    {274      name: "AES-GCM",275      iv: nonce.buffer as ArrayBuffer,276      additionalData: aad.buffer as ArrayBuffer,277      tagLength: 128,278    },279    key,280    plaintext.buffer as ArrayBuffer,281  );282  const ctBytes = new Uint8Array(ct);283  const envelope = new Uint8Array(nonce.length + ctBytes.length);284  envelope.set(nonce, 0);285  envelope.set(ctBytes, nonce.length);286  return bytesToBase64(envelope);287}288289async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {290  try {291    const bytes = base64ToBytes(envelopeB64);292    if (bytes.length < 12 + 16) return null;293    const nonce = bytes.subarray(0, 12);294    const ct = bytes.subarray(12);295    const aad = new TextEncoder().encode(ENTRY_AAD);296    const pt = await crypto.subtle.decrypt(297      {298        name: "AES-GCM",299        iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,300        additionalData: aad.buffer as ArrayBuffer,301        tagLength: 128,302      },303      key,304      ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,305    );306    const json = new TextDecoder().decode(pt);307    const parsed = JSON.parse(json);308    if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;309    return parsed as VaultEntry;310  } catch {311    return null;312  }313}314315function hexToBytes(hex: string): Uint8Array {316  if (hex.length % 2 !== 0) throw new Error("hex length must be even");317  const out = new Uint8Array(hex.length / 2);318  for (let i = 0; i < out.length; i++) {319    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);320  }321  return out;322}323324function bytesToBase64(bytes: Uint8Array): string {325  let bin = "";326  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);327  return btoa(bin);328}329330function base64ToBytes(b64: string): Uint8Array {331  const bin = atob(b64);332  const out = new Uint8Array(bin.length);333  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);334  return out;335}