Table of Contents

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)
  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:

{
  "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:

  • 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.