This site is driven by two keys, like the machine it came from. Reading: j and k scroll, d and u move half a page, gg goes to the top, G to the bottom, H and L switch windows, ? opens the help. Space is the Neovim leader and handles content: Space then h home, r research, p projects, g gear, a about, / tags, or Space then a digit to jump to that window. Home is the tmux prefix and handles windows: Home then c opens a terminal, Home then & closes a window, Home then space goes to the next one. While focus is on the keyboard, h j k l move between keys and Enter opens one.

ResearchR AboutA Gear — G for gear — keyboard, terminal, editor, homelab.G
    ProjectsP Tags — / as in vim: search./
    ×
    Menu

    otm: One-Time Messages

    Paste a message, get one link, and the link dies once it's opened. Go + chi + SQLite, with layered encryption: a random key per message, and that key itself encrypted with a master key.

    otmone-time message. Paste something that has no business sitting in a chat log forever, get one link, and that link stops working the moment it’s opened.

    The motivation is mundane and recurring: sending someone a temporary credential and then realising the thing will live forever in two people’s WhatsApp history.

    Two layers of encryption

    What separates otm from an INSERT into a table is how the key is stored. Each message gets its own key, and that key is what gets encrypted:

    msgKey, _ := GenerateRandomKey()                        // 32 random bytes, per message
    cipherText, _ := Encrypt(plaintext, msgKey, nonceMsg)   // AES-GCM
    encryptedKey, _ := Encrypt(msgKey, masterKey, nonceKey) // AES-GCM again

    What lands in the SQLite row is only encrypted_text, encrypted_key and two separate nonces. The master key never touches the database — it’s read from the environment, and the process refuses to start if it isn’t 32 bytes:

    if len(secret) != 44 { // base64 of 32 bytes = 44 chars
        log.Fatal("SECRET_KEY must be a base64-encoded 32-byte key")
    }

    Failing at boot is much better than failing when the first message gets read.

    The limit is worth stating plainly: this is not end-to-end encryption. The server holds the master key, so the server can read the message. What it protects against is a leaked database file or a backup copied somewhere it shouldn’t be — not the server itself.

    Expiry and read-once

    Every message carries an expires_at. If expires_in isn’t supplied, the default is a hundred years — effectively never, but still the same single column, with no NULL to check for everywhere.

    The check happens on read, not in a background sweeper. A row past its time is deleted at the exact moment someone tries to open it:

    if time.Now().After(msg.ExpiresAt) {
        storage.DeleteMessage(db.Conn, id)
        http.Error(w, "Message expired", http.StatusGone)
        return
    }

    One thing worth recording as it is: the API accepts a read_once field, but the handler currently overwrites it with readOnce := true. So every message is read-once, whatever the client sends. The field stays in the request for when the behaviour becomes selectable; until then, the hardcoded line is what actually applies.

    Everything else is small

    • A chi router with one simple rate-limit middleware: 10 requests per 20 seconds per IP, held in an in-memory map behind a mutex.
    • SQLite via mattn/go-sqlite3. For this load, one file is far easier to back up than a database server.
    • A separate log table records creation and reads with IP and country — the message body is never logged.
    • The interface is plain html/template with a PWA manifest, no front-end framework.

    The module has five direct dependencies, three of which are chi, uuid and the SQLite driver.

    The rate limiter that blocked the one thing it was there for

    The first limiter was mounted globally through r.Use, so it counted /static/* as well. A single page load pulls seven requests — the index plus six PWA assets: three favicons, apple-touch-icon, icon-192, and the webmanifest — against a burst of three.

    So a first visit always tripped the 30-second cooldown, and the request right after it — sending the message itself — came back 429. The only thing that limiter reliably protected was the application’s main function.

    What made it hard to see: the symptom went away on its own for a while. Cloudflare began serving the assets from cache, so only about three requests reached the app — right at the burst threshold. That was not a fix, only a coincidence. Once the cache went cold, the PoP changed, or two visitors arrived at once, it came back.

    The fix: the limiter now wraps /api only, and the burst went up to ten because it no longer has to carry static assets.

    What it could not do, and what came next

    There is a limit here that no patch reaches. The master key lives on the server, and every message key is encrypted with it — so the server can decrypt everything it holds. Splitting the key, rotating it, or moving it into a vault all move the problem without removing it, because the server has to be able to read the master key in order to use it.

    That is fine for what this is: a working demonstration of applied cryptography, which is why the post stays up and the repo stays listed. It is not fine for a tool that sells the word secret.

    sirna is the successor, and it changes the promise rather than the implementation: the key never reaches the server at all, and destroying it kills every copy of the ciphertext at once — including copies nobody controls.

    id en
    rss gh in