Message Encryption
EchoHub uses application-layer AES-256-GCM encryption to protect message content in transit between clients and the server. This is an additional layer on top of TLS, protecting against ISPs, proxies, and any middleman that can inspect HTTPS traffic (e.g. corporate proxies with trusted root CA certificates).
How It Works
sequenceDiagram
participant Sender as TUI Client (Sender)
participant Server
participant Receiver as TUI Client (Receiver)
participant IRC as IRC Client
Sender->>Sender: encrypt(plaintext)
Sender->>Server: $ENC$v1$... (SignalR)
Server->>Server: decrypt → validate/sanitize
Server->>Server: fetch embeds on plaintext
Server->>Server: (optional) encrypt for DB storage
Server->>Server: encrypt(plaintext) with fresh nonce
Server->>Receiver: $ENC$v1$... (SignalR broadcast)
Receiver->>Receiver: decrypt → display
Server->>Server: decrypt for IRC
Server->>IRC: plaintext (IRC PRIVMSG)
- Client encrypts the message before sending it over SignalR
- Server decrypts to validate content, sanitize newlines, and fetch link embeds
- Server re-encrypts with a fresh nonce and broadcasts to all connected SignalR clients
- Clients decrypt the broadcast and display the plaintext
- IRC clients receive plaintext automatically (the IRC broadcaster decrypts before forwarding)
Each encryption uses a random 12-byte nonce, so the same message produces different ciphertext every time.
Encryption Key
A 256-bit AES key is auto-generated on first server startup and saved to appsettings.json:
{
"Encryption": {
"Key": "base64-encoded-32-byte-key",
"EncryptDatabase": false
}
}
The key is generated by FirstRunSetup using RandomNumberGenerator.GetBytes(32). If the key is missing or empty when the server starts, a new one is created automatically.
Clients fetch the key after login via an authenticated endpoint (GET /api/server/encryption-key). No manual configuration is needed on the client side.
Encrypted Content Format
Encrypted content uses a self-describing format:
$ENC$v1${nonce_base64}${ciphertext+tag_base64}
$ENC$v1$— version prefix (allows future algorithm changes)- Nonce — 12 bytes, Base64-encoded, randomly generated per message
- Ciphertext + Tag — AES-256-GCM output with 16-byte authentication tag appended
The authentication tag ensures both confidentiality and integrity — any tampering with the ciphertext is detected during decryption.
Database Encryption (Optional)
By default, messages are stored as plaintext in the database. Transport encryption still protects everything on the wire, but the SQLite file itself contains readable messages.
To encrypt messages at rest, enable the setting:
{
"Encryption": {
"Key": "...",
"EncryptDatabase": true
}
}
Behavior by Setting
| Setting | DB Storage | Transport | Key Rotation Risk |
|---|---|---|---|
false (default) |
Plaintext | Encrypted | None - stored data is unaffected |
true |
Encrypted | Encrypted | Changing the key makes old messages unreadable |
Mixed Content
The server handles mixed encrypted/plaintext content in the database gracefully. When reading messages:
- Content starting with
$ENC$v1$is decrypted - Everything else is treated as plaintext
This means you can safely toggle EncryptDatabase at any time. Old messages remain readable regardless of the current setting.
Enabling Encryption at Rest
When you set EncryptDatabase: true, only new messages are encrypted going forward. Existing plaintext messages in the database are not retroactively encrypted. This is intentional — it keeps key rotation safe and avoids irreversible bulk changes.
Key Rotation
Changing the encryption key is safe:
- Plaintext messages — always readable regardless of key
- Messages encrypted with the old key — will show
[encrypted message — decryption failed] - New messages — encrypted with the new key going forward
If you need to recover old encrypted messages, restore the original key from a backup of appsettings.json. Since plaintext messages are never retroactively encrypted, you'll never lose access to your entire history from a key change.
Security Considerations
What This Protects Against
- Passive network sniffing — messages are encrypted even if captured off the wire
- ISP/proxy inspection — content is encrypted at the application layer, independent of TLS
- Database theft (when
EncryptDatabase: true) — SQLite file contains only ciphertext
Limitations
- TLS-inspecting proxies — if a corporate proxy terminates TLS with a trusted root CA, it can intercept the key exchange (
GET /api/server/encryption-key) and read all traffic. A future upgrade to ECDH key exchange would address this. - Server has full access — the server decrypts all messages for processing. This is not end-to-end encryption between users; it's transport encryption between client and server. For true end-to-end encryption where the server cannot read content, use encrypted rooms.
- IRC clients receive plaintext — IRC is an open protocol and third-party clients cannot participate in the encryption scheme.
Troubleshooting
Messages show [encrypted message — decryption failed, try re-logging to fetch the latest key]
The client's encryption key doesn't match the server's. This happens when:
- The server's encryption key was rotated while the client was connected
- The client cached a stale key
Fix: Disconnect and reconnect (re-login). The client fetches the current key on each login.
Messages show [encrypted message — decryption failed] in channel history
The server cannot decrypt messages stored in the database. This happens when:
EncryptDatabasewas enabled, and the key was changed afterwards
Fix: Restore the original key from a backup. There is no way to recover messages encrypted with a lost key.