# 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 ```mermaid 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) ``` 1. **Client encrypts** the message before sending it over SignalR 2. **Server decrypts** to validate content, sanitize newlines, and fetch link embeds 3. **Server re-encrypts** with a fresh nonce and broadcasts to all connected SignalR clients 4. **Clients decrypt** the broadcast and display the plaintext 5. **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`: ```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: ```text $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: ```json { "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](encrypted-rooms.md). - **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: - `EncryptDatabase` was 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.