A zero-knowledge encrypted message relay server for private, trusted groups. The server never reads, stores, or logs any message content. It only routes encrypted blobs between verified members and manages group membership with unanimous consent.
This is not a typical chat server. It is a relay only. Every message that passes through it is already encrypted by the client before sending. The server has no ability to read anything. It forwards, verifies membership, and manages who is allowed in — nothing more.
Designed for small, trusted circles where everyone knows each other. Not built for scale. Built for privacy.
- Node.js — runtime
- Express — REST API
- ws — WebSocket server
- better-sqlite3 — embedded SQLite database
- node-forge — RSA signature verification
- Docker — containerized deployment
securechat-server/
├── src/
│ ├── index.js → Entry point. Starts HTTP + WebSocket server.
│ ├── config.js → Reads environment variables.
│ ├── db/
│ │ ├── database.js → SQLite connection, WAL mode, auto-migrations.
│ │ └── schema.sql → Table definitions.
│ ├── routes/
│ │ └── api.js → All REST endpoints.
│ ├── websocket/
│ │ ├── handler.js → WebSocket connection manager and event router.
│ │ └── events.js → Event type constants.
│ ├── controllers/
│ │ ├── member.js → Founder registration and member listing.
│ │ ├── consensus.js → Join request logic, approval, spam protection.
│ │ └── message.js → Message relay logic.
│ └── middleware/
│ └── verify.js → RSA signature verification middleware.
├── data/
│ └── securechat.db → SQLite database (auto-created, gitignored).
├── .env → Environment variables (gitignored).
├── .env.example → Template for environment variables.
├── Dockerfile → Docker build definition.
├── docker-compose.yml → Docker Compose configuration.
└── package.json
Copy .env.example to .env and fill in:
PORT=3000
FOUNDER_KEY=your_secret_key_here
SERVER_NAME=Your Server Name
| Variable | Description |
|---|---|
PORT |
Port the server listens on. Default 3000. |
FOUNDER_KEY |
Secret string used exactly once to register the founder. Permanently invalidated after first use. |
SERVER_NAME |
Display name of the server returned to clients. |
Important: After the founder registers, the
FOUNDER_KEYis permanently blocked at the database level regardless of what value remains in.env. For maximum security, delete the variable from your deployment environment after registration.
Five tables:
Stores all active and left members.
id TEXT PRIMARY KEY
display_name TEXT
public_key TEXT -- RSA public key (PEM format) for authentication
curve25519_public_key TEXT -- Curve25519 public key for E2EE message encryption
joined_at INTEGER
status TEXT -- 'active' or 'left'Tracks pending, approved, rejected, and expired join requests.
id TEXT PRIMARY KEY
requester_id TEXT
requester_name TEXT
requester_public_key TEXT
requester_curve25519_public_key TEXT
requester_ip TEXT
requested_at INTEGER
expires_at INTEGER -- 48 hours from request time
approvals TEXT -- JSON array of member IDs who approved
rejections TEXT -- JSON array of member IDs who rejected
status TEXT -- 'pending', 'approved', 'rejected', 'expired'Stores general and 1v1 DM channels.
id TEXT PRIMARY KEY
type TEXT -- 'general' or 'dm'
member_ids TEXT -- JSON array of member IDsKey-value store for server state.
founder_set → 'true' after founder registers
founder_id → member ID of the founder
requests_locked → 'true' if join requests are closed
Temporary spam protection. Cleared on request resolution.
ip TEXT PRIMARY KEY
requested_at INTEGER
expires_at INTEGERReturns server info. First call any client should make.
Response:
{
"online": true,
"server_name": "My Server",
"server_url": "https://your-server.com",
"member_count": 3,
"founder_set": true,
"requests_locked": false
}One-time founder registration. Rejected after first use.
Body:
{
"id": "uuid-here",
"display_name": "Alice",
"public_key": "-----BEGIN PUBLIC KEY-----...",
"curve25519_public_key": "base64-encoded-key",
"founder_key": "your_secret_founder_key"
}Response:
{ "success": true, "is_founder": true, "member_id": "uuid-here" }Submit a join request. IP-rate-limited — same IP blocked while a request is pending.
Body:
{
"id": "uuid-here",
"display_name": "Bob",
"public_key": "-----BEGIN PUBLIC KEY-----...",
"curve25519_public_key": "base64-encoded-key"
}Response:
{ "request_id": "uuid", "expires_at": 1234567890000 }Poll join request status. Used by waiting client.
Response:
{
"request_id": "uuid",
"status": "pending",
"requester_name": "Bob",
"expires_at": 1234567890000
}Status values: pending, approved, rejected, expired
All require three headers:
x-member-id: {member_id}
x-signature: {base64(RSA_sign(member_id + timestamp_ms, privateKey))}
x-timestamp: {timestamp_ms}
Signature uses RSA + SHA256 via node-forge. Timestamp must be within 60 seconds of server time.
Returns all active members including online status.
Response:
{
"members": [
{
"id": "uuid",
"display_name": "Alice",
"public_key": "-----BEGIN PUBLIC KEY-----...",
"curve25519_public_key": "base64-key",
"joined_at": 1234567890000,
"online": true
}
]
}Approve or reject a join request. One rejection from any member immediately denies the request. All members must approve for admission.
Body:
{ "approved": true }Returns all channels the authenticated member belongs to.
Response:
{
"channels": [
{ "id": "general", "type": "general", "member_ids": ["uuid1", "uuid2"] },
{ "id": "uuid1:uuid2", "type": "dm", "member_ids": ["uuid1", "uuid2"] }
]
}Lock or unlock join requests. Any authenticated member can call this.
Body:
{ "locked": true }Clean leave. Marks member as left and broadcasts MEMBER_LEFT to all online members. You can only remove yourself.
Connect to: wss://your-server.com
AUTH must be the very first message. Any message before AUTH closes the connection immediately.
{
type: 'AUTH',
payload: {
member_id: 'uuid',
signature: 'base64-signature',
timestamp: 1234567890000
}
}Per-recipient encrypted blobs. Each recipient gets only their own blob.
{
type: 'MESSAGE',
payload: {
channel_id: 'general',
message_id: 'uuid',
sender_id: 'uuid',
sender_curve25519_public_key: 'base64-key',
recipients: [
{ member_id: 'uuid-b', encrypted_blob: 'base64-ciphertext' },
{ member_id: 'uuid-c', encrypted_blob: 'base64-ciphertext' }
]
}
}Send after receiving a message. Triggers DELIVERED to sender.
{ type: 'ACK', payload: { message_id: 'uuid' } }Relay a sender key to a specific member. Used for group E2EE key exchange. Pure relay — server never reads the blob.
{
type: 'KEY_DISTRIBUTION',
payload: {
from_member_id: 'uuid-a',
to_member_id: 'uuid-b',
channel_id: 'general',
encrypted_key_blob: 'base64-encrypted-key'
}
}Approve or reject a join request via WebSocket.
{
type: 'JOIN_RESPONSE',
payload: { request_id: 'uuid', approved: true, member_id: 'your-uuid' }
}Keep-alive. Client should ping every 10 minutes.
{ type: 'PING' }Forwarded to each recipient individually.
{
type: 'MESSAGE',
payload: {
channel_id: 'general',
message_id: 'uuid',
encrypted_blob: 'base64-ciphertext',
sender_id: 'uuid',
sender_name: 'Alice',
sender_curve25519_public_key: 'base64-key'
}
}All recipients ACKed the message.
{ type: 'DELIVERED', payload: { message_id: 'uuid' } }No ACK received within 10 seconds.
{ type: 'UNDELIVERED', payload: { message_id: 'uuid' } }Relayed sender key from another member.
{
type: 'KEY_DISTRIBUTION',
payload: { from_member_id: 'uuid', channel_id: 'general', encrypted_key_blob: 'base64' }
}Broadcast to all online members when someone requests to join.
{
type: 'JOIN_REQUEST',
payload: {
request_id: 'uuid',
requester_id: 'uuid',
requester_name: 'Bob',
requester_public_key: '-----BEGIN PUBLIC KEY-----...',
expires_at: 1234567890000
}
}Sent when a request is approved or rejected.
{ type: 'JOIN_REQUEST_RESOLVED', payload: { request_id: 'uuid', status: 'approved' } }Broadcast to all when a new member is admitted.
{
type: 'MEMBER_JOINED',
payload: {
member_id: 'uuid',
display_name: 'Bob',
public_key: '-----BEGIN PUBLIC KEY-----...',
curve25519_public_key: 'base64-key'
}
}Broadcast when a member leaves or disconnects.
{ type: 'MEMBER_LEFT', payload: { member_id: 'uuid' } }Broadcast when join requests are locked or unlocked.
{ type: 'REQUESTS_LOCK_CHANGED', payload: { locked: true } }Sent when WebSocket AUTH fails. Connection is closed immediately after.
{ type: 'AUTH_FAILED', payload: { reason: 'Signature mismatch' } }Response to PING.
{ type: 'PONG' }1. New person → POST /api/join-request
2. Server stores request, broadcasts JOIN_REQUEST to all online members
3. Each member approves or rejects via POST /api/approve/:id or JOIN_RESPONSE WebSocket event
4. One rejection → immediately denied, IP released
5. All approve → member admitted:
- Added to members table
- Added to general channel
- 1v1 DM channel created with every existing member
- MEMBER_JOINED broadcast to all with both public keys
6. Existing members send their sender keys to new member via KEY_DISTRIBUTION
7. New member sends their sender key to all existing members via KEY_DISTRIBUTION
- Messages never stored — forwarded from memory, immediately discarded
- No IP logging except temporary spam guard, auto-cleared on request resolution
- No message timestamps logged
- No who-sent-what logging
- RSA signature verified on every authenticated REST request
- 60 second timestamp window prevents replay attacks
- WebSocket closes immediately on any message before AUTH
- Unanimous consent required — one rejection = immediate deny
- FOUNDER_KEY one-time use — permanently invalidated at database level after first registration
- Server cannot admit anyone unilaterally
- Join requests auto-expire after 48 hours
- Same IP blocked from spamming join requests
- Sender cannot spoof
from_member_idon KEY_DISTRIBUTION — verified against authenticated connection - Display names must be unique
- Push repo to GitHub (keep private)
- New Web Service on Render → connect repo
- Build command:
npm install - Start command:
node src/index.js - Add environment variables:
FOUNDER_KEY,SERVER_NAME,PORT=3000 - Deploy
- Set up UptimeRobot to ping
GET /api/statusevery 5 minutes (free tier spins down after 15 min inactivity) - After founder registers, optionally delete
FOUNDER_KEYfrom Render environment
# Install dependencies
npm install
# Create data directory
mkdir data
# Create .env file
cp .env.example .env
# Edit .env with your values
# Start server
node src/index.js
# In a separate terminal, expose via ngrok
ngrok http 3000Use the ngrok URL as your server URL. Note: free ngrok URLs change on restart.
docker-compose up -dThe client is a separate application (mobile or desktop) that connects to this server. From the server's perspective, the client is responsible for:
- Generating UUID member IDs locally
- Generating RSA keypair (2048-bit minimum) for server authentication
- Generating Curve25519 keypair for message encryption
- Encrypting all message content before sending — server only sees ciphertext
- Implementing the signature scheme:
base64(RSA_SHA256_sign(member_id + timestamp_ms_string)) - Sending
x-member-id,x-signature,x-timestampheaders on every authenticated request - Implementing sender key protocol for group E2EE using
KEY_DISTRIBUTIONevents - Sending
ACKafter receiving each message - Sending
PINGevery 10 minutes to keep WebSocket alive
The server returns member id (not member_id) in all responses. Clients should map accordingly.