All Posts

8 November 202610 menit

Setelah v0.7.1 rilis, roadmap saya cukup jelas: v0.8.0 soal enkripsi at-rest dan audit trail outbox. Dua-duanya bukan fitur yang bikin demo terlihat keren. Nggak ada animasi, nggak ada playground baru, dan kalau dipresentasikan di depan orang, mungkin cuma jadi satu baris di changelog.

Tapi justru di situ letak masalahnya. Selama beberapa versi, Syncraft menyimpan state di IndexedDB apa adanya. Untuk aplikasi catatan atau todo, itu wajar. Untuk aplikasi yang menyimpan data kesehatan, dokumen legal, atau informasi finansial, plaintext di disk adalah masalah compliance yang sulit diabaikan. Outbox juga punya kebiasaan lain yang sama mengganggu: setelah mutasi berhasil dikirim, catatannya langsung dihapus. Padahal justru catatan itu yang dibutuhkan saat ada yang bertanya, "perubahan ini datang dari mana?"

Jadi v0.8.0 saya arahkan ke dua hal itu. Rilisnya membawa enkripsi AES-GCM 256-bit, arsip outbox untuk audit, plus contoh produksi dan panduan backend yang menurut saya sudah lama hilang dari dokumentasi.

Enkripsi yang opsional, dan tetap opsional

Ada satu batasan yang saya pegang sejak awal: enkripsi tidak boleh mengubah cara orang memakai Syncraft. Bukan fitur yang wajib dinyalakan, bukan konfigurasi baru yang harus dipahami sebelum createSyncStore bisa jalan. Kalau tidak butuh, cukup diamkan, dan semuanya berjalan seperti v0.7.1.

Karena itu encryptionKey sifatnya opt-in. Kalau diisi, state yang ditulis ke IndexedDB akan dienkripsi, lalu didekripsi saat dibaca kembali. Kalau tidak diisi, tidak ada perubahan perilaku sama sekali.

Pilihannya jatuh ke AES-GCM 256-bit lewat Web Crypto API. Bukan karena algoritma itu paling baru, tapi karena dia authenticated encryption. Artinya, selain menyembunyikan isi, dia juga bisa membuktikan bahwa ciphertext belum diubah. Web Crypto juga sudah ada di browser, jadi tidak ada dependency kripto baru yang harus diaudit.

import { createSyncStore, generateEncryptionKey } from "@syncraft-labs/core";

const key = await generateEncryptionKey(256);

const store = createSyncStore<VaultState>({
  storageKey: "user-vault",
  initialState: { secrets: [] },
  encryptionKey: key,
});

await store.hydrate();

Di React dan Vue, opsinya diteruskan lewat useSync. Bentuk pemakaiannya tetap sama, cuma ada satu properti tambahan.

const { data, isHydrating, error } = useSync<VaultState>("user-vault", {
  initialState: { secrets: [] },
  encryptionKey: sessionKey,
});

Kalau isHydrating masih true, artinya proses dekripsi belum selesai. Kalau error terisi, bisa jadi kuncinya salah atau ciphertext-nya berubah. Dua kondisi itu memang perlu ditampilkan ke pengguna, bukan disembunyikan.

Apa yang dilindungi, dan apa yang tidak

Enkripsi at-rest sering terdengar seperti payung yang melindungi segalanya. Padahal cakupannya spesifik. Saya tulis threat model-nya di dokumentasi supaya tidak ada ekspektasi yang salah.

SkenarioTerlindungi?Penjelasan
Perangkat hilang atau disk di-imageYaIndexedDB hanya berisi ciphertext. Tanpa kunci, isinya tidak bisa dibaca.
Ekstraksi forensik profil browserYaFile mentah dari profil browser tidak mengandung state plaintext.
Ciphertext diubah atau bit-flippingYaAuth tag AES-GCM akan gagal diverifikasi dan operasi baca melempar SyncraftError.
Serangan downgradeYaStore yang punya encryptionKey menolak data plaintext, bukan menerimanya diam-diam.
Cross-site scripting (XSS)TidakPenyerang yang bisa menjalankan JavaScript di origin yang sama bisa membaca state dari memori.

