Hush
A local-first, direct-only browser chat app. Discord-inspired rooms and direct messages, cryptographic accounts, password-encrypted local history, safety numbers, and encrypted account backups. Maculab sign-in and persistent profiles, with no message relay.
Hosted app
Open https://hush.maculab.dev or the Hush tab next to Explore on Maculab. Source is published at https://maculab.dev/admin/hush.
- Choose Sign in with Maculab. Your existing Maculab account is your Hush account; the app requests only OpenID/profile access. Maculab handles its own password and any two-factor authentication.
- On first use, set a separate chat passphrase (12–1,024 characters). Hush uses it locally through OPAQUE and Argon2id to protect your identity. It cannot be reset. Keep it safe and export a
.hushbackup. - Your Hush ID is visible in the sidebar and through My ID in the top bar, including on mobile. Copy Hush ID copies all 64 hex characters. It is a public fingerprint, not a secret or a connection code.
- Set your display name and upload a profile picture in account settings. These public details persist on the VPS. Pictures are decoded, resized and re-encoded without metadata; no arbitrary SVG or remote image URL uploads are accepted.
- On another browser, sign in with Maculab and enter the same chat passphrase to recover the same identity and profile. Contacts, rooms and history do not sync. Transfer those only using your encrypted local backup.
Existing local accounts can be linked from settings using their existing vault passphrase, preserving their Hush ID. If you used localhost before hosting, export there and restore at the hosted address first; browser storage is isolated by origin. Linking refuses to replace an identity already attached to your Maculab account.
What the VPS stores
SQLite stores the Maculab user ID association, public Hush ID/key/name, sanitized avatar, OPAQUE registration record, encrypted identity backup and hashed session tokens. No messages, notes, contacts, rooms, pairing codes, plaintext identity keys, or chat passwords are sent to this API. The identity backup uses XChaCha20-Poly1305 with a domain-separated key derived from OPAQUE's client-only export key. Public profiles are public. Network services can see account access metadata.
Maculab OAuth uses a confidential client, PKCE S256, one-use state with browser-bound HttpOnly cookies, a fixed callback, and profile-only scope. Its short-lived provider token is used for UserInfo and is not persisted. The stable Maculab numeric ID binds the Hush account even if a username changes. Server API cookies are HttpOnly, Secure and SameSite=Strict over HTTPS; mutations require the exact origin. OPAQUE registration/login are also restricted to the authenticated Maculab identity. A separate chat passphrase protects the identity even if someone gains access to Maculab login; it cannot protect against compromised application code or the device.
The deployed account service listens only on 127.0.0.1:4180, behind the existing Cloudflare Tunnel. Cloudflare serves web and account traffic; chat packets travel directly between browsers. The unprivileged systemd service can write only its data directory and connect only to loopback. Data lives in /var/lib/hush; confidential OAuth configuration is in /etc/hush/maculab.json, outside the source tree. Daily consistent SQLite snapshots and recovery secrets are encrypted by the existing Restic repository on R2. Message history is never part of server backups.
Self-hosting
Use Node 24 LTS or newer. Build locally with npm ci && npm run build, then deploy dist, src, server, package.json, and package-lock.json; run npm ci --omit=dev on the server. Set HUSH_ORIGIN, HUSH_DATA, and HUSH_PORT, then npm run start:hosted. To require Maculab login, set HUSH_MACULAB_CONFIG to a protected file following deploy/maculab.example.json and register its exact /auth/callback URL in Gitea. The example systemd unit uses /opt/hush-runtime/bin/node. HUSH_TRUST_PROXY=1 is only for a loopback Cloudflare Tunnel origin, where CF-Connecting-IP is trusted. Do not expose the service directly in that mode. Without Maculab configuration, development instances allow standalone OPAQUE accounts.
Back up the SQLite database and opaque-setup and OAuth config; changing the OPAQUE setup breaks recovery of existing hosted identities. deploy/hush-backup uses SQLite's online backup API and the existing /usr/local/sbin/maculab-restic wrapper. Restore while Hush is stopped, with ownership hush:hush and mode 0600 on its data files; retain the OAuth callback/client registration. Restore metadata independently of locally stored messages.
Local-only mode
For the original local-only mode, run npm start from this folder, then open http://127.0.0.1:4178 in a current Chrome, Firefox, Edge, or Safari browser. On this Mac, double-click Hush.command on the Desktop to start it. Keep its Terminal window open while using the app. Keep using the same browser profile and exact address: browser storage belongs to an origin. localhost and 127.0.0.1 have different vaults.
The local server serves the compiled app files. It has no account, message, analytics, or upload endpoints, accepts only GET/HEAD, and listens on loopback. Message content is never sent to it. All dependencies and assets are bundled locally, without CDNs or remote fonts. The production service worker caches only the app files for offline use after the first load.
For another device, copy this project, install Node.js, run npm ci, npm run build, then npm start on that device. Each person runs their own copy and creates their own account.
Start a conversation
- Create a local account with a memorable, strong passphrase (at least 12 characters; several randomly chosen words are better). There is no central registration or password reset.
- Export an encrypted
.hushaccount backup. Store your passphrase separately. Browser cleanup or an incognito session can erase your only local copy. - Choose the purple Connect a friend button above any chat. It prepares an invitation immediately, or reopens the one already waiting. Choose Copy invite and paste it into a private chat with your friend. The copied message includes the Hush address, instructions, and the complete signed code. Share invite… opens the device’s share chooser where supported; you choose the app and recipient. Copy code only and Select all remain available. Keep Hush open.
- Your friend opens Hush and chooses I have a code. They choose Paste & continue, or paste the whole invite message or code into the box. Pasting automatically opens the identity review. They compare the safety number and choose Accept & create reply. Copy reply gives them a ready-to-send answer to return to you.
- Use Paste & continue in your original invitation dialog, or paste their whole reply into Their answer. Review the identity and choose Connect now. Both people’s chats open automatically when the connection is ready. Compare the full safety number over a trusted channel and mark the contact verified in contact details.
- Wait for Direct connection ready. Messages are saved locally first; Delivered means the receiving browser saved its encrypted copy and returned a receipt.
Pair again after closing/reloading/locking either browser. The long-term account identity, contacts and history remain. Queued messages stay encrypted on the sender's device until both peers reconnect. Closing the pairing dialog keeps a pending invitation alive for up to ten minutes. The live countdown shows the remaining time; replies share the original invitation’s deadline. Choose Connect beside a saved friend to prepare a targeted invite immediately. Reopening their reconnect dialog keeps that same pending invite. Use Back during identity review to return without discarding it. Accepting a friend’s offer replaces any other pending invite and releases its connection. Connect a friend reopens your current invitation or answer without generating a replacement. Expired or failed invites show Create a fresh invite and I have a new code. An accepted reply that cannot establish a direct connection within 45 seconds also shows recovery controls. Retrying a saved friend keeps their identity pinned. The invite wrapper is processed locally; the original signature, identity, expiry and reply checks still apply. No invite is put in a URL or uploaded to the account service.
Getting around
- Press ⌘K on Mac or Ctrl+K elsewhere to jump to a person or room. Use the arrow keys and Enter to choose.
- Search the current conversation with Search messages. Matches are highlighted locally.
- Toggle Conversation info in the upper-right corner for members, message counts, and encrypted backup.
- On a phone or small window, open Conversations from the upper-left button. Choosing a conversation closes the drawer.
- Messages group by author and date. Reading older messages preserves your position; Jump to latest returns to the newest message. Drafts survive switching rooms and opening panels while the account stays unlocked.
If an existing browser tab still shows the older design after an update, close all Hush tabs and reopen the same address so the updated offline app can activate. Do not clear site data; that contains your encrypted account.
Private rooms
Pair with the people you want to invite, then create a room. Invitations are delivered through existing direct connections and require acceptance. Every participant must pair directly with every other participant to receive everyone's messages: there is no group host forwarding messages. A group supports up to eight people. Its membership is fixed in this version; create a new room to change the roster. Members can decline invitations. A sender's queued messages cannot be delivered to someone who never joins.
Encryption
- Messages:
age-encryption(Typage, maintained by age's author), using native hybrid ML-KEM-768 + X25519 recipients. Every WebRTC pairing generates fresh age identity keys in memory. Each protocol packet is age-encrypted to the remote session key before entering the data channel. Session IDs and monotonically increasing counters reject replayed/cross-session packets. There is no classical-only recipient fallback. - Transport: standard browser WebRTC data channels with DTLS.
iceServersis empty. There is no application signaling server, STUN service, TURN relay, peer directory, DHT or remote bootstrap. - Identity: Ed25519 signing keys generated with libsodium. Signed connection codes bind the age recipient, DTLS certificate fingerprint, session, expiry, and reply to the sender's identity. Replies are bound to the exact offer. Existing-contact reconnects pin the expected identity. Safety-number verification authenticates the human behind a key; a self-signature alone does not do that.
- Local vault: libsodium Argon2id v1.3 with 64 MiB memory and 3 passes, deriving a 256-bit key; XChaCha20-Poly1305 authenticated encryption with a fresh random 192-bit nonce on every save. Account ID and format are authenticated additional data. Private identity keys, contacts, messages and queued deliveries are inside the ciphertext. Display name, account ID and timestamps are unencrypted local metadata.
- Backups: the same encrypted vault format, downloaded directly from memory. Imports validate fixed KDF limits, authenticate the ciphertext and refuse to overwrite an existing identity. The
.hushbackup is an application vault, not an age CLI file.
Honest limits
This is an unaudited early version, not a guarantee of the best possible security. The message layer has hybrid post-quantum confidentiality; the identity signatures and DTLS authentication remain classical. It does not provide post-quantum authentication, Signal's Double Ratchet, per-message key erasure, deniability, or post-compromise recovery. Fresh age keys are discarded when a connection is released, but JavaScript garbage collection cannot guarantee physical memory erasure. Local history is intentionally retained under the vault key.
Compromised browsers/devices/extensions, malicious replacement app code, recipients taking screenshots, and unlocked accounts are outside its protections. Peers learn network addresses, timing and approximate message size. Code exchange through another app gives that app the pairing metadata (not your chat text); share codes privately and compare safety numbers. Hosted app updates are delivered as web code and cached by the service worker. A malicious hosting update can compromise unlocked keys and messages; hosting security and source review remain essential. Self-hosters should update dependencies and rebuild deliberately.
Direct-only is not universal internet connectivity. Same-LAN connections can work; firewalls, client isolation, mDNS filtering and NAT may block them. Compatible public addressing or an existing direct VPN may help. If your browser or macOS requests local-network access, direct LAN chat needs it. Hush never silently enables relaying or contacts a STUN service. No offline recipient delivery, message synchronization, file attachments or voice/video is implemented. Hosted profile lookup does not connect peers or forward messages.
Storage is browser IndexedDB, not a folder of readable text files. Hush asks for persistent storage, but browsers may refuse/evict it; encrypted backup is essential. Each account is restricted to one writable tab with Web Locks. Automatic lock defaults to 15 minutes of inactivity and closes network connections. Your operating system/browser's own backup and sync behavior is outside the app's control. A vault supports 10,000 messages and a 32 MiB ciphertext budget; export before clearing local history. Clearing a conversation only removes your copy and pending sends; it cannot delete recipients' copies.
Development and verification
npm ci
npm run dev
npm test
npm run build
npx playwright install chromium
npm run test:browser
On this Mac, ordinary local-interface UDP probes received no packets, so the WebRTC integration tests use an explicit loopback test configuration: HUSH_TEST_LOOPBACK=1 npm run test:browser. This enables Chromium’s loopback interface and interface enumeration inside temporary test contexts. It grants a test-only microphone permission for enumeration but never opens a microphone; the shipped app denies microphone and camera access. No user browser settings or firewall rules are changed. These tests establish real WebRTC/DTLS channels and inspect age ciphertext; they do not simulate the transport. Cross-device connectivity has not been verified on this network.
Tests cover wrong passwords, ciphertext and code tampering, fixed KDF limits, replay protection, age hybrid encryption, membership checks, group consent, real direct WebRTC delivery, receipts, HTML escaping, encrypted IndexedDB storage, local-only HTTP traffic, locking, queued delivery after re-pairing, encrypted backup restoration, and three-member rooms with the owner offline.
Primary references: Typage, age specification, libsodium password hashing, libsodium XChaCha20-Poly1305, WebRTC data channel security.
Security review, September 24, 2026
Fixed connection cleanup that could miss disconnected or still-starting peers; limited each peer to one reliable ordered channel; bounded pending connections and inbound/outbound work queues; made backup imports insert-only to prevent concurrent overwrite; bound asynchronous delivery to the active account; stopped late saves from reopening a locked UI; cleared visible plaintext and application references on pagehide; tightened protocol IDs and sanitized remote profiles. Pending room invitations are acknowledged only after successful acceptance into the local queue.
Regression coverage includes these fixes, persistent OPAQUE account recovery across server restart, wrong passwords, forged/replayed requests, profile authorization/CSRF, image validation and re-encoding, denied private file routes, Maculab OAuth browser-state binding/PKCE and account binding, fresh-browser identity/avatar recovery without message upload, and the direct chat/GUI tests above. npm audit reported zero known advisories at the review time. This is a focused review and test suite, not an independent security audit or proof that unknown vulnerabilities do not exist.
Account cryptography reference: OPAQUE export key, OPAQUE key stretching, Gitea OAuth2 provider. The OPAQUE client uses the library's memory-constrained Argon2id profile (64 MiB, 3 iterations). Adding arbitrary extra encryption/hash layers would not resolve browser, endpoint or recipient compromise.
Friend connection improvements, September 26, 2026
The pairing dialog now shows three clear steps, a live expiry countdown, a way back from identity review, and recovery controls for expired or failed connections. Reopening a targeted reconnect preserves its code; replacing an invitation closes the abandoned connection. Expired offers cannot be revived by newer replies. Asynchronous acceptance stays bound to the current account and pairing attempt.
Validation: 17 unit tests and 7 browser scenarios pass, including real two-browser encrypted delivery, reconnects, replacement of simultaneous invites, mobile expiry/failure recovery, account restoration, and three-person rooms. Browser transport tests use the documented loopback configuration; connectivity across separate devices and networks remains unverified.