SuperChat Binary Protocol Specification
Overview
This document defines the binary protocol used for communication between SuperChat clients and servers. The protocol is designed to be lightweight, efficient, and easy to implement.
Connection Types
SuperChat supports two connection methods:
- SSH Connection: Automatic authentication via SSH key
- TCP Connection: Direct TCP socket with manual authentication
Both use the same binary protocol after connection is established.
Frame Format
All messages use a simple frame-based format:
+-------------------+-------------------+------------------+------------------+------------------------+
| Length (4 bytes) | Version (1 byte) | Type (1 byte) | Flags (1 byte) | Payload (N bytes) |
+-------------------+-------------------+------------------+------------------+------------------------+
| uint32 big-endian | uint8 | uint8 | uint8 | variable length |
+-------------------+-------------------+------------------+------------------+------------------------+
- Length: Total size of Version + Type + Flags + Payload (excludes the length field itself)
- Version: Protocol version (current version: 1)
- Type: Message type identifier (see Message Types below)
- Flags: Bit flags for compression, encryption, and future extensions
- Payload: Message-specific data
Protocol Version:
- Current protocol version is 1
- Server sends its protocol version in SERVER_CONFIG (first field)
- Both client and server must validate version on every message
Versioning Philosophy:
- Protocol versions aim to be backwards compatible via extension, not modification
- New versions add new message types and fields, but don't change existing ones
- Forward compatibility: Newer clients should work with older servers
- Client can detect server version from SERVER_CONFIG
- Client gracefully degrades features not supported by older server
- Client doesn't send message types the server doesn't understand
- Backward compatibility: Older clients may work with newer servers
- Server can detect client version from frame headers
- Unknown message types should be ignored or return ERROR 1001 (unsupported version)
- If a breaking change is absolutely necessary, increment major version and treat as new protocol
- Goal: Avoid forcing synchronized upgrades of all clients and servers
Flags Byte (bits):
- Bit 0 (rightmost): Compression (0 = uncompressed, 1 = LZ4 compressed)
- Bit 1: Encryption (0 = plaintext, 1 = encrypted payload)
- Bits 2-7: Reserved for future use (must be 0)
Examples:
0x00= No compression, no encryption0x01= Compressed, not encrypted0x02= Not compressed, encrypted0x03= Compressed and encrypted
Max Frame Size: 1 MB (1,048,576 bytes) to prevent DoS attacks
Compression:
- Applied to the entire payload after the Flags byte
- Uses LZ4 block format (much faster than gzip for real-time messaging)
- Structure:
[Uncompressed Size (u32)][LZ4 Compressed Data] - Recommended for payloads larger than 512 bytes
- Decompress before parsing payload structure
- LZ4 chosen for low latency and minimal CPU overhead
Encryption:
- Applied to the entire payload after optional compression
- Used for private/DM channels with end-to-end encryption
- Encryption details covered in DM section below
Password Security
SuperChat uses a client-side hashing with server-side double-hashing approach for password authentication over TCP/WebSocket connections.
Hashing Algorithm
Client-side hash:
password_hash = argon2id(password, salt=nickname, time=3, memory=64MB, threads=4, keyLen=32)
Server-side hash:
stored_hash = bcrypt(password_hash, cost=10)
Security Properties
Protection against network sniffing:
- Password never transmitted in plaintext
- Attacker observing network traffic sees
password_hash, not the original password password_hashcannot be used to derive the original password (Argon2id is one-way)
Protection against password reuse:
- If attacker obtains
password_hashfrom network capture, they can authenticate to SuperChat - However, they still cannot discover the original password
- Original password remains safe for use on other sites
Protection against database breach:
- Database stores
stored_hash = bcrypt(password_hash) - Attacker must crack bcrypt to get
password_hash - Even with
password_hash, they still don't have the original password - Double-hashing provides defense-in-depth
Limitations (TCP/WebSocket connections):
- ❌ Vulnerable to replay attacks (captured
password_hashcan be replayed) - ❌ Vulnerable to MITM attacks (no connection-level encryption)
- ❌ Database breach + network capture = authentication credential compromised
Recommendation:
- Use SSH connections for security-sensitive deployments
- SSH provides connection-level encryption and eliminates all the above vulnerabilities
- TCP/WebSocket are acceptable for convenience, but SSH is recommended for security
Why Not Challenge-Response?
Challenge-response authentication was considered but rejected because:
- Adds protocol complexity (extra round-trip, challenge state management)
- Doesn't protect against MITM attacks (TCP/WebSocket are unencrypted anyway)
- SSH already provides proper encrypted authentication
- Current approach is simpler and provides adequate protection for password reuse
Data Types
Primitive Types
uint8: 1-byte unsigned integeruint16: 2-byte unsigned integer (big-endian)uint32: 4-byte unsigned integer (big-endian)uint64: 8-byte unsigned integer (big-endian)int64: 8-byte signed integer (big-endian)bool: 1-byte (0x00 = false, 0x01 = true)
Composite Types
String (length-prefixed UTF-8):
+-------------------+------------------------+
| Length (uint16) | Data (N bytes UTF-8) |
+-------------------+------------------------+
Timestamp (Unix epoch in milliseconds):
+-------------------+
| int64 |
+-------------------+
- Always represents server time (not client time)
- Server sets all timestamps (
created_at,edited_at,deleted_at, etc.) - Clients should never send timestamps (except PING for RTT calculation)
- This eliminates clock skew issues between clients and ensures consistent message ordering
Optional Field (nullable):
+-------------------+------------------------+
| Present (bool) | Value (if present) |
+-------------------+------------------------+
- Uses full byte (bool) for presence flag instead of bit-packing
- Tradeoff: Wastes 7 bits per optional field, but much simpler to encode/decode
- Byte-aligned fields are faster to parse and easier to implement correctly
- For a chat protocol, the bandwidth savings from bit-packing are negligible
- Simplicity and implementation speed prioritized over micro-optimization
Compressed Payload (when Flags bit 0 is set):
+---------------------------+---------------------------+
| Uncompressed Size (u32) | LZ4 Compressed Data |
+---------------------------+---------------------------+
Uncompressed Size: Size of data after decompression (for buffer allocation)LZ4 Compressed Data: Payload compressed using LZ4 block format- After decompression, parse as normal payload structure based on message type
Message Types
Client → Server Messages
| Type | Name | Description |
|---|---|---|
| 0x01 | AUTH_REQUEST | Authenticate with password |
| 0x02 | SET_NICKNAME | Set/change nickname |
| 0x03 | REGISTER_USER | Register current nickname |
| 0x04 | LIST_CHANNELS | Request channel list |
| 0x05 | JOIN_CHANNEL | Join a channel/subchannel |
| 0x06 | LEAVE_CHANNEL | Leave a channel/subchannel |
| 0x07 | CREATE_CHANNEL | Create new channel |
| 0x08 | CREATE_SUBCHANNEL | Create new subchannel |
| 0x09 | LIST_MESSAGES | Request messages (with filters) |
| 0x0A | POST_MESSAGE | Post a new message |
| 0x0B | EDIT_MESSAGE | Edit an existing message |
| 0x0C | DELETE_MESSAGE | Delete a message (soft-delete) |
| 0x0D | ADD_SSH_KEY | Add SSH public key to account |
| 0x0E | CHANGE_PASSWORD | Change user password |
| 0x0F | GET_USER_INFO | Get info about a user |
| 0x10 | PING | Keepalive ping |
| 0x11 | DISCONNECT | Graceful disconnect notification |
| 0x12 | UPDATE_SSH_KEY_LABEL | Update SSH key label |
| 0x13 | DELETE_SSH_KEY | Delete SSH key from account |
| 0x14 | LIST_SSH_KEYS | Request list of user's SSH keys |
| 0x15 | GET_SUBCHANNELS | Request subchannels for a channel |
| 0x16 | LIST_USERS | Request list of online users |
| 0x17 | LIST_CHANNEL_USERS | Request active users in a specific channel |
| 0x18 | GET_UNREAD_COUNTS | Request unread counts for specific channels |
| 0x19 | START_DM | Initiate a direct message conversation |
| 0x1A | PROVIDE_PUBLIC_KEY | Upload public key for encryption |
| 0x1B | ALLOW_UNENCRYPTED | Explicitly allow unencrypted DMs |
| 0x1C | LOGOUT | Clear authentication, become anonymous |
| 0x1D | UPDATE_READ_STATE | Update last read timestamp for a channel |
| 0x1E | DECLINE_DM | Decline an incoming DM request |
| 0x51 | SUBSCRIBE_THREAD | Subscribe to thread updates |
| 0x52 | UNSUBSCRIBE_THREAD | Unsubscribe from thread updates |
| 0x53 | SUBSCRIBE_CHANNEL | Subscribe to new threads in channel |
| 0x54 | UNSUBSCRIBE_CHANNEL | Unsubscribe from channel updates |
| 0x55 | LIST_SERVERS | Request server list from directory |
| 0x56 | REGISTER_SERVER | Register server with directory |
| 0x57 | HEARTBEAT | Directory heartbeat (keep-alive) |
| 0x58 | VERIFY_RESPONSE | Response to verification challenge |
| 0x59 | BAN_USER | Ban a user (admin only) |
| 0x5A | BAN_IP | Ban an IP address/CIDR (admin only) |
| 0x5B | UNBAN_USER | Remove user ban (admin only) |
| 0x5C | UNBAN_IP | Remove IP ban (admin only) |
| 0x5D | LIST_BANS | Request list of all bans (admin only) |
| 0x5E | DELETE_USER | Delete a user account (admin only) |
| 0x5F | DELETE_CHANNEL | Delete a channel (admin only) |
Server → Client Messages
| Type | Name | Description |
|---|---|---|
| 0x81 | AUTH_RESPONSE | Authentication result |
| 0x82 | NICKNAME_RESPONSE | Nickname change result |
| 0x83 | REGISTER_RESPONSE | Registration result |
| 0x84 | CHANNEL_LIST | List of channels |
| 0x85 | JOIN_RESPONSE | Join result with channel data |
| 0x86 | LEAVE_RESPONSE | Leave confirmation |
| 0x87 | CHANNEL_CREATED | Channel creation result |
| 0x88 | SUBCHANNEL_CREATED | Subchannel creation result |
| 0x89 | MESSAGE_LIST | List of messages |
| 0x8A | MESSAGE_POSTED | Message post confirmation |
| 0x8B | MESSAGE_EDITED | Edit confirmation |
| 0x8C | MESSAGE_DELETED | Delete confirmation |
| 0x8D | NEW_MESSAGE | Real-time message notification |
| 0x8E | PASSWORD_CHANGED | Password change result |
| 0x8F | USER_INFO | User information response |
| 0x90 | PONG | Ping response |
| 0x91 | ERROR | Error response |
| 0x92 | SSH_KEY_LABEL_UPDATED | SSH key label update result |
| 0x93 | SSH_KEY_DELETED | SSH key deletion result |
| 0x94 | SSH_KEY_LIST | List of user's SSH keys |
| 0x95 | SSH_KEY_ADDED | SSH key addition result |
| 0x96 | SUBCHANNEL_LIST | List of subchannels for a channel |
| 0x97 | UNREAD_COUNTS | Unread message counts response |
| 0x98 | SERVER_CONFIG | Server configuration and limits (sent on connect) |
| 0x99 | SUBSCRIBE_OK | Subscription confirmation |
| 0x9A | USER_LIST | List of online users |
| 0x9B | SERVER_LIST | List of discoverable servers |
| 0x9C | REGISTER_ACK | Server registration acknowledgment |
| 0x9D | HEARTBEAT_ACK | Heartbeat acknowledgment |
| 0x9E | VERIFY_REGISTRATION | Verification challenge for new servers |
| 0x9F | USER_BANNED | User ban result (admin response) |
| 0xA0 | SERVER_STATS | Server statistics (user counts, etc.) |
| 0xA1 | KEY_REQUIRED | Server needs encryption key before proceeding |
| 0xA2 | DM_READY | DM channel is ready to use |
| 0xA3 | DM_PENDING | Waiting for other party to complete key setup |
| 0xA4 | DM_REQUEST | Incoming DM request from another user |
| 0xA5 | IP_BANNED | IP ban result (admin response) |
| 0xA6 | USER_UNBANNED | User unban result (admin response) |
| 0xA7 | IP_UNBANNED | IP unban result (admin response) |
| 0xA8 | BAN_LIST | List of bans response (admin) |
| 0xA9 | USER_DELETED | User deletion result (admin response) |
| 0xAA | CHANNEL_DELETED | Channel deletion result (admin response) |
| 0xAB | CHANNEL_USER_LIST | Snapshot of users currently in a channel |
| 0xAC | CHANNEL_PRESENCE | Channel join/leave notification |
| 0xAD | SERVER_PRESENCE | Server-wide presence notification |
| 0xAE | DM_PARTICIPANT_LEFT | A participant has permanently left a DM |
| 0xAF | DM_DECLINED | Notification that a DM request was declined |
Message Payloads
0x01 - AUTH_REQUEST (Client → Server)
Used when connecting to use a registered nickname.
+-------------------+----------------------+
| nickname (String) | password_hash (String) |
+-------------------+----------------------+
Security Note:
- Client sends
password_hash = argon2id(password, nickname_as_salt) - Server performs additional hashing:
stored_hash = bcrypt(password_hash) - This double-hashing approach:
- Protects password from network sniffing (password never transmitted)
- Protects password reuse across sites (attacker with hash can't derive original password)
- Database breach requires cracking bcrypt to get client hash (and client hash still isn't the original password)
- For true connection-level security (protection against MITM), use SSH connection instead of TCP/WebSocket
0x81 - AUTH_RESPONSE (Server → Client)
+-------------------+-------------------+----------------------+----------------------+--------------------+
| success (bool) | user_id (uint64) | nickname (String) | message (String) | user_flags (uint8) |
| | (only if success) | (only if success) | (error if failed) | (success only, optional) |
+-------------------+-------------------+----------------------+----------------------+--------------------+
If success:
user_id: The registered user's IDnickname: The authenticated user's registered nicknamemessage: Welcome message or emptyuser_flags: Optional bitfield describing user capabilities (admins, moderators, etc.). Servers SHOULD include this when known so clients can tailor privileged UI. Bits:0x01(admin),0x02(moderator). Remaining bits are reserved for future roles.- Clients receiving an AUTH_RESPONSE without
user_flagsMUST treat the value as0x00(regular user) for backward compatibility.
If failed:
user_id: Omittednickname: Omittedmessage: Error description
Note: The nickname field was added in V2 to support SSH authentication, where the client needs to know their authenticated nickname without sending SET_NICKNAME.
0x02 - SET_NICKNAME (Client → Server)
Used to set or change nickname.
+--------------------+
| nickname (String) |
+--------------------+
Behavior:
Anonymous users:
- Can change nickname freely to any available nickname
- Nickname change only affects current session
- Previous messages keep old nickname
Registered users:
- Can change nickname to any available (unregistered) nickname
- Nickname change updates all existing messages to show new nickname automatically
- Database UPDATE on User.nickname only (messages link via author_user_id FK)
- Message.author_nickname is only used for anonymous users (where author_user_id is NULL)
- Cannot change to a nickname already registered by another user
0x82 - NICKNAME_RESPONSE (Server → Client)
+-------------------+-------------------+
| success (bool) | message (String) |
+-------------------+-------------------+
Response cases:
- Nickname is registered and client is not authenticated:
success = false,message = "Nickname registered, password required" - Nickname is available:
success = true,message = "Nickname changed to <nickname>"(or "Nickname set to") - Nickname is invalid (format):
success = false,message = "Invalid nickname" - Nickname already taken (registered user trying to change):
success = false,message = "Nickname already in use"
0x03 - REGISTER_USER (Client → Server)
Register current nickname with a password.
+----------------------+
| password_hash (String) |
+----------------------+
Security Note:
- Client sends
password_hash = argon2id(password, nickname_as_salt) - Server performs additional hashing:
stored_hash = bcrypt(password_hash)and stores in database - Same double-hashing approach as AUTH_REQUEST (see 0x01 for details)
- Password never transmitted over the wire
0x83 - REGISTER_RESPONSE (Server → Client)
+-------------------+-------------------+
| success (bool) | user_id (uint64) |
| | (only if success) |
+-------------------+-------------------+
0x04 - LIST_CHANNELS (Client → Server)
Request list of channels (without subchannels).
+------------------------+-------------------+
| from_channel_id (u64) | limit (u16) |
+------------------------+-------------------+
Notes:
from_channel_id: Start listing from this channel ID (exclusive). Use 0 to start from beginning.limit: Maximum number of channels to return (default/max: 1000)- Channels returned in ascending ID order
- Client can stop reading response early if it has enough channels
- For servers with many channels, client can request in batches
0x84 - CHANNEL_LIST (Server → Client)
+----------------------+----------------+
| channel_count (u16) | channels [] |
+----------------------+----------------+
Each channel:
+-------------------+----------------------+------------------------+------------------------+
| channel_id (u64) | name (String) | description (String) | user_count (u32) |
+-------------------+----------------------+------------------------+------------------------+
| is_operator (bool)| type (u8) | retention_hours(u32) | has_subchannels (bool) |
+-------------------+----------------------+------------------------+------------------------+
| subchannel_count(u16) |
+----------------------+
Notes:
- Returns public channels (private channels excluded)
- Channels returned in ascending ID order
has_subchannels: true if channel has subchannels definedsubchannel_count: number of subchannels (0 if none)- To get subchannels, use GET_SUBCHANNELS request
- If
channel_count < limit, there are no more channels to fetch
0x15 - GET_SUBCHANNELS (Client → Server)
Request subchannels for a specific channel.
+-------------------+
| channel_id (u64) |
+-------------------+
0x96 - SUBCHANNEL_LIST (Server → Client)
+-------------------+----------------------+----------------+
| channel_id (u64) | subchannel_count(u16)| subchannels [] |
+-------------------+----------------------+----------------+
Each subchannel:
+----------------------+-------------------+------------------------+------------------------+
| subchannel_id (u64) | name (String) | description (String) | type (u8) |
+----------------------+-------------------+------------------------+------------------------+
| retention_hours(u32) |
+----------------------+
Notes:
- Response includes
channel_idso client knows which channel these subchannels belong to - Allows concurrent requests for multiple channels' subchannels
Type (both channel and subchannel):
- 0x00 = chat
- 0x01 = forum
Type Semantics:
typeis a UI hint for how clients should present the channel- Chat (0x00): Intended for real-time conversation
- Client UI may emphasize chronological message flow
- Threading is still supported by protocol, but may be de-emphasized in UI
- Typically paired with short retention (but not required)
- Forum (0x01): Intended for threaded discussions
- Client UI should emphasize thread structure and navigation
- Threading is expected and encouraged
- Typically paired with longer retention (but not required)
Important: All clients MUST support displaying threaded messages regardless of channel type, since the protocol allows threading in both. The type only suggests the primary UI presentation style.
Notes:
typeandretention_hourson channel apply when channel has no subchannels- If channel has subchannels, each subchannel has its own
typeandretention_hours typeandretention_hoursare independent - any combination is valid- Unread counts are NOT included in channel list (use GET_UNREAD_COUNTS instead)
0x05 - JOIN_CHANNEL (Client → Server)
+-------------------+-----------------------------+
| channel_id (u64) | subchannel_id (Optional u64)|
+-------------------+-----------------------------+
If subchannel_id is not present, join the channel at the root level (for channels without subchannels).
0x06 - LEAVE_CHANNEL (Client → Server)
+-------------------+-----------------------------+-------------------+
| channel_id (u64) | subchannel_id (Optional u64)| permanent (bool) |
+-------------------+-----------------------------+-------------------+
Requests that the server remove the session from the given channel/subchannel. When subchannel_id is omitted the user leaves the root-level channel subscription.
The permanent field controls whether the leave is temporary (navigation) or permanent (close/delete):
permanent = false: User is navigating away but may return. DM channels remain in sidebar.permanent = true: User is permanently leaving. For DM channels, removes the user from the channel's participant list and the DM disappears from their sidebar.
For backwards compatibility, if permanent is not present in the payload, it defaults to false.
0x85 - JOIN_RESPONSE (Server → Client)
+-------------------+-------------------+----------------------+
| success (bool) | channel_id (u64) | subchannel_id |
| | | (Optional u64) |
+-------------------+-------------------+----------------------+
| message (String) |
+-------------------+
If failed, message contains error description.
0x86 - LEAVE_RESPONSE (Server → Client)
+-------------------+-------------------+-----------------------------+-------------------+
| success (bool) | channel_id (u64) | subchannel_id (Optional u64)| message (String) |
+-------------------+-------------------+-----------------------------+-------------------+
Acknowledges a LEAVE_CHANNEL request. On success, the session is no longer counted as present in the channel. On failure, message describes why the leave operation was rejected.
0x09 - LIST_MESSAGES (Client → Server)
Request messages from a channel/subchannel.
+-------------------+-----------------------------+------------------------+
| channel_id (u64) | subchannel_id (Optional u64)| limit (u16) |
+-------------------+-----------------------------+------------------------+
| before_id (Optional u64) | parent_id (Optional u64) |
+-------------------------------+-------------------------+
| after_id (Optional u64) |
+---------------------------+
Parameters:
limit: Max messages to return (default: 50, max: 200)before_id: Return messages older than this message ID (for backward pagination)parent_id: If set, only return replies to this message (thread view)after_id: Return messages newer than this message ID (for forward pagination / catching up)
Behavior:
Without
parent_id: Returns root messages only (thread starters, whereparent_id = null)- Sorted by
created_atdescending (newest first) when usingbefore_idor neither - Sorted by
created_atascending (oldest first) when usingafter_id - Each message includes
reply_countto show thread size - Use for displaying thread list in channel/subchannel
- Sorted by
With
parent_id: Returns all replies under that parent message- Does NOT include the parent message itself (client already has it)
- Returns the full thread tree (all descendants)
- Sorted by thread position (depth-first traversal for proper nesting)
- Use for displaying a single thread's conversation
Pagination:
- Without
before_idorafter_id: Returns most recent messages (sorted newest-first) - With
before_id: Returns messages withid < before_id(older messages, sorted newest-first) - With
after_id: Returns messages withid > after_id(newer messages, sorted oldest-first) - Both set:
before_idtakes precedence,after_idis ignored - Allows scrolling backwards through history (
before_id) or catching up with new messages (after_id)
Use Case - Bandwidth Optimization: When reopening a thread, the client can request only messages posted since last view:
- Client caches thread replies locally with the highest message ID seen
- When reopening thread, send
after_id= highest cached message ID - Server returns only new messages (id > after_id)
- Client merges new messages with cache and re-sorts the tree
- This avoids re-fetching all 50+ messages every time, fetching only the few new ones
0x89 - MESSAGE_LIST (Server → Client)
+----------------------+-----------------------------+-------------------+
| channel_id (u64) | subchannel_id (Optional u64)| parent_id |
| | | (Optional u64) |
+----------------------+-----------------------------+-------------------+
| message_count (u16) | messages [] |
+----------------------+-----------------------------+
Each message:
+-------------------+-----------------------------+-------------------+
| message_id (u64) | channel_id (u64) | subchannel_id |
| | | (Optional u64) |
+-------------------+-----------------------------+-------------------+
| parent_id (Optional u64) | author_user_id (Optional u64) |
+------------------------------+--------------------------------+
| author_nickname (String) | content (String) |
+------------------------------+--------------------------------+
| created_at (Timestamp) | edited_at (Optional Timestamp) |
+------------------------+--------------------------------+
| thread_depth (u8) | reply_count (u32) |
+------------------------+--------------------------------+
Notes:
- Response includes the request context (
channel_id,subchannel_id,parent_id) so clients can match responses to requests author_user_idis null for anonymous usersthread_depth: 0 = root, 1+ = nestedreply_count: Total number of replies (all descendants)
0x0A - POST_MESSAGE (Client → Server)
+-------------------+-----------------------------+-------------------+
| channel_id (u64) | subchannel_id (Optional u64)| parent_id |
| | | (Optional u64) |
+-------------------+-----------------------------+-------------------+
| content (String) |
+-------------------+
If parent_id is set, this is a reply. Otherwise, it's a root message.
0x8A - MESSAGE_POSTED (Server → Client)
Confirmation that message was posted successfully.
+-------------------+-------------------+
| success (bool) | message_id (u64) |
+-------------------+-------------------+
| message (String) |
+-------------------+
Note: The server always sends success=true with this message type. If message posting fails (no nickname, invalid format, message too long, etc.), the server sends an ERROR (0x91) message instead. Therefore, message_id is always present and valid.
0x8D - NEW_MESSAGE (Server → Client)
Real-time notification of a new message (pushed to all users in the channel).
Uses the same format as a single message in MESSAGE_LIST:
+-------------------+-----------------------------+-------------------+
| message_id (u64) | channel_id (u64) | subchannel_id |
| | | (Optional u64) |
+-------------------+-----------------------------+-------------------+
| parent_id (Optional u64) | author_user_id (Optional u64) |
+------------------------------+--------------------------------+
| author_nickname (String) | content (String) |
+------------------------------+--------------------------------+
| created_at (Timestamp) | edited_at (Optional Timestamp) |
+------------------------+--------------------------------+
| thread_depth (u8) | reply_count (u32) |
+------------------------+--------------------------------+
0x0B - EDIT_MESSAGE (Client → Server)
+-------------------+-------------------+
| message_id (u64) | content (String) |
+-------------------+-------------------+
Only the original author can edit a message. Admins can edit any message.
0x8B - MESSAGE_EDITED (Server → Client)
Confirmation of edit + real-time notification to all users.
+-------------------+-------------------+------------------------+
| success (bool) | message_id (u64) | edited_at (Timestamp) |
| | | (only if success) |
+-------------------+-------------------+------------------------+
| new_content (String) | message (String) |
| (only if success) | (error if failed) |
+----------------------+------------------------------------------+
0x0C - DELETE_MESSAGE (Client → Server)
+-------------------+
| message_id (u64) |
+-------------------+
Only the original author can delete a message. Admins can delete any message. This performs a soft-delete (sets deleted_at),
preserving thread structure. Original content is saved in MessageVersion for moderation.
0x8C - MESSAGE_DELETED (Server → Client)
Confirmation of deletion + real-time notification to all users.
+-------------------+-------------------+------------------------+
| success (bool) | message_id (u64) | deleted_at (Timestamp) |
| | | (only if success) |
+-------------------+-------------------+------------------------+
| message (String) (error if failed) |
+---------------------------------------------------------------+
0x0D - ADD_SSH_KEY (Client → Server)
Add an SSH public key to the authenticated user's account.
+----------------------+-------------------+
| public_key (String) | label (String) |
+----------------------+-------------------+
Notes:
- User must be authenticated
public_key: Full SSH public key (e.g., "ssh-rsa AAAA... user@host")label: Optional user-friendly name (e.g., "Work Laptop") - can be empty string- Server parses key, computes SHA256 fingerprint, stores in SSHKey table
- Duplicate keys (same fingerprint) return error
0x95 - SSH_KEY_ADDED (Server → Client)
+-------------------+-------------------+------------------------+------------------------+
| success (bool) | key_id (i64) | fingerprint (String) | error_message (String) |
| | (if success) | (if success) | (if !success) |
+-------------------+-------------------+------------------------+------------------------+
0x1D - UPDATE_READ_STATE (Client → Server)
Update last read timestamp for a channel/subchannel.
+-------------------+-----------------------------+----------------------+
| channel_id (u64) | subchannel_id (Optional u64)| timestamp (i64) |
+-------------------+-----------------------------+----------------------+
Fields:
channel_id: Channel to mark as readsubchannel_id: Optional subchannel (nullable for forward compatibility)timestamp: Unix timestamp (seconds) to mark as "last read at"
Behavior:
- Registered users: Server stores in
UserChannelStatetable - Anonymous users: Client should handle this locally (this message is accepted but ignored by server for anonymous sessions)
- Server accepts any timestamp value (client can move read position forward or backward)
- Client UI should prompt user when leaving a channel: "Mark as read?" with options:
- "Yes, mark read to now"
- "No, leave as-is"
- "Always mark read automatically" (saves preference)
When to send:
- When user leaves a channel (if "mark as read" is enabled)
- When user manually triggers "mark as read" action
- NOT sent automatically on every message view (too chatty)
Anonymous users:
- Store
last_read_atin local client database - Persists across sessions on same device
- Use this timestamp when requesting GET_UNREAD_COUNTS
0x0E - CHANGE_PASSWORD (Client → Server)
Change the authenticated user's password, or remove password for SSH-only authentication.
+-----------------------------+-----------------------------+
| old_password_hash (String) | new_password_hash (String) |
+-----------------------------+-----------------------------+
Notes:
- User must be authenticated
old_password_hash: Current password hash viaargon2id(password, nickname)(empty string for SSH-registered users changing password for first time)new_password_hash: New password hash viaargon2id(password, nickname)(minimum 8 characters plaintext before hashing)- Password Removal: Send empty string for
new_password_hashto remove password and use SSH-only authentication- Only allowed if user has at least one SSH key registered
- Once removed, user can ONLY authenticate via SSH
- If user loses SSH keys, they will be permanently locked out (admin intervention required)
- Server validates old password hash with bcrypt, double-hashes new password hash with bcrypt for storage
0x8E - PASSWORD_CHANGED (Server → Client)
+-------------------+------------------------+
| success (bool) | error_message (String) |
| | (empty if success) |
+-------------------+------------------------+
0x12 - UPDATE_SSH_KEY_LABEL (Client → Server)
Update the label for an SSH key.
+-------------------+----------------------+
| key_id (i64) | new_label (String) |
+-------------------+----------------------+
0x92 - SSH_KEY_LABEL_UPDATED (Server → Client)
+-------------------+------------------------+
| success (bool) | error_message (String) |
| | (empty if success) |
+-------------------+------------------------+
0x13 - DELETE_SSH_KEY (Client → Server)
Delete an SSH key from the user's account.
+-------------------+
| key_id (i64) |
+-------------------+
Notes:
- User must be authenticated
- Cannot delete last SSH key if user has no password set
- Deletion cascades in database (ON DELETE CASCADE)
0x93 - SSH_KEY_DELETED (Server → Client)
+-------------------+------------------------+
| success (bool) | error_message (String) |
| | (empty if success) |
+-------------------+------------------------+
0x14 - LIST_SSH_KEYS (Client → Server)
Request list of all SSH keys for the authenticated user.
(No payload - user identified from session)
0x94 - SSH_KEY_LIST (Server → Client)
+-------------------+----------------+
| key_count (u32) | keys [] |
+-------------------+----------------+
Each key:
+-------------------+------------------------+----------------------+-------------------+
| key_id (i64) | fingerprint (String) | key_type (String) | label (String) |
+-------------------+------------------------+----------------------+-------------------+
| added_at (Timestamp) | last_used_at (Timestamp) |
+----------------------+-------------------------------------------------------------------+
Notes:
key_type: e.g., "ssh-rsa", "ssh-ed25519"label: May be empty stringlast_used_at: 0 if never usedfingerprint: SHA256 format (e.g., "SHA256:abc123...")
0x18 - GET_UNREAD_COUNTS (Client → Server)
Request unread message counts for specific channels/subchannels/threads.
+------------------------+----------------------+----------------+
| since_timestamp (i64?) | target_count (u16) | targets [] |
+------------------------+----------------------+----------------+
Each target:
+-------------------+-----------------------------+----------------------+
| channel_id (u64) | subchannel_id (Optional u64)| thread_id (Optional u64)|
+-------------------+-----------------------------+----------------------+
Fields:
since_timestamp: Optional unix timestamp (seconds). If present, count messages after this time. If null:- Registered users: Server uses stored
last_read_atfromUserChannelStatetable - Anonymous users: Returns error (must provide timestamp)
- Registered users: Server uses stored
target_count: Number of targets to request counts fortargets: Array of channel/subchannel/thread identifierschannel_id: Required - which channel to countsubchannel_id: Optional - if present, count only this subchannelthread_id: Optional - if present, count only messages in this thread (root message ID)
Usage patterns:
- Channel-wide count:
{channel_id: 1, subchannel_id: null, thread_id: null}- all messages in channel - Specific thread:
{channel_id: 1, subchannel_id: null, thread_id: 42}- only replies to message 42 - Subchannel thread:
{channel_id: 1, subchannel_id: 5, thread_id: 100}- thread in subchannel - Registered user, first request: Omit
since_timestamp, server uses stored state - Anonymous user: Must always provide
since_timestamp(typically from local client database)
Response: Server responds with UNREAD_COUNTS (0x97) message.
Performance Note:
- Clients should request counts for visible items only (not all channels/threads at once)
- Server queries use indexed lookups:
- Channel-wide:
WHERE channel_id = ? AND created_at > ? - Thread-specific:
WHERE thread_id = ? AND created_at > ?
- Channel-wide:
- Very fast with proper indexes
0x97 - UNREAD_COUNTS (Server → Client)
Response with unread message counts for requested channels/threads.
+----------------------+----------------+
| count_count (u16) | counts [] |
+----------------------+----------------+
Each count entry:
+-------------------+-----------------------------+----------------------+----------------------+
| channel_id (u64) | subchannel_id (Optional u64)| thread_id (Optional u64)| unread_count (u32) |
+-------------------+-----------------------------+----------------------+----------------------+
Fields:
count_count: Number of count entriescounts: Array of unread counts per channel/subchannel/threadunread_count: Number of messages created after the reference timestamp
Notes:
- Sent in response to GET_UNREAD_COUNTS (0x18)
- Count is based on either:
- The
since_timestampprovided in the request, OR - The stored
last_read_atfromUserChannelStatetable (registered users only)
- The
- If a requested target has 0 unread messages, it's still included in the response with
unread_count = 0 - Response entries match the structure of request targets (include
thread_idif it was requested)
0x0F - GET_USER_INFO (Client → Server)
Request information about a user by nickname.
+-------------------+
| nickname (String) |
+-------------------+
Notes:
- Can be used to check if a nickname is registered before attempting authentication
- Useful for client UI to show "Sign In" vs "Register" options dynamically
- Returns basic public information about the user
0x8F - USER_INFO (Server → Client)
Response with user information.
+-------------------+---------------------+-------------------+
| nickname (String) | is_registered(bool) | user_id |
| | | (Optional u64) |
+-------------------+---------------------+-------------------+
| online (bool) |
+-------------------+
Fields:
nickname: Echo back the nickname that was queriedis_registered: True if this nickname belongs to a registered user (has password)user_id: Only present ifis_registered = true, the user's IDonline: True if the user is currently connected (any session with this nickname)
Notes:
- For anonymous users with this nickname,
is_registered = falseanduser_idis absent - If no user exists with this nickname (neither registered nor currently online),
online = falseandis_registered = false - Client can use
is_registeredto determine whether to show sign-in or registration UI
0x16 - LIST_USERS (Client → Server)
Request list of online or all users.
+-------------------+-------------------+
| limit (u16) | include_offline |
| | (optional bool) |
+-------------------+-------------------+
Fields:
limit: Maximum number of users to return (default: 100, max: 500)include_offline: (Optional) If present and true, include offline registered users. Admin-only feature. If absent or false, only online users are returned.
Notes:
- By default (or when
include_offlineis false), returns only currently connected users (active sessions) - Includes both registered and anonymous users when online-only
- When
include_offlineis true (admin only), returns all registered users regardless of online status - Anonymous users are never included when
include_offlineis true (they have no persistent identity) - Results sorted by connection time for online users (most recent first)
- Server returns ERROR 1005 (permission denied) if non-admin requests with
include_offline = true
0x17 - LIST_CHANNEL_USERS (Client → Server)
Request a snapshot of all sessions currently present in a channel or subchannel.
+-------------------+-----------------------------+
| channel_id (u64) | subchannel_id (Optional u64)|
+-------------------+-----------------------------+
Notes:
- Clients typically request this immediately after a successful
JOIN_CHANNELto pre-populate their roster UI. - When
subchannel_idis omitted, the request targets the root-level channel membership. - Older servers may not implement this request; clients should handle
ERROR 3000(permission denied) orERROR 4001(not found) gracefully.
0x9A - USER_LIST (Server → Client)
Response with list of users (online or all registered users if admin requested with include_offline).
+-------------------+----------------+
| user_count (u16) | users [] |
+-------------------+----------------+
Each user:
+-------------------+---------------------+-------------------+-------------+
| nickname (String) | is_registered(bool) | user_id | online(bool)|
| | | (Optional u64) | |
+-------------------+---------------------+-------------------+-------------+
Fields (per user):
nickname: The user's current nickname (includes ~ prefix for anonymous users in display)is_registered: True if registered user, false if anonymoususer_id: Only present ifis_registered = trueonline: True if user has an active session (connected)
Notes:
- By default (when LIST_USERS has
include_offline = false), shows only currently connected users - Anonymous users appear with their session nickname and
online = true - When admin requests with
include_offline = true, includes all registered users with their online status - Offline registered users appear with
is_registered = true,online = false - Same user_id may appear multiple times if user has multiple sessions (all with
online = true) - Useful for admin features to show all users, not just online ones
0xAB - CHANNEL_USER_LIST (Server → Client)
Snapshot roster for a channel/subchannel, typically sent in response to LIST_CHANNEL_USERS.
+-------------------+-----------------------------+-------------------+
| channel_id (u64) | subchannel_id (Optional u64)| user_count (u16) |
+-------------------+-----------------------------+-------------------+
Each user:
+-------------------+----------------------+-------------------+----------------------+------------------+
| session_id (u64) | nickname (String) | is_registered(bool)| user_id (Optional u64)| user_flags (u8) |
+-------------------+----------------------+-------------------+----------------------+------------------+
Notes:
session_iddistinguishes multiple simultaneous connections from the same account.user_flagsreuses the standard bitfield (0x01= admin,0x02= moderator). Unknown bits should be ignored for forward compatibility.- A follow-up
CHANNEL_PRESENCEevent will be sent for subsequent joins/leaves so clients can keep the roster current without polling.
0xAC - CHANNEL_PRESENCE (Server → Client)
Real-time join/leave event for a channel or subchannel.
+-------------------+-----------------------------+-------------------+----------------------+-------------------+----------------------+------------------+---------------+
| channel_id (u64) | subchannel_id (Optional u64)| session_id (u64) | nickname (String) | is_registered(bool)| user_id (Optional u64)| user_flags (u8) | joined (bool) |
+-------------------+-----------------------------+-------------------+----------------------+-------------------+----------------------+------------------+---------------+
joined = trueindicates the session entered the channel (via join, subscribe promotion, or reconnect).joined = falseindicates the session left or disconnected.- Clients should treat missing support by older servers as an absence of presence updates and may fall back to manual refreshes.
0xAD - SERVER_PRESENCE (Server → Client)
Server-wide presence event for connection lifecycle changes.
+-------------------+----------------------+-------------------+----------------------+------------------+---------------+
| session_id (u64) | nickname (String) | is_registered(bool)| user_id (Optional u64)| user_flags (u8) | online (bool) |
+-------------------+----------------------+-------------------+----------------------+------------------+---------------+
online = truesignals a new active session;online = falsesignals termination.- Enables clients to keep a global roster synchronized after an initial
USER_LISTsnapshot. - Servers MAY omit this message when no listeners have requested presence updates; clients must be resilient to its absence.
0xAE - DM_PARTICIPANT_LEFT (Server → Client)
Notification sent to remaining DM participants when someone permanently leaves a DM conversation.
+---------------------+------------------------+----------------------+
| dm_channel_id (u64) | user_id (Optional u64) | nickname (String) |
+---------------------+------------------------+----------------------+
Fields:
dm_channel_id: The DM channel the participant leftuser_id: The user ID of the participant who left (if registered), absent if anonymousnickname: The display nickname of the participant who left
Notes:
- Sent only when a participant uses "permanent leave" (Ctrl+W in client), not for temporary channel switching
- Allows remaining participants to know they are now talking to no one
- Client should display a system message in the DM indicating the other participant has left
- For anonymous users,
user_idis absent since they have no persistent identity
0x1E - DECLINE_DM (Client → Server)
Decline an incoming DM request.
+---------------------+
| dm_channel_id (u64) |
+---------------------+
Fields:
dm_channel_id: The DM channel/invite ID being declined
Notes:
- Sent when user declines an incoming DM request (from DM_REQUEST modal)
- Server will notify the initiator via DM_DECLINED message
- Server will clean up the pending invite
0xAF - DM_DECLINED (Server → Client)
Notification sent to DM initiator when their request is declined.
+---------------------+------------------------+----------------------+
| dm_channel_id (u64) | user_id (Optional u64) | nickname (String) |
+---------------------+------------------------+----------------------+
Fields:
dm_channel_id: The DM channel/invite ID that was declineduser_id: The user ID who declined (if registered), absent if anonymousnickname: The display nickname of the user who declined
Notes:
- Sent to the DM initiator when the target declines their request
- Client should remove the pending outgoing invite from the sidebar
- May optionally show a notification that the request was declined
0x1C - LOGOUT (Client → Server)
Clear the current session's authentication and become anonymous.
(empty message - no payload)
Notes:
- Clears
session.UserIDon the server, making the session anonymous - Preserves the current nickname, but user can no longer perform authenticated actions
- User can still post messages as an anonymous user with their current nickname
- To fully switch identity, send LOGOUT followed by SET_NICKNAME with a new name
- No response message - operation always succeeds
- Useful for "Go Anonymous" feature or switching between registered accounts
Behavior:
- After logout, session becomes anonymous (user_id = NULL)
- Current nickname is preserved unless explicitly changed
- If nickname is registered, subsequent SET_NICKNAME with same name will require authentication
- User loses access to authenticated-only features (creating channels, SSH keys, etc.)
0x07 - CREATE_CHANNEL (Client → Server)
+-------------------+------------------------+------------+------------------------+
| name (String) | description (String) | type (u8) | retention_hours (u32) |
+-------------------+------------------------+------------+------------------------+
Type:
- 0x00 = chat
- 0x01 = forum
Notes:
typeandretention_hoursare used when channel has no subchannels- If subchannels are added later, their individual type and retention_hours take precedence
0x87 - CHANNEL_CREATED (Server → Client)
Response to CREATE_CHANNEL request + broadcast to all connected clients.
+-------------------+-------------------+------------------------+
| success (bool) | channel_id (u64) | name (String) |
| | (only if success) | (only if success) |
+-------------------+-------------------+------------------------+
| description (String) | type (u8) | retention_hours (u32) |
| (only if success) | (success only) | (only if success) |
+-------------------------+----------------+------------------------+
| message (String) |
+-------------------+
Broadcast behavior:
- Sent to the creating client as confirmation
- Also broadcast to ALL other connected clients as a real-time notification
- Clients should add the new channel to their channel list
- If
success = false, only sent to requesting client (not broadcast)
0x08 - CREATE_SUBCHANNEL (Client → Server)
+-------------------+-------------------+------------------------+
| channel_id (u64) | name (String) | description (String) |
+-------------------+-------------------+------------------------+
| type (u8) | retention_hours (u32) |
+-------------------+------------------------------------------------+
Type:
- 0x00 = chat
- 0x01 = forum
0x88 - SUBCHANNEL_CREATED (Server → Client)
Response to CREATE_SUBCHANNEL request + broadcast to all connected clients.
+-------------------+----------------------+-------------------+
| success (bool) | channel_id (u64) | subchannel_id(u64)|
| | (only if success) | (only if success) |
+-------------------+----------------------+-------------------+
| name (String) | description (String) | type (u8) |
| (only if success) | (only if success) | (success only) |
+-------------------+----------------------+-------------------+
| retention_hours (u32) | message (String) |
| (only if success) | |
+-----------------------+------------------------------------+
Broadcast behavior:
- Sent to the creating client as confirmation
- Also broadcast to ALL other connected clients as a real-time notification
- Clients should add the new subchannel to the appropriate channel in their list
channel_idindicates which channel this subchannel belongs to- If
success = false, only sent to requesting client (not broadcast)
0x19 - START_DM (Client → Server)
Initiate a direct message conversation with another user.
+-------------------+---------------------------+-------------------------+
| target_type (u8) | target_id (varies) | allow_unencrypted(bool) |
+-------------------+---------------------------+-------------------------+
Target Types:
- 0x00 = by user_id (target_id is u64 user_id, registered users only)
- 0x01 = by nickname (target_id is String, could be registered or anonymous)
- 0x02 = by session_id (target_id is u64 session_id, for anonymous users)
allow_unencrypted:
- If true, initiator is willing to accept unencrypted DMs
- If false, DM must be encrypted or will fail
Notes:
- If targeting by nickname and multiple users/sessions have that nickname, server picks first match (prefer registered users)
- For anonymous users, targeting by session_id is more reliable
0x1A - PROVIDE_PUBLIC_KEY (Client → Server)
Upload an X25519 public key for DM encryption.
+-------------------+------------------------+-------------------------+
| key_type (u8) | public_key (32 bytes) | label (String) |
+-------------------+------------------------+-------------------------+
Key Types:
- 0x00 = Derived from SSH key (Ed25519 → X25519 conversion)
- 0x01 = Generated X25519 key (for password-only users)
- 0x02 = Ephemeral X25519 key (for anonymous users, session-only)
public_key:
- X25519 public key (32 bytes, raw format)
- Used for Diffie-Hellman key agreement with other users
label:
- Optional human-readable label (e.g., "laptop", "phone", "work")
- Helps users manage multiple keys
Notes:
- Key is stored in
User.encryption_public_keyfield - For Ed25519 SSH users: derived from SSH key (automatic)
- For password-only users: generated client-side
- For anonymous users: stored temporarily (deleted on disconnect)
- Server never receives or stores private keys
- Client stores private key in
~/.superchat/keys/(or derives from SSH key)
0x1B - ALLOW_UNENCRYPTED (Client → Server)
Explicitly allow unencrypted DMs for the current user.
+------------------------+-------------------+
| dm_channel_id (u64) | permanent (bool) |
+------------------------+-------------------+
dm_channel_id:
- The ID of the DM channel this response applies to
- Provided in the DM_REQUEST or KEY_REQUIRED message
- Ensures response is matched to the correct DM request
permanent:
- If true, allow all future DMs to be unencrypted (sets
User.allow_unencrypted_dms = true) - If false, only allow for current pending DM request (one-time exception)
Notes:
- Used when user doesn't want to set up encryption keys
- If
permanent = true, server stores preference inUser.allow_unencrypted_dms - Permanent preference can be changed later through user settings
- Anonymous users can only use
permanent = false(no persistent preference)
0xA1 - KEY_REQUIRED (Server → Client)
Server needs an encryption key before proceeding with DM.
+-------------------+---------------------------+
| reason (String) | dm_channel_id (Optional u64)|
+-------------------+---------------------------+
reason:
- Human-readable explanation (e.g., "DM encryption requires a key")
dm_channel_id:
- If present, this is for a specific DM channel
- If absent, user needs a key for general DM functionality
Client should:
- Display reason to user
- Prompt user to choose:
- Generate local keypair (ephemeral, device-specific)
- Add existing SSH key (paste/select from ~/.ssh/)
- Generate new SSH key (save to ~/.ssh/)
- Allow unencrypted (if permitted by other party)
- Send PROVIDE_PUBLIC_KEY or ALLOW_UNENCRYPTED
0xA2 - DM_READY (Server → Client)
DM channel is ready to use.
+-------------------+-------------------+------------------------+
| channel_id (u64) | other_user_id | other_nickname(String) |
| | (Optional u64) | |
+-------------------+-------------------+------------------------+
| is_encrypted(bool)| other_public_key (Optional 32 bytes) |
+-------------------+-------------------------------------------+
Notes:
other_user_idis null if other party is anonymousis_encryptedindicates whether this DM uses encryptionother_public_keyis the other party's X25519 public key (32 bytes)- Only present if
is_encrypted = true - Client computes shared secret:
X25519(my_private, other_public_key) - Then derives channel key via HKDF with channel_id
- Only present if
- Client can now use standard JOIN_CHANNEL, POST_MESSAGE, etc. on this channel
0xA3 - DM_PENDING (Server → Client)
Waiting for other party to complete key setup.
+-------------------+---------------------------+------------------------+
| dm_channel_id(u64)| waiting_for_user_id | waiting_for_nickname |
| | (Optional u64) | (String) |
+-------------------+---------------------------+------------------------+
| reason (String) |
+-------------------+
reason:
- "Waiting for
to set up encryption" - "Waiting for
to accept DM request"
Notes:
- Sent to initiator while waiting for recipient to respond
- Client should display waiting indicator
- Will be followed by DM_READY or ERROR
0xA4 - DM_REQUEST (Server → Client)
Incoming DM request from another user.
+-------------------+-------------------------+------------------------+
| dm_channel_id(u64)| from_user_id | from_nickname (String) |
| | (Optional u64) | |
+-------------------+-------------------------+------------------------+
| encryption_status (u8) |
+----------------------------------------------------------------------+
encryption_status:
0= Encryption not possible - Initiator has no encryption key. Conversation will be unencrypted regardless of recipient's key status.1= Encryption required - Initiator has a key and requires encryption. Recipient must set up a key to proceed.2= Encryption optional - Initiator has a key but allows unencrypted. Recipient can set up a key for encryption or skip for unencrypted.
Client should:
- Notify user of incoming DM request
- If
encryption_status = 0, inform user conversation will be unencrypted (no setup option) - If
encryption_status = 1, require key setup before accepting - If
encryption_status = 2, offer choice between setting up encryption or proceeding unencrypted
0x10 - PING (Client → Server)
Keepalive heartbeat to maintain session when idle.
+-------------------+
| timestamp (int64) |
+-------------------+
Notes:
- Client's local timestamp for RTT calculation
- Session timeout: Server disconnects if no PING received for 60 seconds
- CRITICAL: Clients MUST send PING to keep session alive
- Server ONLY updates
Session.last_activityon PING messages - Other messages (POST_MESSAGE, LIST_MESSAGES, etc.) do NOT reset the idle timer
- Send PING every 30 seconds to maintain session (regardless of other activity)
- Failure to send PING will result in disconnection after 60 seconds
- Server ONLY updates
- Connection also closed if socket dies
Rationale:
- Updating session activity on every message creates excessive DB writes (55% overhead)
- PING provides explicit keepalive signal that is cheap to process
- Active clients posting messages every 100ms still need PING for session tracking
0x90 - PONG (Server → Client)
+---------------------------+
| client_timestamp (int64) |
+---------------------------+
Echoes back the client's timestamp.
0x11 - DISCONNECT (Client → Server, Server → Client)
Graceful disconnect notification. Can be sent by either client or server to signal intentional disconnect.
+--------------------+
| reason (Optional String)|
+--------------------+
Fields:
reason(Optional): Human-readable explanation for disconnect- If present: A disconnect reason is provided (e.g., "Server shutting down for maintenance", "Client closing connection")
- If absent (empty payload): Generic disconnect with no specific reason
Direction: Client → Server:
- Client sends DISCONNECT before closing connection gracefully
- Allows server to clean up session immediately
- No response expected from server
Direction: Server → Client:
- Server sends DISCONNECT before forcibly closing client connection
- Common reasons:
"Server shutting down for maintenance"- Graceful server shutdown"Session timeout"- No activity for 60+ seconds"Protocol violation"- Client sent malformed messages"Kicked by operator"- Admin action
- Client should display reason to user and not attempt immediate reconnect
- Connection will be closed by server shortly after sending this message
Notes:
- This is a notification only - no acknowledgment is required or expected
- Used for clean shutdown and user feedback (vs. abrupt connection drop)
- Helps distinguish intentional disconnects from network failures
- Client should display server-provided reason before auto-reconnect
- Empty reason (
reasonfield absent) is valid for simple disconnects
0xA0 - SERVER_STATS (Server → Client)
Response to GET_SERVER_STATS request or sent periodically as a broadcast.
+---------------------------+---------------------------+
| total_users_online (u32) | total_channels (u32) |
+---------------------------+---------------------------+
Fields:
total_users_online: Current number of connected userstotal_channels: Total number of public channels
Delivery:
- Sent as response to GET_SERVER_STATS request
- Optionally broadcast periodically to all connected clients (e.g., every 30 seconds)
- Periodic broadcast allows clients to show live user counts without polling
0x98 - SERVER_CONFIG (Server → Client)
Server configuration and limits. Sent automatically after successful connection (after AUTH_RESPONSE or when anonymous user connects).
+---------------------------+---------------------------+
| protocol_version (u8) | max_message_rate (u16) |
+---------------------------+---------------------------+
| max_channel_creates (u16) | inactive_cleanup_days(u16)|
| (per hour) | |
+---------------------------+---------------------------+
| max_connections_per_ip(u8)| max_message_length (u32) |
+---------------------------+---------------------------+
| max_thread_subs (u16) | max_channel_subs (u16) |
+---------------------------+---------------------------+
| directory_enabled (bool) | |
+---------------------------+---------------------------+
Fields:
protocol_version: Protocol version server speaks (must match client, currently 1)max_message_rate: Maximum messages per minute per user (rate limit)max_channel_creates: Maximum channel creations per user per hourinactive_cleanup_days: Days of inactivity before user state is purged (for registered users)max_connections_per_ip: Maximum simultaneous connections allowed per IP addressmax_message_length: Maximum length of message content in bytesmax_thread_subs: Maximum thread subscriptions per session (default: 50)max_channel_subs: Maximum channel subscriptions per session (default: 10)directory_enabled: Whether this server can provide a list of discoverable servers via LIST_SERVERS request (false = regular server, true = directory server)
Delivery:
- Sent once automatically after connection is established
- For anonymous users: sent immediately after socket connection
- For authenticated users: sent after successful AUTH_RESPONSE
- For SSH users: sent after SSH authentication completes
Client Usage:
- MUST check protocol_version first - disconnect if mismatch
- Use rate limit values to implement client-side rate limiting (prevent hitting server limits)
- Display cleanup policy to users so they know their data retention
- Show connection limits in error messages when appropriate
- Validate message length before sending to avoid errors
0x51 - SUBSCRIBE_THREAD (Client → Server)
Subscribe to real-time updates for a specific thread. When subscribed, the client will receive NEW_MESSAGE notifications for all new messages posted to this thread (including replies at any depth).
+-------------------+
| thread_id (u64) |
+-------------------+
Notes:
thread_id: The root message ID of the thread to subscribe to- Server validates that the thread exists (ERROR 4003 if not found)
- Server checks subscription limit per session (ERROR 5004 if exceeded)
- Client will receive NEW_MESSAGE for any message posted under this thread root
- On success, server responds with SUBSCRIBE_OK
Recommended client behavior:
- Subscribe when entering a thread view
- Unsubscribe when leaving the thread view
- Track subscriptions locally to avoid duplicate subscriptions
0x52 - UNSUBSCRIBE_THREAD (Client → Server)
Unsubscribe from a previously subscribed thread.
+-------------------+
| thread_id (u64) |
+-------------------+
Notes:
thread_id: The root message ID to unsubscribe from- No error if already unsubscribed (idempotent)
- No response sent (fire-and-forget)
0x53 - SUBSCRIBE_CHANNEL (Client → Server)
Subscribe to new threads in a channel or subchannel. When subscribed, the client will receive NEW_MESSAGE notifications for new root messages (thread starters) posted to this channel.
+-------------------+-----------------------------+
| channel_id (u64) | subchannel_id (Optional u64)|
+-------------------+-----------------------------+
Notes:
- Subscribe to root-level messages only (not replies)
- Server validates channel exists (ERROR 4001 if not found)
- Server validates subchannel exists if provided (ERROR 4004 if not found)
- Server checks subscription limit per session (ERROR 5005 if exceeded)
- On success, server responds with SUBSCRIBE_OK
Recommended client behavior:
- Subscribe when viewing a channel's thread list
- Unsubscribe when leaving the channel
- Typically combined with thread subscriptions for full coverage
0x54 - UNSUBSCRIBE_CHANNEL (Client → Server)
Unsubscribe from a previously subscribed channel.
+-------------------+-----------------------------+
| channel_id (u64) | subchannel_id (Optional u64)|
+-------------------+-----------------------------+
Notes:
- No error if already unsubscribed (idempotent)
- No response sent (fire-and-forget)
0x99 - SUBSCRIBE_OK (Server → Client)
Confirmation that a subscription was successful.
+-------------------+-------------------+-----------------------------+
| type (u8) | id (u64) | subchannel_id (Optional u64)|
+-------------------+-------------------+-----------------------------+
Type values:
- 1 = Thread subscription confirmed
- 2 = Channel subscription confirmed
Fields:
type: Indicates which type of subscription was confirmedid: The ID that was subscribed to (thread_id or channel_id depending on type)subchannel_id: Only present for channel subscriptions, null for thread subscriptions
Notes:
- Sent in response to SUBSCRIBE_THREAD or SUBSCRIBE_CHANNEL
- Client can use this to confirm the subscription was registered
- Not sent for unsubscribe operations
Admin Protocol Messages
All admin messages require the user to be authenticated and listed in the server's admin_users configuration. Non-admin users attempting to use these messages will receive an ERROR response with code 3000 (Permission denied).
0x59 - BAN_USER (Client → Server)
Ban a user from the server (admin only).
+-------------------+----------------------+-------------------+
| user_id | nickname | reason (String) |
| (Optional u64) | (Optional String) | |
+-------------------+----------------------+-------------------+
| shadowban (bool) | duration_seconds |
| | (Optional u64) |
+-------------------+----------------------+
Fields:
user_id: Optional user ID to ban (for registered users)nickname: Optional nickname to ban (for anonymous or registered users)reason: Human-readable reason for the ban (required)shadowban: If true, user can post but messages only visible to themduration_seconds: Ban duration in seconds (if absent = permanent ban)
Notes:
- At least one of
user_idornicknamemust be provided - Shadowbanned users can still see the channel and post, but their messages are filtered for other users
- All admin actions are logged in the AdminAction table with admin's nickname and IP
- Bans are checked on authentication and message posting
0x9F - USER_BANNED (Server → Client)
Response to BAN_USER request.
+-------------------+-------------------+-------------------+
| success (bool) | ban_id (u64) | message (String) |
| | (only if success) | (error if failed) |
+-------------------+-------------------+-------------------+
Fields:
success: Whether the ban was created successfullyban_id: Database ID of the created ban (only if success)message: Success message or error description
Response cases:
- Success:
success = true,ban_id = <id>,message = "User <nickname> banned successfully" - Permission denied:
success = false,message = "Permission denied: admin access required" - Invalid input:
success = false,message = "Must provide either UserID or Nickname" - Database error:
success = false,message = "Failed to create ban"
0x5A - BAN_IP (Client → Server)
Ban an IP address or CIDR range from the server (admin only).
+-------------------+-------------------+----------------------+
| ip_cidr (String) | reason (String) | duration_seconds |
| | | (Optional u64) |
+-------------------+-------------------+----------------------+
Fields:
ip_cidr: IP address or CIDR range (e.g., "192.168.1.100" or "10.0.0.0/24")reason: Human-readable reason for the ban (required)duration_seconds: Ban duration in seconds (if absent = permanent ban)
Notes:
- Accepts both single IP addresses (e.g., "192.168.1.100") and CIDR ranges (e.g., "10.0.0.0/24")
- IP bans prevent connection entirely (checked on TCP connect)
- CIDR support allows banning entire subnets
- All admin actions are logged in the AdminAction table
0xA5 - IP_BANNED (Server → Client)
Response to BAN_IP request.
+-------------------+-------------------+-------------------+
| success (bool) | ban_id (u64) | message (String) |
| | (only if success) | (error if failed) |
+-------------------+-------------------+-------------------+
Fields:
success: Whether the ban was created successfullyban_id: Database ID of the created ban (only if success)message: Success message or error description
Response cases:
- Success:
success = true,ban_id = <id>,message = "IP <address> banned successfully" - Permission denied:
success = false,message = "Permission denied: admin access required" - Invalid CIDR:
success = false,message = "Invalid IP or CIDR format" - Database error:
success = false,message = "Failed to create ban"
0x5B - UNBAN_USER (Client → Server)
Remove a user ban (admin only).
+-------------------+----------------------+
| user_id | nickname |
| (Optional u64) | (Optional String) |
+-------------------+----------------------+
Fields:
user_id: Optional user ID to unban (for registered users)nickname: Optional nickname to unban
Notes:
- At least one of
user_idornicknamemust be provided - Removes all active bans for the specified user
- If user has multiple bans (shouldn't happen), removes all of them
- All admin actions are logged in the AdminAction table
0xA6 - USER_UNBANNED (Server → Client)
Response to UNBAN_USER request.
+-------------------+----------------------+-------------------+
| success (bool) | bans_removed (u64) | message (String) |
| | (only if success) | (error if failed) |
+-------------------+----------------------+-------------------+
Fields:
success: Whether the unban was successfulbans_removed: Number of bans removed (typically 1, only if success)message: Success message or error description
Response cases:
- Success:
success = true,bans_removed = 1,message = "User <nickname> unbanned successfully" - No ban found:
success = false,bans_removed = 0,message = "No active ban found for user" - Permission denied:
success = false,message = "Permission denied: admin access required"
0x5C - UNBAN_IP (Client → Server)
Remove an IP ban (admin only).
+-------------------+
| ip_cidr (String) |
+-------------------+
Fields:
ip_cidr: IP address or CIDR range to unban (must match ban exactly)
Notes:
- Must match the exact IP/CIDR that was banned
- For example, if "10.0.0.0/24" was banned, must unban "10.0.0.0/24" exactly
- All admin actions are logged in the AdminAction table
0xA7 - IP_UNBANNED (Server → Client)
Response to UNBAN_IP request.
+-------------------+----------------------+-------------------+
| success (bool) | bans_removed (u64) | message (String) |
| | (only if success) | (error if failed) |
+-------------------+----------------------+-------------------+
Fields:
success: Whether the unban was successfulbans_removed: Number of bans removed (typically 1, only if success)message: Success message or error description
Response cases:
- Success:
success = true,bans_removed = 1,message = "IP <address> unbanned successfully" - No ban found:
success = false,bans_removed = 0,message = "No active ban found for IP" - Permission denied:
success = false,message = "Permission denied: admin access required"
0x5D - LIST_BANS (Client → Server)
Request list of all bans (admin only).
+----------------------+
| include_expired(bool)|
+----------------------+
Fields:
include_expired: If true, include expired bans. If false, only active bans.
Notes:
- Returns all bans (user bans and IP bans)
- Expired bans have
banned_until < current_time - Permanent bans have
banned_until = NULL
0xA8 - BAN_LIST (Server → Client)
Response with list of bans.
+-------------------+----------------+
| ban_count (u16) | bans [] |
+-------------------+----------------+
Each ban:
+-------------------+-------------------+----------------------+
| ban_id (u64) | ban_type (u8) | user_id |
| | | (Optional u64) |
+-------------------+-------------------+----------------------+
| nickname | ip_cidr | reason (String) |
| (Optional String) | (Optional String) | |
+-------------------+-------------------+----------------------+
| shadowban (bool) | banned_at (Timestamp) |
+-------------------+-----------------------------------------+
| banned_until | banned_by (String) |
| (Optional i64) | |
+-------------------+-----------------------------------------+
Ban Types:
- 0x00 = User ban
- 0x01 = IP ban
Fields (per ban):
ban_id: Database ID of the banban_type: 0x00 for user ban, 0x01 for IP banuser_id: Only present for user bans (NULL for IP bans)nickname: Nickname at time of ban (only for user bans)ip_cidr: IP or CIDR (only for IP bans)reason: Admin-provided reason for the banshadowban: True if this is a shadowban (only for user bans)banned_at: When the ban was created (timestamp in milliseconds)banned_until: When the ban expires (optional int64 timestamp in milliseconds, NULL = permanent)banned_by: Nickname of the admin who created the ban
Notes:
- User bans have
user_idandnicknamepopulated,ip_cidris NULL - IP bans have
ip_cidrpopulated,user_idandnicknameare NULL - Shadowban field is always present but only meaningful for user bans
- Banned_until uses optional int64 (signed) to represent timestamp in milliseconds
0x5E - DELETE_USER (Client → Server)
Delete a user account permanently (admin only). Messages by the deleted user are anonymized (author_user_id set to NULL).
+-------------------+
| user_id (u64) |
+-------------------+
Fields:
user_id: User ID to delete
Notes:
- Admin-only operation (requires user_flags = 1)
- All messages by the user are anonymized (preserves content, sets author_user_id=NULL)
- All active sessions for the user are disconnected
- Cascades to delete SSH keys, sessions, and bans
- All admin actions are logged in the AdminAction table
- Admins cannot delete their own account
Error cases:
- Non-admin user: ERROR 1003 (Permission denied)
- User not found:
success = false,message = "User not found" - Self-deletion attempt:
success = false,message = "Cannot delete your own account"
0xA9 - USER_DELETED (Server → Client)
Response to DELETE_USER request + broadcast to all connected clients.
+-------------------+-------------------+
| success (bool) | message (String) |
+-------------------+-------------------+
Fields:
success: Whether the deletion was successfulmessage: Success message or error description
Response cases:
- Success:
success = true,message = "User '<nickname>' deleted successfully (messages anonymized, N sessions disconnected)" - Permission denied:
success = false,message = "Permission denied: admin access required" - User not found:
success = false,message = "User not found" - Self-deletion:
success = false,message = "Cannot delete your own account"
Notes:
- Broadcast to all connected clients so they can update their user lists
- Clients should handle the user being removed from their local cache
0x5F - DELETE_CHANNEL (Client → Server)
Delete a channel permanently (admin only). This will cascade delete all associated messages and subchannels.
+-------------------+----------------------+
| channel_id (u64) | reason (String) |
+-------------------+----------------------+
Fields:
channel_id: Channel ID to deletereason: Admin-provided reason for deletion
Notes:
- Admin-only operation (requires user_flags = 1)
- Cascades to delete all messages, subchannels, and subscriptions
- All admin actions are logged in the AdminAction table
- Users currently in the deleted channel will receive an error on their next action
Error cases:
- Non-admin user: ERROR 1003 (Permission denied)
- Channel not found: ERROR 1004 (Channel not found)
- Invalid channel ID: ERROR 1002 (Invalid format)
0xAA - CHANNEL_DELETED (Server → Client)
Response to DELETE_CHANNEL request + broadcast to all connected clients.
+-------------------+-------------------+-------------------+
| success (bool) | channel_id (u64) | message (String) |
+-------------------+-------------------+-------------------+
Fields:
success: Whether the deletion was successfulchannel_id: ID of the deleted channelmessage: Success message or error description
Response cases:
- Success:
success = true,message = "Channel <name> deleted successfully" - Permission denied:
success = false,message = "Permission denied: admin access required" - Channel not found:
success = false,message = "Channel not found"
Notes:
- Broadcast to all connected clients so they can update their channel lists
- Clients should remove the channel from their local cache
0x91 - ERROR (Server → Client)
Generic error response.
+-------------------+-------------------+
| error_code (u16) | message (String) |
+-------------------+-------------------+
Error Code Categories (1000-9999):
1xxx - Protocol Errors:
- 1000: Invalid message format
- 1001: Unsupported protocol version
- 1002: Invalid frame (malformed, oversized, etc.)
- 1003: Compression error
- 1004: Encryption error
2xxx - Authentication Errors:
- 2000: Authentication required
- 2001: Invalid credentials
- 2002: User already exists (registration)
- 2003: SSH key already registered
- 2004: Session expired
3xxx - Authorization Errors:
- 3000: Permission denied
- 3001: Not channel operator
- 3002: Not message author
- 3003: Channel is private
4xxx - Resource Errors:
- 4000: Resource not found
- 4001: Channel not found
- 4002: Message not found
- 4003: Thread not found
- 4004: Subchannel not found
5xxx - Rate Limit Errors:
- 5000: Rate limit exceeded (general)
- 5001: Message rate limit exceeded
- 5002: Channel creation rate limit exceeded
- 5003: Too many connections from IP
- 5004: Thread subscription limit exceeded (max 50 per session)
- 5005: Channel subscription limit exceeded (max 10 per session)
6xxx - Validation Errors:
- 6000: Invalid input
- 6001: Message too long
- 6002: Invalid channel name
- 6003: Invalid nickname
- 6004: Nickname already taken
9xxx - Server Errors:
- 9000: Internal server error
- 9001: Database error
- 9002: Service unavailable
Server Discovery Protocol
SuperChat supports server discovery through directory services. Any SuperChat server can optionally act as a directory by enabling discovery mode. Servers can announce themselves to directories, and clients can browse available servers. This enables a federated-style discovery model similar to Mastodon's instance list, while keeping servers completely independent.
Key Concepts:
- Directory: Any SuperChat server running in directory mode (accepts REGISTER_SERVER requests and maintains a list of known servers)
- Discoverable Server: Any SuperChat server that announces itself to one or more directories
- Client Discovery: Clients connect to a directory to browse available servers, then disconnect and connect to chosen server
Important: A directory is just a regular SuperChat server with directory mode enabled. The same server binary provides both chat functionality and optional directory services. For example, superchat.win:6465 serves as both a chat server AND a directory.
Directory Configuration:
- Clients maintain a list of directories (default:
superchat.win:6465) - Any server can enable directory mode (via
--enable-directoryflag) - Servers can announce to multiple directories (via
--announce-toflag)
Directory Gossip Protocol:
- Directories periodically query registered servers with LIST_SERVERS
- If a registered server is also a directory, it returns its known servers
- The querying directory discovers new servers and can register to them
- This creates a self-sustaining mesh network of directories
- If the primary directory (e.g., superchat.win) goes down, other directories continue operating
- Gossip interval: every 1-6 hours (configurable, randomized to avoid thundering herd)
Anti-Spam Measures:
- Verification Challenge: Directories connect back to verify servers are real and reachable
- Rate Limiting: 30 requests/hour per IP (enough for heartbeats + retries)
- Adaptive Heartbeat: Directories adjust heartbeat interval based on load
- Deduplication: Only one entry per hostname:port (re-registration updates existing)
Trust Model:
- Verification ensures servers are reachable, but doesn't prevent all abuse
- A malicious actor could still spin up many real servers to flood directories
- We rely on economic disincentives: running real servers is expensive and tedious
- The barrier to entry (actual server infrastructure) deters casual spam
- Directories operated by trusted community members are preferred
0x55 - LIST_SERVERS (Client/Directory → Server)
Request list of discoverable servers from a directory.
+-------------------+
| limit (u16) |
+-------------------+
Notes:
limit: Maximum number of servers to return (default: 100, max: 500)- Returns servers sorted by last heartbeat (most recently active first)
- Only returns servers that have sent heartbeat within their interval window
- Can be sent by clients (browsing servers) or by directories (gossip protocol)
- Regular chat servers (not in directory mode) respond with empty SERVER_LIST (count: 0)
0x9B - SERVER_LIST (Server → Client/Directory)
Response with list of discoverable servers.
+-------------------+----------------+
| server_count(u16) | servers [] |
+-------------------+----------------+
Each server:
+-------------------+-------------------+-------------------+
| hostname (String) | port (u16) | name (String) |
+-------------------+-------------------+-------------------+
| description (String) | user_count (u32) |
+-------------------------------------+-------------------+
| max_users (u32) | uptime_seconds (u64) |
+-------------------+------------------------------------- +
| is_public (bool) | channel_count (u32) |
+-------------------+-------------------------------------+
Fields:
hostname: DNS hostname or IP addressport: TCP port (typically 6465)name: Human-readable server namedescription: Server description/purposeuser_count: Current number of connected usersmax_users: Maximum user capacity (0 = unlimited)uptime_seconds: How long server has been runningis_public: Whether server accepts public registrationschannel_count: Number of channels available on the server
Notes:
- Sorted by last heartbeat time (most recent first)
- Only includes servers with recent heartbeats (within 2x heartbeat interval)
0x56 - REGISTER_SERVER (Server → Directory)
Register or update server entry in directory.
+-------------------+-------------------+-------------------+
| hostname (String) | port (u16) | name (String) |
+-------------------+-------------------+-------------------+
| description (String) | max_users (u32) |
+-------------------------------------+-------------------+
| is_public (bool) | channel_count (u32) |
+-------------------+-------------------------------------+
Fields:
hostname: Server's publicly accessible hostname or IPport: Server's port (must be reachable from directory)name: Human-readable server name (e.g., "Gaming Community")description: Server description/purposemax_users: Maximum user capacity (0 = unlimited)is_public: Whether server accepts public registrationschannel_count: Number of channels available on the server
Behavior:
- If hostname:port already registered: updates existing entry
- If new registration: triggers verification challenge (VERIFY_REGISTRATION)
- Directory may reject if rate limit exceeded (30/hour)
Notes:
- Server must respond to VERIFY_REGISTRATION challenge to complete registration
- After successful registration, server must send HEARTBEAT periodically
- Failed verification removes server from directory
0x9C - REGISTER_ACK (Directory → Server)
Acknowledgment of server registration with heartbeat interval.
+-------------------+------------------------+-------------------+
| success (bool) | heartbeat_interval(u32)| message (String) |
| | (only if success) | (error if failed) |
+-------------------+------------------------+-------------------+
Fields:
success: Whether registration was successfulheartbeat_interval: Seconds between heartbeats (e.g., 300 = 5 minutes)message: Error description if failed, or welcome message if success
Heartbeat Interval:
- Directory calculates based on current load (number of registered servers)
- Typical values: 300s (5 min), 600s (10 min), 1800s (30 min), 3600s (1 hour)
- Server must send HEARTBEAT before interval expires or be removed
Error Cases:
- Rate limit exceeded:
success = false,message = "Rate limit exceeded" - Invalid hostname/port:
success = false,message = "Invalid hostname or port" - Verification failed:
success = false,message = "Could not verify server"
0x9E - VERIFY_REGISTRATION (Directory → Server)
Challenge sent to verify server is reachable and authentic.
+-------------------+
| challenge (u64) |
+-------------------+
Fields:
challenge: Random 64-bit nonce
Behavior:
- Directory connects to server's hostname:port
- Sends VERIFY_REGISTRATION with random challenge
- Server must respond with VERIFY_RESPONSE containing same challenge
- If response matches, registration is confirmed (server added to directory)
- If connection fails or response incorrect, registration is rejected
Two Use Cases:
Initial Registration (server-initiated):
- Server sends REGISTER_SERVER to directory
- Directory immediately verifies by sending VERIFY_REGISTRATION
- If verification succeeds, server is added and receives REGISTER_ACK
- If verification fails, server receives REGISTER_ACK with
success = false
Gossip Discovery (directory-initiated):
- Directory A discovers Server C through gossip from Directory B
- Directory A connects to Server C and sends VERIFY_REGISTRATION
- If verification succeeds, Server C is added to Directory A's list (no REGISTER_SERVER needed)
- If verification fails, Directory A ignores Server C
- Server C is NOT notified of successful gossip-based addition (silent verification)
Notes:
- Prevents registering fake/unreachable servers
- Prevents malicious directories from injecting fake servers through gossip
- Directory times out after 10 seconds if no response
- Servers must respond to VERIFY_REGISTRATION from any directory (not just ones they registered to)
0x58 - VERIFY_RESPONSE (Server → Directory)
Response to verification challenge.
+-------------------+
| challenge (u64) |
+-------------------+
Fields:
challenge: Echo back the challenge from VERIFY_REGISTRATION
Behavior:
- Server receives VERIFY_REGISTRATION on its main listener
- Immediately responds with VERIFY_RESPONSE containing same challenge
- Directory verifies challenge matches and completes registration
Notes:
- Must be sent within 10 seconds of receiving VERIFY_REGISTRATION
- Failure to respond correctly removes server from directory
0x57 - HEARTBEAT (Server → Directory)
Periodic heartbeat to maintain directory listing.
+-------------------+-------------------+
| hostname (String) | port (u16) |
+-------------------+-------------------+
| user_count (u32) | uptime_seconds(u64)|
+-------------------+-------------------+
| channel_count (u32) |
+---------------------------------------+
Fields:
hostname: Server's hostname (must match registration)port: Server's port (must match registration)user_count: Current number of connected users (updated)uptime_seconds: Server uptime in seconds (updated)channel_count: Number of channels available on the server (updated)
Behavior:
- Sent at interval specified in REGISTER_ACK or HEARTBEAT_ACK
- Updates server's metadata in directory (user count, uptime)
- Resets "last seen" timestamp to prevent removal
Notes:
- Must be sent before heartbeat_interval expires
- Missing 3 consecutive heartbeats removes server from directory
- Includes updated stats so directory has current info
0x9D - HEARTBEAT_ACK (Directory → Server)
Acknowledgment of heartbeat with updated interval.
+-------------------+
| heartbeat_interval(u32)|
+-------------------+
Fields:
heartbeat_interval: Seconds until next heartbeat (may be adjusted)
Behavior:
- Directory may adjust interval based on current load
- If interval changes, server should use new value for next heartbeat
- Allows directory to scale heartbeat frequency dynamically
Load-Based Intervals:
< 100 servers: 300s (5 minutes)
< 1000 servers: 600s (10 minutes)
< 5000 servers: 1800s (30 minutes)
>= 5000 servers: 3600s (1 hour)
Notes:
- Server should log interval changes for debugging
- If server ignores interval adjustments, directory may remove it
- Prevents directory overload with thousands of servers
Connection Flow
Anonymous TCP Connection (Read-Only)
Client Server
| |
|--- TCP Connect -------------------> |
| |
|--- LIST_CHANNELS ------------------> |
|<-- CHANNEL_LIST -------------------- |
| |
|--- JOIN_CHANNEL ------------------> |
|<-- JOIN_RESPONSE ------------------- |
|<-- MESSAGE_LIST (initial) ---------- |
| |
|<-- NEW_MESSAGE (real-time) --------- |
|<-- NEW_MESSAGE --------------------- |
| |
Note: Anonymous users can browse and read without setting a nickname. Nickname is only required when posting a message.
Anonymous TCP Connection (Posting)
Client Server
| |
|--- (already connected, browsing) -- |
| |
|--- POST_MESSAGE ------------------> |
|<-- ERROR (nickname required) ------- |
| |
|--- SET_NICKNAME ------------------> |
|<-- NICKNAME_RESPONSE (success) ---- |
| |
|--- POST_MESSAGE ------------------> |
|<-- MESSAGE_POSTED ----------------- |
| |
Note: Server rejects POST_MESSAGE if session has no nickname set. Client must set nickname before posting.
Registered User via Password
Client Server
| |
|--- TCP Connect -------------------> |
| |
|--- SET_NICKNAME ("alice") ---------> |
|<-- NICKNAME_RESPONSE (fail) -------- |
| "Nickname registered" |
| |
|--- AUTH_REQUEST -------------------> |
|<-- AUTH_RESPONSE (success) --------- |
| |
|--- LIST_CHANNELS ------------------> |
|<-- CHANNEL_LIST (with unread) ------ |
| |
SSH Connection
Client Server
| |
|--- SSH Connect (key auth) ---------> |
|<-- SSH authenticated --------------- |
| (Server checks key fingerprint) |
| |
|<-- AUTH_RESPONSE (success) --------- |
| (nickname auto-set) |
| |
|--- LIST_CHANNELS ------------------> |
|<-- CHANNEL_LIST ------------------- |
| |
SSH Key Authentication Flow:
Client connects:
ssh username@superchat.example.comSSH protocol authenticates: Server verifies client has the private key matching their public key
Server receives public key: Full public key is available after SSH authentication
Server computes fingerprint: SHA256 hash of the public key
Server looks up fingerprint in
SSHKeytable:Case A - Key is registered:
- Authenticate as the registered user (ignore SSH username)
- Set session nickname to registered user's nickname
- Example: Key registered to 'elegant', user connects as
bloopie@host→ signed in as 'elegant'
Case B - Key is not registered (first connection):
- Check if SSH username is already registered to a different user
- If username is taken: Reject SSH connection with error message
- If username is available: Proceed with auto-registration
- Auto-register new user with SSH username as nickname
- Store public key and fingerprint in
SSHKeytable - Create
Userrecord with nickname from SSH username - Set session nickname to the new username
- Example: New key, connects as
bloopie@host, 'bloopie' available → auto-register 'bloopie' to this key - Example: New key, connects as
bloopie@host, 'bloopie' already registered → reject connection - Race condition handling: The unique index on
User.nicknameprevents duplicate registrations. If two users attempt to register the same nickname simultaneously, the database constraint will cause the second INSERT to fail, and that SSH connection will be rejected with an error.
Send AUTH_RESPONSE: Notify client of successful authentication with user_id
Key Points:
- SSH key is the source of truth for identity, not the SSH username
- Public key is stored on first connection for future authentication
- SSH username is only used for auto-registration on first connection
- If SSH username is already taken, connection is rejected (prevents confusion)
- Subsequent connections with the same key always authenticate as the registered user
- Users should connect with an available username on their first SSH connection
Direct Message (DM) Flow
Direct messages are private, encrypted (optional) channels between users. The flow handles key setup, encryption negotiation, and supports both registered and anonymous users.
DM Encryption Architecture
SuperChat uses X25519 Diffie-Hellman for key agreement and AES-256-GCM for message encryption. This provides end-to-end encryption where the server never sees plaintext messages or shared secrets.
Key Management by User Type
SSH Ed25519 Users:
- Ed25519 SSH key is converted to X25519 (mathematically equivalent curve)
- Conversion happens client-side automatically
- No additional setup needed - seamless experience
SSH RSA/ECDSA Users:
- SSH key cannot be converted to X25519
- Client generates separate X25519 keypair on first DM
- Public key uploaded via PROVIDE_PUBLIC_KEY
- Private key stored in
~/.superchat/keys/
Password-Only Users:
- Generate X25519 keypair on first DM
- Public key uploaded to server
- Private key stored in
~/.superchat/keys/
Anonymous Users:
- Generate ephemeral X25519 keypair for session
- Keys stored in memory only (destroyed on disconnect)
- Full encryption supported for session duration
Encryption Process
Key Agreement (Diffie-Hellman):
- Each user has an X25519 keypair (public + private)
- Both parties compute shared secret independently:
- Alice:
shared = X25519(alice_private, bob_public) - Bob:
shared = X25519(bob_private, alice_public)
- Alice:
- Math guarantees both compute the same 32-byte shared secret
- Server never sees the shared secret
Key Derivation:
- Channel key derived from shared secret using HKDF-SHA256:
channel_key = HKDF(shared_secret, salt="superchat-dm-v1", info=channel_id)
- Produces 32-byte AES-256 key unique to this DM channel
- Channel key derived from shared secret using HKDF-SHA256:
Message Encryption:
- Messages encrypted with AES-256-GCM using derived channel key
- Nonce (12 bytes) generated randomly per message
- Provides both confidentiality and authenticity
Wire Format:
Encrypted Message Payload: +----------------+------------------+ | Nonce (12 B) | Ciphertext (N B) | +----------------+------------------+- Ciphertext includes GCM authentication tag (16 bytes)
- Frame flags byte has bit 1 set (0x02) for encrypted messages
Security Properties
- Algorithms: X25519 + HKDF-SHA256 + AES-256-GCM (modern standard)
- Forward Secrecy: Not currently implemented (future enhancement)
- End-to-End: Server cannot decrypt messages (no access to shared secret)
- Authentication: Users authenticated via SSH keys or passwords
- Key Storage: Private keys never leave client
- Anonymous Users: Full encryption with ephemeral keys (session-only)
Flow 1: Both Users Have Keys (Simple Case)
User A (has key) Server User B (has key)
| | |
|--- START_DM(target: "bob") --------> | |
| |--- DM_REQUEST from "alice" --------> |
| | |
|<-- DM_PENDING (waiting for bob) ---- | |
| | (B accepts)
| |<-- (implicit accept via |
| | standard flow) |
| | |
|<-- DM_READY (channel_id, bob_pub) -- |--- DM_READY (channel_id, alice_pub) ->|
| | |
Notes:
- Server creates private channel with
is_dm = true - Each party receives the other's X25519 public key
- Both clients compute shared secret via DH and derive channel key
- Server never sees the shared secret or channel key
- Both users can now use standard messaging on this channel
Flow 2: Initiator Needs Key
User A (no key) Server
| |
|--- START_DM(target: "bob") --------> |
| |
|<-- KEY_REQUIRED ------------------- |
| |
|(Client prompts A to set up key) |
| |
|--- PROVIDE_PUBLIC_KEY -------------> |
| or ALLOW_UNENCRYPTED |
| |
|<-- DM_PENDING (waiting for bob) ---- |
| |
|(continues as Flow 1) |
Flow 3: Recipient Needs Key
User A (has key) Server User B (no key)
| | |
|--- START_DM(target: "bob") --------> | |
| |--- DM_REQUEST from "alice" --------> |
| |--- KEY_REQUIRED ------------------> |
| | |
|<-- DM_PENDING (waiting for bob) ---- | (B sets up key)
| | |
| |<-- PROVIDE_PUBLIC_KEY ------------ |
| | or ALLOW_UNENCRYPTED |
| | |
|<-- DM_READY (channel_id, bob_pub) -- |--- DM_READY (channel_id, alice_pub) ->|
| | |
Notes:
- User A sees DM_PENDING immediately
- User B sees DM_REQUEST + KEY_REQUIRED simultaneously
- While B is setting up their key, they don't see "waiting for A" (they're busy with key setup)
- Once B completes key setup, both get DM_READY with each other's public keys
Flow 4: Both Users Need Keys
User A (no key) Server User B (no key)
| | |
|--- START_DM(target: "bob") --------> | |
| | |
|<-- KEY_REQUIRED ------------------- | |
| | |
|(A sets up key) | |
| | |
|--- PROVIDE_PUBLIC_KEY -------------> | |
| |--- DM_REQUEST from "alice" --------> |
| |--- KEY_REQUIRED ------------------> |
| | |
|<-- DM_PENDING (waiting for bob) ---- | (B sets up key)
| | |
|(A sees waiting indicator) | (B busy with UI)
| | |
| |<-- PROVIDE_PUBLIC_KEY ------------ |
| | |
|<-- DM_READY (channel_id, bob_pub) -- |--- DM_READY (channel_id, alice_pub) ->|
| | |
Notes:
- A sets up key first, then waits for B
- B receives DM_REQUEST while setting up key
- Both complete key setup before DM_READY is sent
- Each receives the other's public key to compute shared secret
Flow 5: Unencrypted DM (Both Allow)
User A (no key, allows unencrypted) Server User B (no key, allows unencrypted)
| | |
|--- START_DM(target: "bob", --> | |
| allow_unencrypted: true) | |
| |--- DM_REQUEST from "alice" --------> |
| | (initiator_allows_unencrypted: |
| | true, requires_key: false) |
| | |
| | (B accepts)
| |<-- (implicit accept) |
| | |
|<-- DM_READY (is_encrypted: false) -- |--- DM_READY (is_encrypted: false) -> |
| | |
Notes:
- No KEY_REQUIRED sent to either party
- Server creates unencrypted channel
- Messages are sent in plaintext
- Still private (not in public channel list), just not encrypted
Flow 6: Anonymous User DM
User A (registered, has key) Server User B (anonymous, no key)
| | |
|--- START_DM(target: session_123) -> | |
| |--- DM_REQUEST from "alice" --------> |
| |--- KEY_REQUIRED ------------------> |
| | |
|<-- DM_PENDING (waiting for bob) ---- | (B generates local key)
| | |
| |<-- PROVIDE_PUBLIC_KEY ------------ |
| | (Note: B is still anonymous, |
| | key is session-only) |
| | |
|<-- DM_READY (channel_id) ------------ |--- DM_READY (channel_id) ----------> |
| | (both derive key via DH) |
| | |
Notes:
- Anonymous user B can receive DMs by session_id
- B generates ephemeral X25519 keypair for this session
- B's key is not permanently stored (lost on disconnect)
- Full encryption via DH: both parties compute shared secret normally
- Alternatively, B can choose ALLOW_UNENCRYPTED (skips key generation)
Encryption Details
Key Agreement:
- Each DM uses X25519 Diffie-Hellman between the two participants
- Shared secret computed client-side:
X25519(my_private, their_public) - Server stores only public keys, never sees shared secrets
Key Derivation:
- Channel key =
HKDF-SHA256(shared_secret, salt="superchat-dm-v1", info=channel_id_bytes) - Each DM channel has a unique derived key (same shared secret, different channel IDs)
- 32-byte output used as AES-256 key
Message Encryption:
- Messages in encrypted DMs have Flags bit 1 set (0x02 or 0x03)
- Payload format:
[nonce (12 bytes)][ciphertext + GCM tag] - Nonce generated randomly for each message (never reused)
Key Updates:
- If user generates a new X25519 keypair, they must re-derive all DM channel keys
- Client fetches other party's public key and recomputes shared secret
- No server-side re-encryption needed (DH is symmetric)
Anonymous User Keys:
- Generated client-side as ephemeral X25519 keypair
- Public key uploaded to server (session-only, deleted on disconnect)
- Private key stored in memory only (lost on disconnect)
- Full encryption supported - no plaintext fallback needed
Server Discovery Flow
Client Browsing Servers
Client Directory (superchat.win)
| |
|--- TCP Connect -------------------> |
| |
|--- LIST_SERVERS (limit: 100) -----> |
|<-- SERVER_LIST (50 servers) -------- |
| |
|(User picks server from list) |
| |
|--- DISCONNECT ---------------------> |
| |
(Client connects to chosen server at chat.example.com:6465)
Notes:
- Client connects to directory server(s) temporarily
- Receives server list, displays to user
- Disconnects from directory
- Connects to chosen server for actual chat
Server Registration
Server (chat.example.com) Directory (superchat.win)
| |
|(Server startup with |
| --announce-to superchat.win:6465) |
| |
|--- TCP Connect -------------------> |
| |
|--- REGISTER_SERVER ----------------> |
| (hostname, port, name, desc) |
| |
| |(Directory validates)
| |
| |--- TCP Connect to ---------> |
| | chat.example.com:6465 |
| | |
|<-- VERIFY_REGISTRATION (challenge) --| |
| | |
|--- VERIFY_RESPONSE (challenge) ----->| |
| |(Verification OK) |
| |
|<-- REGISTER_ACK (success, |
| heartbeat_interval: 300s) ------- |
| |
|(Wait 5 minutes) |
| |
|--- HEARTBEAT (hostname, port, |
| user_count, uptime) ------------> |
| |
|<-- HEARTBEAT_ACK (300s) ------------ |
| |
|(Repeat heartbeat every 5 min) |
Notes:
- Server maintains persistent connection to directory
- Sends heartbeat at interval specified by directory
- Directory can adjust interval via HEARTBEAT_ACK
- Missing 3 heartbeats removes server from directory
Server Registration Failure (Verification)
Server (fake.example.com) Directory (superchat.win)
| |
|--- REGISTER_SERVER ----------------> |
| (hostname: fake.example.com:6465) |
| |
| |(Attempts to connect to
| | fake.example.com:6465)
| |
| |(Connection fails - timeout)
| |
|<-- REGISTER_ACK (success: false, |
| message: "Could not verify...") - |
| |
Notes:
- Directory rejects registration if it can't connect back
- Prevents spam registrations of fake servers
- Rate limiting prevents brute-force attempts
Directory Load Adaptation
Server Directory (has 1500 servers)
| |
|--- REGISTER_SERVER ----------------> |
| |
|(Verification succeeds) |
| |
|<-- REGISTER_ACK (success, |
| heartbeat_interval: 600s) ------- |
| "High load, heartbeat every 10m" |
| |
|(Wait 10 minutes - adjusted interval) |
| |
|--- HEARTBEAT ----------------------> |
| |
|<-- HEARTBEAT_ACK (600s) ------------ |
| |
Notes:
- Directory dynamically adjusts heartbeat interval based on number of servers
- Servers respect the interval to avoid overloading directory
- Prevents performance degradation with thousands of servers
Directory Gossip (Network Expansion)
Directory A (superchat.win) Server B (chat.example.com, also a directory)
| |
|(B already registered to A) |
| |
|(Gossip timer fires - every 3 hours) |
| |
|--- LIST_SERVERS (limit: 500) -----> |
| |
|<-- SERVER_LIST (B knows 20 servers) -|
| |
|(A discovers new Server C) |
|(A doesn't know C yet - needs verify) |
| |
|(A connects to Server C) |
| |
| Server C (game.example.org)
| |
|--- VERIFY_REGISTRATION (challenge) -----------------> |
| | |
|<-- VERIFY_RESPONSE (challenge) ------------------- |
| |
|(Verification OK - A adds C to list) |
|(C doesn't know A registered it) |
| |
|(Later, A may announce itself to C) |
|(A connects to C again) |
| |
|--- REGISTER_SERVER (A's info) ----------------------> |
| | |
| | (C verifies A)
| | |
|<-- REGISTER_ACK (success) ------------------------ |
| |
|(Now A and C know about each other) |
|(B acted as bridge for discovery) |
Notes:
- Directories periodically query all registered servers for their server lists (gossip)
- New servers discovered through gossip are verified before being added
- Directory connects to discovered server and sends VERIFY_REGISTRATION
- Only servers that pass verification are added (prevents fake server injection)
- Server is silently added to directory (no notification sent to server)
- Directory may optionally register itself to newly discovered servers (bidirectional)
- This creates a mesh network where directories discover each other
- If superchat.win goes down, other directories continue discovering servers
- Gossip interval is randomized (1-6 hours) to prevent synchronized queries
- Only servers in directory mode respond with SERVER_LIST, regular chat servers return empty list
Network Resilience:
- No single point of failure - multiple directories can exist
- Directories learn from each other through gossip
- Clients can configure multiple directories as fallbacks
- If primary directory fails, clients use secondary directories
Protocol Extensions
Future Considerations
- Typing Indicators: Real-time typing notifications (optional)
- Read Receipts: Show when other party has read your messages (optional)
- Multi-party DMs: Group DMs with 3+ participants
- Channel Moderation: Tools for channel operators (kick, ban, mute)
Explicitly Not Supported
To maintain the old-school, text-focused nature of SuperChat:
- No file attachments / binary blobs: Text only, keeps it simple and prevents abuse
- No emoji reactions: Want to react? Reply with "+1" or "agreed" like the old days
- No rich text / markdown: Plain text only, no formatting wars
Implementation Notes
Client Implementation
- Maintain persistent TCP connection
- Implement automatic reconnection with exponential backoff
- Buffer outgoing messages during disconnection
- Store local state for anonymous users in
~/.config/superchat-client/state.db - SSH connections: Notify user if connected username differs from authenticated nickname
- Example: "Connected as 'bloopie@host' but authenticated as 'elegant' (SSH key registered to 'elegant')"
- Prevents confusion when SSH username doesn't match registered identity
- Display authenticated nickname prominently in UI
Server Implementation
- Use event loop for handling multiple connections (goroutines + channels in Go)
- Implement per-user rate limiting (messages per minute)
- Broadcast NEW_MESSAGE to all sessions in the same channel
- Periodically send SERVER_STATS to all connected clients
- Implement graceful shutdown (notify clients before closing)
Security Considerations
- Validate all string inputs for length and content
- Sanitize message content (strip control characters)
- Rate limit message posting (e.g., 10 messages/minute per user)
- Limit max connections per IP address
- Implement flood protection for channel creation
- Use TLS for TCP connections (or SSH tunneling)