Baris terakhir itu penting. Enkripsi at-rest bukan enkripsi end-to-end. Data tetap plaintext di memori aplikasi, dan tetap dikirim ke server sesuai kontrak pusher yang kamu tentukan. Kalau ada XSS, kunci yang sedang aktif di memori juga ikut terbaca. Mitigasinya tetap CSP yang ketat, sanitasi input, dan kebiasaan lama yang tidak boleh dilupakan: jangan simpan rahasia di tempat yang tidak perlu.

Kunci adalah tanggung jawab aplikasi

Ini bagian yang paling sering saya ulang di dokumentasi: library tidak pernah menyimpan kuncimu.

Syncraft tidak menulis CryptoKey, passphrase, atau byte mentah ke IndexedDB, LocalStorage, sessionStorage, cookie, atau media persisten lainnya. Kunci hidup di memori aplikasi. Aplikasi yang bertanggung jawab menurunkannya saat login, menyimpannya di context atau module variable selama sesi, dan menghapusnya saat logout.

Ada tiga bentuk input yang diterima encryptionKey:

  1. CryptoKey dari Web Crypto, direkomendasikan karena tidak ada derivasi tambahan di tengah jalan.
  2. string passphrase, yang otomatis diturunkan menjadi kunci AES-GCM 256-bit lewat PBKDF2 dengan 100.000 iterasi SHA-256.
  3. Uint8Array berukuran 16, 24, atau 32 byte untuk kunci mentah 128, 192, atau 256 bit.

Untuk passphrase, salt-nya deterministik: syncraft-salt:${storageKey}. Artinya, passphrase yang sama dengan storageKey yang sama akan menghasilkan kunci yang sama. Kalau storageKey diganti, kunci turunannya ikut berubah, dan data lama tidak bisa dibuka. Itu bukan bug, tapi konsekuensi dari desain yang harus diketahui sebelum memilih passphrase sebagai strategi.

Ada juga utilitas exportEncryptionKey dan importEncryptionKey untuk memindahkan kunci dalam bentuk base64. Tapi sekali lagi, tempat menyimpan hasil ekspor itu sepenuhnya tanggung jawab aplikasi. Library tidak akan menyimpannya untukmu.

Dan ini konsekuensi yang paling keras: kalau kunci atau passphrase hilang, data di IndexedDB tidak bisa dipulihkan. Tidak oleh library, tidak oleh server, tidak oleh siapa pun. AES-GCM tidak punya pintu belakang. Saya lebih suka menuliskannya sebagai peringatan tebal di dokumentasi daripada membiarkan orang menemukannya saat panic.

IV baru setiap kali menulis

Detail implementasi yang mungkin terlihat kecil, tapi menentukan: setiap operasi tulis memakai IV acak baru sepanjang 12 byte. IV atau nonce yang dipakai ulang di AES-GCM bisa membocorkan informasi, jadi ini bukan bagian yang boleh dioptimasi dengan cara "pakai IV yang sama biar hemat".

Payload yang disimpan berbentuk seperti ini:

{
  "__syncraft_encrypted": true,
  "iv": "Uint8Array(12)",
  "data": "ArrayBuffer"
}

Saat dibaca, decryptData memverifikasi auth tag sebelum mengembalikan state. Kalau ciphertext berubah sedikit saja, operasinya gagal dan error-nya dinormalisasi menjadi SyncraftError dengan source: "store" dan retryable: false. Tidak ada fallback diam-diam ke data plaintext.

Untuk mode collection, enkripsi dilakukan per entity. Jadi ketika satu baris berubah, yang dienkripsi ulang juga hanya entity itu, bukan seluruh dataset. Ini penting untuk state besar yang memakai storageMode: "collection".

Harga yang dibayar

Enkripsi tidak gratis, tapi di ukuran state yang wajar biayanya kecil. Ini angka dari benchmark internal proyek, dijalankan di browser dengan akselerasi AES-NI:

Ukuran stateEncryptDecryptWrite plaintextWrite terenkripsi
1 KB~0.8 ms~0.6 ms~1.5 ms~1.8 ms
10 KB~1.1 ms~1.0 ms~2.0 ms~2.4 ms
100 KB~3.2 ms~2.8 ms~8.0 ms~9.5 ms
1 MB~18 ms~14 ms~28 ms~35 ms

