sotto voce - "in a low voice." A privacy-first web messenger.
Register with a handle and password, then chat 1:1 with end-to-end encryption, disappearing messages, and an ephemeral secret-chat mode. The server only ever sees ciphertext, and never sees your private key.
- End-to-end encryption. Every message is sealed to the recipient's public
key with X25519
crypto_boxbefore it leaves the browser. - No personal data. Handle + password only. No phone, no email.
- Disappearing messages. Per-message timer; the server purges expired rows and the client counts them down and removes them.
- Secret chats. Never written to disk - relayed only while both parties are online, dropped if the recipient is away.
- Safety-number verification. Compare a symmetric 60-digit number derived from both public keys to rule out anyone in the middle.
- Identity at a glance. Deterministic identicons from each public key.
- Stays logged in. Session (and unwrapped private key) persists locally so a refresh keeps you in. Delivered ticks, typing indicators, presence, day separators, unread counts in the tab title.
- Six themes. Charcoal, Midnight, Plum, Terminal, Mist, Sand - switchable in Settings and remembered per device.
- From your password, the browser derives two keys with Argon2id: an
authKey(sent to the server to log in) and awrapKeythat never leaves the device. - Your X25519 keypair is generated locally. The public key is uploaded; the
private key is encrypted with
wrapKeyand only the opaque blob is stored. - Messages are encrypted client-side; the server stores/relays ciphertext only.
The server is honest-but-curious: it can see who talks to whom and when, but not what is said. Forward secrecy (double-ratchet) is a documented non-goal for v1.
Persistence tradeoff: keeping you logged in across refreshes means the
unwrapped private key sits in localStorage. It never leaves the device, but a
successful XSS could read it. Log out to clear it. A future hardening step is to
re-derive the key from the password on each launch instead.
- Server: Node + Fastify +
ws+ SQLite (better-sqlite3), Argon2id, JWT. - Client: Vite + React + TypeScript,
libsodium-wrappers-sumo, Satoshi + JetBrains Mono.
npm install
# terminal 1 - API + WebSocket on :3300
npm run dev --workspace @sotto/server
# terminal 2 - Vite dev server on :5173 (proxies /api and /ws to :3300)
npm run dev --workspace @sotto/clientOpen the Vite URL, create two accounts in two browser profiles, message between them.
npm test --workspace @sotto/server # db, auth, keys, messages, http, ws
npm test --workspace @sotto/client # crypto, identicon, safety number, session, time, theme| Var | Default | Purpose |
|---|---|---|
PORT |
3300 |
Server listen port |
DB_PATH |
sotto.db |
SQLite file path |
JWT_SECRET |
dev-secret-change-me |
Session signing secret (set in prod) |