otm: Pesan Sekali Baca
Tempel pesan, dapat satu tautan, tautan itu mati setelah dibuka. Go + chi + SQLite, dengan enkripsi berlapis: kunci acak per pesan, kunci itu sendiri dienkripsi dengan kunci induk.
otm — one-time message. Tempel sesuatu yang tidak pantas dikirim lewat chat yang tersimpan selamanya, dapat satu tautan, dan tautan itu berhenti berlaku begitu dibuka.
Motifnya sepele dan berulang: mengirim kredensial sementara ke orang lain, lalu sadar benda itu akan hidup selamanya di riwayat WhatsApp dua orang sekaligus.
Enkripsi berlapis dua
Yang membedakan otm dari sekadar INSERT ke tabel adalah cara kuncinya disimpan. Tiap pesan mendapat kuncinya sendiri, dan kunci itulah yang dienkripsi:
msgKey, _ := GenerateRandomKey() // 32 byte acak, per pesan
cipherText, _ := Encrypt(plaintext, msgKey, nonceMsg) // AES-GCM
encryptedKey, _ := Encrypt(msgKey, masterKey, nonceKey) // AES-GCM lagi
Yang tersimpan di baris SQLite hanya encrypted_text, encrypted_key, dan dua nonce terpisah. Kunci induknya tidak pernah menyentuh database — dia dibaca dari environment, dan proses menolak jalan kalau bukan 32 byte:
if len(secret) != 44 { // base64 dari 32 byte = 44 karakter
log.Fatal("SECRET_KEY must be a base64-encoded 32-byte key")
}
Gagal saat boot jauh lebih baik daripada gagal saat pesan pertama dibaca.
Perlu jujur soal batasnya: ini bukan enkripsi ujung ke ujung. Server memegang kunci induk, jadi server bisa membaca pesannya. Yang dilindungi adalah file database yang bocor atau backup yang ikut tersalin ke tempat yang salah — bukan servernya sendiri.
Kedaluwarsa dan sekali baca
Setiap pesan punya expires_at. Kalau expires_in tidak diisi, defaultnya seratus tahun — praktis tidak pernah kedaluwarsa, tapi tetap satu kolom yang sama, tanpa NULL yang harus dicek di mana-mana.
Pemeriksaannya dilakukan saat pesan dibaca, bukan oleh proses pembersih yang berjalan di latar. Baris yang sudah lewat waktunya dihapus tepat pada saat seseorang mencoba membukanya:
if time.Now().After(msg.ExpiresAt) {
storage.DeleteMessage(db.Conn, id)
http.Error(w, "Message expired", http.StatusGone)
return
}
Satu hal yang perlu dicatat apa adanya: API-nya menerima field read_once, tapi handler-nya saat ini menimpanya dengan readOnce := true. Jadi semua pesan sekali baca, apa pun yang dikirim klien. Field itu tetap ada di request untuk saat perilakunya dibuat bisa dipilih; sampai itu terjadi, yang berlaku adalah baris hardcode tersebut.
Rate limiter yang justru memblokir fungsi utamanya
Limiter pertama dipasang global lewat r.Use, jadi ia ikut menghitung
/static/*. Satu kali muat halaman menarik tujuh permintaan — index ditambah
enam aset PWA: tiga favicon, apple-touch-icon, icon-192, dan webmanifest —
sementara burst-nya hanya tiga.
Akibatnya kunjungan pertama selalu memicu cooldown 30 detik, dan permintaan berikutnya — yaitu pengiriman pesan itu sendiri — ditolak dengan 429. Jadi satu-satunya hal yang benar-benar dijaga limiter itu adalah fungsi utama aplikasinya.
Yang membuatnya sulit terlihat: gejalanya sempat hilang sendiri. Cloudflare mulai menyajikan asetnya dari cache, sehingga hanya sekitar tiga permintaan yang sampai ke aplikasi — pas di ambang burst. Itu bukan perbaikan, cuma kebetulan. Begitu cache dingin, PoP berganti, atau dua pengunjung datang bersamaan, gejalanya kembali.
Perbaikannya: limiter hanya membungkus /api, dan burst dinaikkan ke sepuluh
karena tidak lagi perlu menanggung beban aset statis.
Selebihnya kecil
- Router
chi, satu middleware rate limit sederhana: 10 permintaan per 20 detik per IP, disimpan di map dalam memori dengan mutex. - SQLite lewat
mattn/go-sqlite3. Untuk beban seperti ini, satu berkas jauh lebih mudah dicadangkan daripada satu server database. - Tabel log terpisah mencatat pembuatan dan pembacaan beserta IP dan negaranya — isi pesannya tidak pernah ikut tercatat.
- Antarmukanya
html/templatebiasa dengan manifest PWA, tanpa framework frontend.
Modulnya hanya punya lima dependensi langsung, dan tiga di antaranya adalah chi, uuid, dan driver SQLite.
Yang tidak bisa dilakukannya, dan apa yang menggantikan
Ada satu batas di sini yang tidak dijangkau tambalan apa pun. Kunci induknya hidup di server, dan tiap kunci pesan dienkripsi dengannya — jadi server bisa mendekripsi semua yang dipegangnya. Memecah kuncinya, memutarnya, atau memindahkannya ke vault sama-sama cuma menggeser masalahnya: server tetap harus bisa membaca kunci induk itu untuk memakainya.
Itu tidak apa-apa untuk benda seperti ini: peragaan kriptografi terapan yang memang jalan, dan karena itu tulisannya tetap ada dan repo-nya tetap terdaftar. Tapi itu tidak cukup untuk alat yang menjual kata rahasia.
sirna adalah penerusnya, dan yang diganti janjinya — bukan implementasinya: kuncinya tidak pernah sampai ke server sama sekali, dan menghancurkannya mematikan semua salinan sandi-teksnya sekaligus, termasuk salinan yang tidak dikendalikan siapa pun.