Sampai 100 KB, total write terenkripsi masih di bawah budget frame 16 ms. Di 1 MB, angkanya mulai terasa, dan di situ rekomendasinya pindah ke collection mode supaya enkripsi hanya menyentuh entity yang berubah. Angka ini tetap perlu dibaca sebagai gambaran, bukan jaminan. Perangkat kelas bawah, browser yang berbeda, dan state yang bentuknya kompleks bisa memberi hasil yang lain.

Yang lebih penting: benchmark ini internal. Saya belum mengklaim angka ini sebagai hasil pengujian independen. Untuk v0.10.0 nanti, salah satu targetnya justru membuka coverage dan benchmark supaya orang lain bisa memeriksa sendiri.

Outbox yang selama ini langsung hilang

Kalau enkripsi menyelesaikan masalah data yang diam di disk, audit trail menyelesaikan masalah data yang bergerak.

Secara default, setelah pusher berhasil mengirim mutasi, Syncraft memanggil clearOutbox dan entri itu dihapus. Untuk aplikasi biasa, ini benar: outbox adalah antrean sementara, bukan arsip. Tapi untuk tim yang butuh jejak perubahan, keputusan ini meninggalkan lubang. Saat ada insiden atau pertanyaan "siapa mengubah field ini", catatannya sudah tidak ada.

v0.8.0 menambahkan retainSyncedEntries: true. Kalau diaktifkan, clearOutbox tidak menghapus entri, melainkan memindahkannya secara atomik ke object store outbox_archive. Entri yang diarsipkan mendapat dua field tambahan: archivedAt dan syncStatus: "synced".

const store = createSyncStore<AppState>({
  storageKey: "audit-store",
  retainSyncedEntries: true,
});

const history = await store.getArchivedOutbox();

const deleted = await store.clearArchivedOutbox({
  olderThan: Date.now() - 30 * 24 * 60 * 60 * 1000,
});

getArchivedOutbox() mengembalikan riwayat yang sudah diurutkan secara kronologis. clearArchivedOutbox({ olderThan }) menghapus arsip yang lebih lama dari batas tertentu dan mengembalikan jumlah entri yang dihapus. Kalau dipanggil tanpa opsi, semua arsip dibersihkan.

Di balik layar, database naik ke schema versi 4. Store outbox_archive dibuat dengan index timestamp dan archivedAt, dan migrasi dari database lama berjalan otomatis. Jadi pengguna yang sudah punya data dari versi sebelumnya tidak perlu menghapus IndexedDB atau menjalankan skrip migrasi manual.

Arsip ini bukan event sourcing. Dia tidak membangun ulang state dari nol, tidak menggantikan database server, dan tidak otomatis menyelesaikan konflik. Fungsinya lebih sederhana: menyimpan jejak mutasi yang sudah dikirim supaya bisa diperiksa, diaudit, atau dipakai saat debugging. Retention-nya juga sepenuhnya keputusan aplikasi. Kalau dibiarkan tanpa pembersihan, arsip akan tumbuh dan memakan kuota storage. Karena itu fitur ini paling masuk akal dipasangkan dengan onQuotaExceeded dan kebijakan retensi yang jelas.

Satu catatan jujur: yang dienkripsi di v0.8.0 adalah state dan entity. Entri outbox masih menyimpan patch sebagai data biasa. Jadi untuk data yang sangat sensitif, patch yang belum terkirim atau yang tersimpan di arsip belum mendapat perlindungan yang sama dengan state. Ini saya catat sebagai pekerjaan berikutnya, bukan sesuatu yang saya klaim sudah selesai.

Dokumentasi yang ikut naik kelas

Rilis ini juga menambah tiga halaman yang menurut saya mengubah cara orang mencoba Syncraft.

Pertama, contoh Kanban board yang benar-benar offline-first. Ada optimistic update, penyimpanan IndexedDB, sinkronisasi background, dan listener WebSocket yang memanggil applyRemoteState. Contohnya menunjukkan dua pendekatan backend: mengirim patch granular lewat POST /sync, atau mengirim snapshot penuh lewat PUT biasa. Yang kedua lebih sederhana dan cocok untuk tim yang tidak ingin mem-parse JSON Patch di server.

