// Production adapter that backs `runChirpSyncInitiator` with the live vault. // // The coordinator (chirp-sync-coordinator.ts) never decrypts vault data; it // only moves opaque base64 entry blobs and metadata. This adapter is the // boundary where vault plaintext meets the sync layer โ€” entries leave the // vault as encrypted envelopes here, and incoming envelopes are decrypted // here before being merged via `applyMergedRemoteEntries`. // // Envelope cipher: AES-GCM with a PER-ENTRY key derived as // HKDF-SHA256(ikm = syncMasterKey, salt = "", info = entryId + "sync-entry-v1") // (random 12B nonce, aad = "peach-sync-entry-v1"). Binding the key to the // entry id means a ciphertext minted for one entry slot cannot be replayed // into another: decryptEntryOrNull rejects any envelope whose decrypted // entry.id does not match the map key it arrived under. Both sides hold the // same syncMasterKey (same vault) so keys match without prior negotiation. The // Noise XX session encrypts the transport on top โ€” the envelope is // belt-and-braces so a transport-layer leak doesn't expose plaintext entries. // // Device-manifest exchange (v2 brief ยง4.3) is plumbed here. The extension is // the trust anchor: manifests are signed with its vault-derived Ed25519 key, // and receivers only apply manifests whose issuer is a paired extension. import { HKDF } from "@stablelib/hkdf"; import { SHA256 } from "@stablelib/sha256"; import type { ChirpSyncVaultAdapter } from "./chirp-sync-coordinator"; import { getOrCreateDeviceId } from "./chirp-identity"; import { computeVaultId, deriveSyncSigningKeypair, hexToPubkey, } from "../shared/sync-identity"; import { buildSignedDeviceManifest, verifyDeviceManifest, } from "../shared/device-manifest"; import { applyMergedRemoteEntries, getDeletedEntries, getDerivedKeyHex, getEntries, getEntryById, getVaultVersion, isUnlocked, } from "./vault"; import { findPairedDeviceById, listPairedDevices, upsertPairedDevice, } from "./paired-devices"; import type { VaultEntry } from "../shared/types"; import type { ManifestDeviceEntry, PairedDevice, SignedDeviceManifest, } from "../shared/sync-types"; // Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "sync-entry-v1"). // The info binding prevents a ciphertext from one entry slot being replayed into another. const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"; const ENTRY_AAD = "peach-sync-entry-v1"; export class VaultLockedError extends Error { constructor() { super("vault_locked"); this.name = "VaultLockedError"; } } export function createChirpSyncVaultAdapter(): ChirpSyncVaultAdapter { return { async computeVaultId() { const master = requireMasterKey(); return computeVaultId(master); }, async getOurDeviceId() { return getOrCreateDeviceId(); }, async getLocalVersion() { return getVaultVersion(); }, async buildEntryManifest() { requireUnlocked(); const manifest: Record = {}; for (const entry of getEntries()) { manifest[entry.id] = entry.modifiedAt; } return manifest; }, async buildTombstoneManifest() { requireUnlocked(); const manifest: Record = {}; for (const entry of getDeletedEntries()) { if (typeof entry.deletedAt === "number") { manifest[entry.id] = entry.deletedAt; } } return manifest; }, async getEncryptedEntries(entryIds) { requireUnlocked(); const masterKey = requireMasterKey(); const out: Record = {}; for (const id of entryIds) { const entry = getEntryById(id); if (!entry) continue; const key = await deriveEntryKey(masterKey, id); out[id] = await encryptEntry(key, entry); } return out; }, async applyEntries(entries) { requireUnlocked(); const masterKey = requireMasterKey(); const decrypted: VaultEntry[] = []; for (const [id, envelope] of Object.entries(entries)) { const key = await deriveEntryKey(masterKey, id); const entry = await decryptEntryOrNull(key, envelope); if (!entry) continue; // The map key is the authority on id โ€” refuse mismatched envelopes // so a swapped envelope can't poison a different entry slot. if (entry.id !== id) continue; decrypted.push(entry); } if (decrypted.length > 0) { await applyMergedRemoteEntries(decrypted, {}); } }, async applyTombstones(tombstones) { requireUnlocked(); const cleaned: Record = {}; for (const [id, ts] of Object.entries(tombstones)) { if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) { cleaned[id] = ts; } } if (Object.keys(cleaned).length > 0) { await applyMergedRemoteEntries([], cleaned); } }, async buildOutgoingDeviceManifest() { requireUnlocked(); const masterKey = requireMasterKey(); const issuerDeviceId = await getOrCreateDeviceId(); const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId); const devices = (await listPairedDevices()) .map(toManifestEntry) .filter((d): d is ManifestDeviceEntry => d !== null); return buildSignedDeviceManifest( issuerDeviceId, devices, signing.privateKey, ); }, async applyIncomingDeviceManifest(manifest) { requireUnlocked(); await applyVerifiedExtensionManifest(manifest); }, }; } function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null { if ( !device.deviceId || !device.deviceName || !device.deviceType || !device.longTermPublicKeyHex || !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) || !device.signingPublicKeyHex || !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex) ) { return null; } return { deviceId: device.deviceId, deviceName: device.deviceName, deviceType: device.deviceType, longTermPublicKeyHex: device.longTermPublicKeyHex, signingPublicKeyHex: device.signingPublicKeyHex, wsPort: device.wsPort, pairedAt: device.pairedAt, }; } async function applyVerifiedExtensionManifest( manifest: SignedDeviceManifest, ): Promise { const issuerId = manifest?.body?.issuerDeviceId; if (typeof issuerId !== "string" || !issuerId) return; const issuer = await findPairedDeviceById(issuerId); if ( !issuer || issuer.deviceType !== "extension" || !issuer.signingPublicKeyHex || !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex) ) { return; } let body: Awaited>; try { body = await verifyDeviceManifest( manifest, hexToPubkey(issuer.signingPublicKeyHex), ); } catch { return; } if (body.issuerDeviceId !== issuer.deviceId) return; const ourDeviceId = await getOrCreateDeviceId(); for (const entry of body.devices) { if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) { continue; } const existing = await findPairedDeviceById(entry.deviceId); await upsertPairedDevice({ ...(existing ?? {}), deviceId: entry.deviceId, deviceName: entry.deviceName, deviceType: entry.deviceType, longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(), signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(), pairedAt: existing?.pairedAt ?? entry.pairedAt, trustSource: existing?.trustSource ?? (existing ? "direct" : "vouched"), wsPort: existing?.wsPort ?? entry.wsPort, lastSyncedAt: existing?.lastSyncedAt, }); } } function requireUnlocked(): void { if (!isUnlocked()) throw new VaultLockedError(); } function requireMasterKey(): Uint8Array { if (!isUnlocked()) throw new VaultLockedError(); const hex = getDerivedKeyHex(); if (!hex) throw new VaultLockedError(); return hexToBytes(hex); } async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise { const info = new TextEncoder().encode(entryId + ENTRY_KEY_INFO_SUFFIX); const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32); return crypto.subtle.importKey( "raw", raw.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"], ); } async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise { const plaintext = new TextEncoder().encode(JSON.stringify(entry)); const nonce = crypto.getRandomValues(new Uint8Array(12)); const aad = new TextEncoder().encode(ENTRY_AAD); const ct = await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128, }, key, plaintext.buffer as ArrayBuffer, ); const ctBytes = new Uint8Array(ct); const envelope = new Uint8Array(nonce.length + ctBytes.length); envelope.set(nonce, 0); envelope.set(ctBytes, nonce.length); return bytesToBase64(envelope); } async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise { try { const bytes = base64ToBytes(envelopeB64); if (bytes.length < 12 + 16) return null; const nonce = bytes.subarray(0, 12); const ct = bytes.subarray(12); const aad = new TextEncoder().encode(ENTRY_AAD); const pt = await crypto.subtle.decrypt( { name: "AES-GCM", iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128, }, key, ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer, ); const json = new TextDecoder().decode(pt); const parsed = JSON.parse(json); if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null; return parsed as VaultEntry; } catch { return null; } } function hexToBytes(hex: string): Uint8Array { if (hex.length % 2 !== 0) throw new Error("hex length must be even"); const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) { out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); } return out; } function bytesToBase64(bytes: Uint8Array): string { let bin = ""; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin); } function base64ToBytes(b64: string): Uint8Array { const bin = atob(b64); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; }