Situs ini dikemudikan dua tombol, seperti mesin pemiliknya. Membaca: j dan k menggulir, d dan u setengah layar, gg ke atas, G ke bawah, H dan L pindah jendela, ? membuka panduan. Spasi adalah kunci leader Neovim dan mengurus isi: Spasi lalu h beranda, r riset, p proyek, g gear, a tentang, / tag, atau Spasi lalu angka untuk melompat ke jendela bernomor itu. Home adalah prefix tmux dan mengurus jendela: Home lalu c membuka terminal, Home lalu & menutup jendela, Home lalu spasi ke jendela berikutnya. Saat fokus ada di papan ketik, h j k l memindahkan pilihan antar tombol dan Enter membukanya.

RisetR TentangA Gear — G untuk gear — papan ketik, terminal, editor, homelab.G
    ProyekP Tag — / seperti di vim: cari./
    ×
    Menu

    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.

    otmone-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/template biasa 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.

    id en
    rss gh in