Kedua, panduan scaling dan performance. Isinya bukan trik sintaks, tapi pola arsitektur: normalisasi state menjadi byId dan allIds, memilih satu tab leader lewat Web Locks API supaya tidak ada lima koneksi WebSocket dari lima tab, memadatkan outbox dengan compactOutbox, menangani kuota dengan eviction LRU, memakai React.memo untuk baris yang tidak berubah, dan menyalakan enkripsi hanya untuk store yang memang sensitif.

Ketiga, panduan integrasi backend untuk lima bahasa: Node.js, Go, Python, Java, dan C#. Masing-masing punya contoh untuk REST PUT dan granular JSON Patch. Panduan itu juga membahas hal-hal yang biasanya baru terpikir setelah produksi: idempotency lewat tabel processed_mutations, row locking dengan SELECT ... FOR UPDATE, konversi path dari array ["cards", "c-1"] menjadi pointer RFC 6902 /cards/c-1, dan fan-out WebSocket setelah transaksi commit.

Bagi saya, dokumentasi semacam ini bukan pelengkap rilis. Library yang tidak punya contoh produksi akan selalu terasa lebih sulit daripada sebenarnya. Dan contoh yang bisa di-copy-paste dengan percaya diri lebih berguna daripada satu halaman API reference yang lengkap tapi dingin.

Rilis yang membosankan, dan itu pujian

v0.8.0 tidak punya breaking changes. Semua API publik, hook, composable, dan type tetap kompatibel. Upgrade-nya cukup satu baris:

npm install @syncraft-labs/core@0.8.0 @syncraft-labs/react@0.8.0 @syncraft-labs/vue@0.8.0

Verifikasinya juga tidak dramatis: tujuh test suite lulus, astro check && tsc --noEmit bersih tanpa error dan warning, empat package berhasil di-build, dan nomor versi tersinkron di seluruh monorepo.

Justru itu yang saya inginkan dari rilis keamanan. Fitur keamanan yang baik seharusnya tidak memaksa orang mengubah arsitektur, tidak menambah langkah setup yang rumit, dan tidak membuat aplikasi yang sudah jalan tiba-tiba rusak. Dia cukup ada, opsional, dan bisa dinyalakan saat dibutuhkan.

Yang belum selesai

Setelah v0.8.0, posisinya begini. Syncraft sudah punya enkripsi at-rest, audit trail, conflict resolution, quota recovery, dan observability. Tapi masih ada batas yang jelas.

Enkripsi tidak melindungi dari XSS. Kehilangan kunci berarti kehilangan data, tanpa jalur pemulihan. Outbox dan arsipnya belum ikut dienkripsi. Belum ada alur rotasi kunci yang terintegrasi, jadi mengganti kunci berarti memikirkan migrasi data sendiri. Dan benchmark yang saya tulis masih internal, belum diverifikasi pihak lain.

Roadmap berikutnya tetap seperti yang saya tulis sebelumnya: v0.9.0 untuk dogfooding dan audit ergonomics API, v0.10.0 untuk coverage publik, benchmark, dan soft API freeze, lalu v1.0.0 dengan komitmen semver dan proses RFC formal. Saya tidak mau mempercepat urutan itu hanya karena fitur keamanan sudah ada. Justru setelah enkripsi masuk, pertanyaannya jadi lebih besar: apakah API-nya masih enak dipakai saat aplikasi tumbuh?

Kalau ditarik mundur, v0.8.0 mengingatkan saya bahwa keamanan yang baik itu membosankan. Tidak ada demo yang memukau, tidak ada metrik yang melonjak. Yang ada cuma ciphertext di disk, arsip yang tidak lagi hilang, dan sederet keputusan kecil yang membuat aplikasi lebih jujur soal data yang disimpannya.

Dari tool internal kecil, melewati insiden supply chain, sampai rilis yang akhirnya berani menyentuh enkripsi dan audit. Perjalanan Syncraft Labs masih jauh dari selesai, dan itu tetap bagian yang paling saya nikmati.

Kalau kamu sedang membangun aplikasi local-first yang menyimpan data sensitif, atau sedang memikirkan cara membuat audit trail tanpa menambah database baru, kabari saya. Kotak masuk email saya selalu terbuka.