peer-to-peer encrypted chat — your messages never touch our servers
Lowkey is a zero-trust, peer-to-peer encrypted chat app. The server's only job is matchmaking — once two users are connected, all messages flow directly between their devices via WebRTC DataChannel, encrypted end-to-end with X25519 + XSalsa20-Poly1305 (NaCl Box).
The server never sees your messages or encryption keys.
┌──────────┐ ┌──────────┐
│ User A │◄──── WebRTC DataChannel (E2E) ────────►│ User B │
│ (Flutter) │ │ (Flutter) │
└────┬─────┘ └────┬─────┘
│ WebSocket (signaling only) │
└──────────────►┌──────────────┐◄───────────────────┘
│ Go Server │
│ (Signaling) │
└──────────────┘
The server relays connection setup messages only.
After the P2P link is established, the server is completely out of the loop.
sequenceDiagram
actor User
participant App as Flutter App
participant SP as SharedPreferences
User->>App: Opens app
App->>SP: Check for saved username
SP-->>App: null
App->>User: Show UsernameScreen
User->>App: Enters username → taps "Get Started"
App->>SP: Save username
App->>User: Navigate to ChatScreen
sequenceDiagram
participant A as Alice
participant S as Go Server
participant B as Bob
A->>S: WS connect /ws?username=alice
S->>S: hub.Register("alice")
B->>S: WS connect /ws?username=bob
S->>S: hub.Register("bob")
A->>S: {"type": "session:connect", "target": "bob"}
S->>S: Create session + auto-join both
S-->>A: {"type": "session:joined", "peer": "bob"}
S-->>B: {"type": "session:joined", "peer": "alice"}
sequenceDiagram
participant A as Alice
participant S as Go Server (relay only)
participant B as Bob
Note over A: Generate X25519 key pair
Note over B: Generate X25519 key pair
A->>S: {"type": "key:exchange", pubKey_A}
S->>B: Relay pubKey_A → Bob
B->>S: {"type": "key:exchange", pubKey_B}
S->>A: Relay pubKey_B → Alice
Note over A: SharedSecret = X25519(privKey_A, pubKey_B)
Note over B: SharedSecret = X25519(privKey_B, pubKey_A)
Note over A,B: Both derive the same shared secret — server never sees it
sequenceDiagram
participant A as Alice (Initiator)
participant S as Go Server (relay)
participant B as Bob
A->>A: Create PeerConnection + DataChannel
A->>A: Create SDP Offer
A->>S: signal:offer → Bob
S->>B: Relay offer
B->>B: Set remote desc, create Answer
B->>S: signal:answer → Alice
S->>A: Relay answer
A->>S: signal:ice → Bob
S->>B: Relay ICE candidate
B->>S: signal:ice → Alice
S->>A: Relay ICE candidate
Note over A,B: 🔒 DataChannel OPEN — P2P connected!
Note over S: Server's job is done
sequenceDiagram
participant A as Alice
participant B as Bob
A->>A: Encrypt("hello") with NaCl Box
A->>B: DataChannel → base64(nonce + ciphertext + MAC)
B->>B: Decrypt → "hello"
B-->>A: Display message ✓
Note over A,B: Messages flow directly P2P — zero server involvement
| Layer | Technology | Purpose |
|---|---|---|
| Mobile App | Flutter (Dart) | Cross-platform UI |
| P2P Transport | WebRTC DataChannel | Direct device-to-device messaging |
| Encryption | X25519 + XSalsa20-Poly1305 | End-to-end encryption (NaCl Box via pinenacl) |
| Key Exchange | X25519 Diffie-Hellman | Client-side shared secret derivation |
| Signaling Server | Go | WebSocket-based session matchmaking |
| WebSocket | coder/websocket (Go) |
Server-side WS handling |
| Session Store | In-memory (Go) | Session lifecycle with TTL cleanup |
| Deployment | systemd + nginx | Production reverse proxy with WSS |
lowkey/
├── app/ # Flutter mobile app
│ └── lib/
│ ├── main.dart # App entry, routing
│ ├── screens/
│ │ ├── username_screen.dart # Username setup
│ │ └── chat_screen.dart # Connect panel + chat UI
│ ├── services/
│ │ ├── signaling_service.dart # WebSocket client
│ │ ├── webrtc_service.dart # PeerConnection + DataChannel
│ │ └── crypto_service.dart # X25519 + NaCl Box
│ ├── models/
│ │ └── message.dart # ChatMessage model
│ └── widgets/
│ └── message_bubble.dart # Message UI component
│
├── cmd/server/
│ └── main.go # Server entry point
│
├── internal/
│ ├── signaling/
│ │ ├── hub.go # WebSocket connection registry
│ │ ├── handler.go # Message dispatch + session:connect
│ │ └── messages.go # Protocol message types
│ ├── session/
│ │ ├── session.go # Session struct
│ │ ├── store.go # Store interface
│ │ └── memory_store.go # In-memory store with TTL
│ └── config/
│ └── config.go # Env-based configuration
│
├── deploy/
│ ├── lowkey.service # systemd unit
│ └── nginx-lowkey.conf # nginx reverse proxy config
│
└── README.md
All signaling messages follow the same envelope:
{
"type": "message:type",
"sessionId": "uuid (optional)",
"target": "username (optional)",
"sender": "username (server-stamped)",
"payload": { }
}| Message Type | Direction | Description |
|---|---|---|
session:connect |
Client → Server | Connect to a peer by username |
session:joined |
Server → Client | Both peers notified with each other's username |
key:exchange |
Client ↔ Client (via server) | Exchange X25519 public keys |
signal:offer |
Client → Client (via server) | WebRTC SDP offer |
signal:answer |
Client → Client (via server) | WebRTC SDP answer |
signal:ice |
Client ↔ Client (via server) | ICE candidates |
error |
Server → Client | Error with code + message |
| Property | Value |
|---|---|
| Key Exchange | X25519 Diffie-Hellman (client-side) |
| Symmetric Cipher | XSalsa20-Poly1305 (NaCl Box) |
| Nonce | 24 bytes, random per message |
| Key Generation | Client-side — server never sees the shared secret |
| Message Format | base64(24-byte nonce + ciphertext + Poly1305 MAC) |
- Flutter SDK (≥ 3.11)
- Go (≥ 1.25)
cd cmd/server
go run main.goThe server starts on :8080 by default. Configure via environment variables:
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
Server listen port |
SESSION_TTL |
10m |
Session expiry duration |
CORS_ORIGINS |
* |
Allowed CORS origins |
cd app
flutter pub get
flutter runNote: Update the
_serverUrlinchat_screen.dartto point to your server:
- Local development:
ws://<your-ip>:8080- Production:
wss://lowkey.ayushz.me
Production runs on a VPS with:
- Go binary → compiled and placed at
/opt/lowkey/lowkey-server - systemd →
deploy/lowkey.servicefor process management - nginx →
deploy/nginx-lowkey.confas reverse proxy with WebSocket upgrade - SSL → Let's Encrypt via Certbot for
wss://support
MIT
