ONYX: self-hosted messenger with LAN mode — an indie project story
When you look at existing self-hosted messengers, you usually see one of two things: either complex infrastructure that's hard to deploy (Matrix/Synapse), or minimalism with no encryption. ONYX is an attempt to find the middle ground: easy to deploy, real E2E encryption, and the ability to work entirely in a local network without internet at all.
Project architecture
| Component |
Technology |
| Client |
Flutter (Android, Windows, macOS, Linux) |
| Server |
Node.js — Express + express-ws + ws |
| Database |
MariaDB + Redis (sessions, cache) |
| File storage |
S3-compatible (AWS SDK v3) |
| Transport |
WebSocket (wss://) + HTTP/REST |
| Encryption |
X25519 + XChaCha20-Poly1305 + AES-256-GCM |
LAN mode: works without internet
One of the key features of ONYX is the ability to communicate in a local network without internet at all. For this, a custom device auto-discovery mechanism was implemented.
Discovery protocol via UDP broadcast
Every client broadcasts a JSON packet to 255.255.255.255:45678 every 5 seconds:
{
"username": "alice",
"timestamp": 1710000000000,
"pubkey": "<X25519 public key, base64>"
}
All other clients listen on that port and upon receiving a packet update two tables:
username → source IP address
username → X25519 public key
No mDNS, no manual IP entry — just pure UDP broadcast. The public key is included directly in the broadcast packet, which allows encrypted communication to start immediately without an additional handshake.
Media transfer in LAN
Media files go through a separate channel on port 45679, in chunks of ~32 KB. Each chunk is encrypted independently with AES-256-GCM, which allows decryption and rendering to begin before the full file is received.
Encryption: two layers on elliptic curves
No RSA anywhere in the project — only a modern elliptic curve stack.
E2EE scheme for private chats
- Key exchange: X25519 ECDH with an ephemeral key pair per session
- Key derivation: HKDF-SHA256 with a context label (
onyx-lan-v2 for LAN, separate labels for E2EE chats)
- Encryption: XChaCha20-Poly1305 AEAD
Packet format:
[pubkey 32B] [nonce 12B] [ciphertext] [mac 16B]
Why XChaCha20-Poly1305 and not AES-GCM?
AES-GCM requires hardware acceleration (AES-NI) for decent performance. XChaCha20-Poly1305 runs in constant time on any hardware — important for mobile devices without AES-NI. An additional bonus is the wider nonce (192 bits vs 96 for GCM), which reduces collision risk with a large number of messages in a single session.
AES-256-GCM is used for LAN media — chunked delivery, and hardware acceleration is available on most desktops.
Multi-device and E2EE
One of the trickiest cases — a user with multiple devices while keeping E2E encryption intact.
When a new device connects to an account, it sends an authorization request to a trusted device. The trusted device must explicitly approve the new one — only then does the key exchange happen. This means the server never has access to decrypted content, even when adding a new device.
Private messages and multi-device sync
Private chats work through the central ONYX server with E2EE. Technically, multi-device works like this: when a new message arrives, the server sends it to each of your devices separately — encrypted with that device's specific public key. Technically these are different encrypted messages for each device, just with the same plaintext inside.
One honest limitation: only incoming messages sync across devices. Outgoing messages are visible only on the device they were sent from. This is a deliberate tradeoff — full bidirectional sync with E2EE requires either a separate "copy to self" encryption mechanism or server-side plaintext storage. Both options are either more complex or less secure.
Why Flutter and not Electron or native development
The requirement from day one: one codebase for Windows, macOS, Linux and Android. Three options:
- Native development: 3–5 separate codebases, much more work and constant desync between platforms
- Electron: cross-platform yes, but Chromium in-process means +150–200 MB RAM on startup and DOM rendering instead of native
- Flutter: single codebase, Skia/Impeller rendering without DOM, real 60fps on animations
For a messenger with active animations, media and chats the rendering difference is significant. Flutter Desktop required writing 10+ separate optimization modules (fps_booster, fps_optimizer, fps_stimulator, message_load_optimizer, chat_preloader) — Flutter on desktop lags noticeably without tuning. But the result is smooth UI across all four platforms from one repo.
Desktop-specific integrations
- System tray — app minimizes to tray instead of closing. Online status is preserved.
- Single-instance — prevents multiple copies via IPC. A second launch focuses the existing window.
- Custom titlebar — system titlebar hidden, custom header with drag zone
- Windows-native notifications — separate module, not Flutter overlay
Security beyond E2EE
- PIN + biometrics — Face ID / fingerprint via Flutter Secure Storage
- Proxy support —
proxy_manager.dart, routing through any proxy
- Secure storage — all sensitive data through OS secure storage (Keychain / Android Keystore)
- Active session management — all connected devices are visible, any session can be terminated remotely — but only from a trusted device
Self-hosted groups and channels
Two types of groups and channels in ONYX — fundamentally different models.
Built-in groups and channels (via ONYX server)
Standard groups and channels work through the central ONYX server and are not encrypted — a deliberate tradeoff for reliable sync. Suitable for open communities where end-to-end encryption is not a requirement.
External groups and channels (self-hosted)
Anyone can run their own instance — on a VPS, home server, or directly in a local network. Use cases:
- Local network — file sharing and communication within an office or home network without internet
- Private community — closed group on your own VPS, join by invite
- Public channel — you host it, subscribers join and read posts, also by invite
A group is two-way communication — all participants can write. A channel is one-way broadcast — only admins publish, others read.
Connect to an external server directly from the app — enter the instance address and join the group or channel.
Deploying your own instance
There's a dedicated server software — ONYX Server, available at github.com/wardcore-dev/onyx-server:
git clone https://github.com/wardcore-dev/onyx-server
cd onyx-server
npm install
cp .env.example .env
node server.js
Dependencies: MariaDB + Redis + any S3-compatible storage (MinIO works for a fully local stack). Runs on a $5/month VPS.
Favorites: local notes and storage
ONYX has a dedicated Favorites tab — not a "Saved Messages" clone, but a proper local notebook. You can create any number of favorite chats, each with its own avatar and name, and use them as categories: passwords, ideas, saved media, links.
Everything is stored locally on the device — nothing is sent to the server, nothing is synced. The server knows nothing about your favorites.
Accounts: anonymity, multi-account and deletion
Registration requires only a username and password. No phone number, no email — a deliberate decision to keep minimal data on the server.
Your username is chosen once and forever — it cannot be changed. It's your permanent identifier in the system. You can change your display name, but not the username itself.
Multi-account: register and hold any number of accounts in the app, switch between them freely.
Account deletion: delete your account at any time along with all media and server-side data. No traces left.
Current state
The project is in working beta. Development is ongoing. Happy to answer questions in the comments — especially about the crypto implementation or Flutter Desktop specifics.
Try it out — github.com/wardcore-dev/onyx-server
Tags: #flutter #node #encryption #selfhosted #privacy #opensource #security #webdev
Honestly, the main motivation was just curiosity - I wanted to see if I could build something like this, and then put it out there to see if anyone actually cares.